diff --git a/src/app/globals.css b/src/app/globals.css index 1d03b0f..8be7a96 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -177,6 +177,8 @@ Shadow Tokens ========================= */ --shadow-dropdown: 0 0 20px 4px rgba(0, 0, 0, 0.05); + /* UI/안내바 */ + --shadow-banner: 0 8px 20px 4px rgba(0, 0, 0, 0.08); /* ========================= Blur Tokens @@ -184,6 +186,12 @@ --blur-hover: 1.7px; --blur-button: 2px; + /* ========================= + Z-Index Tokens + ========================= */ + --z-index-header: 10; + --z-index-toast: 60; + /* ========================= Radius Tokens ========================= */ diff --git a/src/app/instructor/(withSidebar)/write/layout.tsx b/src/app/instructor/(withSidebar)/write/layout.tsx index eaa40bb..7a5b867 100644 --- a/src/app/instructor/(withSidebar)/write/layout.tsx +++ b/src/app/instructor/(withSidebar)/write/layout.tsx @@ -15,7 +15,7 @@ const WriteLayout = ({ children }: { children: ReactNode }) => { return (
-
+
{children} diff --git a/src/features/instructor/write/config/write.ts b/src/features/instructor/write/config/write.ts index ba04ac8..7b26e66 100644 --- a/src/features/instructor/write/config/write.ts +++ b/src/features/instructor/write/config/write.ts @@ -18,9 +18,9 @@ export const WRITE_STEPS: { STEP1 ========================= */ export const CATEGORIES = [ - { label: "유인물", items: ["교재 외지/내지"] }, - { label: "홍보물", items: [] }, - { label: "퍼스널 브랜딩", items: [] }, + { label: "유인물", items: ["교재 외지/내지", "대봉투"] }, + { label: "홍보물", items: ["포스터", "배너", "옥외 광고물", "SNS 카드뉴스"] }, + { label: "퍼스널 브랜딩", items: ["포토카드", "스티커", "키링", "명함", "로고"] }, ]; export const SIZE_OPTIONS = [ @@ -52,14 +52,14 @@ export const SIZE_OPTIONS = [ ]; export const CONCEPT_CATEGORIES = [ - { title: "밝은", keywords: ["귀여운", "경쾌한", "맑은"] }, - { title: "부드러운", keywords: ["내츄럴한", "은은한", "온화한"] }, - { title: "고급스러운", keywords: ["우아한", "고상한", "모던한"] }, - { title: "강렬한", keywords: ["화려한", "다이나믹한"] }, - { title: "단정한", keywords: ["점잖은"] }, + { title: "질감", keywords: ["입체감 있는", "평면적인", "거친", "매끈한"] }, + { title: "레이아웃", keywords: ["정돈된", "역동적인", "여백이 많은", "꽉 찬"] }, + { title: "형태", keywords: ["둥근", "각진", "자유로운", "기하학적인"] }, + { title: "색감", keywords: ["화려한", "차분한", "밝은", "어두운"] }, + { title: "무드", keywords: ["귀여운", "시크한", "감성적인", "전문적인"] }, ]; -export const MAX_CONCEPT_SELECT = 2; +export const MAX_CONCEPT_SELECT = 5; /* ========================= STEP2 @@ -123,18 +123,26 @@ export const SIZE_DISPLAY_MAP: Record = { }; export const KEYWORD_API_MAP: Record = { - 귀여운: "CUTE", - 경쾌한: "LIGHT", - 맑은: "CLEAR", - 내츄럴한: "NATURAL", - 은은한: "SUBTLE", - 온화한: "WARM", - 우아한: "ELEGANT", - 고상한: "REFINED", - 모던한: "MODERN", + "입체감 있는": "DIMENSIONAL", + 평면적인: "LIGHT", + 거친: "ROUGH", + 매끈한: "SMOOTH", + 정돈된: "ORDERLY", + 역동적인: "DYNAMIC", + "여백이 많은": "SPACIOUS", + "꽉 찬": "DENSE", + 둥근: "ROUND", + 각진: "ANGULAR", + 자유로운: "FREEFORM", + 기하학적인: "GEOMETRIC", 화려한: "VIVID", - 다이나믹한: "DYNAMIC", - 점잖은: "CALM", + 차분한: "MUTED", + 밝은: "BRIGHT", + 어두운: "DARK", + 귀여운: "CUTE", + 시크한: "CHIC", + 감성적인: "EMOTIONAL", + 전문적인: "PROFESSIONAL", }; export type PageType = diff --git a/src/features/instructor/write/model/writeFormStore.ts b/src/features/instructor/write/model/writeFormStore.ts index aa5b119..7114b9d 100644 --- a/src/features/instructor/write/model/writeFormStore.ts +++ b/src/features/instructor/write/model/writeFormStore.ts @@ -79,7 +79,7 @@ const initialState = { selectedSize: null, selectedKeywords: [], additionalConcept: "", - colorMode: "custom" as ColorMode, + colorMode: "designer" as ColorMode, colors: [null, null, null] as (RgbaColor | null)[], mainColorIndex: 0, basicInfo: { 교재명: "", 강사명: "", 과목명: "" }, diff --git a/src/features/instructor/write/ui/ColorChooseCard.tsx b/src/features/instructor/write/ui/ColorChooseCard.tsx index f23a860..f1ed269 100644 --- a/src/features/instructor/write/ui/ColorChooseCard.tsx +++ b/src/features/instructor/write/ui/ColorChooseCard.tsx @@ -5,14 +5,12 @@ import { useState } from "react"; import type { RgbaColor } from "@/features/instructor/write/lib/color"; import { clamp, hexToRgb, toHex } from "@/features/instructor/write/lib/color"; import { cn } from "@/shared/lib/utils/cn"; -import Radio from "@/shared/ui/Radio"; type ColorChooseCardProps = { index: number; isMain: boolean; isSelected: boolean; color: RgbaColor | null; - onRadioChange: () => void; onCardClick: () => void; onColorChange: (color: RgbaColor) => void; }; @@ -24,7 +22,6 @@ const ColorChooseCard = ({ isMain, isSelected, color, - onRadioChange, onCardClick, onColorChange, }: ColorChooseCardProps) => { @@ -61,14 +58,14 @@ const ColorChooseCard = ({ )} onClick={onCardClick} > -
e.stopPropagation()}> - - {isMain && ( - - Main - + + > + {isMain ? "Main" : "Sub"} +
{ return ( -
+

{title}

-
+
{keywords.map(keyword => ( onSelect(keyword)} + variant="long" + className="w-35" /> ))}
diff --git a/src/features/instructor/write/ui/ConceptResult.tsx b/src/features/instructor/write/ui/ConceptResult.tsx new file mode 100644 index 0000000..19e27a8 --- /dev/null +++ b/src/features/instructor/write/ui/ConceptResult.tsx @@ -0,0 +1,26 @@ +import Chip from "@/shared/ui/Chip"; + +interface ConceptResultProps { + selectedKeywords: string[]; + onRemove: (keyword: string) => void; +} + +const ConceptResult = ({ selectedKeywords, onRemove }: ConceptResultProps) => { + return ( +
+

선택한 컨셉

+
+ {selectedKeywords.map(keyword => ( + onRemove(keyword)} + /> + ))} +
+
+ ); +}; + +export default ConceptResult; diff --git a/src/features/instructor/write/ui/PaymentModal/Step1.tsx b/src/features/instructor/write/ui/PaymentModal/Step1.tsx index 4230f20..6c2591c 100644 --- a/src/features/instructor/write/ui/PaymentModal/Step1.tsx +++ b/src/features/instructor/write/ui/PaymentModal/Step1.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { PLAN_LABEL_MAP, @@ -10,6 +10,7 @@ import { useWriteFormStore } from "@/features/instructor/write/model/writeFormSt import { ArrowDownIcon, CheckboxFillIcon, CheckboxGrayIcon } from "@/shared/assets/icons"; import Button from "@/shared/ui/Button"; import Chip from "@/shared/ui/Chip"; +import Toast from "@/shared/ui/Toast"; /* ───────────────────────────────────────────── InfoRow @@ -123,6 +124,20 @@ const Step1 = ({ onNext, errorMessage }: { onNext: () => void; errorMessage?: st setIsTermsAgreed, } = useWriteFormStore(); + const [prevErrorMessage, setPrevErrorMessage] = useState(errorMessage); + const [autoHide, setAutoHide] = useState(false); + if (errorMessage !== prevErrorMessage) { + setPrevErrorMessage(errorMessage); + setAutoHide(false); + } + const showError = !!errorMessage && !autoHide; + + useEffect(() => { + if (!showError) return; + const timeout = setTimeout(() => setAutoHide(true), 2500); + return () => clearTimeout(timeout); + }, [showError]); + return ( <>
@@ -166,25 +181,27 @@ const Step1 = ({ onNext, errorMessage }: { onNext: () => void; errorMessage?: st
-
+

최종 금액

{selectedPlan ? `${selectedPlan.price.toLocaleString("ko-KR")}원` : "-"}

- {errorMessage && ( -

- {errorMessage} -

- )} - +
+ + +
); diff --git a/src/shared/lib/utils/dropdown.ts b/src/shared/lib/utils/dropdown.ts index 5ccdc3d..e161f59 100644 --- a/src/shared/lib/utils/dropdown.ts +++ b/src/shared/lib/utils/dropdown.ts @@ -5,6 +5,17 @@ export const easeOutCubic = (progress: number) => 1 - (1 - progress) ** 3; export const getDaysInMonth = (year: number, monthIndex: number) => new Date(year, monthIndex + 1, 0).getDate(); +export const getDateForIndices = ( + baseYear: number, + yearIndex: number, + monthIndex: number, + dayIndex: number, +) => { + const year = baseYear + yearIndex; + const daysInMonth = getDaysInMonth(year, monthIndex); + return new Date(year, monthIndex, Math.min(dayIndex, daysInMonth - 1) + 1); +}; + export const getScrollTopForIndex = (index: number, selectedIndex: number) => index * STEP + (index > selectedIndex ? SELECTED_EXTRA : 0); diff --git a/src/shared/lib/utils/file.ts b/src/shared/lib/utils/file.ts index 4cb9c4f..88962fb 100644 --- a/src/shared/lib/utils/file.ts +++ b/src/shared/lib/utils/file.ts @@ -3,3 +3,8 @@ export const formatFileSize = (bytes: number): string => { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; }; + +export const MAX_FILE_SIZE_BYTES = 30 * 1024 * 1024; + +export const isAllowedFileType = (file: File, extensions: string[]) => + extensions.some(extension => file.name.toLowerCase().endsWith(extension)); diff --git a/src/shared/ui/Chip.tsx b/src/shared/ui/Chip.tsx index 5bf8fc7..048c29d 100644 --- a/src/shared/ui/Chip.tsx +++ b/src/shared/ui/Chip.tsx @@ -1,17 +1,19 @@ import { CloseIcon } from "@/shared/assets/icons"; import { cn } from "@/shared/lib/utils/cn"; -type ChipVariant = "selectable" | "removable"; +type ChipVariant = "selectable" | "removable" | "long"; interface ChipProps { label: string; isSelected?: boolean; isHoverPreview?: boolean; disableHover?: boolean; + disabled?: boolean; onRemove?: () => void; onClick?: () => void; removeAriaLabel?: string; variant?: ChipVariant; + className?: string; } const Chip = ({ @@ -19,10 +21,12 @@ const Chip = ({ isSelected = false, isHoverPreview = false, disableHover = false, + disabled = false, onRemove, onClick, removeAriaLabel, variant = "selectable", + className, }: ChipProps) => { if (variant === "removable") { const iconWrapperStyles = "inline-flex size-4 shrink-0 items-center justify-center"; @@ -42,6 +46,28 @@ const Chip = ({ ); } + if (variant === "long") { + return ( +
+ {label} +
+ ); + } + const baseStyles = cn( "group rounded-100 inline-flex h-[34px] items-center justify-center border px-3 py-[6px] transition-colors duration-150", disableHover ? "cursor-default" : "cursor-pointer", diff --git a/src/shared/ui/Header.tsx b/src/shared/ui/Header.tsx index d0976dc..08f5337 100644 --- a/src/shared/ui/Header.tsx +++ b/src/shared/ui/Header.tsx @@ -92,7 +92,7 @@ const Header = () => { 1:1 문의하기 diff --git a/src/shared/ui/Radio.tsx b/src/shared/ui/Radio.tsx index 1900661..932cc8b 100644 --- a/src/shared/ui/Radio.tsx +++ b/src/shared/ui/Radio.tsx @@ -2,13 +2,14 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react"; type RadioProps = Omit, "className" | "type"> & { children?: ReactNode; + labelClassName?: string; }; -const Radio = ({ children, disabled, ...props }: RadioProps) => { +const Radio = ({ children, disabled, labelClassName, ...props }: RadioProps) => { return ( ); diff --git a/src/shared/ui/Sidebar.tsx b/src/shared/ui/Sidebar.tsx index 2078947..ee6c296 100644 --- a/src/shared/ui/Sidebar.tsx +++ b/src/shared/ui/Sidebar.tsx @@ -1,4 +1,6 @@ -import { ReactNode } from "react"; +"use client"; + +import { ReactNode, useEffect, useRef } from "react"; interface SidebarProps { children: ReactNode; @@ -6,8 +8,24 @@ interface SidebarProps { } const Sidebar = ({ children, bottom }: SidebarProps) => { + const asideRef = useRef(null); + + useEffect(() => { + const node = asideRef.current; + if (!node) return; + + const updateWidth = () => { + document.documentElement.style.setProperty("--sidebar-w", `${node.offsetWidth}px`); + }; + + updateWidth(); + const observer = new ResizeObserver(updateWidth); + observer.observe(node); + return () => observer.disconnect(); + }, []); + return ( -