Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/api/coopshop/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ export interface CoopShopDetailResponse extends APIResponse {
phone: string | null;
location: string;
remarks: string | null;
icon_url: string | null;
updated_at: string; // yyyy-MM-dd
}
20 changes: 20 additions & 0 deletions src/components/CampusInfo/CampusInfo.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,34 @@
}

.icon-wrapper {
align-items: center;
border-radius: 8px;
background: #f1f8ff;
display: flex;
flex-shrink: 0;
justify-content: center;
padding: 12px;
width: 32px;
height: 32px;
margin: 0;
}

.icon-image {
display: block;
height: 100%;
object-fit: contain;
width: 100%;
}

.icon-fallback {
color: #072552;
font-family: Pretendard, sans-serif;
font-size: 18px;
font-style: normal;
font-weight: 700;
line-height: 1;
}

.info-title-container {
display: flex;
gap: 16px;
Expand Down
62 changes: 29 additions & 33 deletions src/components/CampusInfo/index.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,13 @@
import { cn } from '@bcsdlab/utils';
import { useSuspenseQuery } from '@tanstack/react-query';
import { coopshopQueries } from 'api/coopshop/queries';
import Book from './svg/book.svg';
import Cafe from './svg/cafe.svg';
import Cut from './svg/cut.svg';
import Flatware from './svg/flatware.svg';
import Glasses from './svg/glasses.svg';
import Laundry from './svg/laundry.svg';
import PostOffice from './svg/post-office.svg';
import Print from './svg/print.svg';
import styles from './CampusInfo.module.scss';

const CAFETERIA_HEAD_TABLE = {
row: ['평일', '주말'],
col: ['아침', '점심', '저녁'],
};

const SHOP_ICON = {
서점: <Book />,
대즐: <Cafe />,
미용실: <Cut />,
세탁소: <Laundry />,
우체국: <PostOffice />,
'복지관 참빛관 편의점': <Cafe />,
복사실: <Print />,
학생식당: <Flatware />,
복지관식당: <Flatware />,
오락실: <Cafe />,
안경원: <Glasses />,
우편취급국: <PostOffice />,
};

const formatDateRange = (fromDate: string, toDate: string) => {
const from = new Date(fromDate);
const to = new Date(toDate);
Expand All @@ -56,6 +33,27 @@ const formatDateRange = (fromDate: string, toDate: string) => {
return `기간 : ${fromFormatted} - ${toFormatted}`;
};

type ShopIconProps = {
iconUrl: string | null | undefined;
name: string;
};

function ShopIcon({ iconUrl, name }: ShopIconProps) {
return (
<div className={styles['icon-wrapper']}>
{iconUrl ? (
// NOTE: 백엔드가 내려주는 소형 반복 아이콘은 호스트가 고정되지 않을 수 있어 <img>를 유지합니다.
// eslint-disable-next-line @next/next/no-img-element
<img className={styles['icon-image']} src={iconUrl} alt={name} decoding="async" />
) : (
<span className={styles['icon-fallback']} aria-hidden="true">
{name.slice(0, 1)}
</span>
Comment on lines +44 to +51
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

이미지 로드 실패 시 폴백이 동작하지 않습니다.

Line 44-48은 iconUrl 존재 여부만 확인해서 렌더링하기 때문에, URL이 깨졌을 때 icon-fallback으로 전환되지 않고 깨진 이미지가 노출됩니다.

수정 예시
+import { useState } from 'react';
 import { cn } from '@bcsdlab/utils';
 import { useSuspenseQuery } from '@tanstack/react-query';
 import { coopshopQueries } from 'api/coopshop/queries';
 import styles from './CampusInfo.module.scss';

 function ShopIcon({ iconUrl, name }: ShopIconProps) {
+  const [isImageError, setIsImageError] = useState(false);
+  const shouldRenderImage = Boolean(iconUrl) && !isImageError;
+
   return (
     <div className={styles['icon-wrapper']}>
-      {iconUrl ? (
+      {shouldRenderImage ? (
         // NOTE: 백엔드가 내려주는 소형 반복 아이콘은 호스트가 고정되지 않을 수 있어 <img>를 유지합니다.
         // eslint-disable-next-line `@next/next/no-img-element`
-        <img className={styles['icon-image']} src={iconUrl} alt="" aria-hidden="true" decoding="async" />
+        <img
+          className={styles['icon-image']}
+          src={iconUrl!}
+          alt=""
+          aria-hidden="true"
+          decoding="async"
+          onError={() => setIsImageError(true)}
+        />
       ) : (
         <span className={styles['icon-fallback']} aria-hidden="true">
           {name.slice(0, 1)}
         </span>
       )}
     </div>
   );
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{iconUrl ? (
// NOTE: 백엔드가 내려주는 소형 반복 아이콘은 호스트가 고정되지 않을 수 있어 <img>를 유지합니다.
// eslint-disable-next-line @next/next/no-img-element
<img className={styles['icon-image']} src={iconUrl} alt="" aria-hidden="true" decoding="async" />
) : (
<span className={styles['icon-fallback']} aria-hidden="true">
{name.slice(0, 1)}
</span>
import { useState } from 'react';
import { cn } from '@bcsdlab/utils';
import { useSuspenseQuery } from '@tanstack/react-query';
import { coopshopQueries } from 'api/coopshop/queries';
import styles from './CampusInfo.module.scss';
function ShopIcon({ iconUrl, name }: ShopIconProps) {
const [isImageError, setIsImageError] = useState(false);
const shouldRenderImage = Boolean(iconUrl) && !isImageError;
return (
<div className={styles['icon-wrapper']}>
{shouldRenderImage ? (
// NOTE: 백엔드가 내려주는 소형 반복 아이콘은 호스트가 고정되지 않을 수 있어 <img>를 유지합니다.
// eslint-disable-next-line `@next/next/no-img-element`
<img
className={styles['icon-image']}
src={iconUrl!}
alt=""
aria-hidden="true"
decoding="async"
onError={() => setIsImageError(true)}
/>
) : (
<span className={styles['icon-fallback']} aria-hidden="true">
{name.slice(0, 1)}
</span>
)}
</div>
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/CampusInfo/index.tsx` around lines 44 - 51, The component
currently renders the <img> whenever iconUrl is truthy so a broken URL shows a
broken image; update the CampusInfo component to track image load state (e.g., a
local state like hasImageError or imageLoaded) and render the fallback when the
image fails to load: keep the existing <img className={styles['icon-image']}
src={iconUrl} ... /> but add an onError handler that sets hasImageError true
(and optionally onLoad to mark success), and change the conditional to show the
<span className={styles['icon-fallback']}>{name.slice(0,1)}</span> when !iconUrl
|| hasImageError so the fallback appears on broken URLs. Ensure aria-hidden/alt
handling remains consistent.

)}
</div>
);
}

function CampusInfo() {
const { data: campusInfo } = useSuspenseQuery(coopshopQueries.allShopInfo());

Expand Down Expand Up @@ -87,10 +85,8 @@ function CampusInfo() {
<div className={styles['info-block']}>
<div className={styles['info-cafeteria']}>
<div className={styles['info-title-container']}>
<div className={styles['icon-wrapper']}>
<Flatware />
</div>
<div className={styles['info-title']}>학생식당</div>
<ShopIcon iconUrl={cafeteriaInfo?.icon_url} name={cafeteriaInfo?.name ?? '학생식당'} />
<div className={styles['info-title']}>{cafeteriaInfo?.name ?? '학생식당'}</div>
</div>
<table className={styles.table}>
<thead>
Expand Down Expand Up @@ -125,9 +121,9 @@ function CampusInfo() {
</div>
</div>

{filteredCampusInfo.slice(0, 2).map(({ id, name, opens }) => (
{filteredCampusInfo.slice(0, 2).map(({ id, name, opens, icon_url }) => (
<div className={styles['info-block']} key={id}>
<div className={styles['icon-wrapper']}>{SHOP_ICON[name as keyof typeof SHOP_ICON]}</div>
<ShopIcon iconUrl={icon_url} name={name} />
<div className={styles['info-description-container']}>
<div className={styles['info-title']}>{name}</div>
{opens.map(({ day_of_week, open_time, close_time }) => (
Expand All @@ -141,9 +137,9 @@ function CampusInfo() {
))}
</div>
<div className={styles['info-column']}>
{filteredCampusInfo.slice(2, 6).map(({ id, name, opens }) => (
{filteredCampusInfo.slice(2, 6).map(({ id, name, opens, icon_url }) => (
<div className={styles['info-block']} key={id}>
<div className={styles['icon-wrapper']}>{SHOP_ICON[name as keyof typeof SHOP_ICON]}</div>
<ShopIcon iconUrl={icon_url} name={name} />
<div className={styles['info-description-container']}>
<div className={styles['info-title']}>{name}</div>
{opens.map(({ day_of_week, open_time, close_time }) => (
Expand All @@ -157,9 +153,9 @@ function CampusInfo() {
))}
</div>
<div className={styles['info-column']}>
{filteredCampusInfo.slice(6).map(({ id, name, opens }) => (
{filteredCampusInfo.slice(6).map(({ id, name, opens, icon_url }) => (
<div className={styles['info-block']} key={id}>
<div className={styles['icon-wrapper']}>{SHOP_ICON[name as keyof typeof SHOP_ICON]}</div>
<ShopIcon iconUrl={icon_url} name={name} />
<div className={styles['info-description-container']}>
<div className={styles['info-title']}>{name}</div>
{opens.map(({ day_of_week, open_time, close_time }) => (
Expand Down
181 changes: 1 addition & 180 deletions src/pages/campusinfo/index.tsx
Original file line number Diff line number Diff line change
@@ -1,180 +1 @@
import { cn } from '@bcsdlab/utils';
import { useSuspenseQuery } from '@tanstack/react-query';
import { coopshopQueries } from 'api/coopshop/queries';
import Book from 'components/CampusInfo/svg/book.svg';
import Cafe from 'components/CampusInfo/svg/cafe.svg';
import Cut from 'components/CampusInfo/svg/cut.svg';
import Flatware from 'components/CampusInfo/svg/flatware.svg';
import Glasses from 'components/CampusInfo/svg/glasses.svg';
import Laundry from 'components/CampusInfo/svg/laundry.svg';
import PostOffice from 'components/CampusInfo/svg/post-office.svg';
import Print from 'components/CampusInfo/svg/print.svg';
import styles from './CampusInfo.module.scss';

const CAFETERIA_HEAD_TABLE = {
row: ['평일', '주말'],
col: ['아침', '점심', '저녁'],
};

const SHOP_ICON = {
서점: <Book />,
대즐: <Cafe />,
미용실: <Cut />,
세탁소: <Laundry />,
우체국: <PostOffice />,
'복지관 참빛관 편의점': <Cafe />,
복사실: <Print />,
학생식당: <Flatware />,
복지관식당: <Flatware />,
오락실: <Cafe />,
안경원: <Glasses />,
우편취급국: <PostOffice />,
};

const formatDateRange = (fromDate: string, toDate: string) => {
const from = new Date(fromDate);
const to = new Date(toDate);

const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric',
};

const fromFormatted = from.toLocaleDateString('ko-KR', options);
let toFormatted = to.toLocaleDateString('ko-KR', options);

if (from.getFullYear() === to.getFullYear()) {
const toOptions: Intl.DateTimeFormatOptions = {
month: 'long',
day: 'numeric',
};

toFormatted = to.toLocaleDateString('ko-KR', toOptions);
}

return `기간 : ${fromFormatted} - ${toFormatted}`;
};

function CampusInfo() {
const { data: campusInfo } = useSuspenseQuery(coopshopQueries.allShopInfo());

const cafeteriaInfo = campusInfo?.coop_shops.find((shop) => shop.name === '학생식당');
const filteredCampusInfo = campusInfo?.coop_shops.filter((shop) => shop.name !== '학생식당');

const getFormattedShopTime = (open: string, close: string) => {
if (open === close) {
return open;
}

return `${open} - ${close}`;
};

const getTimeToTypeAndDay = (type: string, day: string) => {
const target = cafeteriaInfo?.opens?.find((open) => open.day_of_week === day && open.type === type);

return target ? getFormattedShopTime(target.open_time, target.close_time) : '미운영';
};

return (
<div className={styles.container}>
<div className={styles['title-container']}>
<h2 className={styles.title}>{`${campusInfo.semester} 시설물 운영 시간`}</h2>
<div className={styles.subtitle}>{formatDateRange(campusInfo.from_date, campusInfo.to_date)}</div>
</div>
<section className={styles['info-container']}>
<div className={styles['info-column']}>
<div className={styles['info-block']}>
<div className={styles['info-cafeteria']}>
<div className={styles['info-title-container']}>
<div className={styles['icon-wrapper']}>
<Flatware />
</div>
<div className={styles['info-title']}>학생식당</div>
</div>
<table className={styles.table}>
<thead>
<tr className={styles['table-head__tr']}>
<th>시간</th>
{CAFETERIA_HEAD_TABLE.row.map((type) => (
<th className={styles['table-head__th']} key={type}>
{type}
</th>
))}
</tr>
</thead>
<tbody>
{CAFETERIA_HEAD_TABLE.col.map((type) => (
<tr className={styles['table-body__tr']} key={type}>
<td className={styles['table-body__td']}>{type}</td>
{CAFETERIA_HEAD_TABLE.row.map((day) => (
<td
className={cn({
[styles['table-body__td']]: true,
[styles.closed]: getTimeToTypeAndDay(type, day) === '미운영',
})}
key={`${type}-${day}`}
>
{getTimeToTypeAndDay(type, day)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>

{filteredCampusInfo.slice(0, 2).map(({ id, name, opens }) => (
<div className={styles['info-block']} key={id}>
<div className={styles['icon-wrapper']}>{SHOP_ICON[name as keyof typeof SHOP_ICON]}</div>
<div className={styles['info-description-container']}>
<div className={styles['info-title']}>{name}</div>
{opens.map(({ day_of_week, open_time, close_time }) => (
<div
className={styles['info-description']}
key={`${id}-${day_of_week}`}
>{`${day_of_week}: ${getFormattedShopTime(open_time, close_time)}`}</div>
))}
</div>
</div>
))}
</div>
<div className={styles['info-column']}>
{filteredCampusInfo.slice(2, 6).map(({ id, name, opens }) => (
<div className={styles['info-block']} key={id}>
<div className={styles['icon-wrapper']}>{SHOP_ICON[name as keyof typeof SHOP_ICON]}</div>
<div className={styles['info-description-container']}>
<div className={styles['info-title']}>{name}</div>
{opens.map(({ day_of_week, open_time, close_time }) => (
<div
className={styles['info-description']}
key={`${id}-${day_of_week}`}
>{`${day_of_week}: ${getFormattedShopTime(open_time, close_time)}`}</div>
))}
</div>
</div>
))}
</div>
<div className={styles['info-column']}>
{filteredCampusInfo.slice(6).map(({ id, name, opens }) => (
<div className={styles['info-block']} key={id}>
<div className={styles['icon-wrapper']}>{SHOP_ICON[name as keyof typeof SHOP_ICON]}</div>
<div className={styles['info-description-container']}>
<div className={styles['info-title']}>{name}</div>
{opens.map(({ day_of_week, open_time, close_time }) => (
<div
className={styles['info-description']}
key={`${id}-${day_of_week}`}
>{`${day_of_week}: ${getFormattedShopTime(open_time, close_time)}`}</div>
))}
</div>
</div>
))}
</div>
</section>
</div>
);
}

export default CampusInfo;
export { default } from 'components/CampusInfo';
Loading