From 468bf862b61970bf050bd80b7848288a11147a54 Mon Sep 17 00:00:00 2001
From: hyejj19
Date: Fri, 24 Jul 2026 17:54:09 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20=ED=81=90=EB=A0=88=EC=9D=B4=EC=85=98=20?=
=?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=EC=99=80=20?=
=?UTF-8?q?=ED=94=84=EB=A1=9C=EA=B7=B8=EB=9E=A8=20=ED=8F=BC=20=ED=86=B5?=
=?UTF-8?q?=ED=95=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../curation-program-schema-form/design.md | 137 +++++++++++
.../curation-program-schema-form/plan.md | 186 +++++++++++++++
.../curation-program-schema-form/spec.md | 136 +++++++++++
src/pages/curation/CurationCreate.tsx | 158 ++++++++++++
src/pages/curation/CurationDetail.tsx | 5 +
src/pages/curation/CurationEntry.tsx | 64 ++---
.../__tests__/CurationCreate.test.tsx | 108 +++++++++
.../__tests__/CurationDetail.test.tsx | 45 +++-
.../curation/__tests__/CurationEntry.test.tsx | 64 ++++-
.../components/CurationFormFieldRenderer.tsx | 130 +++++++++-
.../components/CurationFormSection.tsx | 5 +-
.../components/CurationObjectArrayField.tsx | 157 ++++++++++++
.../CurationWhiskyCardListField.tsx | 96 ++++----
src/pages/curation/curation-display-utils.ts | 2 +
src/pages/curation/curation-form-model.ts | 177 ++++++++++++--
src/pages/curation/curation-form-schema.ts | 186 ++++++++++++++-
.../curation-whisky-card-list.types.ts | 1 +
.../SchemaDrivenCurationEditPage.tsx | 45 ++++
.../SchemaDrivenCurationForm.tsx | 145 +++++++++++
.../SchemaDrivenCurationPages.test.tsx | 220 +++++++++++++++++
.../__tests__/program-spec.fixture.ts | 139 +++++++++++
.../schema-driven-curation.form-model.test.ts | 84 +++++++
.../schema-driven-curation.schema.test.ts | 100 ++++++++
.../schema-driven-curation.form-model.ts | 72 ++++++
.../schema-driven-curation.mapper.ts | 225 ++++++++++++++++++
.../schema-driven-curation.schema.ts | 126 ++++++++++
.../curation/useCurationSpecFormModel.ts | 31 ---
.../WhiskyCurationCreateGate.tsx | 172 -------------
.../WhiskyCurationCreatePage.tsx | 23 --
.../__tests__/WhiskyCurationPages.test.tsx | 25 +-
.../CurationWhiskyTastingEventCreate.tsx | 12 -
.../WhiskyTastingEventCreateGate.tsx | 166 -------------
.../CurationWhiskyTastingEventCreate.test.tsx | 57 +++--
.../whisky-tasting-event.form-model.ts | 7 +-
src/routes/index.tsx | 28 ++-
src/types/api/curation.api.ts | 1 +
36 files changed, 2772 insertions(+), 563 deletions(-)
create mode 100644 docs/features/curation-program-schema-form/design.md
create mode 100644 docs/features/curation-program-schema-form/plan.md
create mode 100644 docs/features/curation-program-schema-form/spec.md
create mode 100644 src/pages/curation/CurationCreate.tsx
create mode 100644 src/pages/curation/__tests__/CurationCreate.test.tsx
create mode 100644 src/pages/curation/components/CurationObjectArrayField.tsx
create mode 100644 src/pages/curation/schema-driven/SchemaDrivenCurationEditPage.tsx
create mode 100644 src/pages/curation/schema-driven/SchemaDrivenCurationForm.tsx
create mode 100644 src/pages/curation/schema-driven/__tests__/SchemaDrivenCurationPages.test.tsx
create mode 100644 src/pages/curation/schema-driven/__tests__/program-spec.fixture.ts
create mode 100644 src/pages/curation/schema-driven/__tests__/schema-driven-curation.form-model.test.ts
create mode 100644 src/pages/curation/schema-driven/__tests__/schema-driven-curation.schema.test.ts
create mode 100644 src/pages/curation/schema-driven/schema-driven-curation.form-model.ts
create mode 100644 src/pages/curation/schema-driven/schema-driven-curation.mapper.ts
create mode 100644 src/pages/curation/schema-driven/schema-driven-curation.schema.ts
delete mode 100644 src/pages/curation/useCurationSpecFormModel.ts
delete mode 100644 src/pages/curation/whisky-curation/WhiskyCurationCreateGate.tsx
delete mode 100644 src/pages/curation/whisky-curation/WhiskyCurationCreatePage.tsx
delete mode 100644 src/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventCreate.tsx
delete mode 100644 src/pages/curation/whisky-tasting-event/WhiskyTastingEventCreateGate.tsx
diff --git a/docs/features/curation-program-schema-form/design.md b/docs/features/curation-program-schema-form/design.md
new file mode 100644
index 0000000..8a1942d
--- /dev/null
+++ b/docs/features/curation-program-schema-form/design.md
@@ -0,0 +1,137 @@
+# Program Schema-Driven Curation Form Design
+
+## UI Summary
+
+기존 큐레이션 작성 화면의 `DetailPageHeader`, 기본 정보 카드, 이미지 업로드,
+섹션 카드를 유지한다. `PROGRAM` payload는 행사 정보 카드와 프로그램 목록 카드로
+나누며, 프로그램 배열의 각 항목은 번호가 있는 중첩 카드로 표시한다.
+
+requestSpec이 제공하는 필드 순서, 표시 이름, 타입, 필수 여부, 개수 제한을 화면에
+반영한다. 스펙에 앱 렌더링 정보가 없으므로 오른쪽 앱 미리보기 패널은 만들지 않고
+입력 영역을 전체 너비로 사용한다.
+
+## Navigation
+
+- Route:
+ - 모든 스펙의 공통 생성 라우트:
+ `/dashboard/curations/specs/:specCode/new`
+ - `PROGRAM` 진입:
+ `/dashboard/curations/specs/PROGRAM/new`
+ - 수정은 기존 `/dashboard/curations/:id`를 유지하고 상세 응답의
+ `spec.code === PROGRAM`일 때 프로그램 수정 폼을 렌더링한다.
+ - 기존 `/dashboard/curations/tasting-events/new`,
+ `/dashboard/curations/general/new`, `/dashboard/curations/pairings/new`는
+ 동일한 공통 생성 라우트로 redirect하는 호환 경로만 유지한다.
+- Sidebar/menu placement:
+ - 기존 `큐레이션` 메뉴와 `/dashboard/curations` 목록을 유지한다.
+ - 별도 사이드바 항목은 추가하지 않는다.
+- Entry actions:
+ - `/dashboard/curations/new`의 모든 활성 스펙 카드 전체와 `작성하기` 버튼이
+ 각 spec code를 포함한 공통 생성 라우트로 이동한다.
+ - 프로그램 카드는 행사형 큐레이션임을 보여주는 기존 lucide 아이콘과 디자인
+ 시스템 색상을 사용한다.
+ - 앱 미리보기 계약이 없으므로 `PROGRAM` 카드의 미리보기 액션은 표시하지 않는다.
+
+## List View
+
+- Columns:
+ - 기존 큐레이션 목록 컬럼을 변경하지 않는다.
+ - 스펙 표시는 기존 스펙 이름/코드 formatter를 통해 `프로그램`으로 표시한다.
+- Filters/search:
+ - 기존 스펙 필터는 API 스펙 목록 기반이므로 `PROGRAM`을 자동 표시한다.
+- Row actions:
+ - 기존 상세 이동을 유지한다.
+- Empty/loading/error states:
+ - 기존 큐레이션 목록 상태를 유지한다.
+
+## Detail/Create/Edit View
+
+- Sections/cards:
+ - Header:
+ - 생성: `프로그램 작성`
+ - 수정: `프로그램 수정`
+ - 보조 문구는 생성 시 스펙 이름, 수정 시 큐레이션 ID를 표시한다.
+ - `기본 정보`:
+ - 기존 `CurationBasicInfoSection`을 사용한다.
+ - 큐레이션명, 설명, 이미지, 노출 기간, 노출 순서, 활성 상태를 유지한다.
+ - `행사 정보`:
+ - `programs`를 제외한 requestSpec 루트 필드를 스펙 순서로 렌더링한다.
+ - 날짜/숫자/짧은 문자열은 2열, 주소·URL·enum 다중 선택은 전체 너비를 사용한다.
+ - `프로그램`:
+ - 섹션 제목에 현재 개수와 스펙의 1~20개 제한을 표시한다.
+ - 빈 상태에 `프로그램을 추가해주세요.`와 `프로그램 추가` 버튼을 표시한다.
+ - 각 항목은 `프로그램 1`, `프로그램 2` 형태의 카드이며 우측 상단에 `삭제`가 있다.
+ - 프로그램 필드는 requestSpec의 item properties 순서로 표시한다.
+ - `시음 위스키`는 해당 프로그램 카드 안의 하위 영역에 기존 위스키 카드 목록
+ UI를 재사용한다.
+- Fields:
+ - string/date/time/number는 기존 `Input`, `Textarea`를 사용한다.
+ - 단일 enum은 `Select`로 표시한다.
+ - enum 배열은 체크박스 목록으로 표시하며 선택 개수 제한을 안내한다.
+ - `alcohol-card-list`는 DB 검색/직접 입력/테이스팅 태그/이미지 입력을 포함한
+ 기존 위스키 카드 UI를 사용한다.
+ - optional 필드는 required marker를 표시하지 않는다.
+- Validation messages:
+ - 기존 필드 메시지 형식인 `<필드명>은/는 필수입니다.`를 유지한다.
+ - 배열 최소/최대:
+ `<필드명>을 최소 N개 이상 추가해주세요.`,
+ `<필드명>은 최대 N개까지 추가할 수 있습니다.`
+ - 행사 날짜:
+ `행사 종료일은 행사 시작일보다 빠를 수 없습니다.`
+ - 프로그램 시간:
+ `종료 시간은 시작 시간보다 빠를 수 없습니다.`
+ - enum:
+ `<필드명>을 선택해주세요.`
+- Primary/secondary actions:
+ - Primary: `저장` 또는 `수정`
+ - Secondary: `목록`
+ - 저장 중 또는 이미지 업로드 중에는 primary action을 비활성화한다.
+
+## State and Feedback
+
+- Loading:
+ - 모든 생성 유형의 스펙 목록/상세 로딩은 단일 작성 페이지의 로딩 카드를 사용한다.
+- Empty:
+ - 스펙이 없거나 비활성이면 기존 `큐레이션 스펙을 찾을 수 없습니다.` 상태를
+ 표시한다.
+ - 프로그램 배열이 비어 있으면 카드 안에 dashed empty state를 표시한다.
+- Error:
+ - 스펙 목록/상세 조회 실패는 기존 retry/back blocking state를 사용한다.
+ - 지원하지 않는 requestSpec 구조는 `이 스펙은 아직 자동 폼에서 지원하지
+않습니다.` 오류 카드와 목록 이동을 표시한다.
+- Success:
+ - 생성: `<스펙 이름> 큐레이션이 등록되었습니다.`
+ - 수정: 기존 `큐레이션이 수정되었습니다.` 토스트를 유지한다.
+ - 성공 후 큐레이션 목록으로 이동한다.
+- Destructive confirmation:
+ - 아직 저장되지 않은 프로그램/위스키 항목 삭제는 즉시 반영한다.
+ - 서버 레코드 삭제는 이 기능에 포함하지 않는다.
+
+## Design System Usage
+
+- Components:
+ - `DetailPageHeader`, `CurationBasicInfoSection`, `CurationSectionCard`,
+ `CurationFormFieldRenderer`, `CurationWhiskyCardListField`, `FormField`
+ - shadcn `Card`, `Button`, `Input`, `Textarea`, `Select`, `Checkbox`, `Badge`
+- Tokens/classes:
+ - `border-border`, `bg-card`, `bg-muted/20`, `text-muted-foreground`,
+ `text-destructive`, `ring-ring` 등 기존 토큰을 사용한다.
+ - 페이지 주요 영역은 `grid`, `minmax(0,1fr)`, `md:grid-cols-2`를 사용하고
+ 고정 픽셀 열 너비를 추가하지 않는다.
+- Existing pages to mirror:
+ - 공통 기본 정보와 저장: `WhiskyTastingEventForm.tsx`
+ - 공통 스펙 조회 상태: `CurationCreate.tsx`
+ - 반복 카드와 위스키 입력: `CurationWhiskyCardListField.tsx`
+ - 일반 payload 상세 fallback: `CurationDetail.tsx`
+
+## Manual UI Review Points
+
+- [ ] 생성 유형 화면에서 프로그램 카드가 다른 활성 스펙과 자연스럽게 정렬되는지 확인한다.
+- [ ] `/dashboard/curations/specs/PROGRAM/new` 직접 진입 시 스펙 로딩 후 폼이 표시되는지 확인한다.
+- [ ] 모바일/태블릿/데스크톱에서 행사 정보의 1열/2열 전환과 중첩 프로그램 카드가
+ 가로 스크롤 없이 표시되는지 확인한다.
+- [ ] 프로그램 추가/삭제 후 번호와 개수 badge가 즉시 갱신되는지 확인한다.
+- [ ] 두 프로그램 각각의 시음 위스키가 서로 섞이지 않고 독립적으로 추가/삭제되는지 확인한다.
+- [ ] enum 단일/다중 선택의 키보드 접근성과 required/error 표시를 확인한다.
+- [ ] 저장 요청 payload의 프로그램 및 위스키 배열 순서가 화면 순서와 같은지 확인한다.
+- [ ] 기존 시음회/추천/페어링 생성·수정 화면에 시각적 회귀가 없는지 확인한다.
diff --git a/docs/features/curation-program-schema-form/plan.md b/docs/features/curation-program-schema-form/plan.md
new file mode 100644
index 0000000..a7bbfea
--- /dev/null
+++ b/docs/features/curation-program-schema-form/plan.md
@@ -0,0 +1,186 @@
+# Program Schema-Driven Curation Form Implementation Plan
+
+## Summary
+
+Add `PROGRAM` as a known curation spec and use one spec-code create route/page
+for every curation type. The page resolves the list/detail spec once, then
+selects a form-model/renderer strategy from the resolved spec. Extend the
+shared curation schema vocabulary with enum select, enum multi-select, and
+object array fields. Refactor the shared whisky card list to use its model key
+as a React Hook Form path so each program can own an independent nested whisky
+list. Preserve the specialized form renderers where their interaction model is
+not expressible by the generic renderer, but remove page-level routing and
+spec-query ownership from them.
+
+## Inputs
+
+- Spec: `docs/features/curation-program-schema-form/spec.md`
+- Design: `docs/features/curation-program-schema-form/design.md`
+- API source:
+ - Published admin API docs for curation spec list/detail and curation
+ create/update endpoints.
+ - Backend `main` resource
+ `bottlenote-mono/src/main/resources/openapi/curation/program.json`,
+ introduced by backend commit `d7944c51`.
+ - The development API could not be authenticated through direct probing, and
+ the published docs do not yet include a `PROGRAM` example.
+
+## Data and API Mapping
+
+- `CurationSpecCode.PROGRAM` -> known code used by entry routing and detail form
+ dispatch.
+- `/dashboard/curations/specs/:specCode/new` ->
+ `useCurationSpecByCode(specCode)` -> `useCurationSpec(specId)` -> dynamic form
+ model/renderer strategy.
+- Known create strategies:
+ - `WHISKY_TASTING_EVENT` -> tasting-event form model and form renderer.
+ - `RECOMMENDED_WHISKY`, `WHISKY_PAIRING` -> whisky-curation form model and
+ form renderer.
+ - object requestSpec, including `PROGRAM` -> generic schema-driven form model
+ and renderer.
+- `requestSpec.properties` -> ordered root payload field models.
+- `string enum` -> single select field with enum values as options.
+- `array` with `items.enum` -> multi-select checkbox field.
+- `array` with `items.type === object` -> object array field with recursively
+ parsed item property models.
+- `x-field-style: alcohol-card-list` -> shared whisky card list field whose
+ `key` may be a nested path such as `programs.0.whiskies`.
+- `required`, `nullable`, `minLength`, `maxLength`, `minimum`, `maximum`,
+ `minItems`, `maxItems` -> initial values and Zod validation.
+- Common curation fields -> existing `CurationBasicInfoSection`.
+- Create form -> `POST /admin/api/v2/curations` with `specId`, common fields,
+ and object `payload`.
+- Edit form -> `PUT /admin/api/v2/curations/{curationId}` with the same shape.
+- `CurationV2Detail.payload` -> recursively normalized edit form values.
+
+## Implementation Steps
+
+1. Extend the curation API/code vocabulary.
+ - Add `PROGRAM` to `CurationSpecCode` in
+ `src/types/api/curation.api.ts`.
+ - Update `src/pages/curation/curation-display-utils.ts` to show `프로그램`
+ for the known code.
+2. Extend the shared schema-driven field model.
+ - In `src/pages/curation/curation-form-model.ts`, add:
+ - single enum select model,
+ - enum multi-select model,
+ - recursively parsed object array model.
+ - Resolve supported array/object shapes explicitly. Throw a descriptive
+ error for unsupported schema structures instead of falling back to text.
+ - Preserve property order from `requestSpec.properties`.
+ - Add a helper that prefixes a field model key recursively for nested React
+ Hook Form paths.
+3. Extend shared rendering and validation.
+ - Add single enum and multi-enum inputs to
+ `src/pages/curation/components/CurationFormFieldRenderer.tsx`.
+ - Add
+ `src/pages/curation/components/CurationObjectArrayField.tsx` for
+ add/remove and nested item rendering.
+ - Extend `src/pages/curation/curation-form-schema.ts` with recursive Zod
+ generation for all supported field kinds and shared whisky-card item
+ validation.
+4. Make the shared whisky list path-aware.
+ - Refactor
+ `src/pages/curation/components/CurationWhiskyCardListField.tsx` so
+ `useFieldArray`, `useWatch`, `getValues`, `setValue`, `clearErrors`,
+ error lookup, and nested registration paths derive from
+ `fieldModel.key`.
+ - Keep current root `alcohols` behavior unchanged for tasting events and
+ whisky curations.
+5. Add the generic object-payload form pipeline under
+ `src/pages/curation/schema-driven/`.
+ - `schema-driven-curation.form-model.ts`: create root sections from
+ requestSpec; separate object arrays into their own sections.
+ - `schema-driven-curation.schema.ts`: combine common curation validation
+ with recursive payload validation and add the `PROGRAM` date/time
+ cross-field checks.
+ - `schema-driven-curation.mapper.ts`: create defaults, hydrate edit values,
+ and recursively serialize trimmed payload values while preserving array
+ order.
+ - `SchemaDrivenCurationForm.tsx`: render common info and generated sections,
+ submit create/update mutations, and handle upload/submission states.
+ - `CurationCreate.tsx`: read `specCode` from route params, resolve active
+ spec list/detail once, handle unsupported schema errors, and select/render
+ the correct form strategy from the resolved spec.
+ - `SchemaDrivenCurationEditPage.tsx`: build and render the same form from a
+ loaded `CurationV2Detail`.
+6. Integrate navigation and detail dispatch.
+ - Render `CurationCreatePage` only at
+ `/dashboard/curations/specs/:specCode/new` with the existing curation
+ roles.
+ - Keep the three prior type-specific paths as redirects to the shared route;
+ they must not render or query a separate page.
+ - Add `PROGRAM` UI metadata to `CurationEntry.tsx`.
+ - Route every active card to the shared spec-code path without per-card
+ href configuration.
+ - Hide preview action for specs without a supported app preview, including
+ `PROGRAM`.
+ - Dispatch `PROGRAM` detail to `SchemaDrivenCurationEditPage` in
+ `CurationDetail.tsx`.
+7. Add tests first for each behavior before its implementation.
+ - Extend `src/pages/curation/__tests__/CurationEntry.test.tsx` for the
+ program card, no preview action, and shared route navigation for all
+ known and unknown active specs.
+ - Add
+ `src/pages/curation/schema-driven/__tests__/schema-driven-curation.form-model.test.ts`
+ for primitive/enum/enum-array/object-array/nested whisky parsing and
+ unsupported structure errors.
+ - Add
+ `src/pages/curation/schema-driven/__tests__/schema-driven-curation.schema.test.ts`
+ for required/limits/date/time validation.
+ - Add
+ `src/pages/curation/schema-driven/__tests__/SchemaDrivenCurationPages.test.tsx`
+ for create rendering, nested program whisky isolation, create payload,
+ edit hydration, and update payload.
+ - Add a shared create-page dispatch test proving that the same page renders
+ the tasting-event, whisky-curation, and generic strategies after one spec
+ lookup.
+ - Retain and run existing curation form/page tests as regressions.
+
+## Edge Cases
+
+- An inactive or missing URL spec code shows the existing missing-spec state.
+- A malformed or unsupported schema shows a blocking unsupported-spec state;
+ it does not render an incorrect generic text field.
+- Optional enum arrays start empty and stop accepting values at `maxItems`.
+- Programs start empty so the `minItems` error is observable on submit.
+- Removing a program removes only its nested whisky list and React Hook Form
+ errors.
+- Nested field paths remain stable after removing an earlier program.
+- Optional empty strings/arrays are excluded from serialized payload objects;
+ required empty values remain available for validation.
+- Nullable API values hydrate as empty UI values.
+- Nested DB/manual whisky mapping follows the existing shared card contract and
+ removes UI-only stats only when they are absent.
+- Existing root `alcohols` pages continue using the same component behavior.
+- Program app preview is intentionally absent until a response/display
+ vocabulary is defined.
+
+## Verification Checklist
+
+- [x] `pnpm test:run`
+- [ ] `pnpm lint`
+- [x] Changed-file ESLint and Prettier checks
+- [x] `pnpm build`
+- [ ] Manual UI: open `/dashboard/curations/new` and confirm the `PROGRAM`
+ card enters `/dashboard/curations/specs/PROGRAM/new`.
+- [ ] Manual UI: add two programs and different whisky lists, then confirm
+ independent state, responsive layout, and submitted array order.
+- [ ] Manual UI: open an existing `PROGRAM` curation and confirm edit hydration.
+
+## Implementation Notes
+
+- Do not duplicate list/detail spec queries inside specialized create gates.
+- Specialized form components may remain as renderer strategies, but all
+ runtime create navigation and page-level state must be owned by
+ `CurationCreatePage`.
+- The dynamic engine supports the schema vocabulary listed in the feature spec,
+ not arbitrary JSON Schema.
+- Do not add static `PROGRAM` payload TypeScript interfaces that duplicate
+ `requestSpec`; form state remains a recursive record/array shape.
+- Do not hardcode `programs.0.whiskies` in the whisky component. All paths must
+ derive from field models.
+- Do not add program-specific enum label maps. Use a generic readable enum
+ formatter until the backend publishes enum label metadata.
+- Do not edit `src/components/ui`.
+- New React components must each live in their own file.
diff --git a/docs/features/curation-program-schema-form/spec.md b/docs/features/curation-program-schema-form/spec.md
new file mode 100644
index 0000000..f8a83d6
--- /dev/null
+++ b/docs/features/curation-program-schema-form/spec.md
@@ -0,0 +1,136 @@
+# Program Schema-Driven Curation Form Spec
+
+## Summary
+
+큐레이션 V2 스펙에 추가된 `PROGRAM`을 어드민에서 생성하고 수정할 수 있게 한다.
+작성 화면은 스펙 code를 받는 동적 라우트로 진입하고, 스펙 상세의 `requestSpec`을
+해석해 행사 정보, 프로그램 목록, 프로그램별 시음 위스키 목록을 렌더링한다.
+
+`PROGRAM` 한 종류만을 위한 필드 하드코딩을 추가하지 않고, 현재 큐레이션 폼의
+JSON Schema 파서/필드 모델/렌더러를 객체 배열과 enum까지 확장한다. 다만
+requestSpec에 없는 화면 문구, 섹션 의미, 앱 미리보기 구성은 자동 추론하지 않는다.
+
+## Source Inputs
+
+- API docs/response shape:
+ - Published admin docs:
+ `GET /admin/api/v2/curation-specs`,
+ `GET /admin/api/v2/curation-specs/{specId}`,
+ `POST /admin/api/v2/curations`,
+ `PUT /admin/api/v2/curations/{curationId}`.
+ - Backend `main` resource:
+ `bottlenote-mono/src/main/resources/openapi/curation/program.json`
+ (`feat: 프로그램 큐레이션 스펙 추가`, backend commit `d7944c51`).
+ - Published admin docs do not yet include a `PROGRAM` example. The backend
+ resource is the current contract source for this feature.
+- Existing UI/code references:
+ - `src/pages/curation/CurationEntry.tsx`
+ - `src/pages/curation/curation-form-model.ts`
+ - `src/pages/curation/components/CurationFormFieldRenderer.tsx`
+ - `src/pages/curation/components/CurationWhiskyCardListField.tsx`
+ - `src/pages/curation/whisky-tasting-event/*`
+ - `src/pages/curation/whisky-curation/*`
+ - `src/routes/index.tsx`
+- User request:
+ - 큐레이션 스펙에 추가된 프로그램의 새 화면 라우트와 렌더링을 추가한다.
+ - 가능한 범위에서 스펙 기반 동적 생성을 사용한다.
+
+## Admin Workflow
+
+- 관리자는 큐레이션 생성 유형 선택 화면에서 활성 스펙 카드를 확인한다.
+- 모든 스펙 카드의 작성하기는 동일한 spec code 기반 작성 라우트로 이동한다.
+- 단일 작성 페이지는 URL의 spec code로 대상 스펙과 상세 `requestSpec`을 조회한다.
+- 단일 작성 페이지는 조회한 스펙의 form style/code에 맞는 폼 모델과 렌더러를
+ 선택한다. 스펙 조회, loading/error/missing 상태와 목록 복귀는 이 페이지가
+ 공통으로 소유한다.
+- 관리자는 공통 큐레이션 기본 정보와 행사 정보를 입력한다.
+- 관리자는 프로그램을 1개 이상 추가하고 각 프로그램의 정보와 선택적인 시음
+ 위스키 목록을 입력한다.
+- 저장 시 폼 값을 `PROGRAM` requestSpec의 object payload로 직렬화해 기존
+ 큐레이션 V2 생성 API로 전송한다.
+- 기존 `PROGRAM` 큐레이션 상세에 진입하면 같은 스펙 기반 폼을 수정 모드로
+ 렌더링하고, 저장 시 기존 큐레이션 V2 수정 API로 전송한다.
+
+## Data Requirements
+
+- Spec list item:
+ - `code`: `PROGRAM`
+ - `name`, `description`, `version`, `isActive`는 생성 유형 카드와 스펙 선택에 사용한다.
+- Spec detail:
+ - `requestSpec.type`: `object`
+ - 행사 필수 필드:
+ `eventStartDate`, `eventEndDate`, `placeName`, `address`, `programs`
+ - 행사 선택 필드:
+ `detailLocation`, `organizer`, `sponsor`, `entryFee`, `officialUrl`,
+ `registrationUrl`, `programTags`
+ - `programTags`: enum string array, 최대 6개
+ - `programs`: object array, 최소 1개/최대 20개
+ - 프로그램 필수 필드:
+ `name`, `type`, `programDate`, `startTime`, `description`
+ - 프로그램 선택 필드:
+ `endTime`, `venue`, `host`, `applicationUrl`, `whiskies`
+ - `type`: enum string
+ - `whiskies`: `x-field-style: alcohol-card-list`, 최대 10개
+- Schema vocabulary used by the UI:
+ - primitive `string`, `integer`, `number`, `boolean`
+ - `format: date|time`
+ - `enum`
+ - array of enum strings
+ - array of objects
+ - nested `x-field-style: alcohol-card-list`
+ - `required`, `nullable`, `minLength`, `maxLength`, `minimum`, `maximum`,
+ `minItems`, `maxItems`, `x-display-name`, `x-placeholder`
+- Mutation payload:
+ - Common fields keep the existing `CurationV2CreateRequest` /
+ `CurationV2UpdateRequest` contract.
+ - `payload` is a single object because the `PROGRAM` container is `object`.
+ - Optional empty strings and empty optional arrays are omitted or normalized
+ consistently before submission; required values are retained.
+
+## Acceptance Criteria
+
+- [ ] 활성 `PROGRAM` 스펙이 생성 유형 선택 화면에 표시된다.
+- [ ] 모든 활성 스펙 카드가 `/dashboard/curations/specs/{specCode}/new` 형식의
+ 단일 작성 페이지로 이동한다.
+- [ ] `PROGRAM` 작성하기가 준비중 토스트가 아니라 동적 생성 라우트로 이동한다.
+- [ ] 동적 라우트가 URL의 spec code로 활성 스펙 상세를 조회한다.
+- [ ] 행사 필드의 입력 종류, 필수 여부, 길이/최솟값/최댓값이 requestSpec에서 생성된다.
+- [ ] `programTags`와 `type` enum을 선택할 수 있다.
+- [ ] 프로그램을 1~20개 추가/삭제할 수 있고 배열 순서가 저장 순서가 된다.
+- [ ] 각 프로그램 안에서 시음 위스키를 최대 10개까지 추가/삭제할 수 있다.
+- [ ] 생성 요청의 `specId`와 object `payload`가 스펙 계약에 맞게 전송된다.
+- [ ] 기존 `PROGRAM` 상세는 읽기 전용 fallback 대신 수정 폼으로 렌더링된다.
+- [ ] 종료일은 시작일보다 빠를 수 없고, 프로그램 종료 시간은 시작 시간보다
+ 빠를 수 없다.
+- [ ] 기존 시음회/추천 위스키/페어링 생성·수정 동작이 유지된다.
+- [ ] 지원하지 않는 스키마 구조는 잘못된 text input으로 조용히 렌더링하지 않고
+ 명시적인 지원 불가 상태를 표시한다.
+
+## In Scope
+
+- `PROGRAM` spec code 타입 추가.
+- spec code 기반 동적 생성 라우트.
+- 기존 생성 라우트의 단일 작성 페이지 통합.
+- object requestSpec의 공통 스키마 기반 폼.
+- primitive, enum, enum array, object array, 중첩 alcohol card list 렌더링.
+- `PROGRAM` 생성과 수정.
+- 생성 유형 카드의 프로그램 표시와 진입.
+- 기존 시음회/추천 위스키/페어링 생성 화면의 공통 동적 라우트 통합.
+- 스키마 파서, 폼 모델, validation, mapper, 페이지 테스트.
+
+## Out of Scope
+
+- 백엔드 `program.json` 변경.
+- Product 앱의 프로그램 큐레이션 화면.
+- requestSpec에 없는 프로그램 전용 앱 미리보기 자동 생성.
+- 모든 JSON Schema 키워드와 임의 깊이의 재귀 구조 지원.
+- 프로그램 배열의 드래그 앤 드롭 정렬. 이번 범위에서는 추가 순서를 저장 순서로
+ 유지한다.
+
+## Open Questions
+
+- `PROGRAM`의 enum 값은 스펙에 별도 한글 라벨 메타데이터가 없다. 이번 구현은
+ enum code를 읽기 좋은 일반 표시 문자열로 변환한다. 정확한 한글 라벨이 필요하면
+ 백엔드 스펙에 enum 라벨 메타데이터 vocabulary를 추가해야 한다.
+- 개발 API는 직접 인증 조회가 불가능해 배포된 응답 대신 backend `main`의
+ `program.json`을 계약 소스로 사용한다.
diff --git a/src/pages/curation/CurationCreate.tsx b/src/pages/curation/CurationCreate.tsx
new file mode 100644
index 0000000..99af2d4
--- /dev/null
+++ b/src/pages/curation/CurationCreate.tsx
@@ -0,0 +1,158 @@
+import { AlertCircle, Loader2 } from 'lucide-react';
+import { useNavigate, useParams } from 'react-router';
+
+import { DetailPageHeader } from '@/components/common/DetailPageHeader';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent } from '@/components/ui/card';
+import { useCurationSpec, useCurationSpecByCode } from '@/hooks/useCurations';
+import {
+ CurationSpecCode,
+ type CurationV2Spec,
+ type CurationV2SpecCode,
+} from '@/types/api';
+
+import { SchemaDrivenCurationForm } from './schema-driven/SchemaDrivenCurationForm';
+import {
+ createSchemaDrivenCurationFormModel,
+ type SchemaDrivenCurationFormModel,
+} from './schema-driven/schema-driven-curation.form-model';
+import { WhiskyCurationForm } from './whisky-curation/WhiskyCurationForm';
+import {
+ createWhiskyCurationFormModel,
+ type WhiskyCurationFormModel,
+} from './whisky-curation/whisky-curation.schema';
+import { WhiskyTastingEventForm } from './whisky-tasting-event/WhiskyTastingEventForm';
+import {
+ createWhiskyTastingEventFormModel,
+ type WhiskyTastingEventFormModel,
+} from './whisky-tasting-event/whisky-tasting-event.form-model';
+
+type CurationCreateStrategy =
+ | { kind: 'tasting-event'; formModel: WhiskyTastingEventFormModel }
+ | { kind: 'whisky-curation'; formModel: WhiskyCurationFormModel }
+ | { kind: 'schema-driven'; formModel: SchemaDrivenCurationFormModel };
+
+export function CurationCreatePage() {
+ const navigate = useNavigate();
+ const { specCode } = useParams<{ specCode: CurationV2SpecCode }>();
+ const specsQuery = useCurationSpecByCode(specCode);
+ const targetSpec = specsQuery.data?.isActive ? specsQuery.data : null;
+ const specDetailQuery = useCurationSpec(targetSpec?.id, { showErrorToast: false });
+ const handleBack = () => navigate('/dashboard/curations');
+ let strategy: CurationCreateStrategy | null = null;
+ let schemaError: Error | null = null;
+
+ if (specDetailQuery.data) {
+ try {
+ strategy = resolveCurationCreateStrategy(specDetailQuery.data);
+ } catch (error) {
+ schemaError = error instanceof Error ? error : new Error('스펙을 해석하지 못했습니다.');
+ }
+ }
+
+ if (strategy) {
+ switch (strategy.kind) {
+ case 'tasting-event':
+ return (
+
+ );
+ case 'whisky-curation':
+ return (
+
+ );
+ case 'schema-driven':
+ return ;
+ }
+ }
+
+ const isLoading = specsQuery.isLoading || (Boolean(targetSpec) && specDetailQuery.isLoading);
+ const isError = specsQuery.isError || specDetailQuery.isError;
+ const title = targetSpec?.name ? `${targetSpec.name} 작성` : '큐레이션 작성';
+
+ return (
+
+
+ 목록
+
+ }
+ />
+
+
+
+ {isLoading ? (
+
+
+ 큐레이션 스펙을 불러오는 중입니다.
+
+ ) : (
+
+
+
+
+
+ {schemaError
+ ? '이 스펙은 아직 자동 폼에서 지원하지 않습니다.'
+ : isError
+ ? '큐레이션 스펙을 불러오지 못했습니다.'
+ : '큐레이션 스펙을 찾을 수 없습니다.'}
+
+
+ {schemaError?.message ??
+ (isError
+ ? '잠시 후 다시 시도해 주세요.'
+ : '활성화된 큐레이션 스펙이 등록되어 있지 않습니다.')}
+
+
+
+ {isError && (
+
{
+ void specsQuery.refetch();
+ void specDetailQuery.refetch();
+ }}
+ >
+ 다시 시도
+
+ )}
+
+ )}
+
+
+
+ );
+}
+
+function resolveCurationCreateStrategy(spec: CurationV2Spec): CurationCreateStrategy {
+ switch (spec.code) {
+ case CurationSpecCode.WHISKY_TASTING_EVENT:
+ return {
+ kind: 'tasting-event',
+ formModel: createWhiskyTastingEventFormModel(spec),
+ };
+ case CurationSpecCode.RECOMMENDED_WHISKY:
+ case CurationSpecCode.WHISKY_PAIRING:
+ return {
+ kind: 'whisky-curation',
+ formModel: createWhiskyCurationFormModel(spec),
+ };
+ default:
+ return {
+ kind: 'schema-driven',
+ formModel: createSchemaDrivenCurationFormModel(spec),
+ };
+ }
+}
diff --git a/src/pages/curation/CurationDetail.tsx b/src/pages/curation/CurationDetail.tsx
index 3e181a9..d9ef6c5 100644
--- a/src/pages/curation/CurationDetail.tsx
+++ b/src/pages/curation/CurationDetail.tsx
@@ -10,6 +10,7 @@ import { CurationSpecCode, type CurationV2Detail } from '@/types/api';
import { CurationWhiskyTastingEventEditPage } from './whisky-tasting-event/CurationWhiskyTastingEventEdit';
import { WhiskyCurationEditPage } from './whisky-curation/WhiskyCurationEditPage';
+import { SchemaDrivenCurationEditPage } from './schema-driven/SchemaDrivenCurationEditPage';
import {
formatCurationDateTime,
formatCurationExposurePeriod,
@@ -37,6 +38,10 @@ export function CurationDetailPage() {
return ;
}
+ if (!isLoading && !isError && curation?.spec.code === CurationSpecCode.PROGRAM) {
+ return ;
+ }
+
return (
= {
WHISKY_TASTING_EVENT: {
- href: '/dashboard/curations/tasting-events/new',
+ hasPreview: true,
icon: CalendarDays,
order: 1,
label: '시음회',
@@ -66,7 +66,7 @@ const CURATION_SPEC_UI_CONFIG: Record(
CurationSpecCode.WHISKY_TASTING_EVENT
);
- const { showToast } = useToast();
const { data: specs = [], isError, isLoading, refetch } = useCurationSpecs();
const activeSpecs = specs.filter((spec) => spec.isActive).sort(compareCurationSpecs);
@@ -174,15 +185,7 @@ export function CurationEntryPage() {
const example = config?.example ?? spec.name;
const fields = config?.fields ?? ['이름', '설명', '커버', 'payload'];
const handleStart = () => {
- if (config) {
- navigate(config.href);
- return;
- }
-
- showToast({
- type: 'info',
- message: '준비중입니다',
- });
+ navigate(`/dashboard/curations/specs/${encodeURIComponent(spec.code)}/new`);
};
return (
@@ -212,22 +215,23 @@ export function CurationEntryPage() {
{label}
- {
- event.stopPropagation();
- if (config) {
+ {config?.hasPreview && (
+ {
+ event.stopPropagation();
setSelectedPreviewCode(spec.code as KnownCurationV2SpecCode);
- }
- }}
- >
- 미리보기
-
+ }}
+ >
+ 미리보기
+
+ )}
diff --git a/src/pages/curation/__tests__/CurationCreate.test.tsx b/src/pages/curation/__tests__/CurationCreate.test.tsx
new file mode 100644
index 0000000..eac1d8d
--- /dev/null
+++ b/src/pages/curation/__tests__/CurationCreate.test.tsx
@@ -0,0 +1,108 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { screen } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+
+import { server } from '@/test/mocks/server';
+import { wrapApiResponse } from '@/test/mocks/data';
+import { render } from '@/test/test-utils';
+import type { CurationV2Spec } from '@/types/api';
+
+import { CurationCreatePage } from '../CurationCreate';
+
+const SPEC_BASE = '/admin/api/v2/curation-specs';
+const routeState = vi.hoisted(() => ({ specCode: 'PROGRAM' }));
+
+vi.mock('react-router', async (importOriginal) => {
+ const actual = await importOriginal();
+
+ return {
+ ...actual,
+ useNavigate: () => vi.fn(),
+ useParams: () => ({ specCode: routeState.specCode }),
+ };
+});
+
+vi.mock('../whisky-tasting-event/WhiskyTastingEventForm', () => ({
+ WhiskyTastingEventForm: () => 시음회 전략 렌더러
,
+}));
+
+vi.mock('../whisky-tasting-event/whisky-tasting-event.form-model', () => ({
+ createWhiskyTastingEventFormModel: () => ({ kind: 'tasting-event' }),
+}));
+
+vi.mock('../whisky-curation/WhiskyCurationForm', () => ({
+ WhiskyCurationForm: () => 위스키 큐레이션 전략 렌더러
,
+}));
+
+vi.mock('../whisky-curation/whisky-curation.schema', () => ({
+ createWhiskyCurationFormModel: () => ({ kind: 'whisky-curation' }),
+}));
+
+vi.mock('../schema-driven/SchemaDrivenCurationForm', () => ({
+ SchemaDrivenCurationForm: () => schema-driven 전략 렌더러
,
+}));
+
+vi.mock('../schema-driven/schema-driven-curation.form-model', () => ({
+ createSchemaDrivenCurationFormModel: () => ({ kind: 'schema-driven' }),
+}));
+
+describe('CurationCreatePage', () => {
+ beforeEach(() => {
+ routeState.specCode = 'PROGRAM';
+ });
+
+ it.each([
+ ['WHISKY_TASTING_EVENT', '시음회 전략 렌더러'],
+ ['RECOMMENDED_WHISKY', '위스키 큐레이션 전략 렌더러'],
+ ['WHISKY_PAIRING', '위스키 큐레이션 전략 렌더러'],
+ ['PROGRAM', 'schema-driven 전략 렌더러'],
+ ])('같은 페이지가 %s 스펙에 맞는 렌더러를 선택한다', async (specCode, expectedText) => {
+ routeState.specCode = specCode;
+ mockSpec(specCode);
+
+ render( );
+
+ expect(await screen.findByText(expectedText)).toBeInTheDocument();
+ });
+
+ it('URL의 spec code에 해당하는 활성 스펙이 없으면 missing 상태를 표시한다', async () => {
+ routeState.specCode = 'INACTIVE';
+ mockSpec('INACTIVE', false);
+
+ render( );
+
+ expect(await screen.findByText('큐레이션 스펙을 찾을 수 없습니다.')).toBeInTheDocument();
+ });
+});
+
+function mockSpec(code: string, isActive = true) {
+ const spec: CurationV2Spec = {
+ id: 10,
+ code,
+ name: code,
+ description: null,
+ hydratorKey: null,
+ version: 1,
+ isActive,
+ requestSpec: { type: 'object', properties: {} },
+ responseSpec: { type: 'object' },
+ };
+
+ server.use(
+ http.get(SPEC_BASE, () =>
+ HttpResponse.json(
+ wrapApiResponse([
+ {
+ id: spec.id,
+ code: spec.code,
+ name: spec.name,
+ description: spec.description,
+ version: spec.version,
+ isActive: spec.isActive,
+ },
+ ])
+ )
+ ),
+ http.get(`${SPEC_BASE}/:specId`, () => HttpResponse.json(wrapApiResponse(spec)))
+ );
+}
diff --git a/src/pages/curation/__tests__/CurationDetail.test.tsx b/src/pages/curation/__tests__/CurationDetail.test.tsx
index 30d9abb..3e3cbfa 100644
--- a/src/pages/curation/__tests__/CurationDetail.test.tsx
+++ b/src/pages/curation/__tests__/CurationDetail.test.tsx
@@ -9,6 +9,7 @@ import { render } from '@/test/test-utils';
import type { CurationV2Spec, CurationV2UpdateRequest } from '@/types/api';
import { CurationDetailPage } from '../CurationDetail';
+import { programSpec } from '../schema-driven/__tests__/program-spec.fixture';
vi.mock('react-router', async () => {
const actual = await vi.importActual('react-router');
@@ -140,6 +141,49 @@ const tastingEventSpec: CurationV2Spec = {
};
describe('CurationDetailPage', () => {
+ it('PROGRAM 큐레이션은 스펙 기반 수정 폼으로 렌더링한다', async () => {
+ server.use(
+ http.get(`${CURATION_BASE}/:curationId`, () =>
+ HttpResponse.json(
+ wrapApiResponse({
+ id: 10,
+ name: '2026 바쇼',
+ description: '행사 소개',
+ coverImageUrl: null,
+ imageUrls: [],
+ exposureStartDate: '2026-07-01',
+ exposureEndDate: '2026-07-31',
+ displayOrder: 0,
+ isActive: true,
+ createdAt: '2026-07-01T00:00:00',
+ modifiedAt: '2026-07-01T00:00:00',
+ spec: programSpec,
+ payload: {
+ eventStartDate: '2026-07-24',
+ eventEndDate: '2026-07-26',
+ placeName: '코엑스',
+ address: '서울 강남구 영동대로 513',
+ programs: [
+ {
+ name: '마스터클래스',
+ type: 'MASTER_CLASS',
+ programDate: '2026-07-24',
+ startTime: '14:00',
+ description: '행사 설명',
+ },
+ ],
+ },
+ })
+ )
+ )
+ );
+
+ render( );
+
+ expect(await screen.findByRole('heading', { name: '프로그램 수정' })).toBeInTheDocument();
+ expect(screen.getByLabelText('1번 프로그램 프로그램명')).toHaveValue('마스터클래스');
+ });
+
it('시음회 큐레이션은 작성 폼에 기존 값을 채우고 수정 API로 저장한다', async () => {
const user = userEvent.setup();
let capturedBody: CurationV2UpdateRequest | null = null;
@@ -368,5 +412,4 @@ describe('CurationDetailPage', () => {
expect(screen.getByText('channel')).toBeInTheDocument();
expect(screen.getByText('home')).toBeInTheDocument();
});
-
});
diff --git a/src/pages/curation/__tests__/CurationEntry.test.tsx b/src/pages/curation/__tests__/CurationEntry.test.tsx
index 807d068..3fce1a1 100644
--- a/src/pages/curation/__tests__/CurationEntry.test.tsx
+++ b/src/pages/curation/__tests__/CurationEntry.test.tsx
@@ -11,6 +11,16 @@ import { CurationEntryPage } from '../CurationEntry';
const SPEC_BASE = '/admin/api/v2/curation-specs';
const mockShowToast = vi.hoisted(() => vi.fn());
+const mockNavigate = vi.hoisted(() => vi.fn());
+
+vi.mock('react-router', async (importOriginal) => {
+ const actual = await importOriginal();
+
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
vi.mock('@/hooks/useToast', async (importOriginal) => {
const actual = await importOriginal();
@@ -53,6 +63,14 @@ const mockSpecs: CurationV2SpecListItem[] = [
},
{
id: 4,
+ code: 'PROGRAM',
+ name: '프로그램',
+ description: '여러 프로그램과 프로그램별 위스키 라인업을 발행합니다.',
+ version: 1,
+ isActive: true,
+ },
+ {
+ id: 5,
code: 'INACTIVE_SPEC',
name: '비활성 스펙',
description: '비활성 스펙',
@@ -72,6 +90,7 @@ function mockSpecList(specs: CurationV2SpecListItem[] = mockSpecs) {
describe('CurationEntryPage', () => {
beforeEach(() => {
mockShowToast.mockClear();
+ mockNavigate.mockClear();
});
const getFirstPreviewButton = async () => {
@@ -97,6 +116,7 @@ describe('CurationEntryPage', () => {
'시음회',
'일반 큐레이션',
'페어링 · 위스키 → 음식',
+ '프로그램',
]);
expect(
screen.getByText('특정 바에서 특정 기간 동안 위스키를 시음하는 모집을 만듭니다.')
@@ -111,16 +131,48 @@ describe('CurationEntryPage', () => {
render( );
- expect(await screen.findAllByRole('button', { name: /작성하기/ })).toHaveLength(3);
+ expect(await screen.findAllByRole('button', { name: /작성하기/ })).toHaveLength(4);
expect(screen.getAllByRole('button', { name: /미리보기/ })).toHaveLength(3);
});
- it('준비되지 않은 활성 스펙의 작성하기를 누르면 준비중 안내를 표시한다', async () => {
+ it('PROGRAM 작성하기는 spec code 기반 동적 라우트로 이동하고 미리보기는 표시하지 않는다', async () => {
+ const user = userEvent.setup();
+ mockSpecList();
+
+ render( );
+
+ await user.click(await screen.findByRole('button', { name: '프로그램 작성하기' }));
+
+ expect(mockNavigate).toHaveBeenCalledWith('/dashboard/curations/specs/PROGRAM/new');
+ expect(screen.queryByRole('button', { name: '프로그램 미리보기' })).not.toBeInTheDocument();
+ });
+
+ it('기존 전용 스펙 카드도 동일한 spec code 작성 라우트로 이동한다', async () => {
+ const user = userEvent.setup();
+ mockSpecList();
+
+ render( );
+
+ await user.click(await screen.findByRole('button', { name: '시음회 작성하기' }));
+ expect(mockNavigate).toHaveBeenLastCalledWith(
+ '/dashboard/curations/specs/WHISKY_TASTING_EVENT/new'
+ );
+
+ await user.click(screen.getByRole('button', { name: '일반 큐레이션 작성하기' }));
+ expect(mockNavigate).toHaveBeenLastCalledWith(
+ '/dashboard/curations/specs/RECOMMENDED_WHISKY/new'
+ );
+
+ await user.click(screen.getByRole('button', { name: '페어링 · 위스키 → 음식 작성하기' }));
+ expect(mockNavigate).toHaveBeenLastCalledWith('/dashboard/curations/specs/WHISKY_PAIRING/new');
+ });
+
+ it('UI 설정이 없는 활성 스펙도 동적 작성 라우트로 이동한다', async () => {
const user = userEvent.setup();
mockSpecList([
...mockSpecs,
{
- id: 5,
+ id: 6,
code: 'SEASONAL_PICK',
name: '시즌 큐레이션',
description: '아직 작성 화면이 준비되지 않은 스펙',
@@ -135,10 +187,8 @@ describe('CurationEntryPage', () => {
await user.click(screen.getByRole('button', { name: '시즌 큐레이션 작성하기' }));
- expect(mockShowToast).toHaveBeenCalledWith({
- type: 'info',
- message: '준비중입니다',
- });
+ expect(mockNavigate).toHaveBeenCalledWith('/dashboard/curations/specs/SEASONAL_PICK/new');
+ expect(mockShowToast).not.toHaveBeenCalled();
});
it('미리보기 패널은 기본 시음회 앱 프레임을 표시한다', async () => {
diff --git a/src/pages/curation/components/CurationFormFieldRenderer.tsx b/src/pages/curation/components/CurationFormFieldRenderer.tsx
index 8fecdbd..36ce000 100644
--- a/src/pages/curation/components/CurationFormFieldRenderer.tsx
+++ b/src/pages/curation/components/CurationFormFieldRenderer.tsx
@@ -8,15 +8,25 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import type {
CurationBooleanRadioFieldModel,
CurationBasicFieldModel,
CurationFieldModel,
+ CurationMultiSelectFieldModel,
CurationNumberFieldModel,
+ CurationSelectFieldModel,
CurationTextFieldModel,
} from '../curation-form-model';
+import { CurationObjectArrayField } from './CurationObjectArrayField';
import {
CurationWhiskyCardListField,
type CurationWhiskyCardListFieldOptions,
@@ -52,6 +62,8 @@ const STANDARD_FIELD_RENDERER_REGISTRY: Record<
textarea: renderTextAreaInput,
number: renderNumberInput,
'boolean-radio': renderBooleanRadioInput,
+ select: renderSelectInput,
+ 'multi-select': renderMultiSelectInput,
};
// 4. renderer registry 레이어: field model의 kind에 맞는 입력 컴포넌트를 선택해 렌더링합니다.
@@ -73,6 +85,16 @@ export function CurationFormFieldRenderer({
);
}
+ if (field.kind === 'object-array') {
+ return (
+
+ );
+ }
+
return ;
}
@@ -95,6 +117,18 @@ function StandardCurationFormFieldRenderer({
);
}
+function renderSelectInput({ field, form, name }: StandardFieldRendererProps) {
+ if (field.kind !== 'select') return null;
+
+ return ;
+}
+
+function renderMultiSelectInput({ field, form, name }: StandardFieldRendererProps) {
+ if (field.kind !== 'multi-select') return null;
+
+ return ;
+}
+
// 일반 입력 필드의 세부 kind별 실제 input 요소를 생성합니다.
function renderStandardInput(
field: CurationBasicFieldModel,
@@ -109,7 +143,7 @@ function renderStandardInput(
function renderTextInput({ field, form, name }: StandardFieldRendererProps) {
if (field.kind !== 'text' && field.kind !== 'date' && field.kind !== 'time') return null;
- if (field.kind === 'text' && field.key === 'placeName') {
+ if (field.kind === 'text' && field.usePlaceSearch) {
return ;
}
@@ -156,7 +190,7 @@ function TextInputField({
}) {
return (
>;
+ name: string;
+}) {
+ const value = useWatch({ control: form.control, name });
+
+ return (
+
+ form.setValue(name, nextValue, { shouldDirty: true, shouldValidate: true })
+ }
+ >
+
+
+
+
+ {field.options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ );
+}
+
+function MultiSelectField({
+ field,
+ form,
+ name,
+}: {
+ field: CurationMultiSelectFieldModel;
+ form: ReturnType>;
+ name: string;
+}) {
+ const watchedValue = useWatch({ control: form.control, name });
+ const values = Array.isArray(watchedValue)
+ ? watchedValue.filter((value): value is string => typeof value === 'string')
+ : [];
+ const maxReached = typeof field.maxItems === 'number' && values.length >= field.maxItems;
+
+ return (
+
+ {field.options.map((option) => {
+ const checked = values.includes(option.value);
+ const id = `${name}-${option.value}`;
+
+ return (
+
+ {
+ const nextValues =
+ nextChecked === true
+ ? [...values, option.value]
+ : values.filter((value) => value !== option.value);
+ form.setValue(name, nextValues, {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ }}
+ />
+
+ {option.label}
+
+
+ );
+ })}
+
+ );
+}
+
// address field model을 장소 검색 입력 컴포넌트로 렌더링합니다.
function AddressField({
field,
@@ -177,7 +295,7 @@ function AddressField({
}) {
return (
void;
+ sectionHeader?: {
+ stepNumber?: number;
+ description?: string;
+ };
+}
+
+export function CurationObjectArrayField({
+ fieldModel,
+ onImageUploadingChange,
+ sectionHeader,
+}: CurationObjectArrayFieldProps) {
+ const form = useFormContext();
+ const fieldArray = useFieldArray({
+ control: form.control,
+ name: fieldModel.key,
+ });
+ const watchedItems = useWatch({
+ control: form.control,
+ name: fieldModel.key,
+ });
+ const items = Array.isArray(watchedItems) ? watchedItems : [];
+ const isMaxReached =
+ typeof fieldModel.maxItems === 'number' && items.length >= fieldModel.maxItems;
+ const fieldError = form.getFieldState(fieldModel.key, form.formState).error;
+ const limitDescription =
+ typeof fieldModel.maxItems === 'number'
+ ? `${fieldModel.minItems}-${fieldModel.maxItems}개까지 등록할 수 있습니다.`
+ : `${fieldModel.minItems}개 이상 등록할 수 있습니다.`;
+
+ return (
+
+ {fieldModel.label}
+ {fieldModel.required && * }
+ >
+ }
+ description={
+ <>
+ {sectionHeader?.description && {sectionHeader.description} }
+ {limitDescription}
+ >
+ }
+ titleSuffix={items.length > 0 ? {items.length} : null}
+ contentClassName="space-y-4"
+ >
+ {fieldError?.message && {fieldError.message}
}
+
+ {fieldArray.fields.length === 0 ? (
+
+ {fieldModel.label}을 추가해주세요.
+
+ ) : (
+
+ {fieldArray.fields.map((arrayField, index) => (
+
+
+
+ {fieldModel.label} {index + 1}
+
+ fieldArray.remove(index)}
+ >
+ 삭제
+
+
+
+
+ {fieldModel.itemFields.map((itemField) => {
+ const nestedField = createNestedFieldModel(fieldModel, itemField, index);
+
+ return (
+
+ );
+ })}
+
+
+ ))}
+
+ )}
+
+
+ fieldArray.append(
+ Object.fromEntries(
+ fieldModel.itemFields.map((field) => [
+ field.key,
+ createDefaultCurationFieldValue(field),
+ ])
+ ),
+ { shouldFocus: false }
+ )
+ }
+ >
+
+ {fieldModel.label} 추가
+
+
+ );
+}
+
+function createNestedFieldModel(
+ parent: CurationObjectArrayFieldModel,
+ field: CurationFieldModel,
+ index: number
+): CurationFieldModel {
+ const nestedField = prefixCurationFieldModelKey(field, `${parent.key}.${index}`);
+
+ return {
+ ...nestedField,
+ ariaLabel: `${index + 1}번 ${parent.label} ${field.label}`,
+ };
+}
diff --git a/src/pages/curation/components/CurationWhiskyCardListField.tsx b/src/pages/curation/components/CurationWhiskyCardListField.tsx
index 414e3fc..6ae133e 100644
--- a/src/pages/curation/components/CurationWhiskyCardListField.tsx
+++ b/src/pages/curation/components/CurationWhiskyCardListField.tsx
@@ -7,7 +7,7 @@ import {
type PointerEvent,
type ReactNode,
} from 'react';
-import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
+import { useFieldArray, useFormContext, useWatch, type FieldValues } from 'react-hook-form';
import { GripVertical, Loader2, Plus, Upload, X } from 'lucide-react';
import { FormField } from '@/components/common/FormField';
@@ -35,7 +35,6 @@ import {
} from '../curation-whisky-card-list.mapper';
import type {
CurationWhiskyCardListFieldModel,
- CurationWhiskyCardListFormValues,
CurationWhiskyCardValue,
CurationWhiskyMirror,
CurationWhiskyStats,
@@ -79,7 +78,8 @@ export function CurationWhiskyCardListField({
showCommentField = true,
renderItemExtra,
}: CurationWhiskyCardListFieldProps) {
- const form = useFormContext();
+ const form = useFormContext();
+ const fieldPath = fieldModel.key;
const fetchAlcoholDetail = useAdminAlcoholDetailLookup();
const { data: categoryReferenceData } = useCategoryReferences();
const { data: regionData } = useRegionList({ size: 200, sortOrder: 'ASC' });
@@ -101,13 +101,12 @@ export function CurationWhiskyCardListField({
const localManualImageUrlsRef = useRef>(new Set());
const alcoholFieldArray = useFieldArray({
control: form.control,
- name: 'alcohols',
+ name: fieldPath,
});
- const watchedAlcohols =
- useWatch({
- control: form.control,
- name: 'alcohols',
- }) ?? [];
+ const watchedAlcohols = (useWatch({
+ control: form.control,
+ name: fieldPath,
+ }) ?? []) as CurationWhiskyCardValue[];
const selectedAlcoholIds = watchedAlcohols
.map((item) => item.alcohol.alcoholId)
.filter((id): id is number => typeof id === 'number');
@@ -142,7 +141,7 @@ export function CurationWhiskyCardListField({
const handleAddEmptyWhisky = () => {
if (isAddDisabled) return;
- form.clearErrors('alcohols');
+ form.clearErrors(fieldPath);
alcoholFieldArray.append(createEmptyItem(), { shouldFocus: false });
};
@@ -158,13 +157,13 @@ export function CurationWhiskyCardListField({
0,
fieldModel.selectedTags.maxItems
);
- form.setValue(`alcohols.${index}` as const, transformItem(curationWhiskyItem), {
+ form.setValue(`${fieldPath}.${index}`, transformItem(curationWhiskyItem), {
shouldDirty: true,
shouldValidate: true,
});
} catch {
form.setValue(
- `alcohols.${index}` as const,
+ `${fieldPath}.${index}`,
transformItem(createBottleNoteCurationWhiskyItem(whisky)),
{
shouldDirty: true,
@@ -181,10 +180,12 @@ export function CurationWhiskyCardListField({
};
const handleUseManualInput = (fieldId: string, index: number) => {
- const currentItem = form.getValues(`alcohols.${index}` as const);
+ const currentItem = form.getValues(`${fieldPath}.${index}`) as
+ | CurationWhiskyCardValue
+ | undefined;
if (!currentItem || currentItem.source !== 'MANUAL') {
- form.setValue(`alcohols.${index}` as const, createEmptyItem(), {
+ form.setValue(`${fieldPath}.${index}`, createEmptyItem(), {
shouldDirty: true,
shouldValidate: true,
});
@@ -205,8 +206,8 @@ export function CurationWhiskyCardListField({
const trimmedValue = value.trim();
if (!trimmedValue) return false;
- const path = `alcohols.${index}.alcohol.selectedTags` as const;
- const currentTags = form.getValues(path) ?? [];
+ const path = `${fieldPath}.${index}.alcohol.selectedTags`;
+ const currentTags = (form.getValues(path) ?? []) as string[];
if (
currentTags.includes(trimmedValue) ||
currentTags.length >= fieldModel.selectedTags.maxItems
@@ -223,8 +224,8 @@ export function CurationWhiskyCardListField({
};
const handleRemoveTag = (index: number, tag: string) => {
- const path = `alcohols.${index}.alcohol.selectedTags` as const;
- const currentTags = form.getValues(path);
+ const path = `${fieldPath}.${index}.alcohol.selectedTags`;
+ const currentTags = (form.getValues(path) ?? []) as string[];
form.setValue(
path,
currentTags.filter((item) => item !== tag),
@@ -236,11 +237,14 @@ export function CurationWhiskyCardListField({
};
const handleHideWhiskyStatsItem = (index: number, key: RemovableWhiskyStatsKey) => {
- const currentStats = form.getValues(`alcohols.${index}.stats` as const);
+ const currentStats = form.getValues(`${fieldPath}.${index}.stats`) as
+ | CurationWhiskyStats
+ | null
+ | undefined;
if (!currentStats) return;
form.setValue(
- `alcohols.${index}.stats` as const,
+ `${fieldPath}.${index}.stats`,
{
...currentStats,
[key]: null,
@@ -288,7 +292,7 @@ export function CurationWhiskyCardListField({
return;
}
- const path = `alcohols.${index}.alcohol.imageUrl` as const;
+ const path = `${fieldPath}.${index}.alcohol.imageUrl`;
const previewUrl = URL.createObjectURL(file);
localManualImageUrlsRef.current.add(previewUrl);
form.setValue(path, previewUrl, {
@@ -317,7 +321,7 @@ export function CurationWhiskyCardListField({
};
const handleRemoveManualWhiskyImage = (index: number) => {
- const path = `alcohols.${index}.alcohol.imageUrl` as const;
+ const path = `${fieldPath}.${index}.alcohol.imageUrl`;
const currentImageUrl = form.getValues(path);
if (currentImageUrl) {
revokeLocalManualImageUrl(currentImageUrl);
@@ -329,7 +333,7 @@ export function CurationWhiskyCardListField({
};
const handleRemoveWhisky = (fieldId: string, index: number) => {
- form.clearErrors('alcohols');
+ form.clearErrors(fieldPath);
alcoholFieldArray.remove(index);
resetWhiskyDragState();
setManualInputFieldIds((prev) => {
@@ -403,7 +407,7 @@ export function CurationWhiskyCardListField({
sourceIndex !== targetIndex
) {
alcoholFieldArray.move(sourceIndex, targetIndex);
- void form.trigger('alcohols');
+ void form.trigger(fieldPath);
}
resetWhiskyDragState();
@@ -443,8 +447,10 @@ export function CurationWhiskyCardListField({
)}
- {form.formState.errors.alcohols?.message && (
- {form.formState.errors.alcohols.message}
+ {form.getFieldState(fieldPath, form.formState).error?.message && (
+
+ {form.getFieldState(fieldPath, form.formState).error?.message}
+
)}
{watchedAlcohols.length === 0 ? (
@@ -455,7 +461,15 @@ export function CurationWhiskyCardListField({
{alcoholFieldArray.fields.map((field, index) => {
const item = watchedAlcohols[index];
- const itemError = form.formState.errors.alcohols?.[index];
+ const itemError = form.getFieldState(`${fieldPath}.${index}`, form.formState).error as
+ | {
+ alcohol?: {
+ korName?: { message?: string };
+ selectedTags?: { message?: string };
+ };
+ comment?: { message?: string };
+ }
+ | undefined;
const tags = item?.alcohol.selectedTags ?? [];
const tagInput = tagInputs[field.id] ?? '';
const itemName =
@@ -616,14 +630,14 @@ export function CurationWhiskyCardListField({
>
@@ -679,21 +693,21 @@ export function CurationWhiskyCardListField({
@@ -702,14 +716,10 @@ export function CurationWhiskyCardListField({
ariaLabel={`${manualFieldPrefix} 지역`}
value={manualRegionValue}
onChange={(value) =>
- form.setValue(
- `alcohols.${index}.alcohol.regionName` as const,
- value,
- {
- shouldDirty: true,
- shouldValidate: true,
- }
- )
+ form.setValue(`${fieldPath}.${index}.alcohol.regionName`, value, {
+ shouldDirty: true,
+ shouldValidate: true,
+ })
}
options={manualRegionOptionsForValue}
placeholder="지역 선택"
@@ -723,7 +733,7 @@ export function CurationWhiskyCardListField({
value={manualCategoryValue}
onChange={(value) =>
form.setValue(
- `alcohols.${index}.alcohol.korCategory` as const,
+ `${fieldPath}.${index}.alcohol.korCategory`,
value,
{
shouldDirty: true,
@@ -803,7 +813,7 @@ export function CurationWhiskyCardListField({
aria-label={`${itemName} ${fieldModel.comment.label}`}
rows={5}
maxLength={fieldModel.comment.maxLength}
- {...form.register(`alcohols.${index}.comment` as const)}
+ {...form.register(`${fieldPath}.${index}.comment`)}
placeholder={`${formatCurationFieldObject(fieldModel.comment.label)} 작성해주세요.`}
className="min-h-40 resize-none rounded-[10px] border-border"
/>
@@ -825,7 +835,7 @@ export function CurationWhiskyCardListField({
className="h-14 w-full rounded-[10px] text-base font-semibold"
onClick={handleAddEmptyWhisky}
disabled={isAddDisabled}
- aria-label={`${fieldModel.label} 추가`}
+ aria-label={`${fieldModel.ariaLabel ?? fieldModel.label} 추가`}
>
추가
diff --git a/src/pages/curation/curation-display-utils.ts b/src/pages/curation/curation-display-utils.ts
index 9a2e10a..e802c9e 100644
--- a/src/pages/curation/curation-display-utils.ts
+++ b/src/pages/curation/curation-display-utils.ts
@@ -10,6 +10,8 @@ export function formatCurationSpecCode(code: string): string {
return '추천 위스키';
case 'WHISKY_PAIRING':
return '위스키 페어링';
+ case 'PROGRAM':
+ return '프로그램';
default:
return code;
}
diff --git a/src/pages/curation/curation-form-model.ts b/src/pages/curation/curation-form-model.ts
index 601aa07..9137ebf 100644
--- a/src/pages/curation/curation-form-model.ts
+++ b/src/pages/curation/curation-form-model.ts
@@ -10,7 +10,10 @@ export type CurationFieldKind =
| 'address'
| 'number'
| 'boolean-radio'
- | 'alcohol-card-list';
+ | 'select'
+ | 'multi-select'
+ | 'alcohol-card-list'
+ | 'object-array';
export interface CurationBaseFieldModel {
key: string;
@@ -21,11 +24,15 @@ export interface CurationBaseFieldModel {
fieldStyle?: string;
placeholder?: string;
defaultValue?: unknown;
+ nullable?: boolean;
+ ariaLabel?: string;
}
export interface CurationTextFieldModel extends CurationBaseFieldModel {
kind: 'text' | 'textarea' | 'date' | 'time' | 'address';
+ minLength?: number;
maxLength?: number;
+ usePlaceSearch?: boolean;
}
export interface CurationNumberFieldModel extends CurationBaseFieldModel {
@@ -47,12 +54,41 @@ export interface CurationBooleanRadioFieldModel extends CurationBaseFieldModel {
falseLabel: string;
}
+export interface CurationSelectOption {
+ value: string;
+ label: string;
+}
+
+export interface CurationSelectFieldModel extends CurationBaseFieldModel {
+ kind: 'select';
+ options: CurationSelectOption[];
+}
+
+export interface CurationMultiSelectFieldModel extends CurationBaseFieldModel {
+ kind: 'multi-select';
+ options: CurationSelectOption[];
+ minItems: number;
+ maxItems?: number;
+}
+
export type CurationBasicFieldModel =
| CurationTextFieldModel
| CurationNumberFieldModel
- | CurationBooleanRadioFieldModel;
+ | CurationBooleanRadioFieldModel
+ | CurationSelectFieldModel
+ | CurationMultiSelectFieldModel;
-export type CurationFieldModel = CurationBasicFieldModel | CurationWhiskyCardListFieldModel;
+export interface CurationObjectArrayFieldModel extends CurationBaseFieldModel {
+ kind: 'object-array';
+ minItems: number;
+ maxItems?: number;
+ itemFields: CurationFieldModel[];
+}
+
+export type CurationFieldModel =
+ | CurationBasicFieldModel
+ | CurationWhiskyCardListFieldModel
+ | CurationObjectArrayFieldModel;
export interface CurationFieldVisibilityCondition {
fieldKey: string;
@@ -98,34 +134,66 @@ export function createCurationFormModelFromRequestSpec(
requestSpec: JsonSchemaNode,
options: CreateCurationFormModelOptions
): CurationFormModel {
- const payloadFields = Object.keys(getSchemaProperties(requestSpec)).map((key) => {
- const fieldSchema = getSchemaProperty(requestSpec, key);
- const kind = getSchemaFieldKind(key, fieldSchema);
- const customField = options.createCustomField?.({ requestSpec, fieldSchema, key, kind });
+ const payloadFields = Object.keys(getSchemaProperties(requestSpec)).map((key) =>
+ createCurationFieldModel(requestSpec, key, options)
+ );
+
+ return {
+ payloadFields,
+ sections: options.createSections(payloadFields),
+ };
+}
+
+function createCurationFieldModel(
+ requestSpec: JsonSchemaNode,
+ key: string,
+ options: Pick
= {}
+): CurationFieldModel {
+ const fieldSchema = getSchemaProperty(requestSpec, key);
+ const kind = getSchemaFieldKind(key, fieldSchema);
+ const customField = options.createCustomField?.({ requestSpec, fieldSchema, key, kind });
- if (customField) {
- return customField;
+ if (customField) {
+ return customField;
+ }
+
+ if (kind === 'alcohol-card-list') {
+ if (!fieldSchema.items) {
+ throw new Error(`지원하지 않는 큐레이션 스키마 필드: ${key}의 items가 없습니다.`);
}
- if (kind === 'alcohol-card-list') {
- return createAlcoholCardListFieldModel({
- key,
- label: getSchemaDisplayLabel(fieldSchema),
- required: isSchemaPropertyRequired(requestSpec, key),
- minItems: fieldSchema.minItems ?? 1,
- maxItems: typeof fieldSchema.maxItems === 'number' ? fieldSchema.maxItems : undefined,
- itemSchema: fieldSchema.items!,
- });
+ return createAlcoholCardListFieldModel({
+ key,
+ label: getSchemaDisplayLabel(fieldSchema),
+ required: isSchemaPropertyRequired(requestSpec, key),
+ minItems: fieldSchema.minItems ?? (isSchemaPropertyRequired(requestSpec, key) ? 1 : 0),
+ maxItems: typeof fieldSchema.maxItems === 'number' ? fieldSchema.maxItems : undefined,
+ itemSchema: fieldSchema.items,
+ });
+ }
+
+ if (kind === 'object-array') {
+ const itemSchema = fieldSchema.items;
+ if (!itemSchema || itemSchema.type !== 'object') {
+ throw new Error(`지원하지 않는 큐레이션 스키마 필드: ${key}의 object items가 없습니다.`);
}
- const basicField = createCurationBasicFieldModel(requestSpec, key);
- return options.overrideField?.(basicField) ?? basicField;
- });
+ return {
+ key,
+ kind,
+ label: getSchemaDisplayLabel(fieldSchema) || key,
+ required: isSchemaPropertyRequired(requestSpec, key),
+ nullable: fieldSchema.nullable,
+ minItems: fieldSchema.minItems ?? 0,
+ maxItems: typeof fieldSchema.maxItems === 'number' ? fieldSchema.maxItems : undefined,
+ itemFields: Object.keys(getSchemaProperties(itemSchema)).map((itemKey) =>
+ createCurationFieldModel(itemSchema, itemKey)
+ ),
+ };
+ }
- return {
- payloadFields,
- sections: options.createSections(payloadFields),
- };
+ const basicField = createCurationBasicFieldModel(requestSpec, key);
+ return options.overrideField?.(basicField) ?? basicField;
}
export function getSchemaProperties(schema: JsonSchemaNode): Record {
@@ -183,6 +251,15 @@ function resolveSchemaKind(
if (key.toLowerCase().includes('time')) return 'time';
if (schema.type === 'integer' || schema.type === 'number') return 'number';
if (schema.type === 'boolean') return 'boolean-radio';
+ if (schema.type === 'array' && schema.items?.enum) return 'multi-select';
+ if (schema.type === 'array' && schema.items?.type === 'object') return 'object-array';
+ if (schema.type === 'array') {
+ throw new Error(`지원하지 않는 큐레이션 스키마 필드: ${key} 배열 형식`);
+ }
+ if (schema.type === 'object') {
+ throw new Error(`지원하지 않는 큐레이션 스키마 필드: ${key} 객체 형식`);
+ }
+ if (schema.enum) return 'select';
if (schema.maxLength && schema.maxLength > 500) return 'textarea';
return 'text';
@@ -202,6 +279,7 @@ export function createCurationBasicFieldModel(
fieldStyle: getSchemaFieldStyle(fieldSchema),
placeholder: getSchemaPlaceholder(fieldSchema),
defaultValue: fieldSchema.default,
+ nullable: fieldSchema.nullable,
};
const kind = getSchemaFieldKind(key, fieldSchema);
@@ -221,6 +299,20 @@ export function createCurationBasicFieldModel(
trueLabel: '네',
falseLabel: '아니요',
};
+ case 'select':
+ return {
+ ...base,
+ kind,
+ options: createEnumOptions(key, fieldSchema.enum),
+ };
+ case 'multi-select':
+ return {
+ ...base,
+ kind,
+ options: createEnumOptions(key, fieldSchema.items?.enum),
+ minItems: fieldSchema.minItems ?? 0,
+ maxItems: typeof fieldSchema.maxItems === 'number' ? fieldSchema.maxItems : undefined,
+ };
case 'textarea':
case 'date':
case 'time':
@@ -229,11 +321,46 @@ export function createCurationBasicFieldModel(
return {
...base,
kind,
+ minLength: fieldSchema.minLength,
maxLength: fieldSchema.maxLength,
};
case 'alcohol-card-list':
throw new Error('alcohol-card-list는 createAlcoholCardListFieldModel로 생성해야 합니다.');
+ case 'object-array':
+ throw new Error('object-array는 createCurationFieldModel로 생성해야 합니다.');
+ }
+}
+
+function createEnumOptions(
+ key: string,
+ enumValues: JsonSchemaNode['enum']
+): CurationSelectOption[] {
+ if (!enumValues || enumValues.some((value) => typeof value !== 'string')) {
+ throw new Error(`지원하지 않는 큐레이션 스키마 필드: ${key} enum은 문자열이어야 합니다.`);
}
+
+ return enumValues.map((value) => ({
+ value: value as string,
+ label: formatSchemaEnumLabel(value as string),
+ }));
+}
+
+export function formatSchemaEnumLabel(value: string): string {
+ return value
+ .toLowerCase()
+ .split('_')
+ .filter(Boolean)
+ .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
+ .join(' ');
+}
+
+export function prefixCurationFieldModelKey(
+ field: CurationFieldModel,
+ prefix: string
+): CurationFieldModel {
+ const key = `${prefix}.${field.key}`;
+
+ return { ...field, key };
}
export interface AlcoholCardListFieldParams {
diff --git a/src/pages/curation/curation-form-schema.ts b/src/pages/curation/curation-form-schema.ts
index 8094ee7..8a93132 100644
--- a/src/pages/curation/curation-form-schema.ts
+++ b/src/pages/curation/curation-form-schema.ts
@@ -1,10 +1,14 @@
import { z } from 'zod';
import type {
- CurationBasicFieldModel,
+ CurationFieldModel,
+ CurationMultiSelectFieldModel,
CurationNumberFieldModel,
+ CurationObjectArrayFieldModel,
+ CurationSelectFieldModel,
CurationTextFieldModel,
} from './curation-form-model';
+import type { CurationWhiskyCardListFieldModel } from './curation-whisky-card-list.types';
function getTopicParticle(label: string): '은' | '는' {
const trimmedLabel = label.trim();
@@ -45,8 +49,15 @@ export function formatCurationFieldObject(label: string): string {
function createTextFieldValueSchema(field: CurationTextFieldModel): z.ZodType {
let schema = z.string();
- if (field.required) {
+ const minimumLength = field.required ? Math.max(field.minLength ?? 0, 1) : field.minLength;
+
+ if (field.required && minimumLength === 1) {
schema = schema.min(1, `${formatCurationFieldTopic(field.label)} 필수입니다.`);
+ } else if (minimumLength) {
+ schema = schema.refine(
+ (value) => (!field.required && value.length === 0) || value.length >= minimumLength,
+ `${formatCurationFieldTopic(field.label)} 최소 ${minimumLength}자 이상 입력해주세요.`
+ );
}
if (field.maxLength) {
@@ -59,6 +70,143 @@ function createTextFieldValueSchema(field: CurationTextFieldModel): z.ZodType {
+ const allowedValues = new Set(field.options.map((option) => option.value));
+ let schema = z.string();
+
+ if (field.required) {
+ schema = schema.min(1, `${formatCurationFieldObject(field.label)} 선택해주세요.`);
+ }
+
+ return schema.refine(
+ (value) => (!field.required && value === '') || allowedValues.has(value),
+ `${formatCurationFieldObject(field.label)} 올바르게 선택해주세요.`
+ );
+}
+
+function createMultiSelectFieldValueSchema(
+ field: CurationMultiSelectFieldModel
+): z.ZodType {
+ const allowedValues = new Set(field.options.map((option) => option.value));
+ let schema = z.array(
+ z.string().refine((value) => allowedValues.has(value), {
+ message: `${formatCurationFieldObject(field.label)} 올바르게 선택해주세요.`,
+ })
+ );
+
+ const minimumItems = field.required ? Math.max(field.minItems, 1) : field.minItems;
+ if (minimumItems > 0) {
+ schema = schema.min(
+ minimumItems,
+ `${formatCurationFieldObject(field.label)} 최소 ${minimumItems}개 이상 선택해주세요.`
+ );
+ }
+
+ if (typeof field.maxItems === 'number') {
+ schema = schema.max(
+ field.maxItems,
+ `${formatCurationFieldTopic(field.label)} 최대 ${field.maxItems}개까지 선택할 수 있습니다.`
+ );
+ }
+
+ return schema;
+}
+
+export function createCurationWhiskyCardValueSchema(field: CurationWhiskyCardListFieldModel) {
+ return z.object({
+ source: z.enum(['BOTTLE_NOTE', 'MANUAL']),
+ alcohol: z.object({
+ alcoholId: z.number().nullable(),
+ korName: z.string().min(1, '위스키 한글명은 필수입니다.'),
+ engName: z.string().optional(),
+ imageUrl: z.string().nullable().optional(),
+ abv: z.string().optional(),
+ cask: z.string().optional(),
+ volume: z.string().optional(),
+ regionName: z.string().optional(),
+ korCategory: z.string().optional(),
+ selectedTags: z
+ .array(z.string().min(1, `${field.selectedTags.label}를 입력해주세요.`))
+ .min(
+ field.selectedTags.minItems,
+ `${field.selectedTags.label}를 최소 ${field.selectedTags.minItems}개 이상 추가해주세요.`
+ )
+ .max(
+ field.selectedTags.maxItems,
+ `${field.selectedTags.label}는 최대 ${field.selectedTags.maxItems}개까지 추가할 수 있습니다.`
+ ),
+ }),
+ stats: z
+ .object({
+ rating: z.number().nullable().optional(),
+ totalRatingsCount: z.number().nullable().optional(),
+ reviewCount: z.number().nullable().optional(),
+ totalPickCount: z.number().nullable().optional(),
+ })
+ .nullable()
+ .optional(),
+ comment: z
+ .string()
+ .max(
+ field.comment.maxLength,
+ `${field.comment.label}은 최대 ${field.comment.maxLength}자까지 입력할 수 있습니다.`
+ )
+ .optional()
+ .nullable(),
+ });
+}
+
+function createWhiskyCardListValueSchema(
+ field: CurationWhiskyCardListFieldModel
+): z.ZodType {
+ let schema = z.array(createCurationWhiskyCardValueSchema(field));
+ const minimumItems = field.required ? Math.max(field.minItems, 1) : field.minItems;
+
+ if (minimumItems > 0) {
+ schema = schema.min(
+ minimumItems,
+ `${formatCurationFieldObject(field.label)} 최소 ${minimumItems}개 이상 추가해주세요.`
+ );
+ }
+
+ if (typeof field.maxItems === 'number') {
+ schema = schema.max(
+ field.maxItems,
+ `${formatCurationFieldTopic(field.label)} 최대 ${field.maxItems}개까지 추가할 수 있습니다.`
+ );
+ }
+
+ return schema;
+}
+
+function createObjectArrayValueSchema(field: CurationObjectArrayFieldModel): z.ZodType {
+ const itemShape = field.itemFields.reduce>>(
+ (shape, itemField) => {
+ shape[itemField.key] = createCurationFieldValueSchema(itemField);
+ return shape;
+ },
+ {}
+ );
+ let schema = z.array(z.object(itemShape));
+ const minimumItems = field.required ? Math.max(field.minItems, 1) : field.minItems;
+
+ if (minimumItems > 0) {
+ schema = schema.min(
+ minimumItems,
+ `${formatCurationFieldObject(field.label)} 최소 ${minimumItems}개 이상 추가해주세요.`
+ );
+ }
+
+ if (typeof field.maxItems === 'number') {
+ schema = schema.max(
+ field.maxItems,
+ `${formatCurationFieldTopic(field.label)} 최대 ${field.maxItems}개까지 추가할 수 있습니다.`
+ );
+ }
+
+ return schema;
+}
+
// number/integer field model을 숫자 Zod schema로 변환합니다.
function createNumberFieldValueSchema(field: CurationNumberFieldModel): z.ZodType {
let schema = z.number();
@@ -85,12 +233,20 @@ function createNumberFieldValueSchema(field: CurationNumberFieldModel): z.ZodTyp
}
// field model의 kind에 맞는 Zod value schema를 생성합니다.
-export function createCurationFieldValueSchema(field: CurationBasicFieldModel): z.ZodType {
+export function createCurationFieldValueSchema(field: CurationFieldModel): z.ZodType {
switch (field.kind) {
case 'number':
return createNumberFieldValueSchema(field);
case 'boolean-radio':
return z.boolean();
+ case 'select':
+ return createSelectFieldValueSchema(field);
+ case 'multi-select':
+ return createMultiSelectFieldValueSchema(field);
+ case 'alcohol-card-list':
+ return createWhiskyCardListValueSchema(field);
+ case 'object-array':
+ return createObjectArrayValueSchema(field);
case 'text':
case 'textarea':
case 'date':
@@ -99,3 +255,27 @@ export function createCurationFieldValueSchema(field: CurationBasicFieldModel):
return createTextFieldValueSchema(field);
}
}
+
+export function createDefaultCurationFieldValue(field: CurationFieldModel): unknown {
+ if ('defaultValue' in field && field.defaultValue !== undefined) {
+ return field.defaultValue;
+ }
+
+ switch (field.kind) {
+ case 'alcohol-card-list':
+ case 'multi-select':
+ case 'object-array':
+ return [];
+ case 'boolean-radio':
+ return false;
+ case 'number':
+ return field.minimum ?? 0;
+ case 'select':
+ case 'date':
+ case 'time':
+ case 'textarea':
+ case 'text':
+ case 'address':
+ return '';
+ }
+}
diff --git a/src/pages/curation/curation-whisky-card-list.types.ts b/src/pages/curation/curation-whisky-card-list.types.ts
index 6a51eb8..c093f3f 100644
--- a/src/pages/curation/curation-whisky-card-list.types.ts
+++ b/src/pages/curation/curation-whisky-card-list.types.ts
@@ -33,6 +33,7 @@ export interface CurationWhiskyCardListFormValues {
export interface CurationWhiskyCardListFieldModel {
key: string;
+ ariaLabel?: string;
kind: 'alcohol-card-list';
label: string;
required: boolean;
diff --git a/src/pages/curation/schema-driven/SchemaDrivenCurationEditPage.tsx b/src/pages/curation/schema-driven/SchemaDrivenCurationEditPage.tsx
new file mode 100644
index 0000000..b5d113d
--- /dev/null
+++ b/src/pages/curation/schema-driven/SchemaDrivenCurationEditPage.tsx
@@ -0,0 +1,45 @@
+import { AlertCircle } from 'lucide-react';
+import { useNavigate } from 'react-router';
+
+import { Button } from '@/components/ui/button';
+import { Card, CardContent } from '@/components/ui/card';
+import type { CurationV2Detail } from '@/types/api';
+
+import { SchemaDrivenCurationForm } from './SchemaDrivenCurationForm';
+import { createSchemaDrivenCurationFormModel } from './schema-driven-curation.form-model';
+
+export function SchemaDrivenCurationEditPage({ curation }: { curation: CurationV2Detail }) {
+ const navigate = useNavigate();
+ const handleBack = () => navigate('/dashboard/curations');
+ let formModel = null;
+ let schemaError: Error | null = null;
+
+ try {
+ formModel = createSchemaDrivenCurationFormModel(curation.spec);
+ } catch (error) {
+ schemaError = error instanceof Error ? error : new Error('스펙을 해석하지 못했습니다.');
+ }
+
+ if (formModel) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
이 스펙은 아직 자동 폼에서 지원하지 않습니다.
+
{schemaError?.message}
+
+
+
+ 목록
+
+
+
+ );
+}
diff --git a/src/pages/curation/schema-driven/SchemaDrivenCurationForm.tsx b/src/pages/curation/schema-driven/SchemaDrivenCurationForm.tsx
new file mode 100644
index 0000000..ab16c66
--- /dev/null
+++ b/src/pages/curation/schema-driven/SchemaDrivenCurationForm.tsx
@@ -0,0 +1,145 @@
+import { useState } from 'react';
+import { FormProvider, useForm, type FieldErrors, 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,
+ CurationV2Detail,
+ CurationV2UpdateRequest,
+} from '@/types/api';
+
+import { CurationBasicInfoSection } from '../components/CurationBasicInfoSection';
+import { CurationFormErrorFocusProvider } from '../components/CurationFormErrorFocusProvider';
+import { CurationFormSection } from '../components/CurationFormSection';
+import { useCurationFormErrorFocus } from '../form-error-focus';
+import {
+ buildSchemaDrivenCurationPayload,
+ createSchemaDrivenCurationFormStateFromCuration,
+} from './schema-driven-curation.mapper';
+import type { SchemaDrivenCurationFormModel } from './schema-driven-curation.form-model';
+import {
+ createDefaultSchemaDrivenCurationFormState,
+ createSchemaDrivenCurationFormSchema,
+ type SchemaDrivenCurationFormState,
+} from './schema-driven-curation.schema';
+
+interface SchemaDrivenCurationFormProps {
+ formModel: SchemaDrivenCurationFormModel;
+ curation?: CurationV2Detail;
+ onBack: () => void;
+}
+
+export function SchemaDrivenCurationForm({
+ formModel,
+ curation,
+ onBack,
+}: SchemaDrivenCurationFormProps) {
+ 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 formSchema = createSchemaDrivenCurationFormSchema(formModel, {
+ mode: isEditMode ? 'edit' : 'create',
+ });
+ const defaultValues = curation
+ ? createSchemaDrivenCurationFormStateFromCuration(curation, formModel)
+ : createDefaultSchemaDrivenCurationFormState(formModel);
+ const errorFocus = useCurationFormErrorFocus();
+ const form = useForm({
+ resolver: zodResolver(
+ formSchema as never
+ ) as unknown as Resolver,
+ defaultValues,
+ mode: 'onSubmit',
+ shouldFocusError: false,
+ });
+ const createMutation = useCurationCreate({
+ successMessage: `${formModel.spec.name} 큐레이션이 등록되었습니다.`,
+ onSuccess: () => navigate('/dashboard/curations'),
+ });
+ const updateMutation = useCurationUpdate({
+ onSuccess: () => navigate('/dashboard/curations'),
+ });
+ const isSubmitting = createMutation.isPending || updateMutation.isPending;
+ const isImageUploading = isCurationImageUploading || isWhiskyImageUploading;
+
+ const handleSubmit = form.handleSubmit(
+ (values) => {
+ const request: CurationV2CreateRequest | CurationV2UpdateRequest = {
+ specId: formModel.spec.id,
+ name: values.name.trim(),
+ description: values.description.trim() || null,
+ imageUrls: values.imageUrls,
+ exposureStartDate: values.exposureStartDate || null,
+ exposureEndDate: values.exposureEndDate || null,
+ displayOrder: values.displayOrder,
+ isActive: values.isActive,
+ payload: buildSchemaDrivenCurationPayload(values, formModel),
+ };
+
+ if (curation) {
+ updateMutation.mutate({ curationId: curation.id, data: request });
+ } else {
+ createMutation.mutate(request);
+ }
+ },
+ (errors: FieldErrors) => {
+ showToast({ type: 'warning', message: '입력 정보를 확인해주세요.' });
+ errorFocus.focusFirstError(errors);
+ }
+ );
+
+ return (
+
+
+
+ 목록
+
+
+
+ {isSubmitting ? '저장 중...' : isEditMode ? '수정' : '저장'}
+
+ >
+ }
+ />
+
+
+
+
+
+ {formModel.sections.map((section) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/pages/curation/schema-driven/__tests__/SchemaDrivenCurationPages.test.tsx b/src/pages/curation/schema-driven/__tests__/SchemaDrivenCurationPages.test.tsx
new file mode 100644
index 0000000..32b3221
--- /dev/null
+++ b/src/pages/curation/schema-driven/__tests__/SchemaDrivenCurationPages.test.tsx
@@ -0,0 +1,220 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+
+import { server } from '@/test/mocks/server';
+import { wrapApiResponse } from '@/test/mocks/data';
+import { render } from '@/test/test-utils';
+import type { CurationV2CreateRequest, CurationV2Detail } from '@/types/api';
+
+import { SchemaDrivenCurationEditPage } from '../SchemaDrivenCurationEditPage';
+import { CurationCreatePage } from '../../CurationCreate';
+import { programSpec } from './program-spec.fixture';
+
+const SPEC_BASE = '/admin/api/v2/curation-specs';
+const CURATION_BASE = '/admin/api/v2/curations';
+const mockNavigate = vi.hoisted(() => vi.fn());
+
+vi.mock('react-router', async (importOriginal) => {
+ const actual = await importOriginal();
+
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ useParams: () => ({ specCode: 'PROGRAM' }),
+ };
+});
+
+function mockProgramSpec() {
+ server.use(
+ http.get(SPEC_BASE, () =>
+ HttpResponse.json(
+ wrapApiResponse([
+ {
+ id: programSpec.id,
+ code: programSpec.code,
+ name: programSpec.name,
+ description: programSpec.description,
+ version: programSpec.version,
+ isActive: programSpec.isActive,
+ },
+ ])
+ )
+ ),
+ http.get(`${SPEC_BASE}/:specId`, () => HttpResponse.json(wrapApiResponse(programSpec)))
+ );
+}
+
+describe('schema driven curation pages', () => {
+ beforeEach(() => {
+ mockNavigate.mockClear();
+ });
+
+ it('PROGRAM requestSpec으로 행사 필드와 프로그램 object array를 렌더링한다', async () => {
+ const user = userEvent.setup();
+ mockProgramSpec();
+
+ render( );
+
+ expect(await screen.findByLabelText('행사 시작일')).toBeInTheDocument();
+ expect(screen.getByLabelText('행사 종료일')).toBeInTheDocument();
+ expect(screen.getByLabelText('프로그램 태그')).toBeInTheDocument();
+ expect(screen.getByText('프로그램을 추가해주세요.')).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: '프로그램 추가' }));
+
+ expect(screen.getByRole('heading', { name: '프로그램 1' })).toBeInTheDocument();
+ expect(screen.getByLabelText('1번 프로그램 프로그램명')).toBeInTheDocument();
+ expect(screen.getByLabelText('1번 프로그램 프로그램 유형')).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: '1번 프로그램 시음 위스키 추가' })
+ ).toBeInTheDocument();
+ });
+
+ it('입력한 프로그램 배열 순서와 object payload로 생성 요청을 전송한다', async () => {
+ const user = userEvent.setup();
+ let capturedBody: CurationV2CreateRequest | null = null;
+ mockProgramSpec();
+ server.use(
+ http.post(CURATION_BASE, async ({ request }) => {
+ capturedBody = (await request.json()) as CurationV2CreateRequest;
+ return HttpResponse.json(wrapApiResponse({ targetId: 100 }));
+ })
+ );
+
+ render( );
+
+ await screen.findByLabelText('큐레이션명');
+ fillCommonFields();
+ fireEvent.change(screen.getByLabelText('행사 시작일'), {
+ target: { value: '2026-07-24' },
+ });
+ fireEvent.change(screen.getByLabelText('행사 종료일'), {
+ target: { value: '2026-07-26' },
+ });
+ fireEvent.change(screen.getByLabelText('장소명'), { target: { value: '코엑스' } });
+ fireEvent.change(screen.getByLabelText('장소 및 주소'), {
+ target: { value: '서울 강남구 영동대로 513' },
+ });
+
+ await user.click(screen.getByRole('button', { name: '프로그램 추가' }));
+ fillProgram(1, '마스터클래스');
+
+ await user.click(screen.getByRole('button', { name: '프로그램 추가' }));
+ fillProgram(2, '테이스팅');
+
+ await user.click(screen.getByRole('button', { name: '저장' }));
+
+ await waitFor(() => expect(capturedBody).not.toBeNull());
+ expect(capturedBody).toMatchObject({
+ specId: 4,
+ payload: {
+ eventStartDate: '2026-07-24',
+ eventEndDate: '2026-07-26',
+ placeName: '코엑스',
+ address: '서울 강남구 영동대로 513',
+ programs: [
+ expect.objectContaining({ name: '마스터클래스' }),
+ expect.objectContaining({ name: '테이스팅' }),
+ ],
+ },
+ });
+ });
+
+ it('각 프로그램의 중첩 위스키 목록을 독립적인 field path로 관리한다', async () => {
+ const user = userEvent.setup();
+ mockProgramSpec();
+
+ render( );
+
+ await screen.findByLabelText('행사 시작일');
+ await user.click(screen.getByRole('button', { name: '프로그램 추가' }));
+ await user.click(screen.getByRole('button', { name: '프로그램 추가' }));
+
+ const firstProgram = screen.getByRole('group', { name: '프로그램 1' });
+ const secondProgram = screen.getByRole('group', { name: '프로그램 2' });
+
+ await user.click(
+ within(firstProgram).getByRole('button', {
+ name: '1번 프로그램 시음 위스키 추가',
+ })
+ );
+
+ expect(within(firstProgram).getByRole('button', { name: '직접 입력' })).toBeInTheDocument();
+ expect(
+ within(secondProgram).queryByRole('button', { name: '직접 입력' })
+ ).not.toBeInTheDocument();
+ });
+
+ it('PROGRAM 상세 payload를 수정 폼에 복원한다', async () => {
+ const curation: CurationV2Detail = {
+ id: 10,
+ name: '2026 바쇼',
+ description: '행사 소개',
+ coverImageUrl: null,
+ imageUrls: [],
+ exposureStartDate: '2026-07-01',
+ exposureEndDate: '2026-07-31',
+ displayOrder: 0,
+ isActive: true,
+ createdAt: '2026-07-01T00:00:00',
+ modifiedAt: '2026-07-01T00:00:00',
+ spec: programSpec,
+ payload: {
+ eventStartDate: '2026-07-24',
+ eventEndDate: '2026-07-26',
+ placeName: '코엑스',
+ address: '서울 강남구 영동대로 513',
+ programs: [
+ {
+ name: '마스터클래스',
+ type: 'MASTER_CLASS',
+ programDate: '2026-07-24',
+ startTime: '14:00',
+ description: '행사 설명',
+ whiskies: [],
+ },
+ ],
+ },
+ };
+
+ render( );
+
+ expect(screen.getByLabelText('큐레이션명')).toHaveValue('2026 바쇼');
+ expect(screen.getByLabelText('행사 시작일')).toHaveValue('2026-07-24');
+ expect(screen.getByLabelText('1번 프로그램 프로그램명')).toHaveValue('마스터클래스');
+ });
+});
+
+function fillCommonFields() {
+ fireEvent.change(screen.getByLabelText('큐레이션명'), {
+ target: { value: '2026 바쇼' },
+ });
+ fireEvent.change(screen.getByLabelText('설명'), {
+ target: { value: '여러 프로그램을 소개합니다.' },
+ });
+ fireEvent.change(screen.getByLabelText('광고노출 시작일'), {
+ target: { value: '2026-07-01' },
+ });
+ fireEvent.change(screen.getByLabelText('광고노출 종료일'), {
+ target: { value: '2026-07-31' },
+ });
+}
+
+function fillProgram(index: number, name: string) {
+ fireEvent.change(screen.getByLabelText(`${index}번 프로그램 프로그램명`), {
+ target: { value: name },
+ });
+ fireEvent.click(screen.getByLabelText(`${index}번 프로그램 프로그램 유형`));
+ fireEvent.click(screen.getByRole('option', { name: 'Master Class' }));
+ fireEvent.change(screen.getByLabelText(`${index}번 프로그램 날짜`), {
+ target: { value: '2026-07-24' },
+ });
+ fireEvent.change(screen.getByLabelText(`${index}번 프로그램 시작 시간`), {
+ target: { value: '14:00' },
+ });
+ fireEvent.change(screen.getByLabelText(`${index}번 프로그램 프로그램 설명`), {
+ target: { value: '프로그램 설명' },
+ });
+}
diff --git a/src/pages/curation/schema-driven/__tests__/program-spec.fixture.ts b/src/pages/curation/schema-driven/__tests__/program-spec.fixture.ts
new file mode 100644
index 0000000..ddd13c5
--- /dev/null
+++ b/src/pages/curation/schema-driven/__tests__/program-spec.fixture.ts
@@ -0,0 +1,139 @@
+import type { CurationV2Spec } from '@/types/api';
+
+export const programSpec: CurationV2Spec = {
+ id: 4,
+ code: 'PROGRAM',
+ name: '프로그램',
+ description: '여러 프로그램과 프로그램별 위스키 라인업을 발행합니다.',
+ hydratorKey: 'alcohol',
+ version: 1,
+ isActive: true,
+ requestSpec: {
+ type: 'object',
+ required: ['eventStartDate', 'eventEndDate', 'placeName', 'address', 'programs'],
+ properties: {
+ eventStartDate: {
+ type: 'string',
+ format: 'date',
+ 'x-display-name': '행사 시작일',
+ },
+ eventEndDate: {
+ type: 'string',
+ format: 'date',
+ 'x-display-name': '행사 종료일',
+ },
+ placeName: {
+ type: 'string',
+ maxLength: 100,
+ 'x-field-style': 'plain-text',
+ 'x-display-name': '장소명',
+ },
+ address: {
+ type: 'string',
+ maxLength: 200,
+ 'x-field-style': 'plain-text',
+ 'x-display-name': '장소 및 주소',
+ },
+ entryFee: {
+ type: 'integer',
+ minimum: 0,
+ nullable: true,
+ 'x-display-name': '참가비',
+ },
+ programTags: {
+ type: 'array',
+ maxItems: 2,
+ 'x-display-name': '프로그램 태그',
+ items: {
+ type: 'string',
+ enum: ['WHISKY', 'COCKTAIL', 'BEER'],
+ },
+ },
+ programs: {
+ type: 'array',
+ minItems: 1,
+ maxItems: 20,
+ 'x-display-name': '프로그램',
+ items: {
+ type: 'object',
+ required: ['name', 'type', 'programDate', 'startTime', 'description'],
+ properties: {
+ name: {
+ type: 'string',
+ minLength: 1,
+ maxLength: 120,
+ 'x-display-name': '프로그램명',
+ },
+ type: {
+ type: 'string',
+ enum: ['MASTER_CLASS', 'TASTING', 'SEMINAR'],
+ 'x-display-name': '프로그램 유형',
+ },
+ programDate: {
+ type: 'string',
+ format: 'date',
+ 'x-display-name': '날짜',
+ },
+ startTime: {
+ type: 'string',
+ format: 'time',
+ 'x-display-name': '시작 시간',
+ },
+ endTime: {
+ type: 'string',
+ format: 'time',
+ nullable: true,
+ 'x-display-name': '종료 시간',
+ },
+ description: {
+ type: 'string',
+ minLength: 1,
+ maxLength: 1000,
+ 'x-field-style': 'long-text',
+ 'x-display-name': '프로그램 설명',
+ },
+ whiskies: {
+ type: 'array',
+ maxItems: 10,
+ 'x-field-style': 'alcohol-card-list',
+ 'x-display-name': '시음 위스키',
+ items: {
+ type: 'object',
+ required: ['source', 'alcohol'],
+ properties: {
+ source: {
+ type: 'string',
+ enum: ['BOTTLE_NOTE', 'MANUAL'],
+ },
+ alcohol: {
+ type: 'object',
+ required: ['korName', 'selectedTags'],
+ properties: {
+ alcoholId: { type: 'integer', nullable: true },
+ korName: { type: 'string', maxLength: 100 },
+ selectedTags: {
+ type: 'array',
+ maxItems: 12,
+ items: { type: 'string' },
+ 'x-display-name': '테이스팅 태그',
+ },
+ },
+ },
+ comment: {
+ type: 'string',
+ maxLength: 500,
+ nullable: true,
+ 'x-display-name': '위스키 기대평',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ responseSpec: {
+ type: 'object',
+ },
+};
diff --git a/src/pages/curation/schema-driven/__tests__/schema-driven-curation.form-model.test.ts b/src/pages/curation/schema-driven/__tests__/schema-driven-curation.form-model.test.ts
new file mode 100644
index 0000000..d549abc
--- /dev/null
+++ b/src/pages/curation/schema-driven/__tests__/schema-driven-curation.form-model.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from 'vitest';
+
+import type { CurationV2Spec } from '@/types/api';
+
+import { createSchemaDrivenCurationFormModel } from '../schema-driven-curation.form-model';
+import { programSpec } from './program-spec.fixture';
+
+describe('createSchemaDrivenCurationFormModel', () => {
+ it('object requestSpec의 primitive, enum, enum array, object array를 모델로 만든다', () => {
+ const model = createSchemaDrivenCurationFormModel(programSpec);
+
+ expect(model.payloadFields.map((field) => [field.key, field.kind])).toEqual([
+ ['eventStartDate', 'date'],
+ ['eventEndDate', 'date'],
+ ['placeName', 'text'],
+ ['address', 'text'],
+ ['entryFee', 'number'],
+ ['programTags', 'multi-select'],
+ ['programs', 'object-array'],
+ ]);
+
+ const programTags = model.payloadFields.find((field) => field.key === 'programTags');
+ expect(programTags).toMatchObject({
+ kind: 'multi-select',
+ maxItems: 2,
+ options: [
+ { value: 'WHISKY', label: 'Whisky' },
+ { value: 'COCKTAIL', label: 'Cocktail' },
+ { value: 'BEER', label: 'Beer' },
+ ],
+ });
+ });
+
+ it('object array 안의 enum과 alcohol-card-list를 재귀 모델로 만든다', () => {
+ const model = createSchemaDrivenCurationFormModel(programSpec);
+ const programs = model.payloadFields.find((field) => field.key === 'programs');
+
+ expect(programs).toMatchObject({
+ kind: 'object-array',
+ minItems: 1,
+ maxItems: 20,
+ });
+
+ if (!programs || programs.kind !== 'object-array') {
+ throw new Error('programs object-array 모델이 필요합니다.');
+ }
+
+ expect(programs.itemFields.map((field) => [field.key, field.kind])).toEqual([
+ ['name', 'text'],
+ ['type', 'select'],
+ ['programDate', 'date'],
+ ['startTime', 'time'],
+ ['endTime', 'time'],
+ ['description', 'textarea'],
+ ['whiskies', 'alcohol-card-list'],
+ ]);
+ expect(programs.itemFields.find((field) => field.key === 'whiskies')).toMatchObject({
+ label: '시음 위스키',
+ minItems: 0,
+ maxItems: 10,
+ });
+ });
+
+ it('지원하지 않는 object 단일 필드는 명시적으로 실패한다', () => {
+ const unsupportedSpec: CurationV2Spec = {
+ ...programSpec,
+ requestSpec: {
+ type: 'object',
+ properties: {
+ metadata: {
+ type: 'object',
+ properties: {
+ value: { type: 'string' },
+ },
+ },
+ },
+ },
+ };
+
+ expect(() => createSchemaDrivenCurationFormModel(unsupportedSpec)).toThrow(
+ '지원하지 않는 큐레이션 스키마 필드'
+ );
+ });
+});
diff --git a/src/pages/curation/schema-driven/__tests__/schema-driven-curation.schema.test.ts b/src/pages/curation/schema-driven/__tests__/schema-driven-curation.schema.test.ts
new file mode 100644
index 0000000..58f797c
--- /dev/null
+++ b/src/pages/curation/schema-driven/__tests__/schema-driven-curation.schema.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it } from 'vitest';
+
+import { createSchemaDrivenCurationFormModel } from '../schema-driven-curation.form-model';
+import {
+ createDefaultSchemaDrivenCurationFormState,
+ createSchemaDrivenCurationFormSchema,
+} from '../schema-driven-curation.schema';
+import { programSpec } from './program-spec.fixture';
+
+describe('schema driven curation validation', () => {
+ const formModel = createSchemaDrivenCurationFormModel(programSpec);
+ const schema = createSchemaDrivenCurationFormSchema(formModel);
+
+ it('required root field와 programs minItems를 검증한다', () => {
+ const result = schema.safeParse(createDefaultSchemaDrivenCurationFormState(formModel));
+
+ expect(result.success).toBe(false);
+ if (result.success) return;
+
+ expect(result.error.flatten().fieldErrors.eventStartDate).toContain(
+ '행사 시작일은 필수입니다.'
+ );
+ expect(result.error.flatten().fieldErrors.programs).toContain(
+ '프로그램을 최소 1개 이상 추가해주세요.'
+ );
+ });
+
+ it('enum array maxItems와 enum 값을 검증한다', () => {
+ const values = createValidValues();
+ values.programTags = ['WHISKY', 'COCKTAIL', 'BEER'];
+ values.programs[0]!.type = 'UNKNOWN';
+
+ const result = schema.safeParse(values);
+
+ expect(result.success).toBe(false);
+ if (result.success) return;
+
+ expect(result.error.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ path: ['programTags'],
+ message: '프로그램 태그는 최대 2개까지 선택할 수 있습니다.',
+ }),
+ expect.objectContaining({
+ path: ['programs', 0, 'type'],
+ }),
+ ])
+ );
+ });
+
+ it('행사 종료일과 프로그램 종료 시간의 순서를 검증한다', () => {
+ const values = createValidValues();
+ values.eventEndDate = '2026-07-23';
+ values.programs[0]!.endTime = '13:00';
+
+ const result = schema.safeParse(values);
+
+ expect(result.success).toBe(false);
+ if (result.success) return;
+
+ expect(result.error.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ path: ['eventEndDate'],
+ message: '행사 종료일은 행사 시작일보다 빠를 수 없습니다.',
+ }),
+ expect.objectContaining({
+ path: ['programs', 0, 'endTime'],
+ message: '종료 시간은 시작 시간보다 빠를 수 없습니다.',
+ }),
+ ])
+ );
+ });
+
+ function createValidValues() {
+ return {
+ ...createDefaultSchemaDrivenCurationFormState(formModel),
+ name: '2026 바쇼',
+ description: '프로그램 소개',
+ exposureStartDate: '2026-07-01',
+ exposureEndDate: '2026-07-31',
+ eventStartDate: '2026-07-24',
+ eventEndDate: '2026-07-26',
+ placeName: '코엑스',
+ address: '서울 강남구 영동대로 513',
+ programTags: [] as string[],
+ programs: [
+ {
+ name: '마스터클래스',
+ type: 'MASTER_CLASS',
+ programDate: '2026-07-24',
+ startTime: '14:00',
+ endTime: '15:30',
+ description: '브랜드 앰버서더와 함께합니다.',
+ whiskies: [],
+ },
+ ],
+ };
+ }
+});
diff --git a/src/pages/curation/schema-driven/schema-driven-curation.form-model.ts b/src/pages/curation/schema-driven/schema-driven-curation.form-model.ts
new file mode 100644
index 0000000..7dd6a7a
--- /dev/null
+++ b/src/pages/curation/schema-driven/schema-driven-curation.form-model.ts
@@ -0,0 +1,72 @@
+import type { CurationV2Spec } from '@/types/api';
+
+import {
+ createCurationFormModelFromRequestSpec,
+ type CurationFieldModel,
+ type CurationFormModel,
+ type CurationFormSectionModel,
+} from '../curation-form-model';
+
+export interface SchemaDrivenCurationFormModel extends CurationFormModel {
+ spec: CurationV2Spec;
+ title: string;
+ editTitle: string;
+}
+
+export function createSchemaDrivenCurationFormModel(
+ spec: CurationV2Spec
+): SchemaDrivenCurationFormModel {
+ if (spec.requestSpec.type !== 'object' || spec.requestSpec['x-container'] === 'array') {
+ throw new Error('지원하지 않는 큐레이션 스키마: object payload만 자동 생성할 수 있습니다.');
+ }
+
+ const formModel = createCurationFormModelFromRequestSpec(spec.requestSpec, {
+ createSections: (fields) => createSchemaDrivenSections(spec, fields),
+ });
+
+ return {
+ ...formModel,
+ spec,
+ title: `${spec.name} 작성`,
+ editTitle: `${spec.name} 수정`,
+ };
+}
+
+function createSchemaDrivenSections(
+ spec: CurationV2Spec,
+ fields: CurationFieldModel[]
+): CurationFormSectionModel[] {
+ const rootFields = fields.filter((field) => field.kind !== 'object-array');
+ const objectArrayFields = fields.filter((field) => field.kind === 'object-array');
+ const sections: CurationFormSectionModel[] = [];
+
+ if (rootFields.length > 0) {
+ sections.push({
+ id: 'payload',
+ title: `${spec.name} 정보`,
+ stepNumber: 2,
+ description: `${spec.name} 정보를 입력해주세요.`,
+ contentClassName: 'grid gap-4 md:grid-cols-2',
+ fields: rootFields.map((field) => ({
+ field,
+ className:
+ field.kind === 'textarea' || field.kind === 'multi-select' || field.key.includes('Url')
+ ? 'md:col-span-2'
+ : undefined,
+ })),
+ });
+ }
+
+ objectArrayFields.forEach((field) => {
+ sections.push({
+ id: field.key,
+ title: field.label,
+ stepNumber: sections.length + 2,
+ description: `${field.label} 정보를 입력해주세요.`,
+ contentClassName: 'space-y-4',
+ fields: [{ field }],
+ });
+ });
+
+ return sections;
+}
diff --git a/src/pages/curation/schema-driven/schema-driven-curation.mapper.ts b/src/pages/curation/schema-driven/schema-driven-curation.mapper.ts
new file mode 100644
index 0000000..323f5cf
--- /dev/null
+++ b/src/pages/curation/schema-driven/schema-driven-curation.mapper.ts
@@ -0,0 +1,225 @@
+import type { CurationV2Detail } from '@/types/api';
+
+import type { CurationFieldModel, CurationObjectArrayFieldModel } from '../curation-form-model';
+import type { CurationWhiskyCardValue } from '../curation-whisky-card-list.types';
+import {
+ createDefaultSchemaDrivenCurationFormState,
+ type SchemaDrivenCurationFormState,
+} from './schema-driven-curation.schema';
+import type { SchemaDrivenCurationFormModel } from './schema-driven-curation.form-model';
+
+const WHISKY_TEXT_KEYS = [
+ 'korName',
+ 'engName',
+ 'imageUrl',
+ 'abv',
+ 'cask',
+ 'volume',
+ 'regionName',
+ 'korCategory',
+] as const;
+
+export function createSchemaDrivenCurationFormStateFromCuration(
+ curation: CurationV2Detail,
+ formModel: SchemaDrivenCurationFormModel
+): SchemaDrivenCurationFormState {
+ const formState = createDefaultSchemaDrivenCurationFormState(formModel);
+ const payload = isRecord(curation.payload) ? curation.payload : {};
+
+ formState.name = curation.name;
+ formState.description = curation.description ?? '';
+ formState.imageUrls =
+ curation.imageUrls.length > 0
+ ? [...curation.imageUrls]
+ : curation.coverImageUrl
+ ? [curation.coverImageUrl]
+ : [];
+ formState.exposureStartDate = toInputDate(curation.exposureStartDate);
+ formState.exposureEndDate = toInputDate(curation.exposureEndDate);
+ formState.displayOrder = curation.displayOrder;
+ formState.isActive = curation.isActive;
+
+ for (const field of formModel.payloadFields) {
+ if (!Object.prototype.hasOwnProperty.call(payload, field.key)) continue;
+ formState[field.key] = hydrateFieldValue(field, payload[field.key]);
+ }
+
+ return formState;
+}
+
+export function buildSchemaDrivenCurationPayload(
+ values: SchemaDrivenCurationFormState,
+ formModel: SchemaDrivenCurationFormModel
+): Record {
+ return serializeFields(formModel.payloadFields, values);
+}
+
+function hydrateFieldValue(field: CurationFieldModel, value: unknown): unknown {
+ switch (field.kind) {
+ case 'object-array':
+ return Array.isArray(value)
+ ? value
+ .filter(isRecord)
+ .map((item) =>
+ Object.fromEntries(
+ field.itemFields.map((itemField) => [
+ itemField.key,
+ hydrateFieldValue(itemField, item[itemField.key]),
+ ])
+ )
+ )
+ : [];
+ case 'alcohol-card-list':
+ return hydrateWhiskies(value);
+ case 'multi-select':
+ return Array.isArray(value)
+ ? value.filter(
+ (item): item is string =>
+ typeof item === 'string' && field.options.some((option) => option.value === item)
+ )
+ : [];
+ case 'select':
+ return typeof value === 'string' ? value : '';
+ case 'boolean-radio':
+ return typeof value === 'boolean' ? value : Boolean(value);
+ case 'number': {
+ const numberValue = Number(value);
+ return Number.isFinite(numberValue) ? numberValue : (field.minimum ?? 0);
+ }
+ case 'date':
+ return toInputDate(value);
+ case 'time':
+ return toInputTime(value);
+ case 'textarea':
+ case 'text':
+ case 'address':
+ return value === null || value === undefined ? '' : String(value);
+ }
+}
+
+function serializeFields(
+ fields: CurationFieldModel[],
+ values: Record
+): Record {
+ return fields.reduce>((payload, field) => {
+ const serializedValue = serializeFieldValue(field, values[field.key]);
+ if (!field.required && isEmptyOptionalValue(serializedValue)) {
+ return payload;
+ }
+
+ payload[field.key] = serializedValue;
+ return payload;
+ }, {});
+}
+
+function serializeFieldValue(field: CurationFieldModel, value: unknown): unknown {
+ switch (field.kind) {
+ case 'object-array':
+ return serializeObjectArray(field, value);
+ case 'alcohol-card-list':
+ return Array.isArray(value) ? value.map(serializeWhisky) : [];
+ case 'multi-select':
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
+ case 'number':
+ case 'boolean-radio':
+ return value;
+ case 'select':
+ case 'textarea':
+ case 'text':
+ case 'address':
+ case 'date':
+ case 'time':
+ return typeof value === 'string' ? value.trim() : '';
+ }
+}
+
+function serializeObjectArray(field: CurationObjectArrayFieldModel, value: unknown) {
+ if (!Array.isArray(value)) return [];
+
+ return value.filter(isRecord).map((item) => serializeFields(field.itemFields, item));
+}
+
+function serializeWhisky(value: unknown) {
+ const item = isRecord(value) ? value : {};
+ const alcoholValue = isRecord(item.alcohol) ? item.alcohol : {};
+ const source = item.source === 'BOTTLE_NOTE' ? 'BOTTLE_NOTE' : 'MANUAL';
+ const alcohol: Record = {
+ alcoholId:
+ source === 'MANUAL' || typeof alcoholValue.alcoholId !== 'number'
+ ? null
+ : alcoholValue.alcoholId,
+ selectedTags: Array.isArray(alcoholValue.selectedTags)
+ ? alcoholValue.selectedTags
+ .filter((tag): tag is string => typeof tag === 'string')
+ .map((tag) => tag.trim())
+ .filter(Boolean)
+ : [],
+ };
+
+ WHISKY_TEXT_KEYS.forEach((key) => {
+ const text = typeof alcoholValue[key] === 'string' ? alcoholValue[key].trim() : '';
+ if (key === 'korName' || text) {
+ alcohol[key] = text;
+ }
+ });
+
+ const comment = typeof item.comment === 'string' ? item.comment.trim() : '';
+
+ return {
+ source,
+ alcohol,
+ ...(comment ? { comment } : {}),
+ };
+}
+
+function hydrateWhiskies(value: unknown): CurationWhiskyCardValue[] {
+ if (!Array.isArray(value)) return [];
+
+ return value.filter(isRecord).map((item) => {
+ const alcohol = isRecord(item.alcohol) ? item.alcohol : {};
+
+ return {
+ source: item.source === 'BOTTLE_NOTE' ? 'BOTTLE_NOTE' : 'MANUAL',
+ alcohol: {
+ alcoholId: typeof alcohol.alcoholId === 'number' ? alcohol.alcoholId : null,
+ korName: normalizeText(alcohol.korName),
+ engName: normalizeText(alcohol.engName),
+ imageUrl: normalizeText(alcohol.imageUrl),
+ abv: normalizeText(alcohol.abv),
+ cask: normalizeText(alcohol.cask),
+ volume: normalizeText(alcohol.volume),
+ regionName: normalizeText(alcohol.regionName),
+ korCategory: normalizeText(alcohol.korCategory),
+ selectedTags: Array.isArray(alcohol.selectedTags)
+ ? alcohol.selectedTags.map(normalizeText).filter(Boolean)
+ : [],
+ },
+ comment: normalizeText(item.comment),
+ };
+ });
+}
+
+function isEmptyOptionalValue(value: unknown) {
+ return (
+ value === '' ||
+ value === null ||
+ value === undefined ||
+ (Array.isArray(value) && value.length === 0)
+ );
+}
+
+function normalizeText(value: unknown) {
+ return value === null || value === undefined ? '' : String(value);
+}
+
+function toInputDate(value: unknown) {
+ return normalizeText(value).slice(0, 10);
+}
+
+function toInputTime(value: unknown) {
+ return normalizeText(value).slice(0, 5);
+}
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+}
diff --git a/src/pages/curation/schema-driven/schema-driven-curation.schema.ts b/src/pages/curation/schema-driven/schema-driven-curation.schema.ts
new file mode 100644
index 0000000..57af503
--- /dev/null
+++ b/src/pages/curation/schema-driven/schema-driven-curation.schema.ts
@@ -0,0 +1,126 @@
+import { z } from 'zod';
+
+import { compareDateInputValues } from '@/lib/date-validation';
+
+import {
+ createCurationFieldValueSchema,
+ createDefaultCurationFieldValue,
+} from '../curation-form-schema';
+import type { SchemaDrivenCurationFormModel } from './schema-driven-curation.form-model';
+
+export interface SchemaDrivenCurationFormState {
+ name: string;
+ description: string;
+ imageUrls: string[];
+ exposureStartDate: string;
+ exposureEndDate: string;
+ displayOrder: number;
+ isActive: boolean;
+ [key: string]: unknown;
+}
+
+export function createDefaultSchemaDrivenCurationFormState(
+ formModel: SchemaDrivenCurationFormModel
+): SchemaDrivenCurationFormState {
+ const formState: SchemaDrivenCurationFormState = {
+ name: '',
+ description: '',
+ imageUrls: [],
+ exposureStartDate: '',
+ exposureEndDate: '',
+ displayOrder: 0,
+ isActive: true,
+ };
+
+ for (const field of formModel.payloadFields) {
+ formState[field.key] = createDefaultCurationFieldValue(field);
+ }
+
+ return formState;
+}
+
+export function createSchemaDrivenCurationFormSchema(
+ formModel: SchemaDrivenCurationFormModel,
+ options: { mode?: 'create' | 'edit' } = {}
+): z.ZodType {
+ const isEditMode = options.mode === 'edit';
+ const payloadShape = formModel.payloadFields.reduce>>(
+ (shape, field) => {
+ shape[field.key] = createCurationFieldValueSchema(field);
+ return shape;
+ },
+ {}
+ );
+
+ 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(),
+ ...payloadShape,
+ })
+ .superRefine((values, context) => {
+ const formValues = values as Record;
+ addDateOrderIssue(
+ values.exposureStartDate,
+ values.exposureEndDate,
+ ['exposureEndDate'],
+ '광고노출 종료일은 광고노출 시작일보다 빠를 수 없습니다.',
+ context
+ );
+
+ const eventStartDate =
+ typeof formValues.eventStartDate === 'string' ? formValues.eventStartDate : '';
+ const eventEndDate =
+ typeof formValues.eventEndDate === 'string' ? formValues.eventEndDate : '';
+ addDateOrderIssue(
+ eventStartDate,
+ eventEndDate,
+ ['eventEndDate'],
+ '행사 종료일은 행사 시작일보다 빠를 수 없습니다.',
+ context
+ );
+
+ if (!Array.isArray(formValues.programs)) return;
+
+ formValues.programs.forEach((program: unknown, index: number) => {
+ if (!program || typeof program !== 'object' || Array.isArray(program)) return;
+
+ const record = program as Record;
+ const startTime = typeof record.startTime === 'string' ? record.startTime : '';
+ const endTime = typeof record.endTime === 'string' ? record.endTime : '';
+ addDateOrderIssue(
+ startTime,
+ endTime,
+ ['programs', index, 'endTime'],
+ '종료 시간은 시작 시간보다 빠를 수 없습니다.',
+ context
+ );
+ });
+ }) as unknown as z.ZodType;
+}
+
+function addDateOrderIssue(
+ start: string,
+ end: string,
+ path: Array,
+ message: string,
+ context: z.RefinementCtx
+) {
+ if (!start || !end || compareDateInputValues(end, start) >= 0) return;
+
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path,
+ message,
+ });
+}
diff --git a/src/pages/curation/useCurationSpecFormModel.ts b/src/pages/curation/useCurationSpecFormModel.ts
deleted file mode 100644
index 8237aef..0000000
--- a/src/pages/curation/useCurationSpecFormModel.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { useCurationSpec, useCurationSpecByCode } from '@/hooks/useCurations';
-import type { CurationV2Spec, CurationV2SpecCode } from '@/types/api';
-
-interface UseCurationSpecFormModelOptions {
- specCode: CurationV2SpecCode;
- createFormModel: (spec: CurationV2Spec) => TFormModel;
- showErrorToast?: boolean;
-}
-
-// 큐레이션 스펙 목록/상세 조회 후 타입별 form model 생성 함수로 연결합니다.
-export function useCurationSpecFormModel({
- specCode,
- createFormModel,
- showErrorToast = false,
-}: UseCurationSpecFormModelOptions) {
- const specsQuery = useCurationSpecByCode(specCode);
- const targetSpec = specsQuery.data?.isActive ? specsQuery.data : null;
- const specDetailQuery = useCurationSpec(targetSpec?.id, {
- showErrorToast,
- });
- const specDetail = specDetailQuery.data;
- const formModel = specDetail ? createFormModel(specDetail) : null;
-
- return {
- specsQuery,
- targetSpec,
- specDetailQuery,
- specDetail,
- formModel,
- };
-}
diff --git a/src/pages/curation/whisky-curation/WhiskyCurationCreateGate.tsx b/src/pages/curation/whisky-curation/WhiskyCurationCreateGate.tsx
deleted file mode 100644
index 164f327..0000000
--- a/src/pages/curation/whisky-curation/WhiskyCurationCreateGate.tsx
+++ /dev/null
@@ -1,172 +0,0 @@
-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/WhiskyCurationCreatePage.tsx b/src/pages/curation/whisky-curation/WhiskyCurationCreatePage.tsx
deleted file mode 100644
index a3cebd5..0000000
--- a/src/pages/curation/whisky-curation/WhiskyCurationCreatePage.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import type { CurationV2SpecCode } from '@/types/api';
-
-import { WhiskyCurationCreateGate } from './WhiskyCurationCreateGate';
-import { WhiskyCurationForm } from './WhiskyCurationForm';
-
-interface WhiskyCurationCreatePageProps {
- specCode: CurationV2SpecCode;
- fallbackTitle: string;
-}
-
-// 추천/페어링 큐레이션 작성 페이지. 코멘트·페어링 표시 여부는 폼이 스펙에서 판별합니다.
-export function WhiskyCurationCreatePage({
- specCode,
- fallbackTitle,
-}: WhiskyCurationCreatePageProps) {
- return (
-
- {({ specDetail, formModel, onBack }) => (
-
- )}
-
- );
-}
diff --git a/src/pages/curation/whisky-curation/__tests__/WhiskyCurationPages.test.tsx b/src/pages/curation/whisky-curation/__tests__/WhiskyCurationPages.test.tsx
index 7cd37e1..7b83018 100644
--- a/src/pages/curation/whisky-curation/__tests__/WhiskyCurationPages.test.tsx
+++ b/src/pages/curation/whisky-curation/__tests__/WhiskyCurationPages.test.tsx
@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, it } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -14,19 +14,28 @@ import type {
CurationV2UpdateRequest,
} from '@/types/api';
-import { WhiskyCurationCreatePage } from '../WhiskyCurationCreatePage';
+import { CurationCreatePage } from '../../CurationCreate';
import { WhiskyCurationEditPage } from '../WhiskyCurationEditPage';
+const routeState = vi.hoisted(() => ({ specCode: 'RECOMMENDED_WHISKY' }));
+
+vi.mock('react-router', async (importOriginal) => {
+ const actual = await importOriginal();
+
+ return {
+ ...actual,
+ useParams: () => ({ specCode: routeState.specCode }),
+ };
+});
+
function renderRecommendedCreatePage() {
- return render(
-
- );
+ routeState.specCode = 'RECOMMENDED_WHISKY';
+ return render( );
}
function renderPairingCreatePage() {
- return render(
-
- );
+ routeState.specCode = 'WHISKY_PAIRING';
+ return render( );
}
const SPEC_BASE = '/admin/api/v2/curation-specs';
diff --git a/src/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventCreate.tsx b/src/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventCreate.tsx
deleted file mode 100644
index 7ad350a..0000000
--- a/src/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventCreate.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { WhiskyTastingEventCreateGate } from './WhiskyTastingEventCreateGate';
-import { WhiskyTastingEventForm } from './WhiskyTastingEventForm';
-
-export function CurationWhiskyTastingEventCreatePage() {
- return (
-
- {({ specDetail, formModel, onBack }) => (
-
- )}
-
- );
-}
diff --git a/src/pages/curation/whisky-tasting-event/WhiskyTastingEventCreateGate.tsx b/src/pages/curation/whisky-tasting-event/WhiskyTastingEventCreateGate.tsx
deleted file mode 100644
index bbc2155..0000000
--- a/src/pages/curation/whisky-tasting-event/WhiskyTastingEventCreateGate.tsx
+++ /dev/null
@@ -1,166 +0,0 @@
-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/__tests__/CurationWhiskyTastingEventCreate.test.tsx b/src/pages/curation/whisky-tasting-event/__tests__/CurationWhiskyTastingEventCreate.test.tsx
index 2e80b72..ed8b821 100644
--- a/src/pages/curation/whisky-tasting-event/__tests__/CurationWhiskyTastingEventCreate.test.tsx
+++ b/src/pages/curation/whisky-tasting-event/__tests__/CurationWhiskyTastingEventCreate.test.tsx
@@ -9,7 +9,16 @@ import { wrapApiError, wrapApiResponse } from '@/test/mocks/data';
import { useAuthStore } from '@/stores/auth';
import type { CurationV2CreateRequest, CurationV2Spec } from '@/types/api';
-import { CurationWhiskyTastingEventCreatePage } from '../CurationWhiskyTastingEventCreate';
+import { CurationCreatePage } from '../../CurationCreate';
+
+vi.mock('react-router', async (importOriginal) => {
+ const actual = await importOriginal();
+
+ return {
+ ...actual,
+ useParams: () => ({ specCode: 'WHISKY_TASTING_EVENT' }),
+ };
+});
const SPEC_BASE = '/admin/api/v2/curation-specs';
const CURATION_BASE = '/admin/api/v2/curations';
@@ -279,7 +288,7 @@ async function typeTastingTagSearch(
return searchInput;
}
-describe('CurationWhiskyTastingEventCreatePage', () => {
+describe('CurationCreatePage whisky tasting event strategy', () => {
beforeEach(() => {
setCurrentUserRoles([]);
Object.defineProperty(URL, 'createObjectURL', {
@@ -303,7 +312,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
const scrollIntoViewSpy = vi.spyOn(Element.prototype, 'scrollIntoView');
mockSpecSuccess();
- render( );
+ render( );
const nameInput = await screen.findByLabelText('큐레이션명');
await user.click(screen.getByRole('button', { name: '저장' }));
@@ -321,7 +330,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
it('스펙 목록과 상세 조회 후 시음회 작성 폼을 렌더링한다', async () => {
mockSpecSuccess();
- render( );
+ render( );
expect(await screen.findByRole('heading', { name: '시음회 작성' })).toBeInTheDocument();
expect(await screen.findByText('기본정보')).toBeInTheDocument();
@@ -363,7 +372,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
const user = userEvent.setup();
mockSpecSuccess();
- render( );
+ render( );
const capacityInput = await screen.findByLabelText('총 모집 인원수');
const undecidedCheckbox = screen.getByRole('checkbox', { name: '모집 인원 미정' });
@@ -414,7 +423,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
);
});
- render( );
+ render( );
const placeNameInput = await screen.findByLabelText('장소명');
await user.click(placeNameInput);
@@ -449,7 +458,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
const user = userEvent.setup();
mockSpecSuccess(createTastingEventSpec({ alcoholsMaxItems: 1 }));
- render( );
+ render( );
expect(await screen.findByText(/1-1개까지 등록할 수 있습니다/)).toBeInTheDocument();
@@ -465,7 +474,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
const user = userEvent.setup();
mockSpecSuccess();
- render( );
+ render( );
await user.click(await screen.findByRole('button', { name: '시음 위스키 추가' }));
await user.click(screen.getByRole('button', { name: '직접 입력' }));
@@ -483,7 +492,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
const user = userEvent.setup();
mockSpecSuccess();
- render( );
+ render( );
await user.click(await screen.findByRole('button', { name: '시음 위스키 추가' }));
let whiskySearchInput = await screen.findByPlaceholderText('위스키 검색 ...');
@@ -539,7 +548,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
const user = userEvent.setup();
mockSpecSuccess();
- render( );
+ render( );
await user.click(await screen.findByRole('button', { name: '시음 위스키 추가' }));
const whiskySearchInput = await screen.findByPlaceholderText('위스키 검색 ...');
@@ -571,7 +580,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
setCurrentUserRoles(['ROOT_ADMIN']);
mockSpecSuccess();
- render( );
+ render( );
expect(await screen.findByText('관리자 전용 설정')).toBeInTheDocument();
expect(screen.getByLabelText('노출 순서')).not.toBeDisabled();
@@ -583,7 +592,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
it('관리자가 아니면 관리자 전용 설정 조작 시 안내를 표시한다', async () => {
mockSpecSuccess();
- render( );
+ render( );
const displayOrderInput = await screen.findByLabelText('노출 순서');
@@ -598,7 +607,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
const user = userEvent.setup();
mockSpecSuccess();
- render( );
+ render( );
expect(await screen.findByText('시음회 참여자를 모집할 목적이신가요?')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: '네' })).toHaveAttribute('aria-checked', 'true');
@@ -632,7 +641,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
const user = userEvent.setup();
mockSpecSuccess();
- render( );
+ render( );
expect(await screen.findByLabelText('신청링크')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: '네' })).toHaveAttribute('aria-checked', 'true');
@@ -645,7 +654,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
it('광고노출 시작일이나 종료일이 과거 날짜여도 오늘 날짜 기준 validation을 표시하지 않는다', async () => {
mockSpecSuccess();
- render( );
+ render( );
await screen.findByLabelText('광고노출 시작일');
@@ -665,7 +674,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
it('광고노출 종료일이 시작일보다 빠르면 저장 전 validation을 표시한다', async () => {
mockSpecSuccess();
- render( );
+ render( );
await screen.findByLabelText('광고노출 시작일');
@@ -682,7 +691,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
mockSpecSuccess();
mockImageUpload();
- render( );
+ render( );
const imageFileInput = await screen.findByLabelText('큐레이션 이미지 파일 선택');
@@ -734,11 +743,9 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
)
);
- render( );
+ render( );
- expect(
- await screen.findByText('시음회 스펙 상세를 불러올 권한이 없습니다.')
- ).toBeInTheDocument();
+ expect(await screen.findByText('큐레이션 스펙을 불러오지 못했습니다.')).toBeInTheDocument();
expect(screen.queryByLabelText('큐레이션명')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /저장/ })).not.toBeInTheDocument();
});
@@ -747,7 +754,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
const user = userEvent.setup();
mockSpecSuccess();
- render( );
+ render( );
await screen.findByLabelText('큐레이션명');
@@ -801,7 +808,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
})
);
- render( );
+ render( );
await screen.findByLabelText('큐레이션명');
@@ -925,7 +932,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
})
);
- render( );
+ render( );
await screen.findByLabelText('큐레이션명');
@@ -1001,7 +1008,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => {
})
);
- render( );
+ render( );
await screen.findByLabelText('큐레이션명');
diff --git a/src/pages/curation/whisky-tasting-event/whisky-tasting-event.form-model.ts b/src/pages/curation/whisky-tasting-event/whisky-tasting-event.form-model.ts
index 97306bb..9cf86c1 100644
--- a/src/pages/curation/whisky-tasting-event/whisky-tasting-event.form-model.ts
+++ b/src/pages/curation/whisky-tasting-event/whisky-tasting-event.form-model.ts
@@ -24,6 +24,7 @@ const TASTING_EVENT_PLACE_NAME_FIELD: CurationTextFieldModel = {
label: '장소명',
required: true,
kind: 'text',
+ usePlaceSearch: true,
placeholder: '장소명을 입력하거나 검색하세요.',
maxLength: 100,
};
@@ -31,7 +32,9 @@ const TASTING_EVENT_PLACE_NAME_FIELD: CurationTextFieldModel = {
export type WhiskyTastingEventFormModel = CurationFormModel;
// 시음회 스펙을 자동 렌더링 파이프라인의 form model로 변환합니다.
-export function createWhiskyTastingEventFormModel(spec: CurationV2Spec): WhiskyTastingEventFormModel {
+export function createWhiskyTastingEventFormModel(
+ spec: CurationV2Spec
+): WhiskyTastingEventFormModel {
// 1. requestSpec 레이어: 상세 스펙의 requestSpec을 화면 생성의 단일 입력으로 사용합니다.
const formModel = createCurationFormModelFromRequestSpec(spec.requestSpec, {
// 2. schema parser 레이어: 공통 parser가 JSON Schema와 x-* 메타데이터를 읽습니다.
@@ -54,6 +57,8 @@ export function createWhiskyTastingEventFormModel(spec: CurationV2Spec): WhiskyT
// 공통 field model에 시음회 화면에서 필요한 라벨, placeholder, 단위 override를 적용합니다.
function applyTastingEventFieldOverrides(field: CurationBasicFieldModel): CurationBasicFieldModel {
switch (field.key) {
+ case 'placeName':
+ return field.kind === 'text' ? { ...field, usePlaceSearch: true } : field;
case 'eventTime':
return field.kind === 'text' || field.kind === 'time'
? ({ ...field, kind: 'time' } satisfies CurationTextFieldModel)
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index ef455ac..95728ef 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -20,8 +20,7 @@ import { BannerCreatePage } from '@/pages/banners/BannerCreate';
import { CurationEntryPage } from '@/pages/curation/CurationEntry';
import { CurationListPage } from '@/pages/curation/CurationList';
import { CurationDetailPage } from '@/pages/curation/CurationDetail';
-import { CurationWhiskyTastingEventCreatePage } from '@/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventCreate';
-import { WhiskyCurationCreatePage } from '@/pages/curation/whisky-curation/WhiskyCurationCreatePage';
+import { CurationCreatePage } from '@/pages/curation/CurationCreate';
import { CurationSpecCode } from '@/types/api';
import { InquiryListPage } from '@/pages/inquiries/InquiryList';
import { PolicyListPage } from '@/pages/policies/PolicyList';
@@ -82,7 +81,10 @@ export function AppRoutes() {
path="dashboard/curations/tasting-events/new"
element={
-
+
}
/>
@@ -90,9 +92,9 @@ export function AppRoutes() {
path="dashboard/curations/general/new"
element={
-
}
@@ -101,13 +103,21 @@ export function AppRoutes() {
path="dashboard/curations/pairings/new"
element={
-
}
/>
+
+
+
+ }
+ />