feat: 추가 이미지는 기존 묶음에 덧붙여 올리고 메뉴 배지는 공통코드 FRCS_BADGE_CD를 쓴다
백엔드 40f85c4 — 묶음 업로드가 atchFileId를 받으면 그 묶음 뒤에 새 장만 덧붙인다. 저장된 장이 앞에 그대로 있고 새 장이 뒤에만 붙는 저장은 이 경로로 보내 저장된 장을 다시 읽지 않는다 (파일이 다른 서버에 있어도 됨). 순서 변경·삭제는 전처럼 통째로 다시 올린다. 앱 API가 frcsBadge의 쉼표 코드를 FRCS_BADGE_CD 이름으로 풀므로 배지 선택지·표시·검증도 그 코드표로 바꾼다(하드코딩 BEST/MAIN/NEW/SALE 제거). Co-Authored-By: Claude Fable 5.1
@0dd1b275235dcade59e6dde3d7a9827bf7069504
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
... | ... | @@ -51,6 +51,8 @@ |
| 51 | 51 |
candidates?: MerchantDetailSummary[]; |
| 52 | 52 |
products?: MerchantProduct[]; |
| 53 | 53 |
categories: CommonCode[]; |
| 54 |
+ /** 메뉴 표의 배지 코드표(`FRCS_BADGE_CD`). */ |
|
| 55 |
+ badgeCodes?: CommonCode[]; |
|
| 54 | 56 |
} |
| 55 | 57 |
|
| 56 | 58 |
/** |
... | ... | @@ -68,6 +70,7 @@ |
| 68 | 70 |
candidates = [], |
| 69 | 71 |
products = [], |
| 70 | 72 |
categories, |
| 73 |
+ badgeCodes = [], |
|
| 71 | 74 |
}: MerchantDetailFormProps) {
|
| 72 | 75 |
const router = useRouter(); |
| 73 | 76 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
... | ... | @@ -337,7 +340,7 @@ |
| 337 | 340 |
description="앱 상세 화면의 메뉴 목록에 노출된다" |
| 338 | 341 |
className={styles.section}
|
| 339 | 342 |
> |
| 340 |
- <MerchantProductTable frcsNo={frcsNo} products={products} canAdd={isEdit} />
|
|
| 343 |
+ <MerchantProductTable frcsNo={frcsNo} badgeCodes={badgeCodes} products={products} canAdd={isEdit} />
|
|
| 341 | 344 |
</FoxCard> |
| 342 | 345 |
|
| 343 | 346 |
{detail && (
|
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
... | ... | @@ -36,9 +36,10 @@ |
| 36 | 36 |
return <MerchantDetailForm merchant={merchant} categories={await categoriesPromise} />;
|
| 37 | 37 |
} |
| 38 | 38 |
|
| 39 |
- const [products, categories, merchant] = await Promise.all([ |
|
| 39 |
+ const [products, categories, badgeCodes, merchant] = await Promise.all([ |
|
| 40 | 40 |
fetchMerchantProducts(frcsNo), |
| 41 | 41 |
categoriesPromise, |
| 42 |
+ fetchCommonCodes(CODE_GROUP.merchantProductBadge), |
|
| 42 | 43 |
// 상세 응답에 업체명이 빠지는 백엔드 결함 — 코드만 왔으면 가맹점 목록에서 이름을 채운다. |
| 43 | 44 |
detail.name === detail.id ? fetchMerchantSummary(frcsNo) : Promise.resolve(null), |
| 44 | 45 |
]); |
... | ... | @@ -47,6 +48,7 @@ |
| 47 | 48 |
detail={merchant ? { ...detail, name: merchant.name } : detail}
|
| 48 | 49 |
products={products}
|
| 49 | 50 |
categories={categories}
|
| 51 |
+ badgeCodes={badgeCodes}
|
|
| 50 | 52 |
/> |
| 51 | 53 |
); |
| 52 | 54 |
} |
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/[productSn]/page.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/[productSn]/page.tsx
... | ... | @@ -1,11 +1,13 @@ |
| 1 | 1 |
import type { Metadata } from 'next';
|
| 2 | 2 |
import { notFound } from 'next/navigation';
|
| 3 | 3 |
import { verifySession } from '@/lib/auth/dal';
|
| 4 |
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
|
|
| 4 | 5 |
import { fetchMerchantDetail } from '@/lib/data/repositories/merchant-detail-repository';
|
| 5 | 6 |
import {
|
| 6 | 7 |
fetchMerchantProduct, |
| 7 | 8 |
fetchMerchantProducts, |
| 8 | 9 |
} from '@/lib/data/repositories/merchant-product-repository'; |
| 10 |
+import { CODE_GROUP } from '@/lib/domain/common-code';
|
|
| 9 | 11 |
import { MerchantProductForm } from '../_components/merchant-product-form';
|
| 10 | 12 |
|
| 11 | 13 |
export const metadata: Metadata = { title: '가맹몰 메뉴 수정' };
|
... | ... | @@ -19,14 +21,22 @@ |
| 19 | 21 |
await verifySession(); |
| 20 | 22 |
|
| 21 | 23 |
const { frcsNo, productSn } = await params;
|
| 22 |
- const [detail, product, products] = await Promise.all([ |
|
| 24 |
+ const [detail, product, products, badgeCodes] = await Promise.all([ |
|
| 23 | 25 |
fetchMerchantDetail(frcsNo), |
| 24 | 26 |
fetchMerchantProduct(productSn), |
| 25 | 27 |
fetchMerchantProducts(frcsNo), |
| 28 |
+ fetchCommonCodes(CODE_GROUP.merchantProductBadge), |
|
| 26 | 29 |
]); |
| 27 | 30 |
if (!detail || !product || product.frcsNo !== frcsNo) {
|
| 28 | 31 |
notFound(); |
| 29 | 32 |
} |
| 30 | 33 |
|
| 31 |
- return <MerchantProductForm detail={detail} product={product} productCount={products.length} />;
|
|
| 34 |
+ return ( |
|
| 35 |
+ <MerchantProductForm |
|
| 36 |
+ detail={detail}
|
|
| 37 |
+ badgeCodes={badgeCodes}
|
|
| 38 |
+ product={product}
|
|
| 39 |
+ productCount={products.length}
|
|
| 40 |
+ /> |
|
| 41 |
+ ); |
|
| 32 | 42 |
} |
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/_components/merchant-product-form.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/_components/merchant-product-form.tsx
... | ... | @@ -15,6 +15,7 @@ |
| 15 | 15 |
import { submitFormAction } from '@/app/_hooks/submit-form-action';
|
| 16 | 16 |
import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
|
| 17 | 17 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 18 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 18 | 19 |
import { FILE_MODULE, fileModuleAccept } from '@/lib/domain/file-module';
|
| 19 | 20 |
import { formatOptionalValue, type MerchantDetail } from '@/lib/domain/merchant-detail';
|
| 20 | 21 |
import {
|
... | ... | @@ -23,7 +24,6 @@ |
| 23 | 24 |
} from '@/lib/domain/merchant-detail-query'; |
| 24 | 25 |
import {
|
| 25 | 26 |
MAX_PRODUCT_BADGES, |
| 26 |
- MERCHANT_PRODUCT_BADGE_OPTIONS, |
|
| 27 | 27 |
MERCHANT_PRODUCT_VISIBILITY_OPTIONS, |
| 28 | 28 |
type MerchantProduct, |
| 29 | 29 |
} from '@/lib/domain/merchant-product'; |
... | ... | @@ -40,6 +40,8 @@ |
| 40 | 40 |
|
| 41 | 41 |
interface MerchantProductFormProps {
|
| 42 | 42 |
detail: MerchantDetail; |
| 43 |
+ /** 배지 선택지 — 공통코드 `FRCS_BADGE_CD`. */ |
|
| 44 |
+ badgeCodes: CommonCode[]; |
|
| 43 | 45 |
product?: MerchantProduct; |
| 44 | 46 |
productCount: number; |
| 45 | 47 |
} |
... | ... | @@ -48,7 +50,12 @@ |
| 48 | 50 |
* 가맹몰 메뉴 등록·수정 — 기획 BO-MAL-004. 대상 업체는 읽기 전용이고 이 화면에서 바꾸지 않는다. |
| 49 | 51 |
* 저장하면 업체 상세 화면으로 돌아간다. |
| 50 | 52 |
*/ |
| 51 |
-export function MerchantProductForm({ detail, product, productCount }: MerchantProductFormProps) {
|
|
| 53 |
+export function MerchantProductForm({
|
|
| 54 |
+ detail, |
|
| 55 |
+ badgeCodes, |
|
| 56 |
+ product, |
|
| 57 |
+ productCount, |
|
| 58 |
+}: MerchantProductFormProps) {
|
|
| 52 | 59 |
const router = useRouter(); |
| 53 | 60 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
| 54 | 61 |
const [isBusy, startAction] = useBlockingAction(); |
... | ... | @@ -213,7 +220,7 @@ |
| 213 | 220 |
label="배지" |
| 214 | 221 |
name="badges" |
| 215 | 222 |
optionalText="복수 선택" |
| 216 |
- options={MERCHANT_PRODUCT_BADGE_OPTIONS}
|
|
| 223 |
+ options={badgeCodes.map((code) => ({ value: code.code, label: code.label }))}
|
|
| 217 | 224 |
values={badges}
|
| 218 | 225 |
onChange={setBadges}
|
| 219 | 226 |
maxSelected={MAX_PRODUCT_BADGES}
|
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/new/page.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/new/page.tsx
... | ... | @@ -1,8 +1,10 @@ |
| 1 | 1 |
import type { Metadata } from 'next';
|
| 2 | 2 |
import { notFound } from 'next/navigation';
|
| 3 | 3 |
import { verifySession } from '@/lib/auth/dal';
|
| 4 |
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
|
|
| 4 | 5 |
import { fetchMerchantDetail } from '@/lib/data/repositories/merchant-detail-repository';
|
| 5 | 6 |
import { fetchMerchantProducts } from '@/lib/data/repositories/merchant-product-repository';
|
| 7 |
+import { CODE_GROUP } from '@/lib/domain/common-code';
|
|
| 6 | 8 |
import { MerchantProductForm } from '../_components/merchant-product-form';
|
| 7 | 9 |
|
| 8 | 10 |
export const metadata: Metadata = { title: '가맹몰 메뉴 등록' };
|
... | ... | @@ -21,6 +23,9 @@ |
| 21 | 23 |
notFound(); |
| 22 | 24 |
} |
| 23 | 25 |
|
| 24 |
- const products = await fetchMerchantProducts(frcsNo); |
|
| 25 |
- return <MerchantProductForm detail={detail} productCount={products.length} />;
|
|
| 26 |
+ const [products, badgeCodes] = await Promise.all([ |
|
| 27 |
+ fetchMerchantProducts(frcsNo), |
|
| 28 |
+ fetchCommonCodes(CODE_GROUP.merchantProductBadge), |
|
| 29 |
+ ]); |
|
| 30 |
+ return <MerchantProductForm detail={detail} badgeCodes={badgeCodes} productCount={products.length} />;
|
|
| 26 | 31 |
} |
--- app/(protected)/(basic)/system/merchant-details/_actions.ts
+++ app/(protected)/(basic)/system/merchant-details/_actions.ts
... | ... | @@ -39,10 +39,7 @@ |
| 39 | 39 |
MERCHANT_DETAILS_PATH, |
| 40 | 40 |
merchantDetailPath, |
| 41 | 41 |
} from '@/lib/domain/merchant-detail-query'; |
| 42 |
-import {
|
|
| 43 |
- MERCHANT_PRODUCT_BADGE_OPTIONS, |
|
| 44 |
- type MerchantProductBadge, |
|
| 45 |
-} from '@/lib/domain/merchant-product'; |
|
| 42 |
+import type { MerchantProductBadge } from '@/lib/domain/merchant-product';
|
|
| 46 | 43 |
import {
|
| 47 | 44 |
validateMerchantProduct, |
| 48 | 45 |
type MerchantProductFormState, |
... | ... | @@ -167,8 +164,10 @@ |
| 167 | 164 |
} |
| 168 | 165 |
|
| 169 | 166 |
/** |
| 170 |
- * 추가 이미지 묶음. 백엔드가 한 `atchFileId` 아래 `fileSn` 1..n으로 채번하므로 순서·삭제·추가 |
|
| 171 |
- * 어느 것이든 바뀌면 묶음을 통째로 다시 올린다 — 저장된 장은 백엔드에서 다시 읽어 합친다. |
|
| 167 |
+ * 추가 이미지 묶음. 백엔드가 한 `atchFileId` 아래 `fileSn` 1..n으로 채번한다. |
|
| 168 |
+ * - 저장된 장이 그대로 앞에 있고 새 장이 뒤에만 붙었으면 기존 묶음에 덧붙인다(백엔드 40f85c4) — |
|
| 169 |
+ * 저장된 장을 다시 읽지 않아 빠르고, 파일이 다른 서버에 있어도 된다. |
|
| 170 |
+ * - 순서를 바꾸거나 저장된 장을 지웠으면 묶음을 통째로 다시 올린다 — 저장된 장은 백엔드에서 다시 읽는다. |
|
| 172 | 171 |
* |
| 173 | 172 |
* 폼: 칸마다 `imageSlot`(`saved:<fileSn>` | `new`)과 `imageFile`(같은 순서, 안 고른 칸은 빈 파일). |
| 174 | 173 |
*/ |
... | ... | @@ -199,6 +198,23 @@ |
| 199 | 198 |
} |
| 200 | 199 |
if (untouched) {
|
| 201 | 200 |
return retainedId; |
| 201 |
+ } |
|
| 202 |
+ |
|
| 203 |
+ const appendOnly = |
|
| 204 |
+ retainedId !== '' && |
|
| 205 |
+ savedSns.length > 0 && |
|
| 206 |
+ savedSns.every((sn, index) => sn === index + 1) && |
|
| 207 |
+ slots.every((slot, index) => |
|
| 208 |
+ index < savedSns.length ? files[index] === null && slot === `saved:${index + 1}` : files[index] !== null
|
|
| 209 |
+ ); |
|
| 210 |
+ if (appendOnly) {
|
|
| 211 |
+ const appended = files.slice(savedSns.length).filter((file): file is File => file !== null); |
|
| 212 |
+ appended.forEach(assertImage); |
|
| 213 |
+ const appendedBytes = appended.reduce((total, file) => total + file.size, 0); |
|
| 214 |
+ if (appendedBytes > MAX_ATTACHMENT_BYTES) {
|
|
| 215 |
+ throw new InputError(REQUEST_OVERSIZE_MESSAGE); |
|
| 216 |
+ } |
|
| 217 |
+ return uploadAttachments(appended, FILE_MODULE.merchantImage.id, retainedId); |
|
| 202 | 218 |
} |
| 203 | 219 |
|
| 204 | 220 |
let newBytes = 0; |
... | ... | @@ -398,10 +414,8 @@ |
| 398 | 414 |
function readBadges(formData: FormData): MerchantProductBadge[] {
|
| 399 | 415 |
return formData |
| 400 | 416 |
.getAll('badges')
|
| 401 |
- .map((entry) => (typeof entry === 'string' ? entry : '')) |
|
| 402 |
- .filter((value): value is MerchantProductBadge => |
|
| 403 |
- MERCHANT_PRODUCT_BADGE_OPTIONS.some((option) => option.value === value) |
|
| 404 |
- ); |
|
| 417 |
+ .map((entry) => (typeof entry === 'string' ? entry.trim() : '')) |
|
| 418 |
+ .filter((value) => value !== ''); |
|
| 405 | 419 |
} |
| 406 | 420 |
|
| 407 | 421 |
async function resolveProductImageId(formData: FormData): Promise<string> {
|
... | ... | @@ -427,17 +441,23 @@ |
| 427 | 441 |
|
| 428 | 442 |
let failed: MerchantProductFormState | null = null; |
| 429 | 443 |
try {
|
| 430 |
- const imageFileId = await resolveProductImageId(formData); |
|
| 431 |
- const validation = validateMerchantProduct({
|
|
| 432 |
- frcsNo, |
|
| 433 |
- name: readString(formData, 'name'), |
|
| 434 |
- price: readOptionalInteger(formData, 'price'), |
|
| 435 |
- description: readString(formData, 'description'), |
|
| 436 |
- badges: readBadges(formData), |
|
| 437 |
- isVisible: readString(formData, 'isVisible') !== 'N', |
|
| 438 |
- sortOrder: readOptionalInteger(formData, 'sortOrder'), |
|
| 439 |
- imageFileId, |
|
| 440 |
- }); |
|
| 444 |
+ const [imageFileId, badgeCodes] = await Promise.all([ |
|
| 445 |
+ resolveProductImageId(formData), |
|
| 446 |
+ fetchCommonCodes(CODE_GROUP.merchantProductBadge), |
|
| 447 |
+ ]); |
|
| 448 |
+ const validation = validateMerchantProduct( |
|
| 449 |
+ {
|
|
| 450 |
+ frcsNo, |
|
| 451 |
+ name: readString(formData, 'name'), |
|
| 452 |
+ price: readOptionalInteger(formData, 'price'), |
|
| 453 |
+ description: readString(formData, 'description'), |
|
| 454 |
+ badges: readBadges(formData), |
|
| 455 |
+ isVisible: readString(formData, 'isVisible') !== 'N', |
|
| 456 |
+ sortOrder: readOptionalInteger(formData, 'sortOrder'), |
|
| 457 |
+ imageFileId, |
|
| 458 |
+ }, |
|
| 459 |
+ badgeCodes |
|
| 460 |
+ ); |
|
| 441 | 461 |
if (!validation.ok) {
|
| 442 | 462 |
return { status: 'error', errors: validation.errors };
|
| 443 | 463 |
} |
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.tsx
... | ... | @@ -7,6 +7,7 @@ |
| 7 | 7 |
import { FoxTable } from '@fox/core/components/fox-table';
|
| 8 | 8 |
import { FoxTableCell } from '@fox/core/components/fox-table-cell';
|
| 9 | 9 |
import { FoxTableRow } from '@fox/core/components/fox-table-row';
|
| 10 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 10 | 11 |
import { EMPTY_FIELD_PLACEHOLDER } from '@/lib/domain/merchant-detail';
|
| 11 | 12 |
import { merchantProductNewPath } from '@/lib/domain/merchant-detail-query';
|
| 12 | 13 |
import {
|
... | ... | @@ -21,6 +22,8 @@ |
| 21 | 22 |
|
| 22 | 23 |
interface MerchantProductTableProps {
|
| 23 | 24 |
frcsNo: string; |
| 25 |
+ /** 배지 코드표(`FRCS_BADGE_CD`) — 배지 열의 이름. */ |
|
| 26 |
+ badgeCodes: CommonCode[]; |
|
| 24 | 27 |
products: MerchantProduct[]; |
| 25 | 28 |
/** 상세가 아직 저장되지 않은 가맹점 — 백엔드가 메뉴 등록을 거부하므로 추가를 막는다. */ |
| 26 | 29 |
canAdd: boolean; |
... | ... | @@ -30,7 +33,7 @@ |
| 30 | 33 |
* 기획 BO-MAL-002 ⑥ — 메뉴는 목록으로만 보이고 등록·수정은 별도 화면에서 한다. |
| 31 | 34 |
* 목록 화면 뼈대(FoxListContainer)는 머리말·검색까지 딸려 와 카드 안에는 표 조각만 놓는다. |
| 32 | 35 |
*/ |
| 33 |
-export function MerchantProductTable({ frcsNo, products, canAdd }: MerchantProductTableProps) {
|
|
| 36 |
+export function MerchantProductTable({ frcsNo, badgeCodes, products, canAdd }: MerchantProductTableProps) {
|
|
| 34 | 37 |
const router = useRouter(); |
| 35 | 38 |
|
| 36 | 39 |
return ( |
... | ... | @@ -96,7 +99,7 @@ |
| 96 | 99 |
<span className={styles.badges}>
|
| 97 | 100 |
{product.badges.map((badge) => (
|
| 98 | 101 |
<FoxBadge key={badge} type="pastel" color="neutral" size="sm">
|
| 99 |
- {formatProductBadgeLabel(badge)}
|
|
| 102 |
+ {formatProductBadgeLabel(badgeCodes, badge)}
|
|
| 100 | 103 |
</FoxBadge> |
| 101 | 104 |
))} |
| 102 | 105 |
</span> |
--- lib/data/repositories/file-repository.ts
+++ lib/data/repositories/file-repository.ts
... | ... | @@ -70,10 +70,14 @@ |
| 70 | 70 |
/** |
| 71 | 71 |
* 여러 장을 **한 `atchFileId` 아래** 올린다 — 파일마다 `fileSn`이 1,2,3…으로 붙는다. |
| 72 | 72 |
* 4컷만화처럼 묶음이 하나의 콘텐츠에 매달리는 자리에 쓴다. |
| 73 |
+ * |
|
| 74 |
+ * `appendTo`를 주면 그 묶음 뒤에 덧붙이고 같은 id를 돌려준다(백엔드 40f85c4). 백엔드는 원본 파일명과 |
|
| 75 |
+ * 크기가 같은 장을 이미 있는 것으로 보고 건너뛴다 — 같은 파일을 두 번 넣으면 한 장만 남는다. |
|
| 73 | 76 |
*/ |
| 74 | 77 |
export async function uploadAttachments( |
| 75 | 78 |
files: File[], |
| 76 |
- moduleId: string |
|
| 79 |
+ moduleId: string, |
|
| 80 |
+ appendTo?: string |
|
| 77 | 81 |
): Promise<string> {
|
| 78 | 82 |
const accessToken = await getSessionAccessToken(); |
| 79 | 83 |
|
... | ... | @@ -88,6 +92,7 @@ |
| 88 | 92 |
{
|
| 89 | 93 |
method: 'POST', |
| 90 | 94 |
multipart, |
| 95 |
+ ...(appendTo ? { query: { atchFileId: appendTo } } : {}),
|
|
| 91 | 96 |
accessToken: accessToken ?? undefined, |
| 92 | 97 |
cache: 'no-store', |
| 93 | 98 |
timeoutMs: UPLOAD_TIMEOUT_MS, |
--- lib/domain/common-code.ts
+++ lib/domain/common-code.ts
... | ... | @@ -45,6 +45,8 @@ |
| 45 | 45 |
merchantBusiness: 'FRCS_TPBIZ_CD', |
| 46 | 46 |
/** 업종 세분류(4자리 = 업종 2자리 + 업태 2자리). 값이 위 그룹에 없을 때 여기서 찾는다(사용자 확정). */ |
| 47 | 47 |
merchantBusinessDetail: 'FRCS_CD', |
| 48 |
+ /** 가맹몰 메뉴 배지 — 앱 API가 `frcsBadge`의 쉼표 구분 코드를 이 그룹 이름으로 풀어 준다(백엔드 40f85c4). */ |
|
| 49 |
+ merchantProductBadge: 'FRCS_BADGE_CD', |
|
| 48 | 50 |
} as const; |
| 49 | 51 |
|
| 50 | 52 |
/** 값이 없는 항목의 화면 표기. */ |
--- lib/domain/merchant-product-form.ts
+++ lib/domain/merchant-product-form.ts
... | ... | @@ -1,8 +1,5 @@ |
| 1 |
-import {
|
|
| 2 |
- MAX_PRODUCT_BADGES, |
|
| 3 |
- MERCHANT_PRODUCT_BADGE_OPTIONS, |
|
| 4 |
- type MerchantProductBadge, |
|
| 5 |
-} from '@/lib/domain/merchant-product'; |
|
| 1 |
+import type { CommonCode } from '@/lib/domain/common-code';
|
|
| 2 |
+import { MAX_PRODUCT_BADGES, type MerchantProductBadge } from '@/lib/domain/merchant-product';
|
|
| 6 | 3 |
|
| 7 | 4 |
/** 기획 BO-MAL-004 ② — 필수는 메뉴명·가격·노출 여부 셋. */ |
| 8 | 5 |
export const PRODUCT_NAME_MAX_LENGTH = 100; |
... | ... | @@ -40,7 +37,8 @@ |
| 40 | 37 |
| { ok: false; errors: MerchantProductFormErrors };
|
| 41 | 38 |
|
| 42 | 39 |
export function validateMerchantProduct( |
| 43 |
- values: MerchantProductValues |
|
| 40 |
+ values: MerchantProductValues, |
|
| 41 |
+ badgeCodes: readonly CommonCode[] |
|
| 44 | 42 |
): ValidationResult {
|
| 45 | 43 |
const errors: MerchantProductFormErrors = {};
|
| 46 | 44 |
|
... | ... | @@ -65,9 +63,7 @@ |
| 65 | 63 |
errors.description = `설명은 ${PRODUCT_DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`;
|
| 66 | 64 |
} |
| 67 | 65 |
|
| 68 |
- const badges = values.badges.filter((badge) => |
|
| 69 |
- MERCHANT_PRODUCT_BADGE_OPTIONS.some((option) => option.value === badge) |
|
| 70 |
- ); |
|
| 66 |
+ const badges = values.badges.filter((badge) => badgeCodes.some((code) => code.code === badge)); |
|
| 71 | 67 |
if (badges.length > MAX_PRODUCT_BADGES) {
|
| 72 | 68 |
errors.badges = `배지는 최대 ${MAX_PRODUCT_BADGES}개까지 고를 수 있습니다.`;
|
| 73 | 69 |
} |
--- lib/domain/merchant-product.ts
+++ lib/domain/merchant-product.ts
... | ... | @@ -1,3 +1,5 @@ |
| 1 |
+import { formatCommonCode, type CommonCode } from '@/lib/domain/common-code';
|
|
| 2 |
+ |
|
| 1 | 3 |
/** |
| 2 | 4 |
* 가맹몰 메뉴 — 백엔드 `TB_COM_VOUCHER_PRODUCT`(`/api/v1/mngr/voc/product`). |
| 3 | 5 |
* 기획(BO-MAL-004)은 「메뉴」, 백엔드는 product라 부른다. 코드는 백엔드를 따르고 화면 문구만 |
... | ... | @@ -19,15 +21,8 @@ |
| 19 | 21 |
isVisible: boolean; |
| 20 | 22 |
}; |
| 21 | 23 |
|
| 22 |
-export const MERCHANT_PRODUCT_BADGE_OPTIONS = [ |
|
| 23 |
- { value: 'BEST', label: 'BEST' },
|
|
| 24 |
- { value: 'MAIN', label: '대표' },
|
|
| 25 |
- { value: 'NEW', label: '신규' },
|
|
| 26 |
- { value: 'SALE', label: '할인' },
|
|
| 27 |
-] as const; |
|
| 28 |
- |
|
| 29 |
-export type MerchantProductBadge = |
|
| 30 |
- (typeof MERCHANT_PRODUCT_BADGE_OPTIONS)[number]['value']; |
|
| 24 |
+/** 배지 코드 — 공통코드 `FRCS_BADGE_CD`의 상세코드(예: BEST·MAIN). 기획의 4종은 코드표가 정한다. */ |
|
| 25 |
+export type MerchantProductBadge = string; |
|
| 31 | 26 |
|
| 32 | 27 |
/** 기획 BO-MAL-004 ③ [의사결정 필요] 잠정값 — 배지 동시 선택 상한. */ |
| 33 | 28 |
export const MAX_PRODUCT_BADGES = 2; |
... | ... | @@ -35,8 +30,8 @@ |
| 35 | 30 |
const BADGE_SEPARATOR = ','; |
| 36 | 31 |
|
| 37 | 32 |
/** |
| 38 |
- * 배지 ↔ 백엔드 `frcsBadge`(문자열 하나). 앱·백엔드에 규약이 없어 여기서 정한다(사용자 확정): |
|
| 39 |
- * 코드를 쉼표로 잇고 순서를 보존한다. 예) `BEST,MAIN` |
|
| 33 |
+ * 배지 ↔ 백엔드 `frcsBadge`(문자열 하나) — 코드를 쉼표로 잇고 순서를 보존한다. 예) `BEST,MAIN`. |
|
| 34 |
+ * 앱 API도 같은 규칙으로 잘라 `FRCS_BADGE_CD` 이름을 붙인다(백엔드 40f85c4). |
|
| 40 | 35 |
*/ |
| 41 | 36 |
export function parseProductBadges(raw: string | null): MerchantProductBadge[] {
|
| 42 | 37 |
if (!raw) {
|
... | ... | @@ -45,20 +40,18 @@ |
| 45 | 40 |
return raw |
| 46 | 41 |
.split(BADGE_SEPARATOR) |
| 47 | 42 |
.map((token) => token.trim()) |
| 48 |
- .filter((token): token is MerchantProductBadge => |
|
| 49 |
- MERCHANT_PRODUCT_BADGE_OPTIONS.some((option) => option.value === token) |
|
| 50 |
- ); |
|
| 43 |
+ .filter((token) => token !== ''); |
|
| 51 | 44 |
} |
| 52 | 45 |
|
| 53 | 46 |
export function joinProductBadges(badges: readonly MerchantProductBadge[]): string {
|
| 54 | 47 |
return badges.join(BADGE_SEPARATOR); |
| 55 | 48 |
} |
| 56 | 49 |
|
| 57 |
-export function formatProductBadgeLabel(badge: MerchantProductBadge): string {
|
|
| 58 |
- return ( |
|
| 59 |
- MERCHANT_PRODUCT_BADGE_OPTIONS.find((option) => option.value === badge)?.label ?? |
|
| 60 |
- badge |
|
| 61 |
- ); |
|
| 50 |
+export function formatProductBadgeLabel( |
|
| 51 |
+ codes: readonly CommonCode[], |
|
| 52 |
+ badge: MerchantProductBadge |
|
| 53 |
+): string {
|
|
| 54 |
+ return formatCommonCode(codes, badge); |
|
| 62 | 55 |
} |
| 63 | 56 |
|
| 64 | 57 |
export const MERCHANT_PRODUCT_VISIBILITY_OPTIONS = [ |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?