diff --git a/package.json b/package.json index 9cbb5aa..7eaa1a9 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "immer": "^11.1.3", "lucide-react": "^0.562.0", "react": "^19.2.0", + "react-daum-postcode": "^3.2.0", "react-day-picker": "^9.13.0", "react-dom": "^19.2.0", "react-hook-form": "^7.69.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27660b3..8601736 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: react: specifier: ^19.2.0 version: 19.2.3 + react-daum-postcode: + specifier: ^3.2.0 + version: 3.2.0(react@19.2.3) react-day-picker: specifier: ^9.13.0 version: 9.13.0(react@19.2.3) @@ -1701,6 +1704,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + react-daum-postcode@3.2.0: + resolution: {integrity: sha512-NHY8TUicZXMqykbKYT8kUo2PEU7xu1DFsdRmyWJrLEUY93Xhd3rEdoJ7vFqrvs+Grl9wIm9Byxh3bI+eZxepMQ==} + peerDependencies: + react: '>=16.8.0' + react-day-picker@9.13.0: resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==} engines: {node: '>=18'} @@ -3389,6 +3397,10 @@ snapshots: punycode@2.3.1: {} + react-daum-postcode@3.2.0(react@19.2.3): + dependencies: + react: 19.2.3 + react-day-picker@9.13.0(react@19.2.3): dependencies: '@date-fns/tz': 1.4.1 diff --git a/src/api/auth.ts b/src/api/auth.ts index 4a7f7cc..76271a5 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -6,6 +6,8 @@ import type { ResponseLoginDto, ResponseLogoutDto, ResponseRefreshDto, + RequestVerifyOwnerDto, + ResponseVerifyOwnerDto, } from "@/types/auth"; import { api } from "./axios"; import { useAuthStore } from "@/stores/useAuthStore"; @@ -65,3 +67,13 @@ export const postRefresh = async (): Promise => { } return data.result; }; + +export const patchVerifyOwner = async ( + body: RequestVerifyOwnerDto, +): Promise => { + const { data } = await api.patch>( + "/api/users/role/owner", + body, + ); + return data.result; +}; diff --git a/src/api/menu.ts b/src/api/menu.ts new file mode 100644 index 0000000..9ba4a2c --- /dev/null +++ b/src/api/menu.ts @@ -0,0 +1,34 @@ +import type { ApiResponse } from "@/types/api"; +import type { + RequestMenuCreateDto, + RequestMenuImageDto, + ResponseMenuCreateDto, + ResponseMenuImageDto, +} from "@/types/menus"; +import { api } from "./axios"; + +export const postMenuCreate = async ( + storeId: number, + body: RequestMenuCreateDto, +): Promise => { + const { data } = await api.post>( + `/api/v1/stores/${storeId}/menus`, + body, + ); + return data.result; +}; + +export const postMenuImage = async ( + storeId: number, + body: RequestMenuImageDto, +): Promise => { + const formData = new FormData(); + + formData.append("image", body.image); + + const { data } = await api.post>( + `/api/v1/stores/${storeId}/menus/images`, + formData, + ); + return data.result; +}; diff --git a/src/api/store.ts b/src/api/store.ts new file mode 100644 index 0000000..365da90 --- /dev/null +++ b/src/api/store.ts @@ -0,0 +1,33 @@ +import type { + RequestMainImageDto, + RequestStoreCreateDto, + ResponseMainImageDto, + ResponseStoreCreateDto, +} from "@/types/store"; +import { api } from "./axios"; +import type { ApiResponse } from "@/types/api"; + +export const postRegisterStore = async ( + body: RequestStoreCreateDto, +): Promise => { + const { data } = await api.post>( + "/api/v1/stores", + body, + ); + return data.result; +}; + +export const postMainImage = async ( + storeId: number, + body: RequestMainImageDto, +): Promise => { + const formData = new FormData(); + + formData.append("mainImage", body.mainImage); + + const { data } = await api.post>( + `/api/v1/stores/${storeId}/main-image`, + formData, + ); + return data.result; +}; diff --git a/src/components/store-registration/BusinessAuth.schema.ts b/src/components/store-registration/BusinessAuth.schema.ts new file mode 100644 index 0000000..3ddd7fd --- /dev/null +++ b/src/components/store-registration/BusinessAuth.schema.ts @@ -0,0 +1,38 @@ +import z from "zod"; + +export const BusinessAuthSchema = z.object({ + businessNumber: z + .string() + .regex(/^[0-9]+$/, "숫자만 입력해주세요.") + .length(10, "1234567890 형식의 10자리 숫자를 입력해주세요."), + startDate: z + .string() + .regex(/^[0-9]+$/, "숫자만 입력해주세요.") + .length(8, "YYYYMMDD 형식의 8자리 숫자를 입력해주세요.") + .refine( + (val) => { + const year = parseInt(val.substring(0, 4)); + const month = parseInt(val.substring(4, 6)); + const day = parseInt(val.substring(6, 8)); + + if (month < 1 || month > 12) return false; + + const date = new Date(year, month - 1, day); + + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return false; + } + + if (date > new Date()) return false; + + return true; + }, + { message: "유효하지 않은 날짜입니다. (예: 20240101)" }, + ), +}); + +export type BusinessAuthFormValues = z.infer; diff --git a/src/components/store-registration/CompleteModal.tsx b/src/components/store-registration/CompleteModal.tsx index 01844e6..4c3ca03 100644 --- a/src/components/store-registration/CompleteModal.tsx +++ b/src/components/store-registration/CompleteModal.tsx @@ -8,6 +8,7 @@ import { import { useEffect } from "react"; import type { StoreInfoFormValues } from "./StoreInfo.schema"; import { Check } from "lucide-react"; +import { categoryLabel } from "@/types/store"; interface CompleteModalProps { isOpen: boolean; @@ -50,7 +51,9 @@ export default function CompleteModal({
음식 종류 - {data.category || "-"} + + {data.category ? categoryLabel[data.category] : "-"} +
주소 @@ -60,7 +63,9 @@ export default function CompleteModal({
전화번호 - {data.phone || "-"} + + {data.phoneNumber || "-"} +
운영 시간 diff --git a/src/components/store-registration/Menu.schema.ts b/src/components/store-registration/Menu.schema.ts index a2e06a4..cf2d92d 100644 --- a/src/components/store-registration/Menu.schema.ts +++ b/src/components/store-registration/Menu.schema.ts @@ -1,19 +1,46 @@ import { z } from "zod"; +export const MenuCategoryEnum = z.enum(["MAIN", "SIDE", "BEVERAGE", "ALCOHOL"]); + +const MAX_FILE_SIZE = 1 * 1024 * 1024; + +const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/png"]; + export const MenuSchema = z.object({ - menus: z - .array( - z.object({ - menuName: z.string().min(1, "메뉴명을 입력하세요."), - price: z - .string() - .min(1, "가격을 입력하세요.") - .regex(/^\d+$/, "숫자만 입력해주세요."), - description: z.string().optional(), - image: z.any().optional(), - }), - ) - .optional(), + menus: z.array( + z.object({ + name: z.string().min(1, "메뉴명을 입력하세요."), + description: z.string().max(500, "500자 이내로 입력하세요.").optional(), + price: z + .string() + .min(1, "가격을 입력하세요.") + .regex(/^(0|[1-9]\d*)$/, "0 이상의 올바른 숫자를 입력하세요."), + category: MenuCategoryEnum, + imageKey: z + .any() + .optional() + .refine( + (file) => { + if (!file || typeof file === "string") return true; + return file instanceof File ? file.size <= MAX_FILE_SIZE : true; + }, + { + message: "이미지 크기는 1MB 이하여야 합니다.", + }, + ) + .refine( + (file) => { + if (!file || typeof file === "string") return true; + return file instanceof File + ? ACCEPTED_IMAGE_TYPES.includes(file.type) + : true; + }, + { + message: ".jpg, .png 형식의 이미지만 업로드 가능합니다.", + }, + ), + }), + ), }); export type MenuFormValues = z.infer; diff --git a/src/components/store-registration/MenuItemInput.tsx b/src/components/store-registration/MenuItemInput.tsx index aad7fe9..5ee21ea 100644 --- a/src/components/store-registration/MenuItemInput.tsx +++ b/src/components/store-registration/MenuItemInput.tsx @@ -1,73 +1,87 @@ -import type { - FieldErrors, - UseFormRegister, - UseFormSetValue, +import { + Controller, + useWatch, + type Control, + type FieldErrors, + type UseFormRegister, + type UseFormSetValue, + type UseFormTrigger, } from "react-hook-form"; import type { MenuFormValues } from "./Menu.schema"; import { useEffect, + useMemo, useRef, - useState, type ChangeEvent, type MouseEvent, } from "react"; import { Trash2, Upload, X } from "lucide-react"; -import { Label } from "@radix-ui/react-label"; +import { Label } from "@/components/ui/label"; interface MenuItemInputProps { index: number; onDelete: () => void; register: UseFormRegister; + control: Control; errors: FieldErrors; setValue: UseFormSetValue; + trigger: UseFormTrigger; } +const CATEGORY_LABELS: Record = { + MAIN: "메인 메뉴", + SIDE: "사이드 메뉴", + BEVERAGE: "음료", + ALCOHOL: "주류", +}; + export default function MenuItemInput({ index, onDelete, register, + control, errors, setValue, + trigger, }: MenuItemInputProps) { - //임시 이미지 주소 저장소 - const [previewUrl, setPreviewUrl] = useState(null); - const fileInputRef = useRef(null); - // previewUrl이 바뀔 때나 컴포넌트가 사라질 때 Cleanup + const watchedImage = useWatch({ + control, + name: `menus.${index}.imageKey`, + }); + + const previewUrl = useMemo(() => { + if (watchedImage instanceof File) { + return URL.createObjectURL(watchedImage); + } + if (typeof watchedImage === "string") { + return watchedImage; + } + return null; + }, [watchedImage]); + + // 메모리 누수 방지를 위한 Cleanup useEffect(() => { - return () => { - if (previewUrl) { - URL.revokeObjectURL(previewUrl); - } - }; - }, [previewUrl]); + // 파일로 생성된 URL인 경우에만 해제 + if (watchedImage instanceof File && previewUrl) { + return () => URL.revokeObjectURL(previewUrl); + } + }, [watchedImage, previewUrl]); - //파일을 브라우저용 임시 주소로 변환 const handleImageChange = (e: ChangeEvent) => { const file = e.target.files?.[0]; if (file) { - //메모리 누수 방지를 위해 사용하지 않는 이미지 URL을 해제 - if (previewUrl) { - URL.revokeObjectURL(previewUrl); - } - //URL.createObjectURL: 파일 객체를 임시 URL 문자열로 만들어주는 브라우저 기능 - const url = URL.createObjectURL(file); - setPreviewUrl(url); - setValue(`menus.${index}.image`, file); + setValue(`menus.${index}.imageKey`, file, { shouldValidate: true }); } }; const handleRemoveImage = (e: MouseEvent) => { e.preventDefault(); - if (previewUrl) { - URL.revokeObjectURL(previewUrl); - } - setPreviewUrl(null); if (fileInputRef.current) { fileInputRef.current.value = ""; } - setValue(`menus.${index}.image`, null); + setValue(`menus.${index}.imageKey`, undefined, { shouldValidate: true }); }; return ( @@ -85,7 +99,12 @@ export default function MenuItemInput({
- +
-
-

• 권장 크기: 800 x 800px

-

• 최대 용량: 5MB

-

• 형식: JPG, PNG

+
+

• 최대 용량: 1MB

+

• 형식: JPG(JPEG), PNG

+ {errors.menus?.[index]?.imageKey && ( +

+ • {(errors.menus[index]?.imageKey as any).message} +

+ )}
-
-
- + + ( + + )} + /> +
+
+