@@ -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 (
-
-
-
- {message}
-
-
- );
-}
-
-function BlockingState({ title, description, onRetry, onBack }: BlockingStateProps) {
- return (
-
-
-
-
-
-
{title}
-
{description}
-
-
-
- {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 (
-
-
-
- {message}
-
-
- );
-}
-
-function BlockingState({ title, description, onRetry, onBack }: BlockingStateProps) {
- return (
-
-
-
-
-
-
{title}
-
{description}
-
-
-
- {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 (
+
+
+
+ {message}
+
+
+ );
+}
+
+interface BlockingStateProps {
+ title: string;
+ description: string;
+ onRetry?: () => void;
+ onBack: () => void;
+}
+
+function BlockingState({ title, description, onRetry, onBack }: BlockingStateProps) {
+ return (
+
+
+
+
+
+
{title}
+
{description}
+
+
+
+ {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 (
+
+
+
+ {message}
+
+
+ );
+}
+
+interface BlockingStateProps {
+ title: string;
+ description: string;
+ onRetry?: () => void;
+ onBack: () => void;
+}
+
+function BlockingState({ title, description, onRetry, onBack }: BlockingStateProps) {
+ return (
+
+
+
+
+
+
{title}
+
{description}
+
+
+
+ {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={
-
+
}
/>