diff --git a/apps/seller/src/assets/icons/preview/noimage.svg b/apps/seller/src/assets/icons/preview/noimage.svg new file mode 100644 index 00000000..8f2af0b7 --- /dev/null +++ b/apps/seller/src/assets/icons/preview/noimage.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/seller/src/assets/icons/preview/preview-icon1.png b/apps/seller/src/assets/icons/preview/preview-icon1.png new file mode 100644 index 00000000..eb6c18cc Binary files /dev/null and b/apps/seller/src/assets/icons/preview/preview-icon1.png differ diff --git a/apps/seller/src/assets/icons/preview/preview-icon2.png b/apps/seller/src/assets/icons/preview/preview-icon2.png new file mode 100644 index 00000000..3b2fecc6 Binary files /dev/null and b/apps/seller/src/assets/icons/preview/preview-icon2.png differ diff --git a/apps/seller/src/assets/icons/preview/preview-icon3.png b/apps/seller/src/assets/icons/preview/preview-icon3.png new file mode 100644 index 00000000..b873e44e Binary files /dev/null and b/apps/seller/src/assets/icons/preview/preview-icon3.png differ diff --git a/apps/seller/src/entity/products/create/create-delivery/index.ts b/apps/seller/src/entity/products/create/create-delivery/index.ts new file mode 100644 index 00000000..15986c05 --- /dev/null +++ b/apps/seller/src/entity/products/create/create-delivery/index.ts @@ -0,0 +1,2 @@ +export { DeliveryCompany } from './product-delivery-company' +export { DeliveryTerms } from './product-delivery-terms' diff --git a/apps/seller/src/entity/products/create/create-disclosure/index.ts b/apps/seller/src/entity/products/create/create-disclosure/index.ts new file mode 100644 index 00000000..d3731691 --- /dev/null +++ b/apps/seller/src/entity/products/create/create-disclosure/index.ts @@ -0,0 +1,2 @@ +export { DISCLOSURE_FIELDS, RADIO_OPTIONS } from './product-disclosure.constant' +export type { ProductInfoNoticeKey } from './product-disclosure.constant' diff --git a/apps/seller/src/entity/products/create/create-disclosure/product-disclosure.constant.ts b/apps/seller/src/entity/products/create/create-disclosure/product-disclosure.constant.ts index 70350c8e..91ede12d 100644 --- a/apps/seller/src/entity/products/create/create-disclosure/product-disclosure.constant.ts +++ b/apps/seller/src/entity/products/create/create-disclosure/product-disclosure.constant.ts @@ -5,17 +5,17 @@ export const DISCLOSURE_FIELDS = [ { label: '4. 소재지', key: 'originLocation' }, { label: '5. 제조년월일', key: 'manufactureDate' }, { label: '6. 소비기한 또는 품질 유지기한', key: 'expirationDate' }, - { label: '7. 보관방법', key: 'storageGuide' }, - { label: '8. 포장 단위 별 내용물 용량(중량) 수량', key: 'packagingContents' }, - { label: '9. 포장 단위별 수량', key: 'packagingQuantityUnit' }, + // { label: '7. 보관방법', key: 'storageGuide' }, + { label: '7. 포장 단위 별 내용물 용량(중량) 수량', key: 'packagingContents' }, + { label: '8. 포장 단위별 수량', key: 'packagingQuantityUnit' }, { - label: '10. 원재료명 (농수산물의 원산지 표시 등에 관한 법률)', + label: '9. 원재료명 (농수산물의 원산지 표시 등에 관한 법률)', key: 'rawMaterialName', }, - { label: '11. 영양성분', key: 'nutritionInfo' }, - { label: '12. 유전자 변형 식품에 해당하는 경우의 표시', key: 'transgenic' }, - { label: '13. 소비자 안전을 위한 주의사항', key: 'customerWarning' }, - { label: '14. 수입 식품의 경우', key: 'importFood' }, + { label: '10. 영양성분', key: 'nutritionInfo' }, + { label: '11. 유전자 변형 식품에 해당하는 경우의 표시', key: 'transgenic' }, + { label: '12. 소비자 안전을 위한 주의사항', key: 'customerWarning' }, + { label: '13. 수입 식품의 경우', key: 'importFood' }, ] as const export const RADIO_OPTIONS = [ diff --git a/apps/seller/src/entity/products/create/create-form/create-form.types.ts b/apps/seller/src/entity/products/create/create-form/create-form.types.ts new file mode 100644 index 00000000..2231a3c1 --- /dev/null +++ b/apps/seller/src/entity/products/create/create-form/create-form.types.ts @@ -0,0 +1,13 @@ +import { + DeliveryFormInput, + ProductDisclosureFormInput, + ProductFormInput, + ProductOptionFormInput, + ThumbnailFormInput, +} from './create-indivisual-form.type' + +export type CreateFormType = ProductFormInput & + DeliveryFormInput & { + options: ProductOptionFormInput[] // Feature의 스키마 대신 Entity의 순수 타입을 사용 + } & ProductDisclosureFormInput & + ThumbnailFormInput diff --git a/apps/seller/src/entity/products/create/create-form/product-form.type.ts b/apps/seller/src/entity/products/create/create-form/create-indivisual-form.type.ts similarity index 86% rename from apps/seller/src/entity/products/create/create-form/product-form.type.ts rename to apps/seller/src/entity/products/create/create-form/create-indivisual-form.type.ts index a7e102bb..cd93376d 100644 --- a/apps/seller/src/entity/products/create/create-form/product-form.type.ts +++ b/apps/seller/src/entity/products/create/create-form/create-indivisual-form.type.ts @@ -6,7 +6,7 @@ export type ProductFormInput = { productionTime: string price: number | null discountAmount: number | null - discountType: 'won' | 'percentage' + discountType: 'AMOUNT' | 'RATE' } export type DeliveryFormInput = { @@ -16,8 +16,13 @@ export type DeliveryFormInput = { deliveryMinFee: number | null } +export type ThumbnailFormInput = { + mainImage: File | null + extraImages: File[] +} + export type ProductOptionFormInput = { - mainCategory: string + mainCategory: 'bread' | 'snack' | '' subCategory: string optionName: string ingredientCategories: ('glutenFree' | 'vegan')[] diff --git a/apps/seller/src/entity/products/create/create-form/create.api.ts b/apps/seller/src/entity/products/create/create-form/create.api.ts new file mode 100644 index 00000000..04077400 --- /dev/null +++ b/apps/seller/src/entity/products/create/create-form/create.api.ts @@ -0,0 +1,21 @@ +import { client } from '@/shared/utils/axios' + +import { ApiResponse, StoreInfo } from './create.type' + +// storeId 조회 +export const getMyStore = async () => { + const response = await client.get>( + '/api/v1/seller/stores', + ) + return response.data.result.store +} + +// 상품 등록 +export const createProduct = async (formData: FormData) => { + const response = await client.post('/api/v1/seller/boards', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + return response.data +} diff --git a/apps/seller/src/entity/products/create/create-form/create.query.ts b/apps/seller/src/entity/products/create/create-form/create.query.ts new file mode 100644 index 00000000..4e86afcf --- /dev/null +++ b/apps/seller/src/entity/products/create/create-form/create.query.ts @@ -0,0 +1,16 @@ +import { queryOptions } from '@tanstack/react-query' + +import { getMyStore } from './create.api' + +export const productKeys = { + all: ['products'] as const, + myStore: () => [...productKeys.all, 'myStore'] as const, +} + +export const productQueries = { + myStore: () => + queryOptions({ + queryKey: productKeys.myStore(), + queryFn: getMyStore, + }), +} diff --git a/apps/seller/src/entity/products/create/create-form/create.type.ts b/apps/seller/src/entity/products/create/create-form/create.type.ts new file mode 100644 index 00000000..882f0f84 --- /dev/null +++ b/apps/seller/src/entity/products/create/create-form/create.type.ts @@ -0,0 +1,66 @@ +// API 요청 타입 +export interface CreateProductRequest { + storeId: number + title: string + isFresh: boolean + productionStartAt: string + price: number + discountType: 'AMOUNT' | 'RATE' + discountValue: number + deliveryCondition: string + deliveryCompany: string + deliveryFee: number + freeShippingConditions: number + content: string + products: ProductOptionRequest[] + productInfoNoticeRequest: Record +} + +export interface ProductOptionRequest { + title: string + category: string + plusPriceWithBoardPrice: number + stock: number + dietaryTags: { + glutenFreeTag: boolean + highProteinTag: boolean + sugarFreeTag: boolean + veganTag: boolean + ketogenicTag: boolean + } + availability: { + monday: boolean + tuesday: boolean + wednesday: boolean + thursday: boolean + friday: boolean + saturday: boolean + sunday: boolean + } + nutritionInfo: { + totalWeight: number + servingSize: number + carbohydrates: number + sugars: number + protein: number + fat: number + calories: number + } | null +} + +// API 응답 타입 +export interface StoreInfo { + storeId: number + name: string + introduce: string + profile: string + phoneNumber: string + email: string +} + +export interface ApiResponse { + success: boolean + code: number + message: string + result: T +} diff --git a/apps/seller/src/entity/products/create/create-form/index.ts b/apps/seller/src/entity/products/create/create-form/index.ts new file mode 100644 index 00000000..a9220c34 --- /dev/null +++ b/apps/seller/src/entity/products/create/create-form/index.ts @@ -0,0 +1,10 @@ +export * from './create-form.types' +export * from './create-indivisual-form.type' +export { getMyStore, createProduct } from './create.api' +export { productQueries, productKeys } from './create.query' +export type { + CreateProductRequest, + ProductOptionRequest, + StoreInfo, + ApiResponse, +} from './create.type' diff --git a/apps/seller/src/entity/products/create/create-header/index.ts b/apps/seller/src/entity/products/create/create-header/index.ts new file mode 100644 index 00000000..af7a350e --- /dev/null +++ b/apps/seller/src/entity/products/create/create-header/index.ts @@ -0,0 +1,3 @@ +export { CategoryOptions } from './category-options.constants' +export { EssentialOptions } from './essential-options.constants' +export type { OptionTags } from './options-tag.type' diff --git a/apps/seller/src/entity/products/create/create-info/index.ts b/apps/seller/src/entity/products/create/create-info/index.ts new file mode 100644 index 00000000..49ba22de --- /dev/null +++ b/apps/seller/src/entity/products/create/create-info/index.ts @@ -0,0 +1,2 @@ +export { ProductDiscountType } from './product-discount-type.constants' +export { productionTimes } from './production-time.constants' diff --git a/apps/seller/src/entity/products/create/create-info/product-discount-type.constants.ts b/apps/seller/src/entity/products/create/create-info/product-discount-type.constants.ts index 641e95ea..114de0a0 100644 --- a/apps/seller/src/entity/products/create/create-info/product-discount-type.constants.ts +++ b/apps/seller/src/entity/products/create/create-info/product-discount-type.constants.ts @@ -1,10 +1,10 @@ export const ProductDiscountType = [ { label: '원', - value: 'won', + value: 'AMOUNT', }, { label: '%', - value: 'percentage', + value: 'RATE', }, ] diff --git a/apps/seller/src/entity/products/create/create-info/production-time.constants.ts b/apps/seller/src/entity/products/create/create-info/production-time.constants.ts index 578e37c8..64b0fed5 100644 --- a/apps/seller/src/entity/products/create/create-info/production-time.constants.ts +++ b/apps/seller/src/entity/products/create/create-info/production-time.constants.ts @@ -1,22 +1,29 @@ +// export const productionTimes = [ +// { +// label: '03:00~04:00', +// value: '03:00~04:00', +// }, +// { +// label: '04:00~05:00', +// value: '04:00~05:00', +// }, +// { +// label: '05:00~06:00', +// value: '05:00~06:00', +// }, +// { +// label: '06:00~07:00', +// value: '06:00~07:00', +// }, +// { +// label: '07:00~08:00', +// value: '07:00~08:00', +// }, +// ] export const productionTimes = [ - { - label: '03:00~04:00', - value: '03:00~04:00', - }, - { - label: '04:00~05:00', - value: '04:00~05:00', - }, - { - label: '05:00~06:00', - value: '05:00~06:00', - }, - { - label: '06:00~07:00', - value: '06:00~07:00', - }, - { - label: '07:00~08:00', - value: '07:00~08:00', - }, + { label: '03:00~04:00', value: '03:00' }, + { label: '04:00~05:00', value: '04:00' }, + { label: '05:00~06:00', value: '05:00' }, + { label: '06:00~07:00', value: '06:00' }, + { label: '07:00~08:00', value: '07:00' }, ] diff --git a/apps/seller/src/entity/products/create/create-options/index.ts b/apps/seller/src/entity/products/create/create-options/index.ts new file mode 100644 index 00000000..ce4e42e0 --- /dev/null +++ b/apps/seller/src/entity/products/create/create-options/index.ts @@ -0,0 +1,7 @@ +export { NUTRITION_FIELDS } from './product-nutritions.constant' +export type { NutritionFieldKey } from './product-nutritions.constant' +export { + MAIN_CATEGORY_OPTIONS, + SUB_CATEGORY_MAP, +} from './product-options.constant' +export { SHIPPING_DAYS } from './product-shipping-days.constant' diff --git a/apps/seller/src/entity/products/create/create-store/create-store-header.type.ts b/apps/seller/src/entity/products/create/create-store/create-store-header.type.ts new file mode 100644 index 00000000..8be8d920 --- /dev/null +++ b/apps/seller/src/entity/products/create/create-store/create-store-header.type.ts @@ -0,0 +1,32 @@ +export type ProductFileType = Record + +export interface NutritionData { + sugar: number | null + protein: number | null + fat: number | null + ingredientCategories: string[] +} + +export interface ActiveTags { + [key: string]: boolean +} + +export interface CreateFormHeaderType { + productFields: ProductFileType + currentStep: number + headerHeight: number + + setProductFields: React.Dispatch> + setCurrentStep: React.Dispatch> + setHeaderHeight: React.Dispatch> + scrollToStep: (index: number) => void + + isScrollingToStep: React.MutableRefObject + + nutritionDataList: NutritionData[] + setNutritionData: (index: number, data: NutritionData) => void + activeTags: ActiveTags + + productPrice: number | null + setProductPrice: React.Dispatch> +} diff --git a/apps/seller/src/entity/products/create/create-store/index.ts b/apps/seller/src/entity/products/create/create-store/index.ts new file mode 100644 index 00000000..e4f95c2e --- /dev/null +++ b/apps/seller/src/entity/products/create/create-store/index.ts @@ -0,0 +1 @@ +export * from './create-store-header.type' diff --git a/apps/seller/src/entity/products/index.ts b/apps/seller/src/entity/products/index.ts deleted file mode 100644 index 6644c541..00000000 --- a/apps/seller/src/entity/products/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Create - Delivery -export * from './create/create-delivery/product-delivery-company' -export * from './create/create-delivery/product-delivery-terms' - -// Create - Disclosure -export * from './create/create-disclosure/product-disclosure.constant' - -// Create - Form Types -export * from './create/create-form/product-form.type' - -// Create - Header -export * from './create/create-header/category-options.constants' -export * from './create/create-header/essential-options.constants' -export * from './create/create-header/options-tag.type' - -// Create - Info (Product Info) -export * from './create/create-info/product-discount-type.constants' -export * from './create/create-info/production-time.constants' - -// Create - Options -export * from './create/create-options/product-nutritions.constant' -export * from './create/create-options/product-options.constant' -export * from './create/create-options/product-shipping-days.constant' diff --git a/apps/seller/src/features/products/create/create-calculation/create-form-number-input.hook.ts b/apps/seller/src/features/products/create/create-calculation/create-form-number-input.hook.ts index f7bf576f..5e3558f6 100644 --- a/apps/seller/src/features/products/create/create-calculation/create-form-number-input.hook.ts +++ b/apps/seller/src/features/products/create/create-calculation/create-form-number-input.hook.ts @@ -1,18 +1,24 @@ import { useEffect, useState } from 'react' export function useNumberInput( - value: number | null, + value: number | null | undefined, // undefined 추가 onChange: (value: number | null) => void, options?: { allowNegative?: boolean }, ) { const { allowNegative = false } = options ?? {} + // 💡 undefined 체크 추가하여 에러 방지 const [displayValue, setDisplayValue] = useState( - value !== null ? value.toLocaleString('ko-KR') : '', + value !== null && value !== undefined ? value.toLocaleString('ko-KR') : '', ) useEffect(() => { - setDisplayValue(value !== null ? value.toLocaleString('ko-KR') : '') + // 💡 undefined 체크 추가하여 에러 방지 + setDisplayValue( + value !== null && value !== undefined + ? value.toLocaleString('ko-KR') + : '', + ) }, [value]) const handleChange = (e: React.ChangeEvent) => { @@ -22,7 +28,6 @@ export function useNumberInput( ? raw.replace(/[^0-9-]/g, '').replace(/(?!^)-/g, '') : raw.replace(/[^0-9]/g, '') - // '-' 만 입력된 중간 상태는 표시만 유지, onChange는 호출 안 함 if (cleaned === '-') { setDisplayValue('-') return diff --git a/apps/seller/src/features/products/create/create-calculation/index.ts b/apps/seller/src/features/products/create/create-calculation/index.ts new file mode 100644 index 00000000..46ae923c --- /dev/null +++ b/apps/seller/src/features/products/create/create-calculation/index.ts @@ -0,0 +1,2 @@ +export { useFloatInput } from './create-form-float-input.hook' +export { useNumberInput } from './create-form-number-input.hook' diff --git a/apps/seller/src/features/products/create/create-draft/create-draft-dialog.ui.tsx b/apps/seller/src/features/products/create/create-draft/create-draft-dialog.ui.tsx new file mode 100644 index 00000000..388832a8 --- /dev/null +++ b/apps/seller/src/features/products/create/create-draft/create-draft-dialog.ui.tsx @@ -0,0 +1,50 @@ +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@dessert/ui' + +interface CreateDraftDialogProps { + isOpen: boolean + onClose: () => void + onConfirm: () => void +} + +export function CreateDraftDialog({ + isOpen, + onClose, + onConfirm, +}: CreateDraftDialogProps) { + return ( + !open && onClose()}> + + + 임시저장된 내용이 있어요 + + 작성 중이던 상품 등록 페이지가 있어요.
+ 불러올까요? 취소하면 기존에 등록된 페이지는 +
삭제됩니다. +
+
+ +
+ ) +} diff --git a/apps/seller/src/features/products/create/create-draft/create-draft.store.tsx b/apps/seller/src/features/products/create/create-draft/create-draft.store.tsx new file mode 100644 index 00000000..ac2191e7 --- /dev/null +++ b/apps/seller/src/features/products/create/create-draft/create-draft.store.tsx @@ -0,0 +1,26 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +import { CreateFormType } from '@/entity/products/create/create-form' + +// 이미지는 localStorage 저장 불가 사유로 제외합니다 +type DraftData = Omit & { + productDetail: string +} + +interface CreateDraftStore { + draft: DraftData | null + saveDraft: (data: DraftData) => void + clearDraft: () => void +} + +export const useCreateDraftStore = create()( + persist( + (set) => ({ + draft: null, + saveDraft: (data) => set({ draft: data }), + clearDraft: () => set({ draft: null }), + }), + { name: 'product-create-draft' }, + ), +) diff --git a/apps/seller/src/features/products/create/create-draft/index.ts b/apps/seller/src/features/products/create/create-draft/index.ts new file mode 100644 index 00000000..5f445600 --- /dev/null +++ b/apps/seller/src/features/products/create/create-draft/index.ts @@ -0,0 +1,3 @@ +export { useCreateDraft } from './use-create-draft.hook' +export { useCreateDraftStore } from './create-draft.store' +export { CreateDraftDialog } from './create-draft-dialog.ui' diff --git a/apps/seller/src/features/products/create/create-draft/use-create-draft.hook.ts b/apps/seller/src/features/products/create/create-draft/use-create-draft.hook.ts new file mode 100644 index 00000000..c3213897 --- /dev/null +++ b/apps/seller/src/features/products/create/create-draft/use-create-draft.hook.ts @@ -0,0 +1,37 @@ +import { toast } from '@dessert/ui' +import { useFormContext } from 'react-hook-form' + +import { CreateFormType } from '@/entity/products/create/create-form' + +import { useCreateDraftStore } from './create-draft.store' +import { useProductCreationStore } from '../create-store/product-creation.store' +export const useCreateDraft = () => { + const form = useFormContext() + const { productDetail } = useProductCreationStore() + const { draft, saveDraft, clearDraft } = useCreateDraftStore() + + const handleSaveDraft = () => { + const formData = form.getValues() + saveDraft({ + ...formData, + productDetail, + }) + toast.success('임시저장을 완료했어요 ') + } + + // 폼에 임시저장 데이터 복원 + const handleRestoreDraft = () => { + if (!draft) return + const { productDetail: savedDetail, ...formValues } = draft + form.reset(formValues as CreateFormType) + useProductCreationStore.getState().setProductDetail(savedDetail) + clearDraft() + } + + return { + draft, + handleSaveDraft, + handleRestoreDraft, + clearDraft, + } +} diff --git a/apps/seller/src/features/products/create/create-footer/create-footer.ui.tsx b/apps/seller/src/features/products/create/create-footer/create-footer.ui.tsx new file mode 100644 index 00000000..a3184f2d --- /dev/null +++ b/apps/seller/src/features/products/create/create-footer/create-footer.ui.tsx @@ -0,0 +1,45 @@ +import { Button } from '@dessert/ui' +import { useFormContext } from 'react-hook-form' + +import { CreateFormType } from '@/entity/products/create/create-form' +import { useSubmitCreateForm } from '@/features/products/create/create-form' + +import { useCreateDraft } from '../create-draft' + +interface ProductFooterProps { + onPreview: () => void +} + +export const ProductFooter = ({ onPreview }: ProductFooterProps) => { + const { handleSaveDraft } = useCreateDraft() + const { handleSubmit, isPending } = useSubmitCreateForm() + const { + formState: { isDirty }, + } = useFormContext() + const hasAnyInput = isDirty + + return ( +
+
+ ) +} diff --git a/apps/seller/src/features/products/create/create-footer/index.ts b/apps/seller/src/features/products/create/create-footer/index.ts new file mode 100644 index 00000000..3fb03d6f --- /dev/null +++ b/apps/seller/src/features/products/create/create-footer/index.ts @@ -0,0 +1 @@ +export { ProductFooter } from './create-footer.ui' diff --git a/apps/seller/src/features/products/create/create-form-delivery/create-form-delivery-area.ui.tsx b/apps/seller/src/features/products/create/create-form-delivery/create-form-delivery-area.ui.tsx index 6d2f3c16..e29460b0 100644 --- a/apps/seller/src/features/products/create/create-form-delivery/create-form-delivery-area.ui.tsx +++ b/apps/seller/src/features/products/create/create-form-delivery/create-form-delivery-area.ui.tsx @@ -3,12 +3,15 @@ import { useEffect } from 'react' import { Dropdown, Input, Label } from '@dessert/ui' import { Controller } from 'react-hook-form' -import { DeliveryCompany, DeliveryTerms } from '@/entity/products' -import { InfoTooltip } from '../create-form/info-tooltip.ui' +import { + DeliveryCompany, + DeliveryTerms, +} from '@/entity/products/create/create-delivery' import { cn } from '@/shared/libs/utils' import { useProductDeliveryForm } from './use-product-delivery-form.hook' -import { useCreateFormSteps } from '../create-form/use-create-form-steps.hook' +import { InfoTooltip } from '../create-form/info-tooltip.ui' +import { useCreateHeaderSteps } from '../create-store' export const ProductDeliveryArea = () => { const { @@ -25,10 +28,10 @@ export const ProductDeliveryArea = () => { formState: { errors }, } = form - const { setProductFields } = useCreateFormSteps() + const { setProductFields } = useCreateHeaderSteps() useEffect(() => { - setProductFields((prev) => ({ ...prev, productDelivery: isFormField })) + setProductFields({ productDelivery: isFormField }) }, [isFormField, setProductFields]) return ( diff --git a/apps/seller/src/features/products/create/create-form-delivery/use-product-delivery-form.hook.ts b/apps/seller/src/features/products/create/create-form-delivery/use-product-delivery-form.hook.ts index ac2f5071..3b40ebc0 100644 --- a/apps/seller/src/features/products/create/create-form-delivery/use-product-delivery-form.hook.ts +++ b/apps/seller/src/features/products/create/create-form-delivery/use-product-delivery-form.hook.ts @@ -1,10 +1,11 @@ import { useFormContext } from 'react-hook-form' -import { useNumberInput } from '../create-calculation/create-form-number-input.hook' -import { CreateProductForm } from '../create-form/product-create.types' +import { CreateFormType } from '@/entity/products/create/create-form' + +import { useNumberInput } from '../create-calculation' export function useProductDeliveryForm() { - const form = useFormContext() + const form = useFormContext() const deliveryTerms = form.watch('deliveryTerms') const deliveryCompany = form.watch('deliveryCompany') diff --git a/apps/seller/src/features/products/create/create-form-detail/create-detail-editor-modal.ui.tsx b/apps/seller/src/features/products/create/create-form-detail/create-detail-editor-modal.ui.tsx new file mode 100644 index 00000000..ee0b88f8 --- /dev/null +++ b/apps/seller/src/features/products/create/create-form-detail/create-detail-editor-modal.ui.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react' + +import { BbanggreuiOvenLogo } from '@dessert/icons' +import { Button, Editor, LogoHeader } from '@dessert/ui' + +import { useProductCreationStore } from '../create-store/product-creation.store' + +interface ProductEditorModalProps { + isOpen: boolean + onClose: () => void + onImageUpload: (file: File) => Promise +} + +export const ProductEditorModal = ({ + isOpen, + onClose, + onImageUpload, +}: ProductEditorModalProps) => { + const { productDetail, setProductDetail } = useProductCreationStore() + + // 편집 시 로컬 상태를 사용하고 등록 시에만 스토어에 반영합니다 (CodeRabbit 피드백 반영) + const [localDetail, setLocalDetail] = useState(productDetail) + if (!isOpen) return null + + return ( +
+ {/* Header */} +
e.stopPropagation()}> + +
+ + {/* Content Body */} +
e.stopPropagation()} + > + {/* Editor wrapper */} +
+ +
+
+ + {/* Footer Nav */} +
e.stopPropagation()} + > +
+
+ ) +} diff --git a/apps/seller/src/features/products/create/create-form-detail/create-form-detail-area.ui.tsx b/apps/seller/src/features/products/create/create-form-detail/create-form-detail-area.ui.tsx index fdf436b9..ca9d82e8 100644 --- a/apps/seller/src/features/products/create/create-form-detail/create-form-detail-area.ui.tsx +++ b/apps/seller/src/features/products/create/create-form-detail/create-form-detail-area.ui.tsx @@ -1,20 +1,20 @@ -import { useEffect } from 'react' - +import { useEffect, useState } from 'react' import { PlusIcon, SquarePenIcon } from '@dessert/icons' import { Button, Label } from '@dessert/ui' -import { useNavigate } from 'react-router-dom' import AppLogoImage from '@/assets/images/apple-120x120.png' -import { ROUTES } from '@/shared/constant/routes' -import { useProductCreationStore } from '../create-form/product-creation.store' -import { useCreateFormSteps } from '../create-form/use-create-form-steps.hook' +import { ProductEditorModal } from './create-detail-editor-modal.ui' +import { useSubmitCreateForm } from '../create-form' +import { useCreateHeaderSteps } from '../create-store' +import { useProductCreationStore } from '../create-store/product-creation.store' export const ProductDetailArea = () => { - const navigate = useNavigate() + const [isEditorOpen, setIsEditorOpen] = useState(false) const { productDetail } = useProductCreationStore() - const { setProductFields } = useCreateFormSteps() + const { setProductFields } = useCreateHeaderSteps() + const { handleSubmit, isPending, editorImageFiles } = useSubmitCreateForm() // Quill 에디터의 빈 콘텐츠 체크 로직 (Zustand 상태 기반) const hasContent = @@ -22,16 +22,9 @@ export const ProductDetailArea = () => { // 내용 유무에 따라 상품 등록 폼의 완료 상태 업데이트 useEffect(() => { - setProductFields((prev) => ({ - ...prev, - productDetail: hasContent, - })) + setProductFields({ productDetail: hasContent }) }, [hasContent, setProductFields]) - const handleEditClick = () => { - navigate(ROUTES.PRODUCTS.CREATE_DETAIL) - } - return ( <>
@@ -62,7 +55,7 @@ export const ProductDetailArea = () => { size="lg" leftIcon={} className="w-full" - onClick={handleEditClick} + onClick={() => setIsEditorOpen(true)} />
) : ( @@ -74,9 +67,19 @@ export const ProductDetailArea = () => { size="lg" leftIcon={} className="w-full" - onClick={handleEditClick} + onClick={() => setIsEditorOpen(true)} /> )} + + setIsEditorOpen(false)} + onImageUpload={async (file) => { + const blobUrl = URL.createObjectURL(file) + editorImageFiles.current.set(blobUrl, file) + return blobUrl // 에디터엔 blob URL로 표시 + }} + /> ) } diff --git a/apps/seller/src/features/products/create/create-form-disclosure/create-disclosure.schema.ts b/apps/seller/src/features/products/create/create-form-disclosure/create-disclosure.schema.ts index 1ce851fe..b6b2c53c 100644 --- a/apps/seller/src/features/products/create/create-form-disclosure/create-disclosure.schema.ts +++ b/apps/seller/src/features/products/create/create-form-disclosure/create-disclosure.schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod' -import { DISCLOSURE_FIELDS } from '@/entity/products' +import { DISCLOSURE_FIELDS } from '@/entity/products/create/create-disclosure' // DISCLOSURE_FIELDS 상수를 기반으로 스키마 객체를 동적 생성합니다. // 타입 추론을 위해 ZodTypeAny를 사용합니다. diff --git a/apps/seller/src/features/products/create/create-form-disclosure/create-form-disclosure-area.ui.tsx b/apps/seller/src/features/products/create/create-form-disclosure/create-form-disclosure-area.ui.tsx index ac3c2254..64666e47 100644 --- a/apps/seller/src/features/products/create/create-form-disclosure/create-form-disclosure-area.ui.tsx +++ b/apps/seller/src/features/products/create/create-form-disclosure/create-form-disclosure-area.ui.tsx @@ -1,11 +1,14 @@ import { Input, Label, Radio } from '@dessert/ui' import { Control, Controller, FieldErrors, useWatch } from 'react-hook-form' -import { DISCLOSURE_FIELDS, RADIO_OPTIONS } from '@/entity/products' +import { + DISCLOSURE_FIELDS, + RADIO_OPTIONS, +} from '@/entity/products/create/create-disclosure' +import { CreateFormType } from '@/entity/products/create/create-form' import { cn } from '@/shared/libs/utils' import { useProductDisclosureForm } from './use-product-disclosure-form.hook' -import { CreateProductForm } from '../create-form/product-create.types' /** * [Refactoring] DisclosureFieldItem @@ -20,10 +23,9 @@ const DisclosureFieldItem = ({ }: { field: (typeof DISCLOSURE_FIELDS)[number] index: number - control: Control - errors: FieldErrors + control: Control + errors: FieldErrors }) => { - // 개별 모드 값만 감시하여 리렌더링 범위 최소화 const modeValue = useWatch({ control, name: `productInfoNoticeMode.${field.key}`, @@ -43,9 +45,9 @@ const DisclosureFieldItem = ({ ( + render={({ field: { value, onChange, name } }) => ( ( )} /> diff --git a/apps/seller/src/features/products/create/create-form-disclosure/use-product-disclosure-form.hook.ts b/apps/seller/src/features/products/create/create-form-disclosure/use-product-disclosure-form.hook.ts index 8ea53220..1d4437f0 100644 --- a/apps/seller/src/features/products/create/create-form-disclosure/use-product-disclosure-form.hook.ts +++ b/apps/seller/src/features/products/create/create-form-disclosure/use-product-disclosure-form.hook.ts @@ -2,12 +2,12 @@ import { useEffect } from 'react' import { useFormContext, useWatch } from 'react-hook-form' -import { DISCLOSURE_FIELDS } from '@/entity/products' +import { DISCLOSURE_FIELDS } from '@/entity/products/create/create-disclosure' +import { CreateFormType } from '@/entity/products/create/create-form' -import { useCreateFormSteps } from '../create-form/use-create-form-steps.hook' -import { CreateProductForm } from '../create-form/product-create.types' +import { useCreateHeaderSteps } from '../create-store' -type NoticeFieldKey = keyof CreateProductForm['productInfoNotice'] +type NoticeFieldKey = keyof CreateFormType['productInfoNotice'] export const useProductDisclosureForm = () => { const { @@ -15,36 +15,38 @@ export const useProductDisclosureForm = () => { watch, setValue, formState: { errors }, - } = useFormContext() - const { setProductFields } = useCreateFormSteps() + } = useFormContext() + + // Zustand 스토어 액션 가져오기 + const { setProductFields } = useCreateHeaderSteps() - // 1. 필요한 값만 콕 집어서 감시 (불필요한 리렌더링 방지) const noticeValues = useWatch({ control, name: 'productInfoNotice' }) const noticeModes = useWatch({ control, name: 'productInfoNoticeMode' }) - // 2. 모드 전환 시 값 초기화 동기화 로직 및 원본 상품명 실시간 반영 useEffect(() => { const subscription = watch((value, { name }) => { - // 2-1. 모드가 'default'로 바뀔 때 값 초기화 if (name?.startsWith('productInfoNoticeMode.')) { const fieldKey = name.split('.')[1] as NoticeFieldKey const currentMode = value.productInfoNoticeMode?.[fieldKey] if (currentMode === 'default') { + // 기획: 기본값 정보가 없을 경우 "해당항목 없음" 노출 (상품명 제외) const resetValue = - fieldKey === 'productName' ? (value.productName ?? '') : '' + fieldKey === 'productName' + ? value.productName || '' + : '해당항목 없음' + setValue(`productInfoNotice.${fieldKey}`, resetValue, { shouldValidate: true, }) } } - // 2-2. 원본 상품명이 바뀔 때, 고시상품명 모드가 'default'이면 자동 동기화 (CodeRabbit 피드백 반영: 클로저 안전 보장) if ( name === 'productName' && value.productInfoNoticeMode?.productName === 'default' ) { - setValue('productInfoNotice.productName', value.productName ?? '', { + setValue('productInfoNotice.productName', value.productName || '', { shouldValidate: true, }) } @@ -52,7 +54,7 @@ export const useProductDisclosureForm = () => { return () => subscription.unsubscribe() }, [watch, setValue]) - // 3. 완료 상태 체크 로직 최적화 + // 완료 상태 체크 및 Zustand 업데이트 useEffect(() => { if (!noticeModes || !noticeValues) return @@ -68,10 +70,8 @@ export const useProductDisclosureForm = () => { return false }) - setProductFields((prev) => { - if (prev.productDisclosure === isComplete) return prev - return { ...prev, productDisclosure: isComplete } - }) + // Zustand 액션 호출: 함수형 업데이트가 아닌 객체 전달 방식 + setProductFields({ productDisclosure: isComplete }) }, [noticeModes, noticeValues, setProductFields]) return { diff --git a/apps/seller/src/features/products/create/create-form-info/create-form-info-area.ui.tsx b/apps/seller/src/features/products/create/create-form-info/create-form-info-area.ui.tsx index c859f349..bb90996d 100644 --- a/apps/seller/src/features/products/create/create-form-info/create-form-info-area.ui.tsx +++ b/apps/seller/src/features/products/create/create-form-info/create-form-info-area.ui.tsx @@ -7,8 +7,8 @@ import { ProductDiscountType } from '@/entity/products/create/create-info/produc import { productionTimes } from '@/entity/products/create/create-info/production-time.constants' import { useProductInfoForm } from './use-product-info-form.hook' -import { useCreateFormSteps } from '../create-form/use-create-form-steps.hook' -import { InfoTooltip } from '../create-form/info-tooltip.ui' +import { InfoTooltip, ProductFinalPrice } from '../create-form' +import { useCreateHeaderSteps } from '../create-store' export const ProductInfoArea = () => { const { @@ -29,10 +29,10 @@ export const ProductInfoArea = () => { formState: { errors }, } = form - const { setProductFields } = useCreateFormSteps() + const { setProductFields } = useCreateHeaderSteps() useEffect(() => { - setProductFields((prev) => ({ ...prev, productInfo: isFormField })) + setProductFields({ productInfo: isFormField }) }, [isFormField, setProductFields]) return ( @@ -133,7 +133,7 @@ export const ProductInfoArea = () => { { {finalPrice !== null && ( -
-

최종 상품 금액

- -
- {price !== null && ( -

- {price.toLocaleString('ko-KR')} -

- )} -

- - {finalPrice.toLocaleString('ko-KR')} - - 원 -

-
-
+ )} ) diff --git a/apps/seller/src/features/products/create/create-form-info/create-info.schema.ts b/apps/seller/src/features/products/create/create-form-info/create-info.schema.ts index 17cc1080..489a6aba 100644 --- a/apps/seller/src/features/products/create/create-form-info/create-info.schema.ts +++ b/apps/seller/src/features/products/create/create-form-info/create-info.schema.ts @@ -18,7 +18,7 @@ export const productSchema = z .min(0, '올바른 가격을 입력해주세요'), z.null(), ]), - discountType: z.enum(['won', 'percentage']), + discountType: z.enum(['AMOUNT', 'RATE']), }) .refine( @@ -36,16 +36,16 @@ export const productSchema = z .refine( (data) => { if (data.discountAmount === null) return true - return data.discountType === 'won' ? data.discountAmount <= 100000 : true + return data.discountType === 'AMOUNT' + ? data.discountAmount <= 100000 + : true }, { message: '올바른 금액을 입력해주세요', path: ['discountAmount'] }, ) .refine( (data) => { if (data.discountAmount === null) return true - return data.discountType === 'percentage' - ? data.discountAmount <= 100 - : true + return data.discountType === 'RATE' ? data.discountAmount <= 100 : true }, { message: '올바른 할인율을 입력해주세요', path: ['discountAmount'] }, ) @@ -53,7 +53,7 @@ export const productSchema = z (data) => { if (data.price === null || data.discountAmount === null) return true const finalPrice = - data.discountType === 'won' + data.discountType === 'AMOUNT' ? data.price - data.discountAmount : data.price * (1 - data.discountAmount / 100) return finalPrice >= 0 diff --git a/apps/seller/src/features/products/create/create-form-info/use-product-info-form.hook.ts b/apps/seller/src/features/products/create/create-form-info/use-product-info-form.hook.ts index 0a498a46..91da9df8 100644 --- a/apps/seller/src/features/products/create/create-form-info/use-product-info-form.hook.ts +++ b/apps/seller/src/features/products/create/create-form-info/use-product-info-form.hook.ts @@ -1,10 +1,13 @@ import { useFormContext } from 'react-hook-form' -import { useNumberInput } from '../create-calculation/create-form-number-input.hook' -import { CreateProductForm } from '../create-form/product-create.types' +import { CreateFormType } from '@/entity/products/create/create-form' + +import { useNumberInput } from '../create-calculation' +import { useCreateHeaderSteps } from '../create-store' export function useProductInfoForm() { - const form = useFormContext() + const form = useFormContext() + const { setProductPrice } = useCreateHeaderSteps() // 실시간으로 가격/할인 최종 금액 계산 const productName = form.watch('productName') @@ -22,7 +25,7 @@ export function useProductInfoForm() { const finalPrice = price !== null && discountAmount !== null - ? discountType === 'won' + ? discountType === 'AMOUNT' ? Math.max(price - discountAmount, 0) : Math.max(price * (1 - discountAmount / 100), 0) : null @@ -30,6 +33,8 @@ export function useProductInfoForm() { const priceInput = useNumberInput(price, (val) => { form.setValue('price', val, { shouldValidate: true }) if (discountAmount !== null) form.trigger('discountAmount') + //상품 옵션 정보의 가격에 사용될 값을 전역 상태에 업데이트 + setProductPrice(val || 0) }) const discountInput = useNumberInput(discountAmount, (val) => { form.setValue('discountAmount', val, { shouldValidate: true }) diff --git a/apps/seller/src/features/products/create/create-form-options/create-form-options-area.ui.tsx b/apps/seller/src/features/products/create/create-form-options/create-form-options-area.ui.tsx index 60def96d..db09d9c1 100644 --- a/apps/seller/src/features/products/create/create-form-options/create-form-options-area.ui.tsx +++ b/apps/seller/src/features/products/create/create-form-options/create-form-options-area.ui.tsx @@ -1,55 +1,17 @@ -import { useEffect } from 'react' +import { Label } from '@dessert/ui' +import { useFieldArray, useFormContext } from 'react-hook-form' -import { Button, Checkbox, Input, Label, Select, Switch } from '@dessert/ui' -import { Copy, Trash2 } from 'lucide-react' -import { Controller } from 'react-hook-form' +import { CreateFormType } from '@/entity/products/create/create-form' -import { NUTRITION_FIELDS } from '@/entity/products/create/create-options/product-nutritions.constant' -import { MAIN_CATEGORY_OPTIONS } from '@/entity/products/create/create-options/product-options.constant' -import DaySelector from '@/shared/block/day-selector/day-selector' - -import { useProductOptionForm } from './use-product-options.form.hook' -import { useCreateFormSteps } from '../create-form/use-create-form-steps.hook' -import { InfoTooltip } from '../create-form/info-tooltip.ui' +import { ProductOptionForm } from './create-form-options-form.ui' +import { DEFAULT_PRODUCT_OPTION } from '../create-form' export const ProductOptionsArea = () => { - const { - form, - mainCategory, - subCategoryOptions, - additionalPrice, - shippingDays, - hasNutrition, - ingredientCategories, - totalPrice, - isFormField, - handleMainCategoryChange, - toggleIngredient, - additionalPriceInput, - stockInput, - nutritionInputs, - toggleShippingDay, - } = useProductOptionForm() - - const { - control, - register, - formState: { errors }, - } = form - - const { setProductFields } = useCreateFormSteps() - - useEffect(() => { - setProductFields((prev) => ({ ...prev, productOptions: isFormField })) - }, [isFormField, setProductFields]) - - const handleDelete = () => { - //삭제 기능 - } - - const handleCopy = () => { - //복사 기능 - } + const form = useFormContext() + const { fields, append, remove, insert } = useFieldArray({ + control: form.control, + name: 'options', + }) return ( <> @@ -59,233 +21,22 @@ export const ProductOptionsArea = () => { className="typo-heading-20-sb text-gray-900" /> - - {/* 카테고리 */} -
- ( - - )} - /> -
- - {/* 옵션명 + 성분 카테고리 */} -
- -
-
-
- -
-
- - {/* 재고 수량 + 발송 요일 */} -
-
-
-
-
-
- - {/* 영양 정보 */} -
-
-
- ( - { - field.onChange(checked) - NUTRITION_FIELDS.forEach(({ key }) => { - const inputProps = - nutritionInputs[key as keyof typeof nutritionInputs] - inputProps.handleNull() - }) - }} - /> - )} + {fields.map((field, index) => ( + fields.length > 1 && remove(index)} + onCopy={() => { + const current = form.getValues(`options.${index}`) + insert(index + 1, { + ...current, + optionName: `${current.optionName} (복사본)`, + }) + }} + onAdd={() => insert(index + 1, DEFAULT_PRODUCT_OPTION)} /> -
- -
- {NUTRITION_FIELDS.map(({ key, label }) => { - const inputProps = - nutritionInputs[key as keyof typeof nutritionInputs] - return ( -
-

{label}

- -
- ) - })} -
-

- 영양 성분은 1회 제공량 기준으로 작성하세요. -

- - {/* 삭제 / 복사 */} -
- - -
- - + + + + {isLast && ( + + + ) +} + +interface DeleteConfirmDialogProps { + isOpen: boolean + onClose: () => void + onConfirm: () => void +} + +function DeleteConfirmDialog({ + isOpen, + onClose, + onConfirm, +}: DeleteConfirmDialogProps) { + return ( + !open && onClose()}> + + + 이미지를 삭제 하시겠어요? + + 현재 등록된 대표 이미지를 삭제하면 기존에 +
등록된 이미지를 복구할 수 없어요.{' '} +
+
+ +
+ ) +} diff --git a/apps/seller/src/features/products/create/create-form-thumbnail-upload/create-thumbnail-upload.schema.ts b/apps/seller/src/features/products/create/create-form-thumbnail-upload/create-thumbnail-upload.schema.ts new file mode 100644 index 00000000..0305fbe8 --- /dev/null +++ b/apps/seller/src/features/products/create/create-form-thumbnail-upload/create-thumbnail-upload.schema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' + +export const thumbnailSchema = z.object({ + mainImage: z + .custom() + .refine((file) => file !== null, { + message: '대표 이미지는 필수 입력사항입니다.', + }), + extraImages: z + .array(z.custom()) + .max(9, { message: '추가 이미지는 최대 9개까지 등록 가능합니다.' }) + .default([]), +}) diff --git a/apps/seller/src/features/products/create/create-form-thumbnail-upload/index.ts b/apps/seller/src/features/products/create/create-form-thumbnail-upload/index.ts new file mode 100644 index 00000000..e0dd1a33 --- /dev/null +++ b/apps/seller/src/features/products/create/create-form-thumbnail-upload/index.ts @@ -0,0 +1,3 @@ +export { thumbnailSchema } from './create-thumbnail-upload.schema' +export { ThumbnailUploadArea } from './create-form-thumbnail-upload-area.ui' +export { useProductThumbnailForm } from './use-product-thumnail-form.hook' diff --git a/apps/seller/src/features/products/create/create-form-thumbnail-upload/use-product-thumnail-form.hook.ts b/apps/seller/src/features/products/create/create-form-thumbnail-upload/use-product-thumnail-form.hook.ts new file mode 100644 index 00000000..06753287 --- /dev/null +++ b/apps/seller/src/features/products/create/create-form-thumbnail-upload/use-product-thumnail-form.hook.ts @@ -0,0 +1,124 @@ +import { useState } from 'react' + +import { toast } from '@dessert/ui' +import { useFormContext } from 'react-hook-form' + +import { CreateFormType } from '@/entity/products/create/create-form' + +const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB +const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png'] +const MIN_SIZE = 160 + +const validateImage = ( + file: File, +): Promise<{ error: string | null; warning: string | null }> => { + return new Promise((resolve) => { + if (!ALLOWED_TYPES.includes(file.type)) { + resolve({ + error: 'jpg, jpeg, png 형식의 이미지만 업로드 가능해요', + warning: null, + }) + return + } + + if (file.size > MAX_FILE_SIZE) { + resolve({ error: '10MB 이하의 이미지만 업로드 가능해요', warning: null }) + return + } + + const img = new Image() + const url = URL.createObjectURL(file) + img.onload = () => { + URL.revokeObjectURL(url) + let warning = null + if ( + img.width < MIN_SIZE || + img.height < MIN_SIZE || + img.width !== img.height + ) { + warning = '권장 크기는 1000×1000 (1:1 비율)이에요' + } + resolve({ error: null, warning }) + } + img.src = url + }) +} + +export function useProductThumbnailForm() { + const form = useFormContext() + + const mainImage = form.watch('mainImage') + const extraImages = form.watch('extraImages') || [] + + const [deleteTarget, setDeleteTarget] = useState(null) + + const isFormField = mainImage !== null + + const handleMainImageChange = (file: File | null) => { + form.setValue('mainImage', file, { shouldValidate: true }) + } + + const handleExtraImagesChange = (files: File[]) => { + form.setValue('extraImages', files, { shouldValidate: true }) + } + + const handleFileChange = async ( + e: React.ChangeEvent, + type: 'main' | 'extra', + ) => { + const files = e.target.files + if (!files) return + + if (type === 'main') { + const file = files[0] + const { error, warning } = await validateImage(file) + if (error) { + toast.error(error) + e.target.value = '' + return + } + if (warning) toast.info(warning) + handleMainImageChange(file) + } else { + const remainingSlots = 9 - extraImages.length + const selectedFiles = Array.from(files).slice(0, remainingSlots) + const validFiles: File[] = [] + + for (const file of selectedFiles) { + const { error, warning } = await validateImage(file) + if (error) { + toast.error(`${file.name}: ${error}`) + } else { + if (warning) toast.info(`${file.name}: ${warning}`) + validFiles.push(file) + } + } + + if (validFiles.length > 0) { + handleExtraImagesChange([...extraImages, ...validFiles]) + } + } + e.target.value = '' + } + + const handleImageDelete = () => { + if (deleteTarget === 'main') { + handleMainImageChange(null) + } else if (typeof deleteTarget === 'number') { + const newImages = extraImages.filter((_, i) => i !== deleteTarget) + handleExtraImagesChange(newImages) + } + setDeleteTarget(null) + } + + return { + form, + mainImage, + extraImages, + isFormField, + deleteTarget, + setDeleteTarget, + handleFileChange, + handleImageDelete, + } +} diff --git a/apps/seller/src/features/products/create/create-form/create-form-container.ui.tsx b/apps/seller/src/features/products/create/create-form/create-form-container.ui.tsx index 4c717b17..0b06281d 100644 --- a/apps/seller/src/features/products/create/create-form/create-form-container.ui.tsx +++ b/apps/seller/src/features/products/create/create-form/create-form-container.ui.tsx @@ -2,17 +2,29 @@ import React from 'react' import { cn } from '@/shared/libs/utils' +import { useCreateHeaderSteps } from '../create-store' + interface CreateFormContainerProps { className?: string children: React.ReactNode + id: string } export const CreateFormContainer = ({ className, children, + id, }: CreateFormContainerProps) => { + const { headerHeight } = useCreateHeaderSteps() + return ( -
+
{children}
) diff --git a/apps/seller/src/features/products/create/create-form/create-form-mapper.ts b/apps/seller/src/features/products/create/create-form/create-form-mapper.ts new file mode 100644 index 00000000..fe7eeb52 --- /dev/null +++ b/apps/seller/src/features/products/create/create-form/create-form-mapper.ts @@ -0,0 +1,159 @@ +import { + CreateFormType, + ProductOptionFormInput, +} from '@/entity/products/create/create-form' + +const CATEGORY_MAP: Record = { + bread_white: 'BREAD', + bread_bagel: 'BAGEL', + bread_cake: 'CAKE', + bread_etc: 'ETC', + snack_jam: 'JAM', + snack_cookie: 'COOKIE', + snack_granola: 'GRANOLA', + snack_etc: 'ETC', +} + +const DAY_MAP: Record = { + mon: 'monday', + tue: 'tuesday', + wed: 'wednesday', + thu: 'thursday', + fri: 'friday', + sat: 'saturday', + sun: 'sunday', +} + +const mapOption = (option: ProductOptionFormInput, index: number) => ({ + [`products[${index}].title`]: option.optionName, + [`products[${index}].category`]: CATEGORY_MAP[option.subCategory] ?? 'ETC', + [`products[${index}].plusPriceWithBoardPrice`]: option.additionalPrice ?? 0, + [`products[${index}].stock`]: option.stockQuantity ?? 0, + [`products[${index}].dietaryTags.glutenFreeTag`]: + option.ingredientCategories.includes('glutenFree'), + [`products[${index}].dietaryTags.highProteinTag`]: + option.protein !== null && option.protein >= 11, + [`products[${index}].dietaryTags.sugarFreeTag`]: + option.sugar !== null && option.sugar < 5, + [`products[${index}].dietaryTags.veganTag`]: + option.ingredientCategories.includes('vegan'), + [`products[${index}].dietaryTags.ketogenicTag`]: false, + ...Object.entries(DAY_MAP).reduce( + (acc, [short, full]) => ({ + ...acc, + [`products[${index}].availability.${full}`]: option.shippingDays.includes( + short as ProductOptionFormInput['shippingDays'][number], + ), + }), + {}, + ), + ...(option.hasNutrition + ? { + [`products[${index}].nutritionInfo.totalWeight`]: + option.totalWeight ?? 0, + [`products[${index}].nutritionInfo.servingSize`]: + option.totalWeight ?? 0, + [`products[${index}].nutritionInfo.carbohydrates`]: + option.carbohydrate ?? 0, + [`products[${index}].nutritionInfo.sugars`]: option.sugar ?? 0, + [`products[${index}].nutritionInfo.protein`]: option.protein ?? 0, + [`products[${index}].nutritionInfo.fat`]: option.fat ?? 0, + [`products[${index}].nutritionInfo.calories`]: option.calories ?? 0, + } + : {}), +}) + +const toProductionTimeFormat = (time: string): string => { + // "06:00~07:00" or "06:00" 둘 다 처리 + const start = time.split('~')[0].trim() // "06:00" + const [hour] = start.split(':') // "06" + const nextHour = String(Number(hour) + 1).padStart(2, '0') // "07" + return `T_${hour}_${nextHour}` // "T_06_07" +} + +// 에디터 이미지 추출 — src에서 파일명만 추출 +const extractEditorImages = ( + content: string, + imageFiles: Map, +): { processedContent: string; files: File[] } => { + const files: File[] = [] + const processedContent = content.replace( + /]+src=["']([^"']+)["'][^>]*>/g, + (match, src) => { + const file = imageFiles.get(src) + if (file) { + files.push(file) + return match.replace(src, file.name) + } + return match + }, + ) + return { processedContent, files } +} + +export const buildProductFormData = ( + form: CreateFormType, + productDetail: string, + editorImageFiles: Map, // blob URL → File 매핑 + storeId: number, +): FormData => { + const multipartData = new FormData() + + // 썸네일 이미지 + if (form.mainImage) { + multipartData.append('thumbnailImgFile', form.mainImage) + } + + // 서브 이미지 + form.extraImages.forEach((file) => { + multipartData.append('productImgs', file) + }) + + // 에디터 이미지 처리 + const { processedContent, files: editorFiles } = extractEditorImages( + productDetail, + editorImageFiles, + ) + editorFiles.forEach((file) => { + multipartData.append('boardDetailImages', file) + }) + + // @ModelAttribute 필드들 직접 append + multipartData.append('storeId', String(storeId)) + multipartData.append('title', form.productName) + multipartData.append('isFresh', String(form.isFresh)) + // 필드명도 productionStartTime으로 변경 + multipartData.append( + 'productionStartTime', + toProductionTimeFormat(form.productionTime), + ) + multipartData.append('price', String(form.price ?? 0)) + multipartData.append( + 'discountType', + form.discountType === 'AMOUNT' ? 'AMOUNT' : 'RATE', + ) + multipartData.append('discountValue', String(form.discountAmount ?? 0)) + multipartData.append('deliveryCondition', form.deliveryTerms) + multipartData.append('deliveryCompany', form.deliveryCompany) + multipartData.append('deliveryFee', String(form.deliveryFee ?? 0)) + multipartData.append( + 'freeShippingConditions', + String(form.deliveryMinFee ?? 0), + ) + multipartData.append('boardDetailRequest.content', processedContent) + + // 옵션들 + form.options.forEach((option, index) => { + const optionFields = mapOption(option, index) + Object.entries(optionFields).forEach(([key, value]) => { + multipartData.append(key, String(value)) + }) + }) + + // 상품 정보 고시 + Object.entries(form.productInfoNotice).forEach(([key, value]) => { + multipartData.append(`productInfoNoticeRequest.${key}`, value) + }) + + return multipartData +} diff --git a/apps/seller/src/features/products/create/create-form/create-form-provider.ui.tsx b/apps/seller/src/features/products/create/create-form/create-form-provider.ui.tsx deleted file mode 100644 index a0633785..00000000 --- a/apps/seller/src/features/products/create/create-form/create-form-provider.ui.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useState } from 'react' - -import { FormStepStatus, FormStepsContext } from './create-form-steps.context' - -export const FormStepsProvider = ({ - children, -}: { - children: React.ReactNode -}) => { - const [productFields, setProductFields] = useState({ - productInfo: false, - productDelivery: false, - productOptions: false, - }) - - return ( - - {children} - - ) -} diff --git a/apps/seller/src/features/products/create/create-form/create-form-steps.context.ts b/apps/seller/src/features/products/create/create-form/create-form-steps.context.ts deleted file mode 100644 index 197b78a1..00000000 --- a/apps/seller/src/features/products/create/create-form/create-form-steps.context.ts +++ /dev/null @@ -1,10 +0,0 @@ -import React, { createContext } from 'react' - -export type FormStepStatus = Record - -interface FormStepsContextType { - productFields: FormStepStatus - setProductFields: React.Dispatch> -} - -export const FormStepsContext = createContext(null) diff --git a/apps/seller/src/features/products/create/create-form/create-form-submit.mutation.ts b/apps/seller/src/features/products/create/create-form/create-form-submit.mutation.ts new file mode 100644 index 00000000..3982ce84 --- /dev/null +++ b/apps/seller/src/features/products/create/create-form/create-form-submit.mutation.ts @@ -0,0 +1,65 @@ +import { useRef } from 'react' + +import { toast } from '@dessert/ui' +import { useMutation, useQuery } from '@tanstack/react-query' +import { useFormContext } from 'react-hook-form' + +import { + CreateFormType, + createProduct, + productQueries, +} from '@/entity/products/create/create-form' + +import { buildProductFormData } from './create-form-mapper' +import { useCreateDraftStore } from '../create-draft' +import { useProductCreationStore } from '../create-store/product-creation.store' + +export const useSubmitCreateForm = () => { + const form = useFormContext() + const { productDetail } = useProductCreationStore() + const { clearDraft } = useCreateDraftStore() + const editorImageFiles = useRef>(new Map()) + + // const { data: store } = useQuery(productQueries.myStore()) + + const { data: store } = useQuery({ + ...productQueries.myStore(), + enabled: false, // ← 쿼리 비활성화 + }) + + const { mutate, isPending } = useMutation({ + mutationFn: createProduct, + onSuccess: () => { + clearDraft() //상품 등록 성공 시 임시 저장 내용 삭제 + toast.success('상품 등록을 완료했어요') + }, + onError: () => { + toast.error('저장이 완료되지 않았어요', '다시 한 번 시도해주세요') + }, + }) + + const handleSubmit = form.handleSubmit( + (data) => { + const storeId = store?.storeId ?? 1 + // if (!store?.storeId) { + // toast.error( + // '스토어 정보를 불러오지 못했어요', + // '다시 한 번 시도해주세요', + // ) + // return + // } + const formData = buildProductFormData( + data, + productDetail, + editorImageFiles.current, + storeId, + ) + mutate(formData) + }, + () => { + toast.error('필수 입력사항을 확인해 주세요') + }, + ) + + return { handleSubmit, isPending, editorImageFiles } +} diff --git a/apps/seller/src/features/products/create/create-form/create-form.schema.ts b/apps/seller/src/features/products/create/create-form/create-form.schema.ts new file mode 100644 index 00000000..475c876c --- /dev/null +++ b/apps/seller/src/features/products/create/create-form/create-form.schema.ts @@ -0,0 +1,13 @@ +import z from 'zod' + +import { deliverySchema } from '../create-form-delivery' +import { disclosureSchema } from '../create-form-disclosure' +import { productSchema } from '../create-form-info' +import { productOptionSchema } from '../create-form-options' +import { thumbnailSchema } from '../create-form-thumbnail-upload' + +export const CreateFormSchema = productSchema + .and(deliverySchema) + .and(z.object({ options: z.array(productOptionSchema).min(1) })) + .and(disclosureSchema) + .and(thumbnailSchema) diff --git a/apps/seller/src/features/products/create/create-form/index.ts b/apps/seller/src/features/products/create/create-form/index.ts index 739d5120..eb592e17 100644 --- a/apps/seller/src/features/products/create/create-form/index.ts +++ b/apps/seller/src/features/products/create/create-form/index.ts @@ -1,6 +1,7 @@ -export { FormStepsProvider } from './create-form-provider.ui' export { CreateFormContainer } from './create-form-container.ui' export { InfoTooltip } from './info-tooltip.ui' -export { useCreateFormSteps } from './use-create-form-steps.hook' -export { useCreateProductForm } from './use-create-product-form.hook' -export * from './product-create.types' +export { useCreateForm } from './use-create-form.hook' +export { ProductFinalPrice } from './product-final-price.ui' +export { CreateFormSchema } from './create-form.schema' +export { DEFAULT_PRODUCT_OPTION } from './use-create-form.hook' +export { useSubmitCreateForm } from './create-form-submit.mutation' diff --git a/apps/seller/src/features/products/create/create-form/product-create.types.ts b/apps/seller/src/features/products/create/create-form/product-create.types.ts deleted file mode 100644 index 8ba751c6..00000000 --- a/apps/seller/src/features/products/create/create-form/product-create.types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - DeliveryFormInput, - ProductDisclosureFormInput, - ProductFormInput, - ProductOptionFormInput, -} from '@/entity/products' - -import { deliverySchema } from '../create-form-delivery/create-delivery.schema' -import { disclosureSchema } from '../create-form-disclosure/create-disclosure.schema' -import { productSchema } from '../create-form-info/create-info.schema' -import { productOptionSchema } from '../create-form-options/create-options.schema' - -export type CreateProductForm = ProductFormInput & - DeliveryFormInput & - ProductOptionFormInput & - ProductDisclosureFormInput - -export const createProductSchema = productSchema - .and(deliverySchema) - .and(productOptionSchema) - .and(disclosureSchema) diff --git a/apps/seller/src/features/products/create/create-form/product-final-price.ui.tsx b/apps/seller/src/features/products/create/create-form/product-final-price.ui.tsx new file mode 100644 index 00000000..9f674e72 --- /dev/null +++ b/apps/seller/src/features/products/create/create-form/product-final-price.ui.tsx @@ -0,0 +1,31 @@ +interface ProductFinalPriceProps { + title: string + price: number | null + finalPrice: number +} + +export const ProductFinalPrice = ({ + title, + price, + finalPrice, +}: ProductFinalPriceProps) => { + return ( +
+

{title}

+ +
+ {price !== null && ( +

+ {price?.toLocaleString('ko-KR')} +

+ )} +

+ + {finalPrice.toLocaleString('ko-KR')} + + 원 +

+
+
+ ) +} diff --git a/apps/seller/src/features/products/create/create-form/use-create-form-steps.hook.ts b/apps/seller/src/features/products/create/create-form/use-create-form-steps.hook.ts deleted file mode 100644 index af10eec3..00000000 --- a/apps/seller/src/features/products/create/create-form/use-create-form-steps.hook.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useContext } from 'react' - -import { FormStepsContext } from './create-form-steps.context' - -export const useCreateFormSteps = () => { - const context = useContext(FormStepsContext) - if (!context) { - throw new Error( - 'useCreateFormSteps는 FormStepsProvider 안에서 사용되어야 합니다.', - ) - } - return context -} diff --git a/apps/seller/src/features/products/create/create-form/use-create-form.hook.ts b/apps/seller/src/features/products/create/create-form/use-create-form.hook.ts new file mode 100644 index 00000000..9ca86d4c --- /dev/null +++ b/apps/seller/src/features/products/create/create-form/use-create-form.hook.ts @@ -0,0 +1,68 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { Resolver, useForm } from 'react-hook-form' + +import { DISCLOSURE_FIELDS } from '@/entity/products/create/create-disclosure' +import { + CreateFormType, + ProductOptionFormInput, +} from '@/entity/products/create/create-form' + +import { CreateFormSchema } from './create-form.schema' + +export const DEFAULT_PRODUCT_OPTION: ProductOptionFormInput = { + mainCategory: '', + subCategory: '', + optionName: '', + ingredientCategories: ['glutenFree'], + additionalPrice: null, + stockQuantity: null, + shippingDays: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'], + hasNutrition: true, + totalWeight: null, + calories: null, + carbohydrate: null, + sugar: null, + protein: null, + fat: null, + sodium: null, +} + +export const useCreateForm = () => { + return useForm({ + resolver: zodResolver(CreateFormSchema) as Resolver, + defaultValues: { + productName: '', + isFresh: true, + productionTime: '', + price: null, + discountAmount: null, + discountType: 'AMOUNT', + + deliveryTerms: '', + deliveryCompany: '', + deliveryFee: null, + deliveryMinFee: null, + + mainImage: null, + extraImages: [], + + options: [DEFAULT_PRODUCT_OPTION] as unknown as CreateFormType['options'], + + productInfoNotice: DISCLOSURE_FIELDS.reduce( + (acc, field) => ({ + ...acc, + [field.key]: '', + }), + {} as CreateFormType['productInfoNotice'], + ), + productInfoNoticeMode: DISCLOSURE_FIELDS.reduce( + (acc, field) => ({ + ...acc, + [field.key]: 'default', + }), + {} as CreateFormType['productInfoNoticeMode'], + ), + }, + mode: 'onChange', + }) +} diff --git a/apps/seller/src/features/products/create/create-form/use-create-product-form.hook.ts b/apps/seller/src/features/products/create/create-form/use-create-product-form.hook.ts deleted file mode 100644 index c2637e15..00000000 --- a/apps/seller/src/features/products/create/create-form/use-create-product-form.hook.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod' -import { Resolver, useForm } from 'react-hook-form' - -import { DISCLOSURE_FIELDS } from '@/entity/products' -import { CreateProductForm, createProductSchema } from './product-create.types' - -export const useCreateProductForm = () => { - return useForm({ - resolver: zodResolver(createProductSchema) as Resolver, - defaultValues: { - productName: '', - isFresh: true, - productionTime: '', - price: null, - discountAmount: null, - discountType: 'won', - - deliveryTerms: '', - deliveryCompany: '', - deliveryFee: null, - deliveryMinFee: null, - - mainCategory: '', - subCategory: '', - optionName: '', - ingredientCategories: ['glutenFree'], - additionalPrice: null, - stockQuantity: null, - shippingDays: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'], - hasNutrition: true, - totalWeight: null, - calories: null, - carbohydrate: null, - sugar: null, - protein: null, - fat: null, - sodium: null, - - productInfoNotice: DISCLOSURE_FIELDS.reduce( - (acc, field) => ({ - ...acc, - [field.key]: '', - }), - {} as CreateProductForm['productInfoNotice'], - ), - productInfoNoticeMode: DISCLOSURE_FIELDS.reduce( - (acc, field) => ({ - ...acc, - [field.key]: 'default', - }), - {} as CreateProductForm['productInfoNoticeMode'], - ), - }, - mode: 'onChange', - }) -} diff --git a/apps/seller/src/features/products/create/create-header/create-header-tags.ui.tsx b/apps/seller/src/features/products/create/create-header/create-header-tags.ui.tsx index 7fafce35..05997f93 100644 --- a/apps/seller/src/features/products/create/create-header/create-header-tags.ui.tsx +++ b/apps/seller/src/features/products/create/create-header/create-header-tags.ui.tsx @@ -1,6 +1,8 @@ import { Chip, Label, Tooltip } from '@dessert/ui' -import { OptionTags } from '@/entity/products' +import { OptionTags } from '@/entity/products/create/create-header' + +import { useCreateHeaderSteps } from '../create-store' interface TagsProps { title: string @@ -15,6 +17,8 @@ export const ProductHeaderTags = ({ titleTooltipProps, tagData, }: TagsProps) => { + const { activeTags } = useCreateHeaderSteps() + return (
@@ -26,16 +30,21 @@ export const ProductHeaderTags = ({ {titleTooltipProps}
- {tagData.map((items) => ( - - - #{items.title} - - -

{items.tooltip}

-
-
- ))} + {tagData.map((items) => { + const isActive = activeTags[items.title] ?? false + return ( + + + + #{items.title} + + + +

{items.tooltip}

+
+
+ ) + })}
) diff --git a/apps/seller/src/features/products/create/create-header/create-header.ui.tsx b/apps/seller/src/features/products/create/create-header/create-header.ui.tsx index 62b35c51..63a5ebad 100644 --- a/apps/seller/src/features/products/create/create-header/create-header.ui.tsx +++ b/apps/seller/src/features/products/create/create-header/create-header.ui.tsx @@ -1,3 +1,5 @@ +import { useEffect, useRef } from 'react' + import { Accordion, AccordionContent, @@ -5,13 +7,15 @@ import { AccordionTrigger, StageTab, } from '@dessert/ui' -import { ChevronDown } from 'lucide-react' -import { CategoryOptions, EssentialOptions } from '@/entity/products' +import { + CategoryOptions, + EssentialOptions, +} from '@/entity/products/create/create-header' import { ProductHeaderTags } from './create-header-tags.ui' -import { useCreateFormSteps } from '../create-form/use-create-form-steps.hook' -import { InfoTooltip } from '../create-form/info-tooltip.ui' +import { InfoTooltip } from '../create-form' +import { useCreateHeaderSteps } from '../create-store' const stagestep = [ '상품 정보', @@ -23,19 +27,39 @@ const stagestep = [ ] export const ProductHeader = () => { - const { productFields } = useCreateFormSteps() + const { productFields, currentStep, scrollToStep, setHeaderHeight } = + useCreateHeaderSteps() + const headerRef = useRef(null) const steps = Object.values(productFields).filter((e) => e === true).length const totalSteps = Object.keys(productFields).length + useEffect(() => { + if (!headerRef.current) return + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const height = Math.round(entry.target.clientHeight) + setHeaderHeight(height) + } + }) + observer.observe(headerRef.current) + return () => observer.disconnect() + }, [setHeaderHeight]) return ( -
+
- {/* TODO : 추후 기능 추가 예정 */} - +
+ scrollToStep(idx)} + /> +
+

필수 입력 사항이{' '} {totalSteps - steps}개{' '} @@ -64,11 +88,10 @@ export const ProductHeader = () => { />

- -
- -
-
+
diff --git a/apps/seller/src/features/products/create/create-preview/create-preview-modal.ui.tsx b/apps/seller/src/features/products/create/create-preview/create-preview-modal.ui.tsx new file mode 100644 index 00000000..1ada7a20 --- /dev/null +++ b/apps/seller/src/features/products/create/create-preview/create-preview-modal.ui.tsx @@ -0,0 +1,237 @@ +import { Button, LogoHeader } from '@dessert/ui' +import { ChevronDown, Heart, Star } from 'lucide-react' + +import NoThumb from '@/assets/icons/preview/noimage.svg' +import Icon1 from '@/assets/icons/preview/preview-icon1.png' +import Icon2 from '@/assets/icons/preview/preview-icon2.png' +import Icon3 from '@/assets/icons/preview/preview-icon3.png' +import { cn } from '@/shared/libs/utils' + +import { CreatePreviewOptionItemUi } from './create-preview-option-item.ui' +import { useCreatePreviewPreviewHook } from './create-preview.hook' + +interface ProductPreviewModalProps { + isOpen: boolean + onClose: () => void +} + +export const ProductPreviewModal = ({ + isOpen, + onClose, +}: ProductPreviewModalProps) => { + const { + formData, + productPrice, + productDetail, + discountPercent, + totalPrice, + isPriceEntered, + allImageUrls, + discountAmount, + } = useCreatePreviewPreviewHook() + + if (!isOpen) return null + + const options = formData.options ?? [] + const BADGES = [ + { label: '맛있어요', icon: Icon1 }, + { label: '담백해요', icon: Icon2 }, + { label: '부드러워요', icon: Icon3 }, + ] + + return ( +
+
e.stopPropagation()}> + +
+ +
e.stopPropagation()} + > + {/* 1. 헤더 */} +
+ +

+ {formData.productName || '{{상품명}}'} +

+
+ + {/* 2. 탭 */} +
+
+ 상품정보 +
+
+ 리뷰 +
+
+ + {/* 3. 이미지 */} +
+
+ {allImageUrls[0] ? ( + product preview + ) : ( +
+ 썸네일이 등록되지 않았습니다 +

등록된 이미지가 없습니다.

+
+ )} + {options.length > 1 && ( +
+ 묶음상품 +
+ )} +
+ 1 / {allImageUrls.length || 1} +
+
+
+ + {/* 4. 상품 기본 정보 */} +
+
+
+

Brand Name

+
+ +
+ +
+

+ {formData.productName || '{{상품명}}'} +

+
+
+ + {isPriceEntered && discountPercent > 0 + ? `${discountPercent}%` + : '{{할인율}}'} + + + {isPriceEntered + ? `${totalPrice.toLocaleString()}원~` + : '{{상품가격}}'} + + {options.length > 1 && ( + + 맛별 가격 상이 + + )} +
+
+ + 4.5 (1,000) + +
+
+
+ + {/* 5. 배송비 */} +
+
+ 배송비 + + {formData.deliveryFee + ? `${formData.deliveryFee.toLocaleString()}원` + : '{{배송비}}'} + + {formData.deliveryMinFee && ( + + ({formData.deliveryMinFee.toLocaleString()}원 이상 구매 시 무료) + + )} +
+
+ + {/* 리뷰 배지 */} +
+

+ 리뷰 대표 배지 +

+
+ {BADGES.map(({ label, icon }) => ( +
+ {label} + {label} +
+ ))} +
+
+ + {/* 6. 상품 옵션 */} +
+
+

상품 옵션

+ {options.length === 0 ? ( +
+ 옵션을 입력해주세요 +
+ ) : ( + options.map((option, idx) => ( + + )) + )} +
+
+ 간단히 보기 +
+
+
+ + {/* 7. 상세 설명 */} +
+ {productDetail ? ( +
+ ) : ( +

+ 상세 설명을 등록해주세요 +

+ )} +
+ + {/* 하단 고정 바 */} +
+
+ +
+ +
+
+ + {/* 푸터 */} +
+
+
+ ) +} diff --git a/apps/seller/src/features/products/create/create-preview/create-preview-option-item.ui.tsx b/apps/seller/src/features/products/create/create-preview/create-preview-option-item.ui.tsx new file mode 100644 index 00000000..2acae173 --- /dev/null +++ b/apps/seller/src/features/products/create/create-preview/create-preview-option-item.ui.tsx @@ -0,0 +1,148 @@ +import { ChevronUp } from 'lucide-react' + +import { ProductOptionFormInput } from '@/entity/products/create/create-form' +import { EssentialOptions } from '@/entity/products/create/create-header' +import { SHIPPING_DAYS } from '@/entity/products/create/create-options' + +const DaySelector = ({ selectedDays }: { selectedDays: string[] }) => { + const days = SHIPPING_DAYS + return ( +
+ {days.map((day) => ( + + {day.label} + + ))} +
+ ) +} + +const getOptionTags = (option: ProductOptionFormInput) => { + const tags: string[] = [] + const { ingredientCategories, protein, fat, sugar } = option + if (ingredientCategories.includes('glutenFree')) + tags.push(EssentialOptions[0].title) + if (ingredientCategories.includes('vegan')) + tags.push(EssentialOptions[1].title) + if (protein !== null && protein >= 11) tags.push(EssentialOptions[2].title) + if (fat !== null && fat < 3) tags.push(EssentialOptions[3].title) + if (sugar !== null && sugar < 5) tags.push(EssentialOptions[4].title) + return tags +} + +interface OptionItemProps { + option: ProductOptionFormInput + idx: number + productPrice: number | null + discountAmount: number +} + +export const CreatePreviewOptionItemUi = ({ + option, + idx, + productPrice, + discountAmount, +}: OptionItemProps) => { + const basePrice = productPrice ?? 0 + const additionalPrice = option.additionalPrice ?? 0 + const originalPrice = basePrice + additionalPrice + const finalPrice = originalPrice - discountAmount + const discountRate = + originalPrice > 0 ? Math.round((discountAmount / originalPrice) * 100) : 0 + + const hasPrice = productPrice !== null + const displayDiscountRate = hasPrice ? `${discountRate}%` : '{{할인율}}' + const displayFinalPrice = hasPrice + ? `${finalPrice.toLocaleString()}원` + : '{{할인가격}}' + + return ( +
+
+

+ {option.optionName || `{{옵션명 ${idx + 1}}}`} +

+
+ {discountAmount > 0 && ( + + {originalPrice.toLocaleString()}원 + + )} + + {displayDiscountRate} + + + {displayFinalPrice} + + +
+
+ + {/* 태그 영역 */} +
+ {getOptionTags(option).length > 0 ? ( + getOptionTags(option).map((tag) => ( + + {tag} + + )) + ) : ( + + 성분 카테고리 미입력 + + )} +
+ +
+

주문 가능날짜

+
+ +
+ 빵켓팅 알림 신청 +
+
+
+ + {option.hasNutrition && ( +
+
+ 영양정보 + + 총 내용량 {option.totalWeight ?? 0}g / {option.calories ?? 0}kcal + +
+
+ {[ + { label: '단백질', key: 'protein' as const }, + { label: '당류', key: 'sugar' as const }, + { label: '탄수화물', key: 'carbohydrate' as const }, + { label: '지방', key: 'fat' as const }, + ].map((nutri) => ( +
+
+ {nutri.label} +
+
+ {option[nutri.key] ?? 0}g +
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/apps/seller/src/features/products/create/create-preview/create-preview.hook.ts b/apps/seller/src/features/products/create/create-preview/create-preview.hook.ts new file mode 100644 index 00000000..52f0da42 --- /dev/null +++ b/apps/seller/src/features/products/create/create-preview/create-preview.hook.ts @@ -0,0 +1,53 @@ +import { useFormContext } from 'react-hook-form' + +import { CreateFormType } from '@/entity/products/create/create-form' + +import { useCreateHeaderSteps } from '../create-store' +import { useProductCreationStore } from '../create-store/product-creation.store' + +export const useCreatePreviewPreviewHook = () => { + const { watch } = useFormContext() + const formData = watch() + const { productPrice } = useCreateHeaderSteps() + const { productDetail } = useProductCreationStore() + + const rawPrice = formData.price + const rawDiscount = formData.discountAmount + const discountType = formData.discountType + + const price = rawPrice ?? 0 + const discountAmount = rawDiscount ?? 0 + + const discountPercent = + discountType === 'AMOUNT' + ? price > 0 + ? Math.round((discountAmount / price) * 100) + : 0 + : discountAmount + + const totalPrice = + discountType === 'AMOUNT' + ? price - discountAmount + : Math.round(price * (1 - discountAmount / 100)) + + const isPriceEntered = rawPrice !== null && rawPrice > 0 + + const mainImageUrl = formData.mainImage + ? URL.createObjectURL(formData.mainImage) + : null + const extraImageUrls = (formData.extraImages ?? []).map((f) => + URL.createObjectURL(f), + ) + const allImageUrls = mainImageUrl ? [mainImageUrl, ...extraImageUrls] : [] + + return { + formData, + productPrice, + productDetail, + discountPercent, + totalPrice, + isPriceEntered, + allImageUrls, + discountAmount, + } +} diff --git a/apps/seller/src/features/products/create/create-preview/index.ts b/apps/seller/src/features/products/create/create-preview/index.ts new file mode 100644 index 00000000..b4166b42 --- /dev/null +++ b/apps/seller/src/features/products/create/create-preview/index.ts @@ -0,0 +1,3 @@ +export { useCreatePreviewPreviewHook } from './create-preview.hook' +export { CreatePreviewOptionItemUi } from './create-preview-option-item.ui' +export { ProductPreviewModal } from './create-preview-modal.ui' diff --git a/apps/seller/src/features/products/create/create-store/create-header-store.store.ts b/apps/seller/src/features/products/create/create-store/create-header-store.store.ts new file mode 100644 index 00000000..c696c7c9 --- /dev/null +++ b/apps/seller/src/features/products/create/create-store/create-header-store.store.ts @@ -0,0 +1,130 @@ +import { create } from 'zustand' + +import { + CategoryOptions, + EssentialOptions, +} from '@/entity/products/create/create-header' +import { + ActiveTags, + NutritionData, + ProductFileType, +} from '@/entity/products/create/create-store' + +interface CreateFormStoreProps { + // --- State --- + productFields: ProductFileType + currentStep: number + headerHeight: number + nutritionDataList: NutritionData[] + productPrice: number | null + isScrolling: boolean // Ref 대신 State로 관리 (Zustand는 선택적 구독이 가능하므로) + + // --- Actions --- + setProductFields: (fields: Partial) => void + setCurrentStep: (step: number) => void + setHeaderHeight: (height: number) => void + setProductPrice: (price: number | null) => void + setNutritionData: (index: number, data: NutritionData) => void + + // 스크롤 로직 + scrollToStep: (index: number) => void + + // --- Computed (Derived State) --- + getActiveTags: () => ActiveTags +} + +export const useCreateHeaderStore = create( + (set, get) => ({ + productFields: { + productInfo: false, + productDelivery: false, + productThumbnail: false, + productOptions: false, + productDetail: false, + productDisclosure: false, + }, + currentStep: 1, + headerHeight: 0, + nutritionDataList: [ + { sugar: null, protein: null, fat: null, ingredientCategories: [] }, + ], + productPrice: null, + isScrolling: false, + + setProductFields: (fields: Partial) => + set( + (state): Partial => ({ + // 1. 리턴 타입을 Partial로 명시 + productFields: { + ...state.productFields, + ...fields, + } as ProductFileType, // 결과물을 FormStepStatus로 단언(Assertion) + }), + ), + setCurrentStep: (step) => set({ currentStep: step }), + setHeaderHeight: (height) => set({ headerHeight: height }), + setProductPrice: (price) => set({ productPrice: price }), + + setNutritionData: (index, data) => + set((state) => { + const next = [...state.nutritionDataList] + next[index] = data + return { nutritionDataList: next } + }), + + // 스크롤 로직 이식 + scrollToStep: (index) => { + const stepIds = [ + 'productInfo', + 'productDelivery', + 'productThumbnail', + 'productOptions', + 'productDetail', + 'productDisclosure', + ] + const targetId = stepIds[index] + const element = document.getElementById(targetId) + if (!element) return + + set({ isScrolling: true, currentStep: index + 1 }) + element.scrollIntoView({ behavior: 'smooth', block: 'start' }) + + const unlock = () => set({ isScrolling: false }) + if ('onscrollend' in window) { + window.addEventListener('scrollend', unlock, { once: true }) + setTimeout(unlock, 1000) // fallback + } else { + setTimeout(unlock, 800) + } + }, + getActiveTags: () => { + const { nutritionDataList } = get() + const allCategories = nutritionDataList.flatMap( + (d) => d.ingredientCategories, + ) + + const isGlutenFree = allCategories.includes('glutenFree') + const isVegan = allCategories.includes('vegan') + const isHighProtein = nutritionDataList.some( + (d) => d.protein !== null && d.protein >= 11, + ) + const isLowFat = nutritionDataList.some( + (d) => d.fat !== null && d.fat < 3, + ) + const isLowSugar = nutritionDataList.some( + (d) => d.sugar !== null && d.sugar < 5, + ) + + return { + [EssentialOptions[0].title]: isGlutenFree, + [EssentialOptions[1].title]: isVegan, + [EssentialOptions[2].title]: isHighProtein, + [EssentialOptions[3].title]: isLowFat, + [EssentialOptions[4].title]: isLowSugar, + [CategoryOptions[0].title]: isLowSugar && isLowFat, + [CategoryOptions[1].title]: isHighProtein, + [CategoryOptions[2].title]: isVegan && isGlutenFree, + } + }, + }), +) diff --git a/apps/seller/src/features/products/create/create-store/index.ts b/apps/seller/src/features/products/create/create-store/index.ts new file mode 100644 index 00000000..51d1a74b --- /dev/null +++ b/apps/seller/src/features/products/create/create-store/index.ts @@ -0,0 +1,2 @@ +export { useCreateHeaderStore } from './create-header-store.store' +export { useCreateHeaderSteps } from './use-create-header-steps.hook' diff --git a/apps/seller/src/features/products/create/create-form/product-creation.store.ts b/apps/seller/src/features/products/create/create-store/product-creation.store.ts similarity index 100% rename from apps/seller/src/features/products/create/create-form/product-creation.store.ts rename to apps/seller/src/features/products/create/create-store/product-creation.store.ts diff --git a/apps/seller/src/features/products/create/create-store/use-create-header-steps.hook.tsx b/apps/seller/src/features/products/create/create-store/use-create-header-steps.hook.tsx new file mode 100644 index 00000000..2c08b414 --- /dev/null +++ b/apps/seller/src/features/products/create/create-store/use-create-header-steps.hook.tsx @@ -0,0 +1,26 @@ +import { useCreateHeaderStore } from './create-header-store.store' + +export const useCreateHeaderSteps = () => { + const store = useCreateHeaderStore() + + return { + // 상태값 (State) + currentStep: store.currentStep, + headerHeight: store.headerHeight, + productFields: store.productFields, + nutritionDataList: store.nutritionDataList, + productPrice: store.productPrice, + activeTags: store.getActiveTags(), + + // 액션 (Actions) + setCurrentStep: store.setCurrentStep, + setHeaderHeight: store.setHeaderHeight, + setProductFields: store.setProductFields, + setNutritionData: store.setNutritionData, + setProductPrice: store.setProductPrice, + scrollToStep: store.scrollToStep, + + // Observer 로직 대응용 Ref 구조 + isScrollingToStep: { current: store.isScrolling }, + } +} diff --git a/apps/seller/src/features/products/create/index.ts b/apps/seller/src/features/products/create/index.ts index 202e6b01..b9845c52 100644 --- a/apps/seller/src/features/products/create/index.ts +++ b/apps/seller/src/features/products/create/index.ts @@ -5,3 +5,9 @@ export * from './create-form-disclosure' export * from './create-form-info' export * from './create-form-options' export * from './create-header' +export * from './create-form-thumbnail-upload' +export * from './create-footer' +export * from './create-calculation' +export * from './create-draft' +export * from './create-preview' +export * from './create-store' diff --git a/apps/seller/src/main.tsx b/apps/seller/src/main.tsx index d8bc3abf..2085148b 100644 --- a/apps/seller/src/main.tsx +++ b/apps/seller/src/main.tsx @@ -8,7 +8,6 @@ import SocialCallbackPage from '@/pages/auth/social-callback-page' import AllOrdersPage from '@/pages/orders/all-orders/all-orders-page' import CompletedOrdersPage from '@/pages/orders/completed-orders/completed-orders-page' import CreatePage from '@/pages/products/create/create-page' -import { DetailEditPage } from '@/pages/products/create/detail-edit-page' import ProductsPage from '@/pages/products/product/product-page' import SettlementPage from '@/pages/settlement/settlement-page' import { ROUTES } from '@/shared/constant/routes' @@ -37,7 +36,6 @@ const router = createBrowserRouter([ { path: ROUTES.ORDERS.COMPLETED, element: }, { path: ROUTES.PRODUCTS.ALL, element: }, { path: ROUTES.PRODUCTS.CREATE, element: }, - { path: ROUTES.PRODUCTS.CREATE_DETAIL, element: }, { path: ROUTES.SETTLEMENTS.ALL, element: }, ], }, diff --git a/apps/seller/src/pages/products/create/create-page.tsx b/apps/seller/src/pages/products/create/create-page.tsx index e2e08057..fd67a3aa 100644 --- a/apps/seller/src/pages/products/create/create-page.tsx +++ b/apps/seller/src/pages/products/create/create-page.tsx @@ -1,68 +1,142 @@ -import { Button } from '@dessert/ui' +import { useEffect, useRef, useState } from 'react' + import { FormProvider } from 'react-hook-form' import { CreateFormContainer, - FormStepsProvider, ProductDeliveryArea, ProductDetailArea, ProductDisclosureArea, + ProductFooter, ProductHeader, ProductInfoArea, ProductOptionsArea, - useCreateProductForm, + ThumbnailUploadArea, + useCreateForm, } from '@/features/products/create' +import { + CreateDraftDialog, + useCreateDraft, + useCreateDraftStore, +} from '@/features/products/create/create-draft' +import { ProductPreviewModal } from '@/features/products/create/create-preview/create-preview-modal.ui' +import { useCreateHeaderSteps } from '@/features/products/create/create-store' function CreatePage() { - const form = useCreateProductForm() + const form = useCreateForm() return ( - - - + ) } function CreatePageInner() { + const { draft } = useCreateDraftStore() + const [isPreviewOpen, setIsPreviewOpen] = useState(false) //미리보기 + const [isDraftModalOpen, setIsDraftModalOpen] = useState(false) //임시저장 + const isInitialMount = useRef(true) + const { setCurrentStep, headerHeight, isScrollingToStep } = + useCreateHeaderSteps() + const { handleRestoreDraft, clearDraft } = useCreateDraft() + + const stepIds = [ + 'productInfo', + 'productDelivery', + 'productThumbnail', + 'productOptions', + 'productDetail', + 'productDisclosure', + ] + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false + if (draft) { + setIsDraftModalOpen(true) + } + } //최초 컴포넌트 마운트 시 modal이 생성되도록 합니다 + }, [draft]) + + useEffect(() => { + const elements = stepIds.map((id) => document.getElementById(id)) + + // 헤더가 가리는 만큼 상단 여백을 줌 + const topMargin = headerHeight > 0 ? headerHeight : 100 + + const observer = new IntersectionObserver( + (entries) => { + // 클릭해서 스크롤 중일 때는 단계 변경 무시 + if (isScrollingToStep.current) return + + const visibleEntries = entries.filter((entry) => entry.isIntersecting) + if (visibleEntries.length === 0) return + + // 화면 상단 기준선에 가장 가까운 섹션 찾기 + const topEntry = visibleEntries.reduce((prev, curr) => { + return curr.boundingClientRect.top < prev.boundingClientRect.top + ? curr + : prev + }) + + const index = stepIds.indexOf(topEntry.target.id) + if (index !== -1) { + setCurrentStep(index + 1) + } + }, + { + // 상단은 헤더 높이만큼 빼고, 하단은 화면 절반 위로 오면 인식 + rootMargin: `-${topMargin}px 0px -50% 0px`, + threshold: 0, + }, + ) + + elements.forEach((el) => el && observer.observe(el)) + return () => observer.disconnect() + }, [headerHeight, setCurrentStep]) // isScrollingToStep은 내부에서 ref로 참조되므로 생략 가능 return ( <> - + - + - + + + + + - + - + - -
-
+ )} ) } diff --git a/apps/seller/src/pages/products/create/detail-edit-page.tsx b/apps/seller/src/pages/products/create/detail-edit-page.tsx deleted file mode 100644 index 639f2a9c..00000000 --- a/apps/seller/src/pages/products/create/detail-edit-page.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { BbanggreuiOvenLogo } from '@dessert/icons' -import { Button, Editor } from '@dessert/ui' -import { useState } from 'react' -import { useNavigate } from 'react-router-dom' - -import { useProductCreationStore } from '@/features/products/create/create-form/product-creation.store' - -export function DetailEditPage() { - const navigate = useNavigate() - const { productDetail, setProductDetail } = useProductCreationStore() - - // 편집 시 로컬 상태를 사용하고 등록 시에만 스토어에 반영합니다 (CodeRabbit 피드백 반영) - const [localDetail, setLocalDetail] = useState(productDetail) - - return ( -
- {/* Header */} -
-
- -
-
- - {/* Content Body */} -
- {/* Editor wrapper */} -
- -
-
- - {/* Footer Nav */} -
-
-
- ) -} diff --git a/apps/seller/src/shared/block/fixed-layout/fixed-layout.tsx b/apps/seller/src/shared/block/fixed-layout/fixed-layout.tsx index e508a95b..472e60b0 100644 --- a/apps/seller/src/shared/block/fixed-layout/fixed-layout.tsx +++ b/apps/seller/src/shared/block/fixed-layout/fixed-layout.tsx @@ -10,10 +10,15 @@ const FixedLayout = () => {
-
+ {/*
+
*/} +
+
+ +
diff --git a/apps/seller/vite.config.ts b/apps/seller/vite.config.ts index 973568e2..c75175c5 100644 --- a/apps/seller/vite.config.ts +++ b/apps/seller/vite.config.ts @@ -10,7 +10,7 @@ export default mergeConfig(baseViteConfig, { // 포트지정 server: { - port: 6075, + port: 3000, }, resolve: { diff --git a/packages/ui/src/accordion/accordion.tsx b/packages/ui/src/accordion/accordion.tsx index d86c0c10..156435d6 100644 --- a/packages/ui/src/accordion/accordion.tsx +++ b/packages/ui/src/accordion/accordion.tsx @@ -33,11 +33,17 @@ function AccordionItem({ ) } +interface CustomTriggerProps + extends React.ComponentProps { + customIcon?: React.ReactNode +} + function AccordionTrigger({ className, children, + customIcon = false, ...props -}: React.ComponentProps) { +}: CustomTriggerProps) { return ( {children} diff --git a/packages/ui/src/chip/chip.tsx b/packages/ui/src/chip/chip.tsx index 3f3b1c61..43295359 100644 --- a/packages/ui/src/chip/chip.tsx +++ b/packages/ui/src/chip/chip.tsx @@ -31,7 +31,7 @@ const Chip = ({ const sizeClasses = { sm: 'px-2 py-1 typo-body-10-r rounded-full', - md: 'px-3 py-1.5 typo-body-12-r rounded-full', + md: 'px-3 py-1.5 typo-body-14-r rounded-full', } const closeClasses = { diff --git a/packages/ui/src/editor/editor.css b/packages/ui/src/editor/editor.css index 85d7cf01..07aa195a 100644 --- a/packages/ui/src/editor/editor.css +++ b/packages/ui/src/editor/editor.css @@ -1,11 +1,18 @@ /* Quill 에디터 스타일 오버라이드 */ /* 툴바 스타일 */ + .quill-wrapper .ql-toolbar { border: none; border-bottom: 1px solid var(--color-gray-200); - padding: 8px 12px; + padding: 20px 48px; font-family: inherit; + position: sticky; + top: 0; + z-index: 10; + background-color: white; + width: 100vw; + left: 0; } /* 에디터 본문 스타일 */ @@ -14,17 +21,22 @@ font-family: inherit; font-size: 14px; max-height: 100%; - display: flex; - flex-direction: column; + background-color: #fafafa; } .quill-wrapper .ql-editor { + position: relative; padding: 12px; line-height: 1.6; color: var(--color-gray-900); overflow-y: auto; flex: 1; outline: none; + max-width: 1440px; + width: 100%; + height: 100%; + margin: auto; + background-color: #fff; } .quill-wrapper .ql-editor.ql-blank::before { @@ -57,3 +69,54 @@ .quill-wrapper .ql-editor img { max-width: 100%; } + +/* 에디터 전용 스타일 */ +/* 프리뷰 렌더링용 - Quill HTML 스타일 복원 */ +.ql-editor h1 { + font-size: 2em; + font-weight: bold; + margin: 0.67em 0; +} + +.ql-editor h2 { + font-size: 1.5em; + font-weight: bold; + margin: 0.75em 0; +} + +.ql-editor h3 { + font-size: 1.17em; + font-weight: bold; + margin: 0.83em 0; +} + +.ql-editor strong { + font-weight: bold; +} + +.ql-editor em { + font-style: italic; +} + +.ql-editor u { + text-decoration: underline; +} + +.ql-editor s { + text-decoration: line-through; +} + +.ql-editor ol li { + list-style-type: decimal; + padding-left: 1.5em; +} + +.ql-editor ul li { + list-style-type: disc; + padding-left: 1.5em; +} + +.ql-editor a { + color: #06c; + text-decoration: underline; +} diff --git a/packages/ui/src/editor/editor.tsx b/packages/ui/src/editor/editor.tsx index 37df1f93..6149ed16 100644 --- a/packages/ui/src/editor/editor.tsx +++ b/packages/ui/src/editor/editor.tsx @@ -44,6 +44,28 @@ import 'react-quill-new/dist/quill.snow.css' import { cn } from '../lib/utils' import './editor.css' +//추가 부분 +const Quill = ReactQuill.Quill + +// 2. Quill 내부 포맷 객체의 타입을 정의합니다. +interface QuillFormat { + sanitize: (url: string) => string +} + +// 'as QuillFormat'을 사용하여 unknown 타입을 강제로 지정 +const Link = Quill.import('formats/link') as QuillFormat +const Image = Quill.import('formats/image') as QuillFormat + +// 커스텀 함수를 할당 +const customSanitize = (url: string) => { + const allowedProtocols = ['http', 'https', 'data', 'blob'] + const protocol = url.split(':')[0] + return allowedProtocols.includes(protocol) ? url : '//:0' +} + +Link.sanitize = customSanitize +Image.sanitize = customSanitize + export interface EditorProps { value?: string onChange?: (value: string) => void @@ -101,11 +123,12 @@ const Editor = ({ image = false, onImageUpload, className = '', - height = 300, + height, }: EditorProps) => { const isReadOnly = disabled || !onChange const quillRef = useRef(null) + // 1. imageHandler를 useCallback으로 감싸고 의존성에 quillRef 포함 const imageHandler = useCallback(() => { if (!onImageUpload) { alert('이미지 업로드 기능이 설정되지 않았습니다.') @@ -118,30 +141,37 @@ const Editor = ({ input.click() input.onchange = async () => { - const file = input.files ? input.files[0] : null + const file = input.files?.[0] if (!file) return try { const url = await onImageUpload(file) + console.log('Upload success, URL:', url) // URL이 제대로 오는지 확인용 + const quill = quillRef.current?.getEditor() if (quill) { - const range = quill.getSelection(true) || { index: quill.getLength() } - quill.insertEmbed(range.index, 'image', url) - quill.setSelection(range.index + 1, 0) + const range = quill.getSelection(true) + // 0번 인덱스일 경우를 위해 length 체크 + const index = range ? range.index : quill.getLength() + + // 이미지 삽입 + quill.insertEmbed(index, 'image', url) + // 커서를 이미지 뒤로 이동 + quill.setSelection(index + 1, 0) } } catch (error) { console.error('Image upload failed:', error) } } - }, [onImageUpload]) + }, [onImageUpload]) // quillRef는 ref객체이므로 의존성에 넣지 않아도 됨 + // 2. modules 설정 최적화 const modules = useMemo(() => { if (!toolbar) return { toolbar: MINIMAL_TOOLBAR } const toolbarConfig = image ? BASE_TOOLBAR.map((group) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if ((group as any[]).includes('link')) { + if ((group as string[]).includes('link')) { return [...group, 'image'] } return group @@ -151,10 +181,11 @@ const Editor = ({ return { toolbar: { container: toolbarConfig, + // 익명함수 호출 대신 imageHandler 직접 연결 handlers: image ? { image: imageHandler } : undefined, }, } - }, [toolbar, image, imageHandler]) + }, [toolbar, image, imageHandler]) // imageHandler를 의존성에 반드시 추가 const formats = useMemo( () => (image ? [...BASE_FORMATS, 'image'] : BASE_FORMATS), @@ -166,6 +197,7 @@ const Editor = ({ className={cn( // eslint-disable-next-line better-tailwindcss/no-unknown-classes 'quill-wrapper w-full overflow-hidden rounded-10 border border-gray-300 bg-white transition-all duration-200', + !height && 'flex flex-1 flex-col', disabled && 'cursor-not-allowed bg-gray-100 opacity-60', className, )} @@ -179,7 +211,7 @@ const Editor = ({ formats={formats} placeholder={placeholder} readOnly={isReadOnly} - style={{ height }} + style={height ? { height } : { height: 'calc(100% - 66px)' }} />
) diff --git a/packages/ui/src/tab/stage-tab.tsx b/packages/ui/src/tab/stage-tab.tsx index 46dc50f8..fe516e89 100644 --- a/packages/ui/src/tab/stage-tab.tsx +++ b/packages/ui/src/tab/stage-tab.tsx @@ -11,9 +11,16 @@ interface StageTabProps { steps: string[] /** 추가적인 클래스명 */ className?: string + /** 단계 클릭 시 실행될 콜백*/ + onStepClick?: (index: number) => void } -export function StageTab({ currentStep, steps, className }: StageTabProps) { +export function StageTab({ + currentStep, + steps, + className, + onStepClick, +}: StageTabProps) { return (
{steps.map((step, index) => { const isActive = index + 1 === currentStep + const Component = onStepClick ? 'button' : 'div' + const componentProps = onStepClick + ? { type: 'button' as const, onClick: () => onStepClick(index) } + : {} return ( -
+ -
+ ) })}