임동욱 임동욱 09-11
feat: 가맹몰 썸네일이 없으면 같은 크기의 틀에 "no image"를 띄운다
목록의 대표이미지와 메뉴 표의 이미지가 `-` 한 글자로 떨어져 열이 들쭉날쭉했다. 두 곳이
같은 56×40 틀을 쓰므로 MerchantThumbnail 하나로 모으고 중복된 SCSS를 지운다.

Co-Authored-By: Claude Opus 5 
@f65aaedd6713e98f6c618fd0f323f6744545757b
app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.module.scss
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.module.scss
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.module.scss
@@ -16,13 +16,3 @@
   align-items: center;
   gap: fox.gap(2);
 }
-
-// 목록 표의 대표이미지 — 기획 BO-MAL-001의 56×40 썸네일. 토큰 스케일이 96px에서 끝나
-// 딱 맞는 값이 없어 리터럴로 두고 근거를 남긴다(꾸미기아이템 썸네일과 같은 처리).
-.thumbnail {
-  inline-size: 5.6rem;
-  block-size: 4rem;
-  border: fox.border(1) solid fox.color(border-neutral-subtler);
-  border-radius: fox.radius(2);
-  object-fit: cover;
-}
app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
@@ -38,6 +38,7 @@
   deleteMerchantDetailsAction,
 } from '../_actions';
 import { MerchantDetailRowActions } from './merchant-detail-row-actions';
+import { MerchantThumbnail } from './merchant-thumbnail';
 import styles from './merchant-detail-list.module.scss';
 
 interface MerchantDetailListProps {
@@ -194,15 +195,9 @@
       header: '대표이미지',
       width: 110,
       align: 'center',
-      render: (row) =>
-        row.thumbnailUrl ? (
-          // 백엔드의 공개 이미지 GET이라 브라우저가 직접 부른다. `next/image`는 백엔드 호스트를
-          // next.config에 등록해야 해 여기서는 <img>로 둔다(꾸미기아이템과 같은 판단).
-          // eslint-disable-next-line @next/next/no-img-element
-          <img className={styles.thumbnail} src={row.thumbnailUrl} alt={`${row.name} 대표이미지`} />
-        ) : (
-          EMPTY_FIELD_PLACEHOLDER
-        ),
+      render: (row) => (
+        <MerchantThumbnail src={row.thumbnailUrl} alt={`${row.name} 대표이미지`} />
+      ),
     },
     { key: 'name', header: '업체명', width: 220, emphasis: true },
     {
app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.module.scss
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.module.scss
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.module.scss
@@ -29,15 +29,6 @@
   overflow-x: auto;
 }
 
-// 기획 BO-MAL-002 메뉴 표의 56×40 썸네일(목록 대표이미지와 같은 크기·같은 근거).
-.thumbnail {
-  inline-size: 5.6rem;
-  block-size: 4rem;
-  border: fox.border(1) solid fox.color(border-neutral-subtler);
-  border-radius: fox.radius(2);
-  object-fit: cover;
-}
-
 .description {
   display: -webkit-box;
   overflow: hidden;
app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.tsx
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.tsx
@@ -16,6 +16,7 @@
   type MerchantProduct,
 } from '@/lib/domain/merchant-product';
 import { MerchantProductRowActions } from './merchant-product-row-actions';
+import { MerchantThumbnail } from './merchant-thumbnail';
 import styles from './merchant-product-table.module.scss';
 
 interface MerchantProductTableProps {
@@ -75,13 +76,7 @@
                   {product.sortOrder ?? index + 1}
                 </FoxTableCell>
                 <FoxTableCell align="center" label="이미지">
-                  {product.imageUrl ? (
-                    // 백엔드의 공개 이미지 GET — 목록 대표이미지와 같은 이유로 <img>를 쓴다.
-                    // eslint-disable-next-line @next/next/no-img-element
-                    <img className={styles.thumbnail} src={product.imageUrl} alt={`${product.name} 이미지`} />
-                  ) : (
-                    EMPTY_FIELD_PLACEHOLDER
-                  )}
+                  <MerchantThumbnail src={product.imageUrl} alt={`${product.name} 이미지`} />
                 </FoxTableCell>
                 <FoxTableCell emphasis label="메뉴명">{product.name}</FoxTableCell>
                 <FoxTableCell label="설명">
 
app/(protected)/(basic)/system/merchant-details/_components/merchant-thumbnail.module.scss (added)
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-thumbnail.module.scss
@@ -0,0 +1,28 @@
+@use "@fox/styles/abstracts" as fox;
+
+// 기획 BO-MAL-001·002의 56×40 썸네일 틀. 토큰 스케일이 96px에서 끝나 딱 맞는 값이 없어
+// 리터럴로 두고 근거를 남긴다(꾸미기아이템 썸네일과 같은 처리).
+.image,
+.placeholder {
+  box-sizing: border-box;
+  inline-size: 5.6rem;
+  block-size: 4rem;
+  border: fox.border(1) solid fox.color(border-neutral-subtler);
+  border-radius: fox.radius(2);
+}
+
+.image {
+  display: block;
+  object-fit: cover;
+}
+
+.placeholder {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  background: fox.color(surface-neutral-gray);
+  color: fox.color(font-neutral-subtle);
+  font-size: fox.font-size(label-xsm);
+  line-height: 1;
+  text-transform: lowercase;
+}
 
app/(protected)/(basic)/system/merchant-details/_components/merchant-thumbnail.tsx (added)
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-thumbnail.tsx
@@ -0,0 +1,24 @@
+import styles from './merchant-thumbnail.module.scss';
+
+interface MerchantThumbnailProps {
+  src: string | null;
+  alt: string;
+}
+
+/**
+ * 목록·메뉴 표의 56×40 썸네일. 이미지가 없어도 같은 크기의 틀에 "no image"를 띄운다 —
+ * `-` 한 글자면 열이 들쭉날쭉해지고 이미지 자리가 어디인지 보이지 않는다(사용자 확정).
+ */
+export function MerchantThumbnail({ src, alt }: MerchantThumbnailProps) {
+  if (!src) {
+    return (
+      <span className={styles.placeholder} role="img" aria-label={`${alt} 없음`}>
+        no image
+      </span>
+    );
+  }
+  // 백엔드의 공개 이미지 GET이라 브라우저가 직접 부른다. `next/image`는 백엔드 호스트를
+  // next.config에 등록해야 해 <img>로 둔다(꾸미기아이템과 같은 판단).
+  // eslint-disable-next-line @next/next/no-img-element
+  return <img className={styles.image} src={src} alt={alt} />;
+}
Add a comment
List