File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
'use client';
import { useState } from 'react';
import { FoxFileUpload } from '@fox/core/components/fox-file-upload';
import { FoxHelperText } from '@fox/core/components/fox-helper-text';
import { FoxSortableList } from '@fox/core/components/fox-sortable-list';
import { FoxTextButton } from '@fox/core/components/fox-text-button';
import { FILE_MODULE, fileModuleAccept } from '@/lib/domain/file-module';
import {
MAX_EXTRA_IMAGES,
type MerchantImageSlot,
} from '@/lib/domain/merchant-detail';
import styles from './merchant-image-field.module.scss';
interface MerchantImageFieldProps {
defaultThumbnailUrl: string | null;
retainedThumbnailId: string | null;
defaultImageUrls: readonly string[];
retainedImageFileId: string | null;
thumbnailError?: string;
imagesError?: string;
}
const ACCEPT = fileModuleAccept(FILE_MODULE.merchantImage);
const IMAGE_HINT = 'JPG · PNG / 5MB 이하';
/**
* 기획 BO-MAL-002 ⑤ — 대표 1장 필수 + 추가 최대 11장, 추가 이미지는 드래그로 순서를 바꾼다.
*
* 추가 이미지는 칸마다 `imageSlot`(`saved:<fileSn>` | `new`)과 `imageFile`을 같은 순서로
* 싣는다. 액션은 이 둘을 자리로 짝지어 묶음을 다시 만든다 — 저장된 장은 백엔드에서 다시 읽고
* 새 장은 그대로 올린다. 그래서 순서·삭제·추가 어느 것이든 폼 한 번으로 끝난다.
*
* 비제어 `FoxFileUpload`를 칸마다 두고 React key로 마운트를 관리한다(4컷만화와 같은 방식).
*/
export function MerchantImageField({
defaultThumbnailUrl,
retainedThumbnailId,
defaultImageUrls,
retainedImageFileId,
thumbnailError,
imagesError,
}: MerchantImageFieldProps) {
const [slots, setSlots] = useState<MerchantImageSlot[]>(() =>
defaultImageUrls.map((url, index) => ({
key: `saved-${index + 1}`,
previewUrl: url,
fileSn: index + 1,
}))
);
const [nextKey, setNextKey] = useState(0);
function addSlot() {
if (slots.length >= MAX_EXTRA_IMAGES) return;
setSlots((current) => [
...current,
{ key: `new-${nextKey}`, previewUrl: null, fileSn: null },
]);
setNextKey((current) => current + 1);
}
function removeSlot(key: string) {
setSlots((current) => current.filter((slot) => slot.key !== key));
}
return (
<div className={styles.root}>
<div className={styles.tiles}>
<div className={styles.thumbnail}>
<FoxFileUpload
mode="image"
name="thumbnailFile"
accept={ACCEPT}
thumbnailPicker
selectLabel="대표이미지 등록"
description={IMAGE_HINT}
thumbnailWidth={220}
thumbnailHeight={160}
defaultFiles={
defaultThumbnailUrl
? [{ id: 'thumbnail', name: '대표이미지', previewUrl: defaultThumbnailUrl }]
: undefined
}
/>
<input type="hidden" name="thumbnailFileId" value={retainedThumbnailId ?? ''} />
<span className={styles.caption}>
대표이미지 <span className={styles.required}>*</span>
</span>
{thumbnailError && <FoxHelperText type="danger" message={thumbnailError} />}
</div>
<input type="hidden" name="imageFileId" value={retainedImageFileId ?? ''} />
<FoxSortableList
items={slots}
getKey={(slot) => slot.key}
onReorder={setSlots}
ariaLabel="추가 이미지 순서"
renderItem={(slot, index) => (
<div className={styles.slot}>
<FoxFileUpload
mode="image"
name="imageFile"
accept={ACCEPT}
thumbnailPicker
selectLabel={`추가 이미지 ${index + 1} 선택`}
thumbnailWidth={160}
thumbnailHeight={160}
defaultFiles={
slot.previewUrl
? [{ id: slot.key, name: `추가 이미지 ${index + 1}`, previewUrl: slot.previewUrl }]
: undefined
}
/>
<input
type="hidden"
name="imageSlot"
value={slot.fileSn === null ? 'new' : `saved:${slot.fileSn}`}
/>
<span className={styles.slotFooter}>
<span className={styles.caption}>추가 이미지 {index + 1}</span>
<FoxTextButton
size="md"
type="secondary"
leadingIcon={<i className="fox-ico fox-ico-Trash" aria-hidden="true" />}
label="삭제"
onAction={() => removeSlot(slot.key)}
/>
</span>
</div>
)}
/>
{slots.length < MAX_EXTRA_IMAGES && (
<button type="button" className={styles.addTile} onClick={addSlot}>
<i className="fox-ico fox-ico-Plus" aria-hidden="true" />
<span>추가</span>
<span className={styles.addHint}>
{slots.length}/{MAX_EXTRA_IMAGES}
</span>
</button>
)}
</div>
{imagesError && <FoxHelperText type="danger" message={imagesError} />}
</div>
);
}