diff --git a/docs/features/curation-v2-component-structure/component-structure.md b/docs/features/curation-v2-component-structure/component-structure.md new file mode 100644 index 0000000..5634607 --- /dev/null +++ b/docs/features/curation-v2-component-structure/component-structure.md @@ -0,0 +1,71 @@ +# Curation V2 Component Structure + +큐레이션 v2 화면은 spec code별 page component가 presentation 조립을 맡고, 저장 폼과 API 계층은 공유한다. + +```mermaid +flowchart TD + Routes["src/routes/index.tsx"] --> Entry["CurationEntryPage"] + Routes --> TastingCreate["CurationWhiskyTastingEventCreatePage"] + Routes --> RecommendedCreate["CurationRecommendedWhiskyCreatePage"] + Routes --> PairingCreate["CurationWhiskyPairingCreatePage"] + Routes --> Detail["CurationDetailPage"] + + Detail -->|"WHISKY_TASTING_EVENT"| TastingEdit["CurationWhiskyTastingEventEditPage"] + Detail -->|"RECOMMENDED_WHISKY"| RecommendedEdit["CurationRecommendedWhiskyEditPage"] + Detail -->|"WHISKY_PAIRING"| PairingEdit["CurationWhiskyPairingEditPage"] + Detail -->|"unknown spec"| ReadOnlyDetail["CurationDetailContent"] + + TastingCreate --> TastingGate["WhiskyTastingEventCreateGate"] + TastingGate --> TastingForm["WhiskyTastingEventForm"] + TastingEdit --> TastingForm + + RecommendedCreate --> WhiskyGate["WhiskyCurationCreateGate"] + PairingCreate --> WhiskyGate + WhiskyGate --> WhiskyForm["WhiskyCurationForm"] + RecommendedEdit --> WhiskyForm + PairingEdit --> WhiskyForm + + RecommendedCreate --> RecommendedPresentation["showCommentField: true"] + RecommendedEdit --> RecommendedPresentation + RecommendedPresentation --> WhiskyForm + + PairingCreate --> PairingPresentation["showCommentField: false
renderItemExtra: WhiskyPairingFields"] + PairingEdit --> PairingPresentation + PairingPresentation --> WhiskyForm + + TastingForm --> BasicInfo["CurationBasicInfoSection
label: 광고노출 시작일/종료일"] + WhiskyForm --> BasicInfoCuration["CurationBasicInfoSection
label: 노출 시작일/종료일"] + WhiskyForm --> ImageSection["CurationImageSection"] + + TastingForm --> FormSection["CurationFormSection"] + WhiskyForm --> FormSection + FormSection --> FieldRenderer["CurationFormFieldRenderer"] + FieldRenderer --> WhiskyCardList["CurationWhiskyCardListField"] + PairingPresentation --> PairingFields["WhiskyPairingFields"] + + TastingForm --> TastingPreview["WhiskyTastingEventPreviewPanel"] + WhiskyForm --> WhiskyPreview["WhiskyCurationPreviewPanel"] + WhiskyPreview --> AppPreview["WhiskyCurationPreview"] + + TastingForm --> Mutations["useCurationCreate / useCurationUpdate"] + WhiskyForm --> Mutations + Mutations --> Service["curationService"] + Service --> ApiTypes["src/types/api/curation.api.ts"] +``` + +## Entry Points + +- 시음회 생성: `src/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventCreate.tsx` +- 시음회 수정: `src/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventEdit.tsx` +- 추천 위스키 생성: `src/pages/curation/whisky-curation/CurationRecommendedWhiskyCreate.tsx` +- 추천 위스키 수정: `src/pages/curation/whisky-curation/CurationRecommendedWhiskyEdit.tsx` +- 위스키 페어링 생성: `src/pages/curation/whisky-curation/CurationWhiskyPairingCreate.tsx` +- 위스키 페어링 수정: `src/pages/curation/whisky-curation/CurationWhiskyPairingEdit.tsx` + +## Shared Layers + +- `WhiskyTastingEventCreateGate`, `WhiskyCurationCreateGate`: spec list/detail loading and blocking states. +- `WhiskyTastingEventForm`, `WhiskyCurationForm`: create/update mutation and common form layout. +- `CurationBasicInfoSection`: shared basic fields. Tasting event keeps ad exposure copy; recommendation and pairing override to plain exposure copy. +- `CurationFormSection` and `CurationFormFieldRenderer`: JSON schema-derived payload fields. +- `curation.api.ts` -> `curation.service.ts` -> `useCurations.ts`: API type, service, TanStack Query hook ownership. diff --git a/src/pages/curation/CurationDetail.tsx b/src/pages/curation/CurationDetail.tsx index bf100a5..6a3df33 100644 --- a/src/pages/curation/CurationDetail.tsx +++ b/src/pages/curation/CurationDetail.tsx @@ -8,8 +8,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useCurationDetail } from '@/hooks/useCurations'; import { CurationSpecCode, type CurationV2Detail } from '@/types/api'; -import { CurationTastingEventEditPage } from './tasting-event/CurationTastingEventCreate'; -import { CurationWhiskyCardEditPage } from './whisky-card/CurationWhiskyCardCreate'; +import { CurationWhiskyTastingEventEditPage } from './whisky-tasting-event/CurationWhiskyTastingEventEdit'; +import { CurationRecommendedWhiskyEditPage } from './whisky-curation/CurationRecommendedWhiskyEdit'; +import { CurationWhiskyPairingEditPage } from './whisky-curation/CurationWhiskyPairingEdit'; import { formatCurationDateTime, formatCurationExposurePeriod, @@ -25,16 +26,15 @@ export function CurationDetailPage() { const { data: curation, isLoading, isError, refetch } = useCurationDetail(curationId); if (!isLoading && !isError && curation?.spec.code === CurationSpecCode.WHISKY_TASTING_EVENT) { - return ; + return ; } - if ( - !isLoading && - !isError && - (curation?.spec.code === CurationSpecCode.RECOMMENDED_WHISKY || - curation?.spec.code === CurationSpecCode.WHISKY_PAIRING) - ) { - return ; + if (!isLoading && !isError && curation?.spec.code === CurationSpecCode.RECOMMENDED_WHISKY) { + return ; + } + + if (!isLoading && !isError && curation?.spec.code === CurationSpecCode.WHISKY_PAIRING) { + return ; } return ( diff --git a/src/pages/curation/CurationEntry.tsx b/src/pages/curation/CurationEntry.tsx index 6c09632..4646b8f 100644 --- a/src/pages/curation/CurationEntry.tsx +++ b/src/pages/curation/CurationEntry.tsx @@ -24,9 +24,9 @@ import { import { CurationPreviewFrame, TastingEventPreview, - WhiskyCardCurationPreview, + WhiskyCurationPreview, type TastingEventPreviewData, - type WhiskyCardCurationPreviewData, + type WhiskyCurationPreviewData, } from './_preview'; interface CurationSpecUiConfig { @@ -299,7 +299,7 @@ function CurationEntryPreview({ specCode }: { specCode: KnownCurationV2SpecCode {specCode === CurationSpecCode.WHISKY_TASTING_EVENT ? ( ) : ( - { expect(screen.getByLabelText('큐레이션명')).toHaveValue('6월 싱글몰트 시음회'); expect(screen.getByLabelText('설명')).toHaveValue(''); expect(screen.getByLabelText('광고노출 시작일')).toHaveValue(''); - expect(screen.getByLabelText('광고노출 시작일')).toBeDisabled(); - expect(screen.getByText('광고노출 시작일은 등록 후 변경할 수 없습니다.')).toBeInTheDocument(); + expect(screen.getByLabelText('광고노출 시작일')).not.toBeDisabled(); + expect( + screen.getAllByText((_, element) => element?.textContent === '광고노출 시작일*').length + ).toBeGreaterThan(0); + expect( + screen.getByText('기존 노출 시작일이 없어 이번 수정에서만 입력할 수 있습니다.') + ).toBeInTheDocument(); expect(screen.getByLabelText('광고노출 종료일')).toHaveValue(''); expect(screen.getByLabelText('광고노출 종료일')).not.toBeDisabled(); + expect( + screen.getAllByText((_, element) => element?.textContent === '광고노출 종료일*').length + ).toBeGreaterThan(0); expect(screen.getByText('날짜 및 장소')).toBeInTheDocument(); expect(screen.getByText('참가 정보')).toBeInTheDocument(); expect(screen.getAllByText('시음 위스키').length).toBeGreaterThan(0); @@ -239,6 +247,7 @@ describe('CurationDetailPage', () => { '첫 잔으로 가볍게 시작하는 위스키' ); + fireEvent.change(screen.getByLabelText('광고노출 시작일'), { target: { value: '2026-06-01' } }); await user.clear(screen.getByLabelText('안내사항')); await user.type(screen.getByLabelText('안내사항'), '수정된 안내사항'); await user.click(screen.getByRole('button', { name: /수정/ })); @@ -249,7 +258,7 @@ describe('CurationDetailPage', () => { name: '6월 싱글몰트 시음회', description: null, imageUrls: ['https://cdn.example.com/cover.jpg'], - exposureStartDate: null, + exposureStartDate: '2026-06-01', exposureEndDate: null, displayOrder: 1, isActive: true, @@ -356,4 +365,5 @@ describe('CurationDetailPage', () => { expect(screen.getByText('channel')).toBeInTheDocument(); expect(screen.getByText('home')).toBeInTheDocument(); }); + }); diff --git a/src/pages/curation/_preview/TastingEventPreviewExample.tsx b/src/pages/curation/_preview/TastingEventPreviewExample.tsx index 2af8d0a..9057759 100644 --- a/src/pages/curation/_preview/TastingEventPreviewExample.tsx +++ b/src/pages/curation/_preview/TastingEventPreviewExample.tsx @@ -21,7 +21,7 @@ const exampleEvent: TastingEventPreviewData = { barAddress: '서울 강남구 테헤란로 123', detailAddress: '4층', isRecruiting: true, - applicationLink: 'https://example.com/tasting-event/apply', + applicationLink: 'https://example.com/whisky-tasting-event/apply', alcohols: [ { source: 'admin-preview-1', diff --git a/src/pages/curation/_preview/WhiskyCardCurationPreview.tsx b/src/pages/curation/_preview/WhiskyCurationPreview.tsx similarity index 91% rename from src/pages/curation/_preview/WhiskyCardCurationPreview.tsx rename to src/pages/curation/_preview/WhiskyCurationPreview.tsx index 1772863..7d602dc 100644 --- a/src/pages/curation/_preview/WhiskyCardCurationPreview.tsx +++ b/src/pages/curation/_preview/WhiskyCurationPreview.tsx @@ -9,13 +9,13 @@ import type { import { CurationPreviewWhiskyCard } from './CurationPreviewWhiskyCard'; import { tastingEventPreviewThemeStyle } from './previewTheme'; -export interface WhiskyCardCurationPreviewPairing { +export interface WhiskyCurationPreviewPairing { itemName: string; pairingNote: string; itemImageUrl?: string; } -export interface WhiskyCardCurationPreviewData { +export interface WhiskyCurationPreviewData { specName: string; name: string; description?: string | null; @@ -23,21 +23,21 @@ export interface WhiskyCardCurationPreviewData { alcohol?: CurationWhiskyMirror; stats?: CurationWhiskyCardValue['stats']; comment?: string | null; - pairings?: WhiskyCardCurationPreviewPairing[]; - items?: Array; + pairings?: WhiskyCurationPreviewPairing[]; + items?: Array; } -interface WhiskyCardCurationPreviewProps { - curation: WhiskyCardCurationPreviewData; +interface WhiskyCurationPreviewProps { + curation: WhiskyCurationPreviewData; pairingTitle?: string; className?: string; } -export function WhiskyCardCurationPreview({ +export function WhiskyCurationPreview({ curation, pairingTitle = '페어링 음식', className, -}: WhiskyCardCurationPreviewProps) { +}: WhiskyCurationPreviewProps) { const visibleItems = getVisibleItems(curation); const coverImageUrl = curation.imageUrls[0] ?? visibleItems[0]?.alcohol.imageUrl ?? ''; const galleryImageUrls = curation.imageUrls.filter((imageUrl) => imageUrl !== coverImageUrl); @@ -84,8 +84,8 @@ export function WhiskyCardCurationPreview({ } function getVisibleItems( - curation: WhiskyCardCurationPreviewData -): Array { + curation: WhiskyCurationPreviewData +): Array { const visibleItems = curation.items?.filter( (item) => @@ -192,7 +192,7 @@ function WhiskyPreviewItem({ order, pairingTitle, }: { - item: CurationWhiskyCardValue & { pairings?: WhiskyCardCurationPreviewPairing[] }; + item: CurationWhiskyCardValue & { pairings?: WhiskyCurationPreviewPairing[] }; order: number; pairingTitle: string; }) { @@ -224,7 +224,7 @@ function WhiskyPreviewItem({ ); } -function PairingPreviewItem({ pairing }: { pairing: WhiskyCardCurationPreviewPairing }) { +function PairingPreviewItem({ pairing }: { pairing: WhiskyCurationPreviewPairing }) { return (
diff --git a/src/pages/curation/_preview/index.ts b/src/pages/curation/_preview/index.ts index 8e91849..3a9ca9f 100644 --- a/src/pages/curation/_preview/index.ts +++ b/src/pages/curation/_preview/index.ts @@ -2,7 +2,7 @@ export { buildTastingEventPreviewModel } from './buildTastingEventPreviewModel'; export { CurationPreviewFrame } from './CurationPreviewFrame'; export { TastingEventPreview } from './TastingEventPreview'; export { TastingEventPreviewExample } from './TastingEventPreviewExample'; -export { WhiskyCardCurationPreview } from './WhiskyCardCurationPreview'; +export { WhiskyCurationPreview } from './WhiskyCurationPreview'; export type { TastingEventPreviewAlcoholItem, TastingEventPreviewCta, @@ -11,6 +11,6 @@ export type { TastingEventPreviewPayload, } from './types'; export type { - WhiskyCardCurationPreviewData, - WhiskyCardCurationPreviewPairing, -} from './WhiskyCardCurationPreview'; + WhiskyCurationPreviewData, + WhiskyCurationPreviewPairing, +} from './WhiskyCurationPreview'; diff --git a/src/pages/curation/tasting-event/components/ActiveExposureTooltip.tsx b/src/pages/curation/components/CurationActiveExposureTooltip.tsx similarity index 95% rename from src/pages/curation/tasting-event/components/ActiveExposureTooltip.tsx rename to src/pages/curation/components/CurationActiveExposureTooltip.tsx index 45617b8..ff3655e 100644 --- a/src/pages/curation/tasting-event/components/ActiveExposureTooltip.tsx +++ b/src/pages/curation/components/CurationActiveExposureTooltip.tsx @@ -2,7 +2,7 @@ import { Info } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -export function ActiveExposureTooltip() { +export function CurationActiveExposureTooltip() { return ( diff --git a/src/pages/curation/tasting-event/components/TastingEventBasicInfoSection.tsx b/src/pages/curation/components/CurationBasicInfoSection.tsx similarity index 72% rename from src/pages/curation/tasting-event/components/TastingEventBasicInfoSection.tsx rename to src/pages/curation/components/CurationBasicInfoSection.tsx index 008909c..4bb2d50 100644 --- a/src/pages/curation/tasting-event/components/TastingEventBasicInfoSection.tsx +++ b/src/pages/curation/components/CurationBasicInfoSection.tsx @@ -8,33 +8,62 @@ import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; -import { CurationSectionCard } from '../../components/CurationSectionCard'; -import { ActiveExposureTooltip } from './ActiveExposureTooltip'; -import { RecruitmentPeriodTooltip } from './RecruitmentPeriodTooltip'; -import { TastingEventImageUploadField } from './TastingEventImageUploadField'; -import type { TastingEventCreateFormState } from '../tasting-event.schema'; +import { CurationActiveExposureTooltip } from './CurationActiveExposureTooltip'; +import { CurationExposurePeriodTooltip } from './CurationExposurePeriodTooltip'; +import { CurationImageUploadField } from './CurationImageUploadField'; +import { CurationSectionCard } from './CurationSectionCard'; -interface TastingEventBasicInfoSectionProps { +interface CurationBasicInfoFormState { + name: string; + description: string; + imageUrls: string[]; + exposureStartDate: string; + exposureEndDate: string; + displayOrder: number; + isActive: boolean; +} + +interface CurationBasicInfoSectionProps { isRootAdmin: boolean; isEditMode?: boolean; + canEditExposureStartDateInEditMode?: boolean; onImageUploadingChange?: (isUploading: boolean) => void; + exposureStartDateLabel?: string; + exposureEndDateLabel?: string; + exposureStartDateHelpText?: string; + editableExposureStartDateHelpText?: string; + exposurePeriodTooltipLabel?: string; + exposurePeriodTooltipContent?: string; } -export function TastingEventBasicInfoSection({ +export function CurationBasicInfoSection({ isRootAdmin, isEditMode = false, + canEditExposureStartDateInEditMode = false, onImageUploadingChange, -}: TastingEventBasicInfoSectionProps) { - const form = useFormContext(); + exposureStartDateLabel = '광고노출 시작일', + exposureEndDateLabel = '광고노출 종료일', + exposureStartDateHelpText = '광고노출 시작일은 등록 후 변경할 수 없습니다.', + editableExposureStartDateHelpText = '기존 노출 시작일이 없어 이번 수정에서만 입력할 수 있습니다.', + exposurePeriodTooltipLabel = '모집기간 안내', + exposurePeriodTooltipContent = '모집 기간동안 보틀노트 앱에서 노출됩니다.', +}: CurationBasicInfoSectionProps) { + const form = useFormContext(); const [isAdminControlAlertVisible, setIsAdminControlAlertVisible] = useState(false); const isActive = useWatch({ control: form.control, name: 'isActive', }); const isNullableBasicInfoAllowed = isEditMode; + const isExposurePeriodRequired = true; const exposureStartDateRegistration = form.register('exposureStartDate'); const exposureEndDateRegistration = form.register('exposureEndDate'); const isAdminControlDisabled = !isRootAdmin; + const isExposureStartDateDisabled = isEditMode && !canEditExposureStartDateInEditMode; + const exposureStartDateHelpMessage = + isEditMode && !isExposureStartDateDisabled + ? editableExposureStartDateHelpText + : exposureStartDateHelpText; const handleExposureDateChange = ( event: ChangeEvent, @@ -48,8 +77,9 @@ export function TastingEventBasicInfoSection({ const otherValue = form.getValues(otherFieldName); const shouldValidateRange = typeof otherValue === 'string' && otherValue.length === 10; + const currentFieldName = event.target.name as 'exposureStartDate' | 'exposureEndDate'; void form.trigger( - shouldValidateRange ? ['exposureStartDate', 'exposureEndDate'] : event.target.name + shouldValidateRange ? ['exposureStartDate', 'exposureEndDate'] : currentFieldName ); }; @@ -85,7 +115,7 @@ export function TastingEventBasicInfoSection({ aria-label="설명" rows={4} {...form.register('description')} - placeholder="시음회 큐레이션에 대한 설명을 입력하세요." + placeholder="큐레이션에 대한 설명을 입력하세요." /> {onImageUploadingChange && ( @@ -103,7 +133,7 @@ export function TastingEventBasicInfoSection({ 최대 3장까지 등록할 수 있고, 등록된 순서대로 노출됩니다.

- + )}
@@ -111,17 +141,18 @@ export function TastingEventBasicInfoSection({
- 광고노출 시작일 - {!isNullableBasicInfoAllowed && ( - * - )} + {exposureStartDateLabel} + {isExposurePeriodRequired && *} - +
handleExposureDateChange( @@ -131,9 +162,7 @@ export function TastingEventBasicInfoSection({ ) } /> -

- 광고노출 시작일은 등록 후 변경할 수 없습니다. -

+

{exposureStartDateHelpMessage}

{form.formState.errors.exposureStartDate?.message && (

{form.formState.errors.exposureStartDate.message} @@ -144,13 +173,13 @@ export function TastingEventBasicInfoSection({ ~ @@ -171,7 +200,7 @@ export function TastingEventBasicInfoSection({

관리자 전용 설정

- +

노출 순서와 활성화 상태 변경은 관리자만 할 수 있습니다. diff --git a/src/pages/curation/tasting-event/components/RecruitmentPeriodTooltip.tsx b/src/pages/curation/components/CurationExposurePeriodTooltip.tsx similarity index 69% rename from src/pages/curation/tasting-event/components/RecruitmentPeriodTooltip.tsx rename to src/pages/curation/components/CurationExposurePeriodTooltip.tsx index 969c287..9bc1ca1 100644 --- a/src/pages/curation/tasting-event/components/RecruitmentPeriodTooltip.tsx +++ b/src/pages/curation/components/CurationExposurePeriodTooltip.tsx @@ -2,21 +2,29 @@ import { Info } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -export function RecruitmentPeriodTooltip() { +interface CurationExposurePeriodTooltipProps { + ariaLabel?: string; + content?: string; +} + +export function CurationExposurePeriodTooltip({ + ariaLabel = '모집기간 안내', + content = '모집 기간동안 보틀노트 앱에서 노출됩니다.', +}: CurationExposurePeriodTooltipProps) { return ( - 모집 기간동안 보틀노트 앱에서 노출됩니다. + {content} diff --git a/src/pages/curation/components/CurationImageSection.tsx b/src/pages/curation/components/CurationImageSection.tsx new file mode 100644 index 0000000..ae0d40a --- /dev/null +++ b/src/pages/curation/components/CurationImageSection.tsx @@ -0,0 +1,18 @@ +import { CurationImageUploadField } from './CurationImageUploadField'; +import { CurationSectionCard } from './CurationSectionCard'; + +interface CurationImageSectionProps { + onUploadingChange: (isUploading: boolean) => void; +} + +export function CurationImageSection({ onUploadingChange }: CurationImageSectionProps) { + return ( + + + + ); +} diff --git a/src/pages/curation/tasting-event/components/TastingEventImageUploadField.tsx b/src/pages/curation/components/CurationImageUploadField.tsx similarity index 97% rename from src/pages/curation/tasting-event/components/TastingEventImageUploadField.tsx rename to src/pages/curation/components/CurationImageUploadField.tsx index d94f473..bff80a5 100644 --- a/src/pages/curation/tasting-event/components/TastingEventImageUploadField.tsx +++ b/src/pages/curation/components/CurationImageUploadField.tsx @@ -5,21 +5,23 @@ import { GripVertical, Upload, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { S3UploadPath, useImageUpload } from '@/hooks/useImageUpload'; -import type { TastingEventCreateFormState } from '../tasting-event.schema'; - const MAX_IMAGE_COUNT = 3; const IMAGE_UPLOAD_ACCEPT = 'image/png,image/jpeg,image/webp'; const SUPPORTED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); const EMPTY_IMAGE_URLS: string[] = []; -interface TastingEventImageUploadFieldProps { +interface CurationImageFormState { + imageUrls: string[]; +} + +interface CurationImageUploadFieldProps { onUploadingChange: (isUploading: boolean) => void; } -export function TastingEventImageUploadField({ +export function CurationImageUploadField({ onUploadingChange, -}: TastingEventImageUploadFieldProps) { - const form = useFormContext(); +}: CurationImageUploadFieldProps) { + const form = useFormContext(); const imageUploadInputRef = useRef(null); const imageUrlsRef = useRef([]); const localImageUrlsRef = useRef>(new Set()); diff --git a/src/pages/curation/components/CurationWhiskyCardListField.tsx b/src/pages/curation/components/CurationWhiskyCardListField.tsx index c56fcdf..661d0cd 100644 --- a/src/pages/curation/components/CurationWhiskyCardListField.tsx +++ b/src/pages/curation/components/CurationWhiskyCardListField.tsx @@ -108,10 +108,13 @@ export function CurationWhiskyCardListField({ .map((item) => item.alcohol.alcoholId) .filter((id): id is number => typeof id === 'number'); - const isMaxReached = watchedAlcohols.length >= fieldModel.maxItems; + const isMaxLimited = typeof fieldModel.maxItems === 'number'; + const isMaxReached = isMaxLimited && watchedAlcohols.length >= fieldModel.maxItems!; const isAddingBottleNoteWhisky = pendingAlcoholId !== null; const isAddDisabled = isMaxReached || isAddingBottleNoteWhisky; - const limitDescription = `${fieldModel.minItems}-${fieldModel.maxItems}개까지 등록할 수 있습니다.`; + const limitDescription = isMaxLimited + ? `${fieldModel.minItems}-${fieldModel.maxItems}개까지 등록할 수 있습니다.` + : `${fieldModel.minItems}개 이상 등록할 수 있습니다.`; const manualCategoryOptions = getCategoryOptions( categoryReferenceData ?? EMPTY_CATEGORY_REFERENCE_MAP ); diff --git a/src/pages/curation/curation-whisky-card-list.types.ts b/src/pages/curation/curation-whisky-card-list.types.ts index 5c11521..6a51eb8 100644 --- a/src/pages/curation/curation-whisky-card-list.types.ts +++ b/src/pages/curation/curation-whisky-card-list.types.ts @@ -37,7 +37,7 @@ export interface CurationWhiskyCardListFieldModel { label: string; required: boolean; minItems: number; - maxItems: number; + maxItems?: number; selectedTags: { label: string; required: boolean; diff --git a/src/pages/curation/tasting-event/CurationTastingEventCreate.tsx b/src/pages/curation/tasting-event/CurationTastingEventCreate.tsx deleted file mode 100644 index 2390ea9..0000000 --- a/src/pages/curation/tasting-event/CurationTastingEventCreate.tsx +++ /dev/null @@ -1,328 +0,0 @@ -import { useState } from 'react'; -import { FormProvider, useForm, type Resolver } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { AlertCircle, Loader2, Save } from 'lucide-react'; -import { useNavigate } from 'react-router'; - -import { DetailPageHeader } from '@/components/common/DetailPageHeader'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; -import { useCurationCreate, useCurationUpdate } from '@/hooks/useCurations'; -import { useToast } from '@/hooks/useToast'; -import { useAuthStore } from '@/stores/auth'; -import { - CurationSpecCode, - type CurationV2CreateRequest, - type CurationV2Detail, - type CurationV2Spec, - type CurationV2UpdateRequest, -} from '@/types/api'; - -import { useCurationSpecFormModel } from '../useCurationSpecFormModel'; -import { TastingEventBasicInfoSection } from './components/TastingEventBasicInfoSection'; -import { TastingEventPreviewPanel } from './components/TastingEventPreviewPanel'; -import { - createTastingEventFormModel, - type TastingEventFormModel, -} from './tasting-event.form-model'; -import { - buildTastingEventPayload, - createTastingEventFormStateFromCuration, -} from './tasting-event.mapper'; -import { - createCurationTastingEventFormSchema, - createDefaultTastingEventCreateFormState, - type TastingEventCreateFormState, -} from './tasting-event.schema'; -import { CurationFormSection } from '../components/CurationFormSection'; - -interface BlockingStateProps { - title: string; - description: string; - onRetry?: () => void; - onBack: () => void; -} - -type CreatePageViewState = - | 'specs-loading' - | 'specs-error' - | 'spec-missing' - | 'detail-loading' - | 'detail-error' - | 'form'; - -export function CurationTastingEventCreatePage() { - const navigate = useNavigate(); - const { specsQuery, targetSpec, specDetailQuery, specDetail, formModel } = - useCurationSpecFormModel({ - specCode: CurationSpecCode.WHISKY_TASTING_EVENT, - createFormModel: createTastingEventFormModel, - }); - - const handleBack = () => { - navigate('/dashboard/curations'); - }; - - let viewState: CreatePageViewState = 'form'; - if (specsQuery.isLoading) { - viewState = 'specs-loading'; - } else if (specsQuery.isError) { - viewState = 'specs-error'; - } else if (!targetSpec) { - viewState = 'spec-missing'; - } else if (specDetailQuery.isLoading) { - viewState = 'detail-loading'; - } else if (specDetailQuery.isError) { - viewState = 'detail-error'; - } else if (!specDetail || !formModel) { - viewState = 'detail-loading'; - } - - const renderContent = () => { - switch (viewState) { - case 'specs-loading': - return ; - case 'specs-error': - return ( - void specsQuery.refetch()} - onBack={handleBack} - /> - ); - case 'spec-missing': - return ( - - ); - case 'detail-loading': - return ; - case 'detail-error': - return ( - void specDetailQuery.refetch()} - onBack={handleBack} - /> - ); - case 'form': - if (!specDetail || !formModel) { - return ; - } - - return ( - - ); - } - }; - - if (viewState === 'form') { - return renderContent(); - } - - return ( -

- - 목록 - - } - /> - - {renderContent()} -
- ); -} - -export function CurationTastingEventEditPage({ curation }: { curation: CurationV2Detail }) { - const navigate = useNavigate(); - const formModel = createTastingEventFormModel(curation.spec); - - return ( - navigate('/dashboard/curations')} - /> - ); -} - -interface TastingEventReadyFormProps { - specDetail: CurationV2Spec; - formModel: TastingEventFormModel; - curation?: CurationV2Detail; - onBack: () => void; -} - -function TastingEventReadyForm({ - specDetail, - formModel, - curation, - onBack, -}: TastingEventReadyFormProps) { - const navigate = useNavigate(); - const isRootAdmin = useAuthStore((state) => state.hasRole('ROOT_ADMIN')); - const { showToast } = useToast(); - const [isCurationImageUploading, setIsCurationImageUploading] = useState(false); - const [isWhiskyImageUploading, setIsWhiskyImageUploading] = useState(false); - const isEditMode = Boolean(curation); - const isImageUploading = isCurationImageUploading || isWhiskyImageUploading; - const formSchema = createCurationTastingEventFormSchema(formModel, { - mode: isEditMode ? 'edit' : 'create', - }); - const defaultValues = curation - ? createTastingEventFormStateFromCuration(curation, formModel) - : createDefaultTastingEventCreateFormState(formModel); - - const form = useForm({ - resolver: zodResolver(formSchema as never) as unknown as Resolver, - defaultValues, - mode: 'onSubmit', - }); - - const createMutation = useCurationCreate({ - successMessage: '시음회 큐레이션이 등록되었습니다.', - onSuccess: () => { - navigate('/dashboard/curations'); - }, - }); - const updateMutation = useCurationUpdate({ - onSuccess: () => { - navigate('/dashboard/curations'); - }, - }); - const isSubmitting = createMutation.isPending || updateMutation.isPending; - - const handleSubmit = form.handleSubmit( - (values) => { - const request: CurationV2CreateRequest | CurationV2UpdateRequest = { - specId: specDetail.id, - name: values.name.trim(), - description: toNullableTrimmedString(values.description), - imageUrls: values.imageUrls, - exposureStartDate: toNullableTrimmedString(values.exposureStartDate), - exposureEndDate: toNullableTrimmedString(values.exposureEndDate), - displayOrder: values.displayOrder, - isActive: values.isActive, - payload: buildTastingEventPayload(values, formModel), - }; - - if (curation) { - updateMutation.mutate({ - curationId: curation.id, - data: request, - }); - return; - } - - createMutation.mutate(request); - }, - () => { - showToast({ type: 'warning', message: '입력 정보를 확인해주세요.' }); - } - ); - - return ( -
- - - - - } - /> - - -
-
- - {formModel.sections.map((section) => ( - - ))} -
- - -
-
-
- ); -} - -function LoadingState({ message }: { message: string }) { - return ( - - - - - ); -} - -function BlockingState({ title, description, onRetry, onBack }: BlockingStateProps) { - return ( - - -
-
-
- {onRetry && ( - - )} - -
-
-
- ); -} - -function toNullableTrimmedString(value: string): string | null { - const normalized = value.trim(); - return normalized || null; -} diff --git a/src/pages/curation/tasting-event/components/TastingEventImageSection.tsx b/src/pages/curation/tasting-event/components/TastingEventImageSection.tsx deleted file mode 100644 index 053020b..0000000 --- a/src/pages/curation/tasting-event/components/TastingEventImageSection.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { CurationSectionCard } from '../../components/CurationSectionCard'; -import { TastingEventImageUploadField } from './TastingEventImageUploadField'; - -interface TastingEventImageSectionProps { - onUploadingChange: (isUploading: boolean) => void; -} - -export function TastingEventImageSection({ onUploadingChange }: TastingEventImageSectionProps) { - return ( - - - - ); -} diff --git a/src/pages/curation/whisky-card/CurationWhiskyCardCreate.tsx b/src/pages/curation/whisky-card/CurationWhiskyCardCreate.tsx deleted file mode 100644 index 9d13d57..0000000 --- a/src/pages/curation/whisky-card/CurationWhiskyCardCreate.tsx +++ /dev/null @@ -1,355 +0,0 @@ -import { useState } from 'react'; -import { FormProvider, useForm, type Resolver } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { AlertCircle, Loader2, Save } from 'lucide-react'; -import { useNavigate } from 'react-router'; - -import { DetailPageHeader } from '@/components/common/DetailPageHeader'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; -import { useCurationCreate, useCurationUpdate } from '@/hooks/useCurations'; -import { useToast } from '@/hooks/useToast'; -import { useAuthStore } from '@/stores/auth'; -import { - CurationSpecCode, - type CurationV2CreateRequest, - type CurationV2Detail, - type CurationV2Spec, - type CurationV2SpecCode, - type CurationV2UpdateRequest, -} from '@/types/api'; - -import { CurationFormSection } from '../components/CurationFormSection'; -import { TastingEventBasicInfoSection } from '../tasting-event/components/TastingEventBasicInfoSection'; -import { TastingEventImageSection } from '../tasting-event/components/TastingEventImageSection'; -import { useCurationSpecFormModel } from '../useCurationSpecFormModel'; -import { WhiskyCardPreviewPanel } from './components/WhiskyCardPreviewPanel'; -import { WhiskyCardPairingFields } from './components/WhiskyCardPairingFields'; -import { - buildWhiskyCardCurationPayload, - createWhiskyCardFormStateFromCuration, -} from './whisky-card-curation.mapper'; -import { - createCurationWhiskyCardFormSchema, - createDefaultPairings, - createDefaultWhiskyCardCurationFormState, - createEmptyWhiskyCardCurationItem, - createWhiskyCardCurationFormModel, - type WhiskyCardCurationFormModel, - type WhiskyCardCurationFormState, -} from './whisky-card-curation.schema'; - -interface BlockingStateProps { - title: string; - description: string; - onRetry?: () => void; - onBack: () => void; -} - -type CreatePageViewState = - | 'specs-loading' - | 'specs-error' - | 'spec-missing' - | 'detail-loading' - | 'detail-error' - | 'form'; - -export function CurationRecommendedWhiskyCreatePage() { - return ; -} - -export function CurationWhiskyPairingCreatePage() { - return ; -} - -export function CurationWhiskyCardEditPage({ curation }: { curation: CurationV2Detail }) { - const navigate = useNavigate(); - const formModel = createWhiskyCardCurationFormModel(curation.spec); - - return ( - navigate('/dashboard/curations')} - /> - ); -} - -function CurationWhiskyCardCreatePage({ specCode }: { specCode: CurationV2SpecCode }) { - const navigate = useNavigate(); - const fallbackTitle = - specCode === CurationSpecCode.WHISKY_PAIRING ? '위스키 페어링 작성' : '추천 위스키 작성'; - const { specsQuery, targetSpec, specDetailQuery, specDetail, formModel } = - useCurationSpecFormModel({ - specCode, - createFormModel: createWhiskyCardCurationFormModel, - }); - - const handleBack = () => { - navigate('/dashboard/curations'); - }; - - let viewState: CreatePageViewState = 'form'; - if (specsQuery.isLoading) { - viewState = 'specs-loading'; - } else if (specsQuery.isError) { - viewState = 'specs-error'; - } else if (!targetSpec) { - viewState = 'spec-missing'; - } else if (specDetailQuery.isLoading) { - viewState = 'detail-loading'; - } else if (specDetailQuery.isError) { - viewState = 'detail-error'; - } else if (!specDetail || !formModel) { - viewState = 'detail-loading'; - } - - const renderContent = () => { - switch (viewState) { - case 'specs-loading': - return ; - case 'specs-error': - return ( - void specsQuery.refetch()} - onBack={handleBack} - /> - ); - case 'spec-missing': - return ( - - ); - case 'detail-loading': - return ; - case 'detail-error': - return ( - void specDetailQuery.refetch()} - onBack={handleBack} - /> - ); - case 'form': - if (!specDetail || !formModel) { - return ; - } - - return ( - - ); - } - }; - - if (viewState === 'form') { - return renderContent(); - } - - return ( -
- - 목록 - - } - /> - - {renderContent()} -
- ); -} - -interface WhiskyCardCurationFormProps { - specDetail: CurationV2Spec; - formModel: WhiskyCardCurationFormModel; - curation?: CurationV2Detail; - onBack: () => void; -} - -function WhiskyCardCurationForm({ - specDetail, - formModel, - curation, - onBack, -}: WhiskyCardCurationFormProps) { - const navigate = useNavigate(); - const isRootAdmin = useAuthStore((state) => state.hasRole('ROOT_ADMIN')); - const { showToast } = useToast(); - const [isCurationImageUploading, setIsCurationImageUploading] = useState(false); - const [isWhiskyImageUploading, setIsWhiskyImageUploading] = useState(false); - const isEditMode = Boolean(curation); - const isImageUploading = isCurationImageUploading || isWhiskyImageUploading; - const formSchema = createCurationWhiskyCardFormSchema(formModel, { - mode: isEditMode ? 'edit' : 'create', - }); - const defaultValues = curation - ? createWhiskyCardFormStateFromCuration(curation, formModel) - : createDefaultWhiskyCardCurationFormState(formModel); - - const form = useForm({ - resolver: zodResolver(formSchema as never) as unknown as Resolver, - defaultValues, - mode: 'onSubmit', - }); - - const createMutation = useCurationCreate({ - successMessage: `${specDetail.name} 큐레이션이 등록되었습니다.`, - onSuccess: () => { - navigate('/dashboard/curations'); - }, - }); - const updateMutation = useCurationUpdate({ - onSuccess: () => { - navigate('/dashboard/curations'); - }, - }); - const isSubmitting = createMutation.isPending || updateMutation.isPending; - - const handleSubmit = form.handleSubmit( - (values) => { - const request: CurationV2CreateRequest | CurationV2UpdateRequest = { - specId: specDetail.id, - name: values.name.trim(), - description: toNullableTrimmedString(values.description), - imageUrls: values.imageUrls, - exposureStartDate: toNullableTrimmedString(values.exposureStartDate), - exposureEndDate: toNullableTrimmedString(values.exposureEndDate), - displayOrder: values.displayOrder, - isActive: values.isActive, - payload: buildWhiskyCardCurationPayload(values, formModel), - }; - - if (curation) { - updateMutation.mutate({ - curationId: curation.id, - data: request, - }); - return; - } - - createMutation.mutate(request); - }, - () => { - showToast({ type: 'warning', message: '입력 정보를 확인해주세요.' }); - } - ); - - return ( -
- - - - - } - /> - - -
-
- - - {formModel.sections.map((section) => ( - createEmptyWhiskyCardCurationItem(formModel), - transformItem: (item) => ({ - ...item, - pairings: createDefaultPairings(formModel), - }), - showCommentField: !formModel.pairings, - renderItemExtra: formModel.pairings - ? ({ index }) => ( - - ) - : undefined, - }} - /> - ))} -
- - -
-
-
- ); -} - -function LoadingState({ message }: { message: string }) { - return ( - - - - - ); -} - -function BlockingState({ title, description, onRetry, onBack }: BlockingStateProps) { - return ( - - -
-
-
- {onRetry && ( - - )} - -
-
-
- ); -} - -function toNullableTrimmedString(value: string): string | null { - const normalized = value.trim(); - return normalized || null; -} diff --git a/src/pages/curation/whisky-curation/CurationRecommendedWhiskyCreate.tsx b/src/pages/curation/whisky-curation/CurationRecommendedWhiskyCreate.tsx new file mode 100644 index 0000000..cd4b6ff --- /dev/null +++ b/src/pages/curation/whisky-curation/CurationRecommendedWhiskyCreate.tsx @@ -0,0 +1,24 @@ +import { CurationSpecCode } from '@/types/api'; + +import { WhiskyCurationCreateGate } from './WhiskyCurationCreateGate'; +import { WhiskyCurationForm } from './WhiskyCurationForm'; + +export function CurationRecommendedWhiskyCreatePage() { + return ( + + {({ specDetail, formModel, onBack }) => ( + + )} + + ); +} diff --git a/src/pages/curation/whisky-curation/CurationRecommendedWhiskyEdit.tsx b/src/pages/curation/whisky-curation/CurationRecommendedWhiskyEdit.tsx new file mode 100644 index 0000000..6e36cda --- /dev/null +++ b/src/pages/curation/whisky-curation/CurationRecommendedWhiskyEdit.tsx @@ -0,0 +1,23 @@ +import { useNavigate } from 'react-router'; + +import type { CurationV2Detail } from '@/types/api'; + +import { WhiskyCurationForm } from './WhiskyCurationForm'; +import { createWhiskyCurationFormModel } from './whisky-curation.schema'; + +export function CurationRecommendedWhiskyEditPage({ curation }: { curation: CurationV2Detail }) { + const navigate = useNavigate(); + const formModel = createWhiskyCurationFormModel(curation.spec); + + return ( + navigate('/dashboard/curations')} + presentation={{ + showCommentField: true, + }} + /> + ); +} diff --git a/src/pages/curation/whisky-curation/CurationWhiskyPairingCreate.tsx b/src/pages/curation/whisky-curation/CurationWhiskyPairingCreate.tsx new file mode 100644 index 0000000..e55c4a3 --- /dev/null +++ b/src/pages/curation/whisky-curation/CurationWhiskyPairingCreate.tsx @@ -0,0 +1,32 @@ +import { CurationSpecCode } from '@/types/api'; + +import { WhiskyPairingFields } from './components/WhiskyPairingFields'; +import { WhiskyCurationCreateGate } from './WhiskyCurationCreateGate'; +import { WhiskyCurationForm } from './WhiskyCurationForm'; + +export function CurationWhiskyPairingCreatePage() { + return ( + + {({ specDetail, formModel, onBack }) => ( + ( + + ), + }} + /> + )} + + ); +} diff --git a/src/pages/curation/whisky-curation/CurationWhiskyPairingEdit.tsx b/src/pages/curation/whisky-curation/CurationWhiskyPairingEdit.tsx new file mode 100644 index 0000000..9f2ce60 --- /dev/null +++ b/src/pages/curation/whisky-curation/CurationWhiskyPairingEdit.tsx @@ -0,0 +1,31 @@ +import { useNavigate } from 'react-router'; + +import type { CurationV2Detail } from '@/types/api'; + +import { WhiskyPairingFields } from './components/WhiskyPairingFields'; +import { WhiskyCurationForm } from './WhiskyCurationForm'; +import { createWhiskyCurationFormModel } from './whisky-curation.schema'; + +export function CurationWhiskyPairingEditPage({ curation }: { curation: CurationV2Detail }) { + const navigate = useNavigate(); + const formModel = createWhiskyCurationFormModel(curation.spec); + + return ( + navigate('/dashboard/curations')} + presentation={{ + showCommentField: false, + renderItemExtra: ({ index, formModel, onUploadingChange }) => ( + + ), + }} + /> + ); +} diff --git a/src/pages/curation/whisky-curation/WhiskyCurationCreateGate.tsx b/src/pages/curation/whisky-curation/WhiskyCurationCreateGate.tsx new file mode 100644 index 0000000..164f327 --- /dev/null +++ b/src/pages/curation/whisky-curation/WhiskyCurationCreateGate.tsx @@ -0,0 +1,172 @@ +import type { ReactNode } from 'react'; +import { AlertCircle, Loader2 } from 'lucide-react'; +import { useNavigate } from 'react-router'; + +import { DetailPageHeader } from '@/components/common/DetailPageHeader'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import type { CurationV2Spec, CurationV2SpecCode } from '@/types/api'; + +import { useCurationSpecFormModel } from '../useCurationSpecFormModel'; +import { + createWhiskyCurationFormModel, + type WhiskyCurationFormModel, +} from './whisky-curation.schema'; + +type CreatePageViewState = + | 'specs-loading' + | 'specs-error' + | 'spec-missing' + | 'detail-loading' + | 'detail-error' + | 'form'; + +interface WhiskyCurationCreateGateRenderArgs { + specDetail: CurationV2Spec; + formModel: WhiskyCurationFormModel; + onBack: () => void; +} + +interface WhiskyCurationCreateGateProps { + specCode: CurationV2SpecCode; + fallbackTitle: string; + children: (args: WhiskyCurationCreateGateRenderArgs) => ReactNode; +} + +export function WhiskyCurationCreateGate({ + specCode, + fallbackTitle, + children, +}: WhiskyCurationCreateGateProps) { + const navigate = useNavigate(); + const { specsQuery, targetSpec, specDetailQuery, specDetail, formModel } = + useCurationSpecFormModel({ + specCode, + createFormModel: createWhiskyCurationFormModel, + }); + + const handleBack = () => { + navigate('/dashboard/curations'); + }; + + let viewState: CreatePageViewState = 'form'; + if (specsQuery.isLoading) { + viewState = 'specs-loading'; + } else if (specsQuery.isError) { + viewState = 'specs-error'; + } else if (!targetSpec) { + viewState = 'spec-missing'; + } else if (specDetailQuery.isLoading) { + viewState = 'detail-loading'; + } else if (specDetailQuery.isError) { + viewState = 'detail-error'; + } else if (!specDetail || !formModel) { + viewState = 'detail-loading'; + } + + const renderContent = () => { + switch (viewState) { + case 'specs-loading': + return ; + case 'specs-error': + return ( + void specsQuery.refetch()} + onBack={handleBack} + /> + ); + case 'spec-missing': + return ( + + ); + case 'detail-loading': + return ; + case 'detail-error': + return ( + void specDetailQuery.refetch()} + onBack={handleBack} + /> + ); + case 'form': + if (!specDetail || !formModel) { + return ; + } + + return children({ specDetail, formModel, onBack: handleBack }); + } + }; + + if (viewState === 'form') { + return renderContent(); + } + + return ( +
+ + 목록 + + } + /> + + {renderContent()} +
+ ); +} + +function LoadingState({ message }: { message: string }) { + return ( + + + + + ); +} + +interface BlockingStateProps { + title: string; + description: string; + onRetry?: () => void; + onBack: () => void; +} + +function BlockingState({ title, description, onRetry, onBack }: BlockingStateProps) { + return ( + + +
+
+
+ {onRetry && ( + + )} + +
+
+
+ ); +} diff --git a/src/pages/curation/whisky-curation/WhiskyCurationForm.tsx b/src/pages/curation/whisky-curation/WhiskyCurationForm.tsx new file mode 100644 index 0000000..4b3d142 --- /dev/null +++ b/src/pages/curation/whisky-curation/WhiskyCurationForm.tsx @@ -0,0 +1,201 @@ +import { useState, type ReactNode } from 'react'; +import { FormProvider, useForm, type Resolver } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Save } from 'lucide-react'; +import { useNavigate } from 'react-router'; + +import { DetailPageHeader } from '@/components/common/DetailPageHeader'; +import { Button } from '@/components/ui/button'; +import { useCurationCreate, useCurationUpdate } from '@/hooks/useCurations'; +import { useToast } from '@/hooks/useToast'; +import { useAuthStore } from '@/stores/auth'; +import { + type CurationV2CreateRequest, + type CurationV2Detail, + type CurationV2Spec, + type CurationV2UpdateRequest, +} from '@/types/api'; + +import { CurationBasicInfoSection } from '../components/CurationBasicInfoSection'; +import { CurationFormSection } from '../components/CurationFormSection'; +import { CurationImageSection } from '../components/CurationImageSection'; +import type { CurationWhiskyCardValue } from '../curation-whisky-card-list.types'; +import { WhiskyCurationPreviewPanel } from './components/WhiskyCurationPreviewPanel'; +import { + buildWhiskyCurationPayload, + createWhiskyCurationFormStateFromCuration, +} from './whisky-curation.mapper'; +import { + createCurationWhiskyFormSchema, + createDefaultPairings, + createDefaultWhiskyCurationFormState, + createEmptyWhiskyCurationItem, + type WhiskyCurationFormModel, + type WhiskyCurationFormState, +} from './whisky-curation.schema'; + +export interface WhiskyCurationItemExtraRenderContext { + index: number; + item: CurationWhiskyCardValue | undefined; + formModel: WhiskyCurationFormModel; + onUploadingChange: (isUploading: boolean) => void; +} + +export interface WhiskyCurationPresentation { + showCommentField: boolean; + renderItemExtra?: (context: WhiskyCurationItemExtraRenderContext) => ReactNode; +} + +interface WhiskyCurationFormProps { + specDetail: CurationV2Spec; + formModel: WhiskyCurationFormModel; + presentation: WhiskyCurationPresentation; + curation?: CurationV2Detail; + onBack: () => void; +} + +export function WhiskyCurationForm({ + specDetail, + formModel, + presentation, + curation, + onBack, +}: WhiskyCurationFormProps) { + const navigate = useNavigate(); + const isRootAdmin = useAuthStore((state) => state.hasRole('ROOT_ADMIN')); + const { showToast } = useToast(); + const [isCurationImageUploading, setIsCurationImageUploading] = useState(false); + const [isWhiskyImageUploading, setIsWhiskyImageUploading] = useState(false); + const isEditMode = Boolean(curation); + const isImageUploading = isCurationImageUploading || isWhiskyImageUploading; + const formSchema = createCurationWhiskyFormSchema(formModel, { + mode: isEditMode ? 'edit' : 'create', + }); + const defaultValues = curation + ? createWhiskyCurationFormStateFromCuration(curation, formModel) + : createDefaultWhiskyCurationFormState(formModel); + + const form = useForm({ + resolver: zodResolver(formSchema as never) as unknown as Resolver, + defaultValues, + mode: 'onSubmit', + }); + + const createMutation = useCurationCreate({ + successMessage: `${specDetail.name} 큐레이션이 등록되었습니다.`, + onSuccess: () => { + navigate('/dashboard/curations'); + }, + }); + const updateMutation = useCurationUpdate({ + onSuccess: () => { + navigate('/dashboard/curations'); + }, + }); + const isSubmitting = createMutation.isPending || updateMutation.isPending; + + const handleSubmit = form.handleSubmit( + (values) => { + const request: CurationV2CreateRequest | CurationV2UpdateRequest = { + specId: specDetail.id, + name: values.name.trim(), + description: toNullableTrimmedString(values.description), + imageUrls: values.imageUrls, + exposureStartDate: toNullableTrimmedString(values.exposureStartDate), + exposureEndDate: toNullableTrimmedString(values.exposureEndDate), + displayOrder: values.displayOrder, + isActive: values.isActive, + payload: buildWhiskyCurationPayload(values, formModel), + }; + + if (curation) { + updateMutation.mutate({ + curationId: curation.id, + data: request, + }); + return; + } + + createMutation.mutate(request); + }, + () => { + showToast({ type: 'warning', message: '입력 정보를 확인해주세요.' }); + } + ); + + return ( +
+ + + + + } + /> + + +
+
+ + + {formModel.sections.map((section) => ( + createEmptyWhiskyCurationItem(formModel), + transformItem: (item) => ({ + ...item, + pairings: createDefaultPairings(formModel), + }), + showCommentField: presentation.showCommentField, + renderItemExtra: presentation.renderItemExtra + ? ({ index, item }) => + presentation.renderItemExtra?.({ + index, + item, + formModel, + onUploadingChange: setIsWhiskyImageUploading, + }) + : undefined, + }} + /> + ))} +
+ + +
+
+
+ ); +} + +function toNullableTrimmedString(value: string): string | null { + const normalized = value.trim(); + return normalized || null; +} diff --git a/src/pages/curation/whisky-card/__tests__/CurationWhiskyCardCreate.test.tsx b/src/pages/curation/whisky-curation/__tests__/WhiskyCurationPages.test.tsx similarity index 79% rename from src/pages/curation/whisky-card/__tests__/CurationWhiskyCardCreate.test.tsx rename to src/pages/curation/whisky-curation/__tests__/WhiskyCurationPages.test.tsx index 06cb93e..73c22af 100644 --- a/src/pages/curation/whisky-card/__tests__/CurationWhiskyCardCreate.test.tsx +++ b/src/pages/curation/whisky-curation/__tests__/WhiskyCurationPages.test.tsx @@ -14,11 +14,9 @@ import type { CurationV2UpdateRequest, } from '@/types/api'; -import { - CurationRecommendedWhiskyCreatePage, - CurationWhiskyCardEditPage, - CurationWhiskyPairingCreatePage, -} from '../CurationWhiskyCardCreate'; +import { CurationRecommendedWhiskyCreatePage } from '../CurationRecommendedWhiskyCreate'; +import { CurationRecommendedWhiskyEditPage } from '../CurationRecommendedWhiskyEdit'; +import { CurationWhiskyPairingCreatePage } from '../CurationWhiskyPairingCreate'; const SPEC_BASE = '/admin/api/v2/curation-specs'; const CURATION_BASE = '/admin/api/v2/curations'; @@ -192,8 +190,8 @@ function fillBasicInfo() { fireEvent.change(screen.getByLabelText('설명'), { target: { value: '앱 홈에 노출할 추천 위스키' }, }); - fireEvent.change(screen.getByLabelText('광고노출 시작일'), { target: { value: '2026-06-01' } }); - fireEvent.change(screen.getByLabelText('광고노출 종료일'), { target: { value: '2026-06-30' } }); + fireEvent.change(screen.getByLabelText('노출 시작일'), { target: { value: '2026-06-01' } }); + fireEvent.change(screen.getByLabelText('노출 종료일'), { target: { value: '2026-06-30' } }); } async function typeTastingTagSearch( @@ -207,7 +205,7 @@ async function typeTastingTagSearch( return searchInput; } -describe('CurationWhiskyCardCreatePage', () => { +describe('whisky curation pages', () => { beforeEach(() => { setCurrentUserRoles([]); }); @@ -219,6 +217,10 @@ describe('CurationWhiskyCardCreatePage', () => { render(); await screen.findByLabelText('큐레이션명'); + expect(screen.getByLabelText('노출 시작일')).toBeInTheDocument(); + expect(screen.getByLabelText('노출 종료일')).toBeInTheDocument(); + expect(screen.queryByLabelText('광고노출 시작일')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('광고노출 종료일')).not.toBeInTheDocument(); expect(screen.getByLabelText('앱 미리보기 프레임')).toBeInTheDocument(); expect(screen.getByText('큐레이션 위스키')).toBeInTheDocument(); @@ -235,6 +237,37 @@ describe('CurationWhiskyCardCreatePage', () => { expect(screen.getByLabelText('1번 수동 위스키 한글명')).toBeInTheDocument(); }); + it('추천 위스키는 노출 종료일이 시작일보다 빠르면 저장 전 validation을 표시한다', async () => { + const user = userEvent.setup(); + let capturedBody: CurationV2CreateRequest | null = null; + mockSpecSuccess(recommendedWhiskySpec); + server.use( + http.post(CURATION_BASE, async ({ request }) => { + capturedBody = (await request.json()) as CurationV2CreateRequest; + return HttpResponse.json(wrapApiResponse({ targetId: 99 })); + }) + ); + + render(); + + await screen.findByLabelText('큐레이션명'); + fireEvent.change(screen.getByLabelText('큐레이션명'), { + target: { value: '이번 주 추천 위스키' }, + }); + fireEvent.change(screen.getByLabelText('설명'), { + target: { value: '앱 홈에 노출할 추천 위스키' }, + }); + fireEvent.change(screen.getByLabelText('노출 시작일'), { target: { value: '2099-06-30' } }); + fireEvent.change(screen.getByLabelText('노출 종료일'), { target: { value: '2099-06-01' } }); + + await user.click(screen.getByRole('button', { name: /저장/ })); + + expect( + await screen.findByText('노출 종료일은 노출 시작일보다 빠를 수 없습니다.') + ).toBeInTheDocument(); + expect(capturedBody).toBeNull(); + }); + it('추천 위스키는 DB 태그를 기본값으로 넣고 수정한 태그와 코멘트를 생성 payload로 전송한다', async () => { const user = userEvent.setup(); let capturedBody: CurationV2CreateRequest | null = null; @@ -534,17 +567,26 @@ describe('CurationWhiskyCardCreatePage', () => { }) ); - render(); + render(); expect(await screen.findByRole('heading', { name: '추천 위스키 수정' })).toBeInTheDocument(); expect(screen.getByLabelText('큐레이션명')).toHaveValue('기존 추천 위스키'); - expect(screen.getByLabelText('광고노출 시작일')).toBeDisabled(); - expect(screen.getByLabelText('광고노출 종료일')).not.toBeDisabled(); - expect(screen.getByText('광고노출 시작일은 등록 후 변경할 수 없습니다.')).toBeInTheDocument(); + expect(screen.getByLabelText('노출 시작일')).not.toBeDisabled(); + expect( + screen.getAllByText((_, element) => element?.textContent === '노출 시작일*').length + ).toBeGreaterThan(0); + expect(screen.getByLabelText('노출 종료일')).not.toBeDisabled(); + expect( + screen.getAllByText((_, element) => element?.textContent === '노출 종료일*').length + ).toBeGreaterThan(0); + expect( + screen.getByText('기존 노출 시작일이 없어 이번 수정에서만 입력할 수 있습니다.') + ).toBeInTheDocument(); expect(screen.getByLabelText('1번 수동 위스키 한글명')).toHaveValue('수동 추천 위스키'); expect(screen.getAllByText('스모키').length).toBeGreaterThan(0); expect(screen.getByLabelText('수동 추천 위스키 기대평')).toHaveValue('기존 코멘트'); + fireEvent.change(screen.getByLabelText('노출 시작일'), { target: { value: '2026-06-01' } }); await user.clear(screen.getByLabelText('수동 추천 위스키 기대평')); await user.type(screen.getByLabelText('수동 추천 위스키 기대평'), '수정된 추천 코멘트'); await user.click(screen.getByRole('button', { name: /수정/ })); @@ -555,7 +597,7 @@ describe('CurationWhiskyCardCreatePage', () => { name: '기존 추천 위스키', description: null, imageUrls: [], - exposureStartDate: null, + exposureStartDate: '2026-06-01', exposureEndDate: null, displayOrder: 3, isActive: true, @@ -573,4 +615,60 @@ describe('CurationWhiskyCardCreatePage', () => { ], }); }); + + it('추천 위스키는 스펙에 최대 개수 제한이 없으면 10개 초과 기존 payload도 수정 저장한다', async () => { + const user = userEvent.setup(); + let capturedBody: CurationV2UpdateRequest | null = null; + const payload = Array.from({ length: 15 }, (_, index) => ({ + source: 'BOTTLE_NOTE', + alcohol: { + alcoholId: 200 + index, + korName: `추천 위스키 ${index + 1}`, + engName: `Recommended Whisky ${index + 1}`, + selectedTags: ['셰리'], + }, + comment: null, + })); + const curation: CurationV2Detail = { + id: 16, + name: '셰리 추천 큐레이션', + description: null, + coverImageUrl: null, + imageUrls: [], + exposureStartDate: null, + exposureEndDate: null, + displayOrder: 0, + isActive: true, + createdAt: '2026-05-15T00:00:00', + modifiedAt: '2026-05-16T00:00:00', + spec: recommendedWhiskySpec, + payload, + }; + server.use( + http.put(`${CURATION_BASE}/:curationId`, async ({ params, request }) => { + expect(params.curationId).toBe('16'); + capturedBody = (await request.json()) as CurationV2UpdateRequest; + return HttpResponse.json( + wrapApiResponse({ + code: 'CURATION_UPDATED', + message: '큐레이션이 수정되었습니다.', + targetId: 16, + responseAt: '2026-05-31T09:00:00', + }) + ); + }) + ); + + render(); + + expect(await screen.findByRole('heading', { name: '추천 위스키 수정' })).toBeInTheDocument(); + expect(screen.getByText('1개 이상 등록할 수 있습니다.')).toBeInTheDocument(); + expect(screen.getAllByText('추천 위스키 15').length).toBeGreaterThan(0); + + await user.click(screen.getByRole('button', { name: /수정/ })); + + await waitFor(() => expect(capturedBody).not.toBeNull()); + const submittedBody = capturedBody as unknown as CurationV2UpdateRequest; + expect(Array.isArray(submittedBody.payload) ? submittedBody.payload : []).toHaveLength(15); + }); }); diff --git a/src/pages/curation/whisky-card/components/WhiskyCardPreviewPanel.tsx b/src/pages/curation/whisky-curation/components/WhiskyCurationPreviewPanel.tsx similarity index 69% rename from src/pages/curation/whisky-card/components/WhiskyCardPreviewPanel.tsx rename to src/pages/curation/whisky-curation/components/WhiskyCurationPreviewPanel.tsx index 1f9cfb8..3c2165b 100644 --- a/src/pages/curation/whisky-card/components/WhiskyCardPreviewPanel.tsx +++ b/src/pages/curation/whisky-curation/components/WhiskyCurationPreviewPanel.tsx @@ -2,23 +2,23 @@ import { useFormContext, useWatch } from 'react-hook-form'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { CurationPreviewFrame, WhiskyCardCurationPreview } from '../../_preview'; +import { CurationPreviewFrame, WhiskyCurationPreview } from '../../_preview'; import type { - WhiskyCardCurationFormModel, - WhiskyCardCurationFormState, -} from '../whisky-card-curation.schema'; + WhiskyCurationFormModel, + WhiskyCurationFormState, +} from '../whisky-curation.schema'; -interface WhiskyCardPreviewPanelProps { - formModel: WhiskyCardCurationFormModel; +interface WhiskyCurationPreviewPanelProps { + formModel: WhiskyCurationFormModel; } -export function WhiskyCardPreviewPanel({ formModel }: WhiskyCardPreviewPanelProps) { - const form = useFormContext(); +export function WhiskyCurationPreviewPanel({ formModel }: WhiskyCurationPreviewPanelProps) { + const form = useFormContext(); const watchedValues = useWatch({ control: form.control }); const previewValues = { ...form.getValues(), ...watchedValues, - } as WhiskyCardCurationFormState; + } as WhiskyCurationFormState; return ( @@ -28,7 +28,7 @@ export function WhiskyCardPreviewPanel({ formModel }: WhiskyCardPreviewPanelProp - void; } -export function WhiskyCardPairingFields({ +export function WhiskyPairingFields({ index, formModel, onUploadingChange, -}: WhiskyCardPairingFieldsProps) { +}: WhiskyPairingFieldsProps) { const pairingModel = formModel.pairings; - const form = useFormContext(); + const form = useFormContext(); const pairings = useWatch({ control: form.control, name: `alcohols.${index}.pairings` as const }) ?? []; const itemErrors = form.formState.errors.alcohols?.[index]?.pairings; diff --git a/src/pages/curation/whisky-card/whisky-card-curation.mapper.ts b/src/pages/curation/whisky-curation/whisky-curation.mapper.ts similarity index 89% rename from src/pages/curation/whisky-card/whisky-card-curation.mapper.ts rename to src/pages/curation/whisky-curation/whisky-curation.mapper.ts index a6972df..73122d7 100644 --- a/src/pages/curation/whisky-card/whisky-card-curation.mapper.ts +++ b/src/pages/curation/whisky-curation/whisky-curation.mapper.ts @@ -6,12 +6,12 @@ import type { CurationWhiskyStats, } from '../curation-whisky-card-list.types'; import { - createDefaultWhiskyCardCurationFormState, + createDefaultWhiskyCurationFormState, type PairingFoodValue, - type WhiskyCardCurationFormModel, - type WhiskyCardCurationFormState, - type WhiskyCardCurationItemFormState, -} from './whisky-card-curation.schema'; + type WhiskyCurationFormModel, + type WhiskyCurationFormState, + type WhiskyCurationItemFormState, +} from './whisky-curation.schema'; const ALCOHOL_OPTIONAL_TEXT_FIELDS = [ 'engName', @@ -23,16 +23,16 @@ const ALCOHOL_OPTIONAL_TEXT_FIELDS = [ 'korCategory', ] as const; -export function buildWhiskyCardCurationPayload( - values: WhiskyCardCurationFormState, - formModel: WhiskyCardCurationFormModel +export function buildWhiskyCurationPayload( + values: WhiskyCurationFormState, + formModel: WhiskyCurationFormModel ): CurationV2Payload { return values.alcohols.map((item) => mapWhiskyCardPayloadItem(item, formModel)); } function mapWhiskyCardPayloadItem( - item: WhiskyCardCurationItemFormState, - formModel: WhiskyCardCurationFormModel + item: WhiskyCurationItemFormState, + formModel: WhiskyCurationFormModel ): CurationV2PayloadItem { const alcohol = { alcoholId: item.source === 'MANUAL' ? null : item.alcohol.alcoholId, @@ -68,11 +68,11 @@ function mapWhiskyCardPayloadItem( return payload; } -export function createWhiskyCardFormStateFromCuration( +export function createWhiskyCurationFormStateFromCuration( curation: CurationV2Detail, - formModel: WhiskyCardCurationFormModel -): WhiskyCardCurationFormState { - const formState = createDefaultWhiskyCardCurationFormState(formModel); + formModel: WhiskyCurationFormModel +): WhiskyCurationFormState { + const formState = createDefaultWhiskyCurationFormState(formModel); const payloadItems = getPayloadItems(curation.payload); formState.name = curation.name; diff --git a/src/pages/curation/whisky-card/whisky-card-curation.schema.ts b/src/pages/curation/whisky-curation/whisky-curation.schema.ts similarity index 77% rename from src/pages/curation/whisky-card/whisky-card-curation.schema.ts rename to src/pages/curation/whisky-curation/whisky-curation.schema.ts index 92730de..a4b263f 100644 --- a/src/pages/curation/whisky-card/whisky-card-curation.schema.ts +++ b/src/pages/curation/whisky-curation/whisky-curation.schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { compareDateInputValues } from '@/lib/date-validation'; import type { CurationV2Spec, JsonSchemaNode } from '@/types/api'; import type { @@ -27,11 +28,11 @@ export interface PairingFoodValue { itemImageUrl?: string; } -export interface WhiskyCardCurationItemFormState extends CurationWhiskyCardValue { +export interface WhiskyCurationItemFormState extends CurationWhiskyCardValue { pairings: PairingFoodValue[]; } -export interface WhiskyCardCurationFormState extends CurationWhiskyCardListFormValues { +export interface WhiskyCurationFormState extends CurationWhiskyCardListFormValues { name: string; description: string; imageUrls: string[]; @@ -39,10 +40,10 @@ export interface WhiskyCardCurationFormState extends CurationWhiskyCardListFormV exposureEndDate: string; displayOrder: number; isActive: boolean; - alcohols: WhiskyCardCurationItemFormState[]; + alcohols: WhiskyCurationItemFormState[]; } -export interface WhiskyCardCurationFormModel extends CurationFormModel { +export interface WhiskyCurationFormModel extends CurationFormModel { spec: CurationV2Spec; title: string; editTitle: string; @@ -60,7 +61,7 @@ export interface WhiskyCardCurationFormModel extends CurationFormModel { items: { label: string; minItems: number; - maxItems: number; + maxItems?: number; }; pairings?: { label: string; @@ -76,9 +77,9 @@ export interface WhiskyCardCurationFormModel extends CurationFormModel { }; } -export function createWhiskyCardCurationFormModel( +export function createWhiskyCurationFormModel( spec: CurationV2Spec -): WhiskyCardCurationFormModel { +): WhiskyCurationFormModel { const alcoholSchema = getSchemaProperty(spec.requestSpec, 'alcohol'); const commentSchema = spec.requestSpec.properties?.comment; const pairingsSchema = spec.requestSpec.properties?.pairings; @@ -155,7 +156,7 @@ function createWhiskyCardListRequestSpec( alcohols: { type: 'array', minItems: spec.requestSpec.minItems ?? 1, - maxItems: spec.requestSpec.maxItems ?? 10, + maxItems: getOptionalMaxItems(spec.requestSpec), 'x-field-style': 'alcohol-card-list', 'x-display-name': label, items: spec.requestSpec, @@ -194,7 +195,7 @@ function createWhiskyCardAlcoholsFieldModel( label: getSchemaDisplayLabel(alcoholsSchema), required: isSchemaPropertyRequired(requestSpec, key), minItems: alcoholsSchema.minItems ?? 1, - maxItems: alcoholsSchema.maxItems ?? 10, + maxItems: getOptionalMaxItems(alcoholsSchema), selectedTags: { label: getSchemaDisplayLabel(selectedTagsSchema) || '테이스팅 태그', required: isSchemaPropertyRequired(alcoholSchema, 'selectedTags'), @@ -224,10 +225,10 @@ function createWhiskyCardFormSections(fields: CurationFieldModel[]): CurationFor ]; } -export function createCurationWhiskyCardFormSchema( - formModel: WhiskyCardCurationFormModel, +export function createCurationWhiskyFormSchema( + formModel: WhiskyCurationFormModel, options: { mode?: 'create' | 'edit' } = {} -): z.ZodType { +): z.ZodType { const isEditMode = options.mode === 'edit'; const curationItemSchema = z.object({ source: z.enum(['BOTTLE_NOTE', 'MANUAL']), @@ -265,31 +266,36 @@ export function createCurationWhiskyCardFormSchema( pairings: createPairingsSchema(formModel), }); - return z.object({ - name: z.string().min(1, '큐레이션명은 필수입니다.'), - description: isEditMode ? z.string() : z.string().min(1, '설명은 필수입니다.'), - imageUrls: z.array(z.string()).max(3, '이미지는 최대 3개까지 등록할 수 있습니다.'), - exposureStartDate: isEditMode ? z.string() : z.string().min(1, '노출 시작일은 필수입니다.'), - exposureEndDate: isEditMode ? z.string() : z.string().min(1, '노출 종료일은 필수입니다.'), - displayOrder: z - .number() - .int('노출 순서는 정수로 입력해주세요.') - .min(0, '노출 순서는 0 이상이어야 합니다.'), - isActive: z.boolean(), - alcohols: z - .array(curationItemSchema) - .min( - formModel.items.minItems, - `${formModel.whiskyLabel}를 최소 ${formModel.items.minItems}개 이상 추가해주세요.` - ) - .max( - formModel.items.maxItems, - `${formModel.whiskyLabel}는 최대 ${formModel.items.maxItems}개까지 추가할 수 있습니다.` - ), - }) as unknown as z.ZodType; + return z + .object({ + name: z.string().min(1, '큐레이션명은 필수입니다.'), + description: isEditMode ? z.string() : z.string().min(1, '설명은 필수입니다.'), + imageUrls: z.array(z.string()).max(3, '이미지는 최대 3개까지 등록할 수 있습니다.'), + exposureStartDate: isEditMode ? z.string() : z.string().min(1, '노출 시작일은 필수입니다.'), + exposureEndDate: isEditMode ? z.string() : z.string().min(1, '노출 종료일은 필수입니다.'), + displayOrder: z + .number() + .int('노출 순서는 정수로 입력해주세요.') + .min(0, '노출 순서는 0 이상이어야 합니다.'), + isActive: z.boolean(), + alcohols: createWhiskyItemsSchema(curationItemSchema, formModel), + }) + .superRefine((values, context) => { + if ( + values.exposureStartDate && + values.exposureEndDate && + compareDateInputValues(values.exposureEndDate, values.exposureStartDate) < 0 + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['exposureEndDate'], + message: '노출 종료일은 노출 시작일보다 빠를 수 없습니다.', + }); + } + }) as unknown as z.ZodType; } -function createCommentSchema(formModel: WhiskyCardCurationFormModel) { +function createCommentSchema(formModel: WhiskyCurationFormModel) { const commentSchema = z .string() .max( @@ -302,7 +308,26 @@ function createCommentSchema(formModel: WhiskyCardCurationFormModel) { : commentSchema; } -function createPairingsSchema(formModel: WhiskyCardCurationFormModel) { +function createWhiskyItemsSchema( + curationItemSchema: z.ZodTypeAny, + formModel: WhiskyCurationFormModel +) { + const itemsSchema = z.array(curationItemSchema).min( + formModel.items.minItems, + `${formModel.whiskyLabel}를 최소 ${formModel.items.minItems}개 이상 추가해주세요.` + ); + + if (typeof formModel.items.maxItems !== 'number') { + return itemsSchema; + } + + return itemsSchema.max( + formModel.items.maxItems, + `${formModel.whiskyLabel}는 최대 ${formModel.items.maxItems}개까지 추가할 수 있습니다.` + ); +} + +function createPairingsSchema(formModel: WhiskyCurationFormModel) { if (!formModel.pairings) { return z.array(z.never()).optional(); } @@ -337,9 +362,13 @@ function createPairingsSchema(formModel: WhiskyCardCurationFormModel) { ); } -export function createDefaultWhiskyCardCurationFormState( - _formModel: WhiskyCardCurationFormModel -): WhiskyCardCurationFormState { +function getOptionalMaxItems(schema: JsonSchemaNode): number | undefined { + return typeof schema.maxItems === 'number' ? schema.maxItems : undefined; +} + +export function createDefaultWhiskyCurationFormState( + _formModel: WhiskyCurationFormModel +): WhiskyCurationFormState { return { name: '', description: '', @@ -367,9 +396,9 @@ export function createEmptyWhiskyMirror(): CurationWhiskyMirror { }; } -export function createEmptyWhiskyCardCurationItem( - formModel: WhiskyCardCurationFormModel -): WhiskyCardCurationItemFormState { +export function createEmptyWhiskyCurationItem( + formModel: WhiskyCurationFormModel +): WhiskyCurationItemFormState { return { source: 'MANUAL', alcohol: createEmptyWhiskyMirror(), @@ -379,7 +408,7 @@ export function createEmptyWhiskyCardCurationItem( }; } -export function createDefaultPairings(formModel: WhiskyCardCurationFormModel): PairingFoodValue[] { +export function createDefaultPairings(formModel: WhiskyCurationFormModel): PairingFoodValue[] { return formModel.pairings ? Array.from({ length: formModel.pairings.minItems }, () => createEmptyPairingFood()) : []; diff --git a/src/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventCreate.tsx b/src/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventCreate.tsx new file mode 100644 index 0000000..7ad350a --- /dev/null +++ b/src/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventCreate.tsx @@ -0,0 +1,12 @@ +import { WhiskyTastingEventCreateGate } from './WhiskyTastingEventCreateGate'; +import { WhiskyTastingEventForm } from './WhiskyTastingEventForm'; + +export function CurationWhiskyTastingEventCreatePage() { + return ( + + {({ specDetail, formModel, onBack }) => ( + + )} + + ); +} diff --git a/src/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventEdit.tsx b/src/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventEdit.tsx new file mode 100644 index 0000000..ea4638b --- /dev/null +++ b/src/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventEdit.tsx @@ -0,0 +1,20 @@ +import { useNavigate } from 'react-router'; + +import type { CurationV2Detail } from '@/types/api'; + +import { WhiskyTastingEventForm } from './WhiskyTastingEventForm'; +import { createWhiskyTastingEventFormModel } from './whisky-tasting-event.form-model'; + +export function CurationWhiskyTastingEventEditPage({ curation }: { curation: CurationV2Detail }) { + const navigate = useNavigate(); + const formModel = createWhiskyTastingEventFormModel(curation.spec); + + return ( + navigate('/dashboard/curations')} + /> + ); +} diff --git a/src/pages/curation/whisky-tasting-event/WhiskyTastingEventCreateGate.tsx b/src/pages/curation/whisky-tasting-event/WhiskyTastingEventCreateGate.tsx new file mode 100644 index 0000000..bbc2155 --- /dev/null +++ b/src/pages/curation/whisky-tasting-event/WhiskyTastingEventCreateGate.tsx @@ -0,0 +1,166 @@ +import type { ReactNode } from 'react'; +import { AlertCircle, Loader2 } from 'lucide-react'; +import { useNavigate } from 'react-router'; + +import { DetailPageHeader } from '@/components/common/DetailPageHeader'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { CurationSpecCode, type CurationV2Spec } from '@/types/api'; + +import { useCurationSpecFormModel } from '../useCurationSpecFormModel'; +import { + createWhiskyTastingEventFormModel, + type WhiskyTastingEventFormModel, +} from './whisky-tasting-event.form-model'; + +type CreatePageViewState = + | 'specs-loading' + | 'specs-error' + | 'spec-missing' + | 'detail-loading' + | 'detail-error' + | 'form'; + +interface WhiskyTastingEventCreateGateRenderArgs { + specDetail: CurationV2Spec; + formModel: WhiskyTastingEventFormModel; + onBack: () => void; +} + +interface WhiskyTastingEventCreateGateProps { + children: (args: WhiskyTastingEventCreateGateRenderArgs) => ReactNode; +} + +export function WhiskyTastingEventCreateGate({ children }: WhiskyTastingEventCreateGateProps) { + const navigate = useNavigate(); + const { specsQuery, targetSpec, specDetailQuery, specDetail, formModel } = + useCurationSpecFormModel({ + specCode: CurationSpecCode.WHISKY_TASTING_EVENT, + createFormModel: createWhiskyTastingEventFormModel, + }); + + const handleBack = () => { + navigate('/dashboard/curations'); + }; + + let viewState: CreatePageViewState = 'form'; + if (specsQuery.isLoading) { + viewState = 'specs-loading'; + } else if (specsQuery.isError) { + viewState = 'specs-error'; + } else if (!targetSpec) { + viewState = 'spec-missing'; + } else if (specDetailQuery.isLoading) { + viewState = 'detail-loading'; + } else if (specDetailQuery.isError) { + viewState = 'detail-error'; + } else if (!specDetail || !formModel) { + viewState = 'detail-loading'; + } + + const renderContent = () => { + switch (viewState) { + case 'specs-loading': + return ; + case 'specs-error': + return ( + void specsQuery.refetch()} + onBack={handleBack} + /> + ); + case 'spec-missing': + return ( + + ); + case 'detail-loading': + return ; + case 'detail-error': + return ( + void specDetailQuery.refetch()} + onBack={handleBack} + /> + ); + case 'form': + if (!specDetail || !formModel) { + return ; + } + + return children({ specDetail, formModel, onBack: handleBack }); + } + }; + + if (viewState === 'form') { + return renderContent(); + } + + return ( +
+ + 목록 + + } + /> + + {renderContent()} +
+ ); +} + +function LoadingState({ message }: { message: string }) { + return ( + + + + + ); +} + +interface BlockingStateProps { + title: string; + description: string; + onRetry?: () => void; + onBack: () => void; +} + +function BlockingState({ title, description, onRetry, onBack }: BlockingStateProps) { + return ( + + +
+
+
+ {onRetry && ( + + )} + +
+
+
+ ); +} diff --git a/src/pages/curation/whisky-tasting-event/WhiskyTastingEventForm.tsx b/src/pages/curation/whisky-tasting-event/WhiskyTastingEventForm.tsx new file mode 100644 index 0000000..668b12c --- /dev/null +++ b/src/pages/curation/whisky-tasting-event/WhiskyTastingEventForm.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react'; +import { FormProvider, useForm, type Resolver } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Save } from 'lucide-react'; +import { useNavigate } from 'react-router'; + +import { DetailPageHeader } from '@/components/common/DetailPageHeader'; +import { Button } from '@/components/ui/button'; +import { useCurationCreate, useCurationUpdate } from '@/hooks/useCurations'; +import { useToast } from '@/hooks/useToast'; +import { useAuthStore } from '@/stores/auth'; +import { + type CurationV2CreateRequest, + type CurationV2Detail, + type CurationV2Spec, + type CurationV2UpdateRequest, +} from '@/types/api'; + +import { CurationBasicInfoSection } from '../components/CurationBasicInfoSection'; +import { CurationFormSection } from '../components/CurationFormSection'; +import { WhiskyTastingEventPreviewPanel } from './components/WhiskyTastingEventPreviewPanel'; +import type { WhiskyTastingEventFormModel } from './whisky-tasting-event.form-model'; +import { + buildWhiskyTastingEventPayload, + createWhiskyTastingEventFormStateFromCuration, +} from './whisky-tasting-event.mapper'; +import { + createDefaultWhiskyTastingEventFormState, + createWhiskyTastingEventFormSchema, + type WhiskyTastingEventFormState, +} from './whisky-tasting-event.schema'; + +interface WhiskyTastingEventFormProps { + specDetail: CurationV2Spec; + formModel: WhiskyTastingEventFormModel; + curation?: CurationV2Detail; + onBack: () => void; +} + +export function WhiskyTastingEventForm({ + specDetail, + formModel, + curation, + onBack, +}: WhiskyTastingEventFormProps) { + const navigate = useNavigate(); + const isRootAdmin = useAuthStore((state) => state.hasRole('ROOT_ADMIN')); + const { showToast } = useToast(); + const [isCurationImageUploading, setIsCurationImageUploading] = useState(false); + const [isWhiskyImageUploading, setIsWhiskyImageUploading] = useState(false); + const isEditMode = Boolean(curation); + const isImageUploading = isCurationImageUploading || isWhiskyImageUploading; + const formSchema = createWhiskyTastingEventFormSchema(formModel, { + mode: isEditMode ? 'edit' : 'create', + }); + const defaultValues = curation + ? createWhiskyTastingEventFormStateFromCuration(curation, formModel) + : createDefaultWhiskyTastingEventFormState(formModel); + + const form = useForm({ + resolver: zodResolver(formSchema as never) as unknown as Resolver, + defaultValues, + mode: 'onSubmit', + }); + + const createMutation = useCurationCreate({ + successMessage: '시음회 큐레이션이 등록되었습니다.', + onSuccess: () => { + navigate('/dashboard/curations'); + }, + }); + const updateMutation = useCurationUpdate({ + onSuccess: () => { + navigate('/dashboard/curations'); + }, + }); + const isSubmitting = createMutation.isPending || updateMutation.isPending; + + const handleSubmit = form.handleSubmit( + (values) => { + const request: CurationV2CreateRequest | CurationV2UpdateRequest = { + specId: specDetail.id, + name: values.name.trim(), + description: toNullableTrimmedString(values.description), + imageUrls: values.imageUrls, + exposureStartDate: toNullableTrimmedString(values.exposureStartDate), + exposureEndDate: toNullableTrimmedString(values.exposureEndDate), + displayOrder: values.displayOrder, + isActive: values.isActive, + payload: buildWhiskyTastingEventPayload(values, formModel), + }; + + if (curation) { + updateMutation.mutate({ + curationId: curation.id, + data: request, + }); + return; + } + + createMutation.mutate(request); + }, + () => { + showToast({ type: 'warning', message: '입력 정보를 확인해주세요.' }); + } + ); + + return ( +
+ + + + + } + /> + + +
+
+ + {formModel.sections.map((section) => ( + + ))} +
+ + +
+
+
+ ); +} + +function toNullableTrimmedString(value: string): string | null { + const normalized = value.trim(); + return normalized || null; +} diff --git a/src/pages/curation/tasting-event/__tests__/CurationTastingEventCreate.test.tsx b/src/pages/curation/whisky-tasting-event/__tests__/CurationWhiskyTastingEventCreate.test.tsx similarity index 97% rename from src/pages/curation/tasting-event/__tests__/CurationTastingEventCreate.test.tsx rename to src/pages/curation/whisky-tasting-event/__tests__/CurationWhiskyTastingEventCreate.test.tsx index 7537302..dbbade8 100644 --- a/src/pages/curation/tasting-event/__tests__/CurationTastingEventCreate.test.tsx +++ b/src/pages/curation/whisky-tasting-event/__tests__/CurationWhiskyTastingEventCreate.test.tsx @@ -9,7 +9,7 @@ import { wrapApiError, wrapApiResponse } from '@/test/mocks/data'; import { useAuthStore } from '@/stores/auth'; import type { CurationV2CreateRequest, CurationV2Spec } from '@/types/api'; -import { CurationTastingEventCreatePage } from '../CurationTastingEventCreate'; +import { CurationWhiskyTastingEventCreatePage } from '../CurationWhiskyTastingEventCreate'; const SPEC_BASE = '/admin/api/v2/curation-specs'; const CURATION_BASE = '/admin/api/v2/curations'; @@ -279,7 +279,7 @@ async function typeTastingTagSearch( return searchInput; } -describe('CurationTastingEventCreatePage', () => { +describe('CurationWhiskyTastingEventCreatePage', () => { beforeEach(() => { setCurrentUserRoles([]); Object.defineProperty(URL, 'createObjectURL', { @@ -300,7 +300,7 @@ describe('CurationTastingEventCreatePage', () => { it('스펙 목록과 상세 조회 후 시음회 작성 폼을 렌더링한다', async () => { mockSpecSuccess(); - render(); + render(); expect(await screen.findByRole('heading', { name: '시음회 작성' })).toBeInTheDocument(); expect(await screen.findByText('기본정보')).toBeInTheDocument(); @@ -366,7 +366,7 @@ describe('CurationTastingEventCreatePage', () => { ); }); - render(); + render(); const placeNameInput = await screen.findByLabelText('장소명'); await user.click(placeNameInput); @@ -401,7 +401,7 @@ describe('CurationTastingEventCreatePage', () => { const user = userEvent.setup(); mockSpecSuccess(createTastingEventSpec({ alcoholsMaxItems: 1 })); - render(); + render(); expect(await screen.findByText(/1-1개까지 등록할 수 있습니다/)).toBeInTheDocument(); @@ -417,7 +417,7 @@ describe('CurationTastingEventCreatePage', () => { const user = userEvent.setup(); mockSpecSuccess(); - render(); + render(); await user.click(await screen.findByRole('button', { name: '시음 위스키 추가' })); await user.click(screen.getByRole('button', { name: '직접 입력' })); @@ -435,7 +435,7 @@ describe('CurationTastingEventCreatePage', () => { const user = userEvent.setup(); mockSpecSuccess(); - render(); + render(); await user.click(await screen.findByRole('button', { name: '시음 위스키 추가' })); let whiskySearchInput = await screen.findByPlaceholderText('위스키 검색 ...'); @@ -491,7 +491,7 @@ describe('CurationTastingEventCreatePage', () => { const user = userEvent.setup(); mockSpecSuccess(); - render(); + render(); await user.click(await screen.findByRole('button', { name: '시음 위스키 추가' })); const whiskySearchInput = await screen.findByPlaceholderText('위스키 검색 ...'); @@ -523,7 +523,7 @@ describe('CurationTastingEventCreatePage', () => { setCurrentUserRoles(['ROOT_ADMIN']); mockSpecSuccess(); - render(); + render(); expect(await screen.findByText('관리자 전용 설정')).toBeInTheDocument(); expect(screen.getByLabelText('노출 순서')).not.toBeDisabled(); @@ -535,7 +535,7 @@ describe('CurationTastingEventCreatePage', () => { it('관리자가 아니면 관리자 전용 설정 조작 시 안내를 표시한다', async () => { mockSpecSuccess(); - render(); + render(); const displayOrderInput = await screen.findByLabelText('노출 순서'); @@ -550,7 +550,7 @@ describe('CurationTastingEventCreatePage', () => { const user = userEvent.setup(); mockSpecSuccess(); - render(); + render(); expect(await screen.findByText('시음회 참여자를 모집할 목적이신가요?')).toBeInTheDocument(); expect(screen.getByRole('radio', { name: '네' })).toHaveAttribute('aria-checked', 'true'); @@ -584,7 +584,7 @@ describe('CurationTastingEventCreatePage', () => { const user = userEvent.setup(); mockSpecSuccess(); - render(); + render(); expect(await screen.findByLabelText('신청링크')).toBeInTheDocument(); expect(screen.getByRole('radio', { name: '네' })).toHaveAttribute('aria-checked', 'true'); @@ -597,7 +597,7 @@ describe('CurationTastingEventCreatePage', () => { it('광고노출 시작일이나 종료일이 과거 날짜여도 오늘 날짜 기준 validation을 표시하지 않는다', async () => { mockSpecSuccess(); - render(); + render(); await screen.findByLabelText('광고노출 시작일'); @@ -617,7 +617,7 @@ describe('CurationTastingEventCreatePage', () => { it('광고노출 종료일이 시작일보다 빠르면 저장 전 validation을 표시한다', async () => { mockSpecSuccess(); - render(); + render(); await screen.findByLabelText('광고노출 시작일'); @@ -634,7 +634,7 @@ describe('CurationTastingEventCreatePage', () => { mockSpecSuccess(); mockImageUpload(); - render(); + render(); const imageFileInput = await screen.findByLabelText('큐레이션 이미지 파일 선택'); @@ -686,7 +686,7 @@ describe('CurationTastingEventCreatePage', () => { ) ); - render(); + render(); expect( await screen.findByText('시음회 스펙 상세를 불러올 권한이 없습니다.') @@ -699,7 +699,7 @@ describe('CurationTastingEventCreatePage', () => { const user = userEvent.setup(); mockSpecSuccess(); - render(); + render(); await screen.findByLabelText('큐레이션명'); @@ -752,7 +752,7 @@ describe('CurationTastingEventCreatePage', () => { }) ); - render(); + render(); await screen.findByLabelText('큐레이션명'); @@ -876,7 +876,7 @@ describe('CurationTastingEventCreatePage', () => { }) ); - render(); + render(); await screen.findByLabelText('큐레이션명'); @@ -952,7 +952,7 @@ describe('CurationTastingEventCreatePage', () => { }) ); - render(); + render(); await screen.findByLabelText('큐레이션명'); diff --git a/src/pages/curation/tasting-event/__tests__/tasting-event.form-model.test.ts b/src/pages/curation/whisky-tasting-event/__tests__/whisky-tasting-event.form-model.test.ts similarity index 96% rename from src/pages/curation/tasting-event/__tests__/tasting-event.form-model.test.ts rename to src/pages/curation/whisky-tasting-event/__tests__/whisky-tasting-event.form-model.test.ts index b48936c..b8dc2c4 100644 --- a/src/pages/curation/tasting-event/__tests__/tasting-event.form-model.test.ts +++ b/src/pages/curation/whisky-tasting-event/__tests__/whisky-tasting-event.form-model.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { CurationV2Spec } from '@/types/api'; -import { createTastingEventFormModel } from '../tasting-event.form-model'; +import { createWhiskyTastingEventFormModel } from '../whisky-tasting-event.form-model'; const tastingEventSpec: CurationV2Spec = { id: 3, @@ -127,9 +127,9 @@ const tastingEventSpec: CurationV2Spec = { }, }; -describe('createTastingEventFormModel', () => { +describe('createWhiskyTastingEventFormModel', () => { it('시음회 requestSpec의 라벨과 제약을 form model로 변환한다', () => { - const formModel = createTastingEventFormModel(tastingEventSpec); + const formModel = createWhiskyTastingEventFormModel(tastingEventSpec); const fieldsByKey = Object.fromEntries( formModel.payloadFields.map((field) => [field.key, field]) ); diff --git a/src/pages/curation/tasting-event/__tests__/tasting-event.preview-model.test.ts b/src/pages/curation/whisky-tasting-event/__tests__/whisky-tasting-event.preview-model.test.ts similarity index 88% rename from src/pages/curation/tasting-event/__tests__/tasting-event.preview-model.test.ts rename to src/pages/curation/whisky-tasting-event/__tests__/whisky-tasting-event.preview-model.test.ts index d7a7428..bc06507 100644 --- a/src/pages/curation/tasting-event/__tests__/tasting-event.preview-model.test.ts +++ b/src/pages/curation/whisky-tasting-event/__tests__/whisky-tasting-event.preview-model.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { createTastingEventPreviewModel } from '../tasting-event.preview-model'; -import type { TastingEventCreateFormState } from '../tasting-event.schema'; +import { createWhiskyTastingEventPreviewModel } from '../whisky-tasting-event.preview-model'; +import type { WhiskyTastingEventFormState } from '../whisky-tasting-event.schema'; -describe('createTastingEventPreviewModel', () => { +describe('createWhiskyTastingEventPreviewModel', () => { it('작성 중인 시음회 form 값을 앱 상세 미리보기 데이터로 변환한다', () => { - const preview = createTastingEventPreviewModel({ + const preview = createWhiskyTastingEventPreviewModel({ name: ' 6월 싱글몰트 시음회 ', description: ' 셰리 캐스크 중심의 시음회 ', imageUrls: ['https://img.example.com/main.png', 'https://img.example.com/sub.png'], @@ -41,7 +41,7 @@ describe('createTastingEventPreviewModel', () => { comment: ' 첫 잔으로 소개합니다. ', }, ], - } satisfies TastingEventCreateFormState); + } satisfies WhiskyTastingEventFormState); expect(preview).toMatchObject({ name: '6월 싱글몰트 시음회', diff --git a/src/pages/curation/tasting-event/components/TastingEventPreviewPanel.tsx b/src/pages/curation/whisky-tasting-event/components/WhiskyTastingEventPreviewPanel.tsx similarity index 66% rename from src/pages/curation/tasting-event/components/TastingEventPreviewPanel.tsx rename to src/pages/curation/whisky-tasting-event/components/WhiskyTastingEventPreviewPanel.tsx index 9f4699b..ddf1f92 100644 --- a/src/pages/curation/tasting-event/components/TastingEventPreviewPanel.tsx +++ b/src/pages/curation/whisky-tasting-event/components/WhiskyTastingEventPreviewPanel.tsx @@ -3,17 +3,17 @@ import { useFormContext, useWatch } from 'react-hook-form'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { CurationPreviewFrame, TastingEventPreview } from '../../_preview'; -import { createTastingEventPreviewModel } from '../tasting-event.preview-model'; -import type { TastingEventCreateFormState } from '../tasting-event.schema'; +import { createWhiskyTastingEventPreviewModel } from '../whisky-tasting-event.preview-model'; +import type { WhiskyTastingEventFormState } from '../whisky-tasting-event.schema'; -export function TastingEventPreviewPanel() { - const form = useFormContext(); +export function WhiskyTastingEventPreviewPanel() { + const form = useFormContext(); const watchedValues = useWatch({ control: form.control }); const previewValues = { ...form.getValues(), ...watchedValues, - } as TastingEventCreateFormState; - const preview = createTastingEventPreviewModel(previewValues); + } as WhiskyTastingEventFormState; + const preview = createWhiskyTastingEventPreviewModel(previewValues); return ( diff --git a/src/pages/curation/tasting-event/tasting-event.form-model.ts b/src/pages/curation/whisky-tasting-event/whisky-tasting-event.form-model.ts similarity index 98% rename from src/pages/curation/tasting-event/tasting-event.form-model.ts rename to src/pages/curation/whisky-tasting-event/whisky-tasting-event.form-model.ts index 16dd34a..1a8a59a 100644 --- a/src/pages/curation/tasting-event/tasting-event.form-model.ts +++ b/src/pages/curation/whisky-tasting-event/whisky-tasting-event.form-model.ts @@ -34,10 +34,10 @@ const TASTING_EVENT_PLACE_NAME_FIELD: CurationTextFieldModel = { maxLength: 100, }; -export type TastingEventFormModel = CurationFormModel; +export type WhiskyTastingEventFormModel = CurationFormModel; // 시음회 스펙을 자동 렌더링 파이프라인의 form model로 변환합니다. -export function createTastingEventFormModel(spec: CurationV2Spec): TastingEventFormModel { +export function createWhiskyTastingEventFormModel(spec: CurationV2Spec): WhiskyTastingEventFormModel { // 1. requestSpec 레이어: 상세 스펙의 requestSpec을 화면 생성의 단일 입력으로 사용합니다. const formModel = createCurationFormModelFromRequestSpec(spec.requestSpec, { // 2. schema parser 레이어: 공통 parser가 JSON Schema와 x-* 메타데이터를 읽습니다. diff --git a/src/pages/curation/tasting-event/tasting-event.mapper.ts b/src/pages/curation/whisky-tasting-event/whisky-tasting-event.mapper.ts similarity index 90% rename from src/pages/curation/tasting-event/tasting-event.mapper.ts rename to src/pages/curation/whisky-tasting-event/whisky-tasting-event.mapper.ts index 7976f19..f652798 100644 --- a/src/pages/curation/tasting-event/tasting-event.mapper.ts +++ b/src/pages/curation/whisky-tasting-event/whisky-tasting-event.mapper.ts @@ -6,11 +6,11 @@ import type { CurationWhiskySource, } from '../curation-whisky-card-list.types'; import { - createDefaultTastingEventCreateFormState, - type TastingEventCreatePayload, - TastingEventCreateFormState, -} from './tasting-event.schema'; -import type { TastingEventFormModel } from './tasting-event.form-model'; + createDefaultWhiskyTastingEventFormState, + type WhiskyTastingEventPayload, + WhiskyTastingEventFormState, +} from './whisky-tasting-event.schema'; +import type { WhiskyTastingEventFormModel } from './whisky-tasting-event.form-model'; const ALCOHOL_OPTIONAL_TEXT_FIELDS = [ 'engName', @@ -23,11 +23,11 @@ const ALCOHOL_OPTIONAL_TEXT_FIELDS = [ ] as const; // 6. submit mapper 레이어: form 값을 서버 payload 필드 목록에 맞춰 동적으로 조립합니다. -export function buildTastingEventPayload( - values: TastingEventCreateFormState, - formModel: TastingEventFormModel -): TastingEventCreatePayload { - return formModel.payloadFields.reduce((payload, field) => { +export function buildWhiskyTastingEventPayload( + values: WhiskyTastingEventFormState, + formModel: WhiskyTastingEventFormModel +): WhiskyTastingEventPayload { + return formModel.payloadFields.reduce((payload, field) => { const key = field.key; const value = values[key]; @@ -46,11 +46,11 @@ export function buildTastingEventPayload( }, {}); } -export function createTastingEventFormStateFromCuration( +export function createWhiskyTastingEventFormStateFromCuration( curation: CurationV2Detail, - formModel: TastingEventFormModel -): TastingEventCreateFormState { - const formState = createDefaultTastingEventCreateFormState(formModel); + formModel: WhiskyTastingEventFormModel +): WhiskyTastingEventFormState { + const formState = createDefaultWhiskyTastingEventFormState(formModel); const payload = isRecord(curation.payload) ? curation.payload : {}; formState.name = curation.name; @@ -110,7 +110,7 @@ function normalizePayloadValue(value: unknown): unknown { } // 시음 위스키 form 값을 서버가 받는 alcohols payload 배열로 변환합니다. -function mapTastingEventAlcohols(values: TastingEventCreateFormState['alcohols']) { +function mapTastingEventAlcohols(values: WhiskyTastingEventFormState['alcohols']) { return values.map((item) => { const comment = item.comment?.trim(); const stats = item.stats diff --git a/src/pages/curation/tasting-event/tasting-event.preview-model.ts b/src/pages/curation/whisky-tasting-event/whisky-tasting-event.preview-model.ts similarity index 94% rename from src/pages/curation/tasting-event/tasting-event.preview-model.ts rename to src/pages/curation/whisky-tasting-event/whisky-tasting-event.preview-model.ts index d4feccf..883e516 100644 --- a/src/pages/curation/tasting-event/tasting-event.preview-model.ts +++ b/src/pages/curation/whisky-tasting-event/whisky-tasting-event.preview-model.ts @@ -3,10 +3,10 @@ import type { CurationWhiskyStats, } from '../curation-whisky-card-list.types'; import type { TastingEventPreviewData } from '../_preview'; -import type { TastingEventCreateFormState } from './tasting-event.schema'; +import type { WhiskyTastingEventFormState } from './whisky-tasting-event.schema'; -export function createTastingEventPreviewModel( - values: TastingEventCreateFormState +export function createWhiskyTastingEventPreviewModel( + values: WhiskyTastingEventFormState ): TastingEventPreviewData { const imageUrls = getStringArray(values.imageUrls); const coverImageUrl = normalizeImageUrl(imageUrls[0]); diff --git a/src/pages/curation/tasting-event/tasting-event.schema.ts b/src/pages/curation/whisky-tasting-event/whisky-tasting-event.schema.ts similarity index 84% rename from src/pages/curation/tasting-event/tasting-event.schema.ts rename to src/pages/curation/whisky-tasting-event/whisky-tasting-event.schema.ts index bcae1f5..6d7e73c 100644 --- a/src/pages/curation/tasting-event/tasting-event.schema.ts +++ b/src/pages/curation/whisky-tasting-event/whisky-tasting-event.schema.ts @@ -11,7 +11,7 @@ import type { CurationWhiskyCardListFieldModel, CurationWhiskyCardListFormValues, } from '../curation-whisky-card-list.types'; -import type { TastingEventFormModel } from './tasting-event.form-model'; +import type { WhiskyTastingEventFormModel } from './whisky-tasting-event.form-model'; // 위스키 카드 리스트 field model을 alcohols 배열 item Zod schema로 변환합니다. export function createTastingEventAlcoholSchema(fieldModel: CurationWhiskyCardListFieldModel) { @@ -58,12 +58,20 @@ export function createTastingEventAlcoholSchema(fieldModel: CurationWhiskyCardLi } // field model의 kind에 따라 시음회 payload 필드 Zod schema를 생성합니다. -function createTastingEventCreatePayloadFieldSchema(field: CurationFieldModel) { +function createWhiskyTastingEventPayloadFieldSchema(field: CurationFieldModel) { if (field.kind === 'alcohol-card-list') { - return z + const fieldSchema = z .array(createTastingEventAlcoholSchema(field)) - .min(field.minItems, `${field.label}를 최소 ${field.minItems}개 이상 추가해주세요.`) - .max(field.maxItems, `${field.label}는 최대 ${field.maxItems}개까지 추가할 수 있습니다.`); + .min(field.minItems, `${field.label}를 최소 ${field.minItems}개 이상 추가해주세요.`); + + if (typeof field.maxItems !== 'number') { + return fieldSchema; + } + + return fieldSchema.max( + field.maxItems, + `${field.label}는 최대 ${field.maxItems}개까지 추가할 수 있습니다.` + ); } if (field.key === 'applicationLink') { @@ -91,21 +99,21 @@ function createConditionalApplicationLinkValueSchema(field: CurationFieldModel) } // requestSpec 기반 form model의 payloadFields를 순회해 동적 payload Zod shape을 생성합니다. -function createTastingEventCreatePayloadShape( - formModel: TastingEventFormModel +function createWhiskyTastingEventPayloadShape( + formModel: WhiskyTastingEventFormModel ): Record> { return formModel.payloadFields.reduce>>((shape, field) => { const key = field.key; - shape[key] = createTastingEventCreatePayloadFieldSchema(field); + shape[key] = createWhiskyTastingEventPayloadFieldSchema(field); return shape; }, {}); } // requestSpec 기반 시음회 form model을 React Hook Form에서 사용할 전체 Zod schema로 변환합니다. -export function createCurationTastingEventFormSchema( - formModel: TastingEventFormModel, +export function createWhiskyTastingEventFormSchema( + formModel: WhiskyTastingEventFormModel, options: { mode?: 'create' | 'edit' } = {} -): z.ZodType { +): z.ZodType { const isEditMode = options.mode === 'edit'; const applicationLinkField = formModel.payloadFields.find( (field) => field.key === 'applicationLink' @@ -125,7 +133,7 @@ export function createCurationTastingEventFormSchema( .int('노출 순서는 정수로 입력해주세요.') .min(0, '노출 순서는 0 이상이어야 합니다.'), isActive: z.boolean(), - ...createTastingEventCreatePayloadShape(formModel), + ...createWhiskyTastingEventPayloadShape(formModel), }) .superRefine((values, context) => { const formValues = values as Record; @@ -156,10 +164,10 @@ export function createCurationTastingEventFormSchema( path: ['applicationLink'], message: `${formatCurationFieldTopic(applicationLinkField.label)} 필수입니다.`, }); - }) as unknown as z.ZodType; + }) as unknown as z.ZodType; } -export interface TastingEventCreateFormState extends CurationWhiskyCardListFormValues { +export interface WhiskyTastingEventFormState extends CurationWhiskyCardListFormValues { name: string; description: string; imageUrls: string[]; @@ -170,9 +178,9 @@ export interface TastingEventCreateFormState extends CurationWhiskyCardListFormV [key: string]: unknown; } -export type TastingEventCreatePayload = Record; +export type WhiskyTastingEventPayload = Record; -const BASE_TASTING_EVENT_CREATE_FORM_STATE: TastingEventCreateFormState = { +const BASE_TASTING_EVENT_CREATE_FORM_STATE: WhiskyTastingEventFormState = { name: '', description: '', imageUrls: [], @@ -184,10 +192,10 @@ const BASE_TASTING_EVENT_CREATE_FORM_STATE: TastingEventCreateFormState = { }; // requestSpec 기반 form model을 React Hook Form의 초기 form state로 변환합니다. -export function createDefaultTastingEventCreateFormState( - formModel: TastingEventFormModel -): TastingEventCreateFormState { - const formState: TastingEventCreateFormState = { +export function createDefaultWhiskyTastingEventFormState( + formModel: WhiskyTastingEventFormModel +): WhiskyTastingEventFormState { + const formState: WhiskyTastingEventFormState = { ...BASE_TASTING_EVENT_CREATE_FORM_STATE, imageUrls: [], alcohols: [], diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 3e9206c..7b4236b 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -22,11 +22,9 @@ import { CurationDetailPage as CurationOldDetailPage } from '@/pages/curation-ol import { CurationEntryPage } from '@/pages/curation/CurationEntry'; import { CurationListPage } from '@/pages/curation/CurationList'; import { CurationDetailPage } from '@/pages/curation/CurationDetail'; -import { CurationTastingEventCreatePage } from '@/pages/curation/tasting-event/CurationTastingEventCreate'; -import { - CurationRecommendedWhiskyCreatePage, - CurationWhiskyPairingCreatePage, -} from '@/pages/curation/whisky-card/CurationWhiskyCardCreate'; +import { CurationWhiskyTastingEventCreatePage } from '@/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventCreate'; +import { CurationRecommendedWhiskyCreatePage } from '@/pages/curation/whisky-curation/CurationRecommendedWhiskyCreate'; +import { CurationWhiskyPairingCreatePage } from '@/pages/curation/whisky-curation/CurationWhiskyPairingCreate'; import { InquiryListPage } from '@/pages/inquiries/InquiryList'; import { PolicyListPage } from '@/pages/policies/PolicyList'; import { UserListPage } from '@/pages/users/UserList'; @@ -86,7 +84,7 @@ export function AppRoutes() { path="dashboard/curations/tasting-events/new" element={ - + } />