+
- {/* 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] ? (
+

+ ) : (
+
+

+
등록된 이미지가 없습니다.
+
+ )}
+ {options.length > 1 && (
+
+ 묶음상품
+
+ )}
+
+ 1 / {allImageUrls.length || 1}
+
+
+
+
+ {/* 4. 상품 기본 정보 */}
+
+
+
+
+ {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}
+
+ ))}
+
+
+
+ {/* 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 (