diff --git a/apps/owner/src/app/(tabs)/mypage/store-info/page.tsx b/apps/owner/src/app/(tabs)/mypage/store-info/page.tsx index d987eba..3dcf797 100644 --- a/apps/owner/src/app/(tabs)/mypage/store-info/page.tsx +++ b/apps/owner/src/app/(tabs)/mypage/store-info/page.tsx @@ -6,6 +6,8 @@ import { Button, Header } from "@compasser/design-system"; import type { StoreUpdateReqDTO, StoreLocationUpdateReqDTO, + StoreTag, + BankType, } from "@compasser/api"; import StoreNameField from "@/app/signup/register/_components/fields/StoreNameField"; @@ -18,13 +20,14 @@ import PhotoUploadSection from "@/app/signup/register/_components/sections/Photo import TagSection from "@/app/signup/register/_components/sections/TagSection"; import BusinessHoursModal from "@/app/signup/register/_components/modals/BusinessHoursModal"; import RandomBoxModal from "@/app/signup/register/_components/modals/RandomBoxModal"; +import AddressSearchBottomSheet from "@/app/signup/register/_components/AddressSearchBottomSheet"; +import type { AddressSearchItem } from "@/app/signup/register/_types/address-search"; import { useMyStoreQuery } from "@/shared/queries/query/useMyStoreQuery"; import { usePatchMyStoreMutation } from "@/shared/queries/mutation/auth/usePatchMyStoreMutation"; import { usePatchMyStoreLocationMutation } from "@/shared/queries/mutation/auth/usePatchMyStoreLocationMutation"; import { useRandomBoxListQuery } from "@/shared/queries/query/useRandomBoxListQuery"; import { useCreateRandomBoxMutation } from "@/shared/queries/mutation/auth/useCreateRandomBoxMutation"; -import { useUpdateRandomBoxMutation } from "@/shared/queries/mutation/auth/useUpdateRandomBoxMutation"; import { useDeleteRandomBoxMutation } from "@/shared/queries/mutation/auth/useDeleteRandomBoxMutation"; import { useStoreImageQuery } from "@/shared/queries/query/useStoreImageQuery"; import { useUploadStoreImageMutation } from "@/shared/queries/mutation/auth/useUploadStoreImageMutation"; @@ -35,7 +38,21 @@ import { EMPTY_BUSINESS_HOURS, } from "@/app/signup/register/_utils/business-hours"; -type FixedTag = "카페" | "베이커리" | "식당"; +const tagOptions = ["카페", "베이커리", "식당"] as const; + +type TagLabel = (typeof tagOptions)[number]; + +const tagMap: Record = { + 카페: "CAFE", + 베이커리: "BAKERY", + 식당: "RESTAURANT", +}; + +const reverseTagMap: Record = { + CAFE: "카페", + BAKERY: "베이커리", + RESTAURANT: "식당", +}; export default function StoreInfoEditPage() { const router = useRouter(); @@ -49,7 +66,6 @@ export default function StoreInfoEditPage() { const patchMyStoreMutation = usePatchMyStoreMutation(); const patchMyStoreLocationMutation = usePatchMyStoreLocationMutation(); const createRandomBoxMutation = useCreateRandomBoxMutation(); - const updateRandomBoxMutation = useUpdateRandomBoxMutation(); const deleteRandomBoxMutation = useDeleteRandomBoxMutation(); const uploadStoreImageMutation = useUploadStoreImageMutation(); const removeStoreImageMutation = useRemoveStoreImageMutation(); @@ -57,46 +73,32 @@ export default function StoreInfoEditPage() { const [storeName, setStoreName] = useState(""); const [storeEmail, setStoreEmail] = useState(""); const [inputAddress, setInputAddress] = useState(""); - const [bankName, setBankName] = useState(""); + const [bankType, setBankType] = useState(""); const [depositor, setDepositor] = useState(""); const [bankAccount, setBankAccount] = useState(""); const [businessHours, setBusinessHours] = useState(EMPTY_BUSINESS_HOURS); - const tagOptions: FixedTag[] = ["카페", "베이커리", "식당"]; - const [selectedTag, setSelectedTag] = useState(""); - - const [selectedRandomBoxIds, setSelectedRandomBoxIds] = useState( - [], - ); - const [isBusinessHoursModalOpen, setIsBusinessHoursModalOpen] = - useState(false); + const [selectedTag, setSelectedTag] = useState<"" | TagLabel>(""); + const [selectedRandomBoxIds, setSelectedRandomBoxIds] = useState([]); + const [isBusinessHoursModalOpen, setIsBusinessHoursModalOpen] = useState(false); const [isRandomBoxModalOpen, setIsRandomBoxModalOpen] = useState(false); + const [isAddressSearchOpen, setIsAddressSearchOpen] = useState(false); const [photoFile, setPhotoFile] = useState(null); const [photoPreviewUrl, setPhotoPreviewUrl] = useState(""); const [imageRemoved, setImageRemoved] = useState(false); - const mapStoreTagToLabel = (tag: string | undefined): FixedTag | "" => { - switch (tag) { - case "CAFE": - return "카페"; - case "BAKERY": - return "베이커리"; - case "RESTAURANT": - return "식당"; - default: - return ""; - } - }; - useEffect(() => { if (!myStore) return; setStoreName(myStore.storeName ?? ""); - setStoreEmail(""); + setStoreEmail(myStore.storeEmail ?? ""); setInputAddress(myStore.inputAddress ?? ""); setBusinessHours(parseBusinessHours(myStore.businessHours)); - setSelectedTag(mapStoreTagToLabel(myStore.tag)); + + if (myStore.tag) { + setSelectedTag(reverseTagMap[myStore.tag as StoreTag] ?? ""); + } }, [myStore]); useEffect(() => { @@ -105,6 +107,16 @@ export default function StoreInfoEditPage() { }, [storeImage, imageRemoved]); const businessHoursRows = useMemo(() => { + const dayKeyMap = { + mon: "MON", + tue: "TUE", + wed: "WED", + thu: "THU", + fri: "FRI", + sat: "SAT", + sun: "SUN", + } as const; + const dayLabelMap = { mon: "월", tue: "화", @@ -115,19 +127,14 @@ export default function StoreInfoEditPage() { sun: "일", } as const; - const orderedDays = [ - "mon", - "tue", - "wed", - "thu", - "fri", - "sat", - "sun", - ] as const; + const orderedDays = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const; return orderedDays.map((day) => { - const value = businessHours[day]; - const formatted = value === "closed" ? "휴무" : value || "-"; + const value = businessHours.weekly[dayKeyMap[day]]; + + const formatted = value.closed + ? "휴무" + : `${value.open || "-"} - ${value.close || "-"}`; return { dayLabel: dayLabelMap[day], @@ -136,6 +143,11 @@ export default function StoreInfoEditPage() { }); }, [businessHours]); + const handleSelectAddress = (item: AddressSearchItem) => { + setInputAddress(item.roadAddress || item.lotNumberAddress || item.label); + setIsAddressSearchOpen(false); + }; + const toggleRandomBoxSelection = (id: number) => { setSelectedRandomBoxIds((prev) => prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id], @@ -160,25 +172,13 @@ export default function StoreInfoEditPage() { price: number; buyLimit: number; content: string; - boxId?: number; + pickupTimeInfo: { + start: string; + end: string; + }; }) => { if (!storeId) return; - if (form.boxId) { - await updateRandomBoxMutation.mutateAsync({ - storeId, - boxId: form.boxId, - body: { - boxName: form.boxName, - stock: form.stock, - price: form.price, - buyLimit: form.buyLimit, - content: form.content, - }, - }); - return; - } - await createRandomBoxMutation.mutateAsync({ storeId, body: { @@ -187,6 +187,7 @@ export default function StoreInfoEditPage() { price: form.price, buyLimit: form.buyLimit, content: form.content, + pickupTimeInfo: form.pickupTimeInfo, }, }); }; @@ -218,10 +219,11 @@ export default function StoreInfoEditPage() { const storePayload: StoreUpdateReqDTO = { storeName, storeEmail, - bankName, + bankType: bankType || undefined, depositor, bankAccount, businessHours, + tag: selectedTag ? tagMap[selectedTag] : undefined, }; const locationPayload: StoreLocationUpdateReqDTO = { @@ -256,17 +258,18 @@ export default function StoreInfoEditPage() {
+ {}} + onSearchAddress={() => setIsAddressSearchOpen(true)} /> setBankType(value as BankType)} onChangeDepositor={setDepositor} onChangeBankAccount={setBankAccount} /> @@ -293,7 +296,7 @@ export default function StoreInfoEditPage() { /> @@ -326,6 +329,12 @@ export default function StoreInfoEditPage() { onClose={() => setIsRandomBoxModalOpen(false)} onSubmit={handleSubmitRandomBox} /> + + setIsAddressSearchOpen(false)} + onSelectAddress={handleSelectAddress} + /> ); } \ No newline at end of file diff --git a/apps/owner/src/app/signup/register/_apis/searchAddressBykakao.ts b/apps/owner/src/app/signup/register/_apis/searchAddressBykakao.ts new file mode 100644 index 0000000..3da000e --- /dev/null +++ b/apps/owner/src/app/signup/register/_apis/searchAddressBykakao.ts @@ -0,0 +1,68 @@ +"use client"; + +import type { AddressSearchItem } from "../_types/address-search"; + +export function searchAddressByKakao( + keyword: string, + size = 10, +): Promise { + return new Promise((resolve, reject) => { + const trimmedKeyword = keyword.trim(); + + if (!trimmedKeyword) { + resolve([]); + return; + } + + if ( + typeof window === "undefined" || + !window.kakao?.maps?.services + ) { + reject(new Error("Kakao services library is not loaded.")); + return; + } + + const geocoder = new window.kakao.maps.services.Geocoder(); + + geocoder.addressSearch( + trimmedKeyword, + (result: KakaoAddressSearchResult[], status: string) => { + const { kakao } = window; + + if (status === kakao.maps.services.Status.ZERO_RESULT) { + resolve([]); + return; + } + + if (status !== kakao.maps.services.Status.OK) { + reject(new Error("주소 검색 중 오류가 발생했습니다.")); + return; + } + + const mapped = result.slice(0, size).map((item, index) => { + const lotNumberAddress = + item.address?.address_name || item.address_name || ""; + + const roadAddress = + item.road_address?.address_name || item.address_name || ""; + + return { + id: `${item.x}-${item.y}-${index}`, + label: roadAddress || lotNumberAddress, + lotNumberAddress, + roadAddress, + longitude: Number(item.x), + latitude: Number(item.y), + }; + }); + + resolve(mapped); + }, + { + page: 1, + size, + analyze_type: window.kakao.maps.services.AnalyzeType.SIMILAR, + }, + ); + }); +} \ No newline at end of file diff --git a/apps/owner/src/app/signup/register/_components/AddressSearchBottomSheet.tsx b/apps/owner/src/app/signup/register/_components/AddressSearchBottomSheet.tsx new file mode 100644 index 0000000..b5c5586 --- /dev/null +++ b/apps/owner/src/app/signup/register/_components/AddressSearchBottomSheet.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { BottomSheet, Input } from "@compasser/design-system"; +import type { AddressSearchItem } from "../_types/address-search"; +import { searchAddressByKakao } from "../_apis/searchAddressBykakao"; + +interface AddressSearchBottomSheetProps { + open: boolean; + onClose: () => void; + onSelectAddress: (item: AddressSearchItem) => void; +} + +export default function AddressSearchBottomSheet({ + open, + onClose, + onSelectAddress, +}: AddressSearchBottomSheetProps) { + const [keyword, setKeyword] = useState(""); + const [results, setResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + + const debounceRef = useRef(null); + + useEffect(() => { + if (!open) { + setKeyword(""); + setResults([]); + setIsSearching(false); + } + }, [open]); + + useEffect(() => { + if (!open) return; + + const trimmedKeyword = keyword.trim(); + + if (!trimmedKeyword) { + setResults([]); + setIsSearching(false); + return; + } + + if (debounceRef.current) { + window.clearTimeout(debounceRef.current); + } + + debounceRef.current = window.setTimeout(async () => { + try { + setIsSearching(true); + const searched = await searchAddressByKakao(trimmedKeyword, 10); + setResults(searched); + } catch (error) { + console.error(error); + setResults([]); + } finally { + setIsSearching(false); + } + }, 250); + + return () => { + if (debounceRef.current) { + window.clearTimeout(debounceRef.current); + } + }; + }, [keyword, open]); + + const handleSelectItem = (item: AddressSearchItem) => { + onSelectAddress(item); + onClose(); + }; + + return ( + + setKeyword(event.target.value)} + placeholder="주소를 입력해주세요" + /> + + {results.length > 0 && ( +
+ {results.map((item) => ( + + ))} +
+ )} + + {keyword.trim() && isSearching && ( +

+ 검색 중... +

+ )} + + {keyword.trim() && !isSearching && results.length === 0 && ( +

+ 검색 결과가 없습니다. +

+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/owner/src/app/signup/register/_components/fields/AccountField.tsx b/apps/owner/src/app/signup/register/_components/fields/AccountField.tsx index fc612e2..dac4e5d 100644 --- a/apps/owner/src/app/signup/register/_components/fields/AccountField.tsx +++ b/apps/owner/src/app/signup/register/_components/fields/AccountField.tsx @@ -3,39 +3,40 @@ import { useMemo, useState } from "react"; import { Input, Tag } from "@compasser/design-system"; import { - filterBankNames, + filterBanks, + getBankLabel, normalizeAccountNumber, } from "@/shared/utils/bank"; type AccountStep = "bank" | "holder" | "account"; interface AccountFieldProps { - bankName: string; + bankType: string; depositor: string; bankAccount: string; - onChangeBankName: (value: string) => void; + onChangeBankType: (value: string) => void; onChangeDepositor: (value: string) => void; onChangeBankAccount: (value: string) => void; } export default function AccountField({ - bankName, + bankType, depositor, bankAccount, - onChangeBankName, + onChangeBankType, onChangeDepositor, onChangeBankAccount, }: AccountFieldProps) { const [step, setStep] = useState("bank"); - const [bankKeyword, setBankKeyword] = useState(bankName); + const [bankKeyword, setBankKeyword] = useState(getBankLabel(bankType)); const filteredBanks = useMemo(() => { - return filterBankNames(bankKeyword); + return filterBanks(bankKeyword); }, [bankKeyword]); const currentValue = step === "bank" - ? bankName + ? bankKeyword : step === "holder" ? depositor : bankAccount; @@ -49,8 +50,8 @@ export default function AccountField({ const handleChangeInput = (nextValue: string) => { if (step === "bank") { - onChangeBankName(nextValue); setBankKeyword(nextValue); + onChangeBankType(""); return; } @@ -62,9 +63,9 @@ export default function AccountField({ onChangeBankAccount(normalizeAccountNumber(nextValue)); }; - const handleSelectBank = (bank: string) => { - onChangeBankName(bank); - setBankKeyword(bank); + const handleSelectBank = (bank: { label: string; value: string }) => { + setBankKeyword(bank.label); + onChangeBankType(bank.value); }; return ( @@ -111,13 +112,13 @@ export default function AccountField({
{filteredBanks.map((bank) => ( handleSelectBank(bank)} > - {bank} + {bank.label} ))}
diff --git a/apps/owner/src/app/signup/register/_components/modals/BusinessHoursModal.tsx b/apps/owner/src/app/signup/register/_components/modals/BusinessHoursModal.tsx index 013de88..7d1dcbe 100644 --- a/apps/owner/src/app/signup/register/_components/modals/BusinessHoursModal.tsx +++ b/apps/owner/src/app/signup/register/_components/modals/BusinessHoursModal.tsx @@ -6,8 +6,10 @@ import TimeRangeField from "./TimeRangeField"; import { EMPTY_BUSINESS_HOURS, parseBusinessHours, + type BusinessDayValue, type BusinessHoursValue, type DayKey, + type ServerDayKey, } from "../../_utils/business-hours"; type BreakTimeOption = "yes" | "no" | null; @@ -15,6 +17,7 @@ type BreakTimeOption = "yes" | "no" | null; interface BusinessHourFormValue { openTime: string; closeTime: string; + closed: boolean; hasBreakTime: BreakTimeOption; breakStartTime: string; breakEndTime: string; @@ -27,19 +30,24 @@ interface BusinessHoursModalProps { onSubmit?: (data: BusinessHoursValue) => void; } -const dayOptions: { key: DayKey; label: string }[] = [ - { key: "mon", label: "월" }, - { key: "tue", label: "화" }, - { key: "wed", label: "수" }, - { key: "thu", label: "목" }, - { key: "fri", label: "금" }, - { key: "sat", label: "토" }, - { key: "sun", label: "일" }, +const dayOptions: { + key: DayKey; + serverKey: ServerDayKey; + label: string; +}[] = [ + { key: "mon", serverKey: "MON", label: "월" }, + { key: "tue", serverKey: "TUE", label: "화" }, + { key: "wed", serverKey: "WED", label: "수" }, + { key: "thu", serverKey: "THU", label: "목" }, + { key: "fri", serverKey: "FRI", label: "금" }, + { key: "sat", serverKey: "SAT", label: "토" }, + { key: "sun", serverKey: "SUN", label: "일" }, ]; const initialBusinessHourFormValue: BusinessHourFormValue = { openTime: "", closeTime: "", + closed: false, hasBreakTime: null, breakStartTime: "", breakEndTime: "", @@ -52,37 +60,64 @@ const getEmptyBusinessHoursForm = (): Record => ( thu: { ...initialBusinessHourFormValue }, fri: { ...initialBusinessHourFormValue }, sat: { ...initialBusinessHourFormValue }, - sun: { ...initialBusinessHourFormValue }, + sun: { + ...initialBusinessHourFormValue, + closed: true, + }, }); +const getDayOption = (day: DayKey) => { + return dayOptions.find((option) => option.key === day); +}; + const toFormValue = ( source?: BusinessHoursValue, ): Record => { const normalized = parseBusinessHours(source); const base = getEmptyBusinessHoursForm(); - (Object.keys(base) as DayKey[]).forEach((day) => { - const value = normalized[day]; - - if (!value || value === "closed") { - return; - } - - const [open, close] = value.split("-"); + dayOptions.forEach(({ key, serverKey }) => { + const value = normalized.weekly[serverKey]; - base[day] = { - ...base[day], - openTime: open ?? "", - closeTime: close ?? "", - hasBreakTime: "no", - breakStartTime: "", - breakEndTime: "", + base[key] = { + openTime: value.open ?? "", + closeTime: value.close ?? "", + closed: value.closed, + hasBreakTime: value["break-time"] ? "yes" : "no", + breakStartTime: value["break-time"]?.start ?? "", + breakEndTime: value["break-time"]?.end ?? "", }; }); return base; }; +const toBusinessDayValue = ( + value: BusinessHourFormValue, +): BusinessDayValue => { + if (value.closed) { + return { + open: null, + close: null, + "break-time": null, + closed: true, + }; + } + + return { + open: value.openTime, + close: value.closeTime, + "break-time": + value.hasBreakTime === "yes" + ? { + start: value.breakStartTime, + end: value.breakEndTime, + } + : null, + closed: false, + }; +}; + export default function BusinessHoursModal({ open, onClose, @@ -105,7 +140,7 @@ export default function BusinessHoursModal({ const updateSelectedDayField = ( key: keyof BusinessHourFormValue, - value: string | BreakTimeOption, + value: string | boolean | BreakTimeOption, ) => { setBusinessHoursForm((prev) => ({ ...prev, @@ -116,6 +151,25 @@ export default function BusinessHoursModal({ })); }; + const handleChangeClosed = (closed: boolean) => { + setBusinessHoursForm((prev) => ({ + ...prev, + [selectedDay]: { + ...prev[selectedDay], + closed, + ...(closed + ? { + openTime: "", + closeTime: "", + hasBreakTime: "no" as BreakTimeOption, + breakStartTime: "", + breakEndTime: "", + } + : {}), + }, + })); + }; + const handleChangeBreakTimeOption = (value: BreakTimeOption) => { setBusinessHoursForm((prev) => { const current = prev[selectedDay]; @@ -137,6 +191,8 @@ export default function BusinessHoursModal({ }; const isDayCompleted = (value: BusinessHourFormValue) => { + if (value.closed) return true; + const hasOpenClose = value.openTime.trim() !== "" && value.closeTime.trim() !== ""; @@ -157,26 +213,26 @@ export default function BusinessHoursModal({ }, [businessHoursForm]); const isRegisterButtonEnabled = useMemo(() => { - return dayOptions.some((day) => completedDays[day.key]); + return dayOptions.every((day) => completedDays[day.key]); }, [completedDays]); const handleSubmit = () => { if (!isRegisterButtonEnabled) return; - const formatted = (Object.keys(businessHoursForm) as DayKey[]).reduce( - (acc, day) => { - const value = businessHoursForm[day]; + const formatted: BusinessHoursValue = { + weekly: { + ...EMPTY_BUSINESS_HOURS.weekly, + }, + }; - if (!isDayCompleted(value)) { - acc[day] = ""; - return acc; - } + (Object.keys(businessHoursForm) as DayKey[]).forEach((day) => { + const dayOption = getDayOption(day); + if (!dayOption) return; - acc[day] = `${value.openTime}-${value.closeTime}`; - return acc; - }, - { ...EMPTY_BUSINESS_HOURS }, - ); + formatted.weekly[dayOption.serverKey] = toBusinessDayValue( + businessHoursForm[day], + ); + }); onSubmit?.(formatted); onClose(); @@ -195,6 +251,7 @@ export default function BusinessHoursModal({
{dayOptions.map((day) => { const isSelected = selectedDay === day.key; + const isCompleted = completedDays[day.key]; return ( - - + + -
+ 휴무 + +
+ + + {!selectedDayValue.closed && ( + <> +
+

영업시간

- {selectedDayValue.hasBreakTime === "yes" && ( -
- updateSelectedDayField("breakStartTime", value) + updateSelectedDayField("openTime", value) } onChangeEndTime={(value) => - updateSelectedDayField("breakEndTime", value) + updateSelectedDayField("closeTime", value) } + className="ml-[5.2rem]" />
- )} -
+ +
+
+

브레이크타임

+ +
+ + + +
+
+ + {selectedDayValue.hasBreakTime === "yes" && ( +
+ + updateSelectedDayField("breakStartTime", value) + } + onChangeEndTime={(value) => + updateSelectedDayField("breakEndTime", value) + } + /> +
+ )} +
+ + )}
+
+

픽업 시작 시간

+ + updatePickupTime("start", event.target.value)} + className="w-[17rem]" + containerClassName="border-gray-300" + inputClassName="text-gray-600" + /> +
+ +
+

픽업 종료 시간

+ + updatePickupTime("end", event.target.value)} + className="w-[17rem]" + containerClassName="border-gray-300" + inputClassName="text-gray-600" + /> +
+

랜덤박스 설명란

diff --git a/apps/owner/src/app/signup/register/_types/address-search.ts b/apps/owner/src/app/signup/register/_types/address-search.ts new file mode 100644 index 0000000..6f8a069 --- /dev/null +++ b/apps/owner/src/app/signup/register/_types/address-search.ts @@ -0,0 +1,8 @@ +export interface AddressSearchItem { + id: string; + label: string; + lotNumberAddress: string; + roadAddress: string; + longitude: number; + latitude: number; +} \ No newline at end of file diff --git a/apps/owner/src/app/signup/register/_utils/business-hours.ts b/apps/owner/src/app/signup/register/_utils/business-hours.ts index 3cd536a..640cb1f 100644 --- a/apps/owner/src/app/signup/register/_utils/business-hours.ts +++ b/apps/owner/src/app/signup/register/_utils/business-hours.ts @@ -1,29 +1,96 @@ export type DayKey = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun"; -export type BusinessHoursValue = Record; +export type ServerDayKey = "MON" | "TUE" | "WED" | "THU" | "FRI" | "SAT" | "SUN"; + +export interface BreakTime { + start: string; + end: string; +} + +export interface BusinessDayValue { + open: string | null; + close: string | null; + "break-time": BreakTime | null; + closed: boolean; +} + +export type BusinessHoursValue = { + weekly: Record; +} & Record; + +const createBusinessDay = (closed = false): BusinessDayValue => ({ + open: closed ? null : "", + close: closed ? null : "", + "break-time": null, + closed, +}); export const EMPTY_BUSINESS_HOURS: BusinessHoursValue = { - mon: "", - tue: "", - wed: "", - thu: "", - fri: "", - sat: "", - sun: "", + weekly: { + MON: createBusinessDay(), + TUE: createBusinessDay(), + WED: createBusinessDay(), + THU: createBusinessDay(), + FRI: createBusinessDay(), + SAT: createBusinessDay(), + SUN: createBusinessDay(true), + }, +}; + +const normalizeBreakTime = (value: unknown): BreakTime | null => { + if (!value || typeof value !== "object") return null; + + const breakTime = value as Partial; + + if (typeof breakTime.start !== "string" || typeof breakTime.end !== "string") { + return null; + } + + return { + start: breakTime.start, + end: breakTime.end, + }; +}; + +const normalizeBusinessDay = ( + value: unknown, + defaultClosed = false, +): BusinessDayValue => { + if (!value || typeof value !== "object") { + return createBusinessDay(defaultClosed); + } + + const day = value as Partial; + + const closed = + typeof day.closed === "boolean" ? day.closed : defaultClosed; + + return { + open: closed ? null : typeof day.open === "string" ? day.open : "", + close: closed ? null : typeof day.close === "string" ? day.close : "", + "break-time": normalizeBreakTime(day["break-time"]), + closed, + }; }; export const parseBusinessHours = (value: unknown): BusinessHoursValue => { if (!value || typeof value !== "object") return EMPTY_BUSINESS_HOURS; - const obj = value as Partial>; + const obj = value as Partial; + + if (!obj.weekly || typeof obj.weekly !== "object") { + return EMPTY_BUSINESS_HOURS; + } return { - mon: typeof obj.mon === "string" ? obj.mon : "", - tue: typeof obj.tue === "string" ? obj.tue : "", - wed: typeof obj.wed === "string" ? obj.wed : "", - thu: typeof obj.thu === "string" ? obj.thu : "", - fri: typeof obj.fri === "string" ? obj.fri : "", - sat: typeof obj.sat === "string" ? obj.sat : "", - sun: typeof obj.sun === "string" ? obj.sun : "", + weekly: { + MON: normalizeBusinessDay(obj.weekly.MON), + TUE: normalizeBusinessDay(obj.weekly.TUE), + WED: normalizeBusinessDay(obj.weekly.WED), + THU: normalizeBusinessDay(obj.weekly.THU), + FRI: normalizeBusinessDay(obj.weekly.FRI), + SAT: normalizeBusinessDay(obj.weekly.SAT), + SUN: normalizeBusinessDay(obj.weekly.SUN, true), + }, }; }; \ No newline at end of file diff --git a/apps/owner/src/app/signup/register/page.tsx b/apps/owner/src/app/signup/register/page.tsx index 0ce9159..67af166 100644 --- a/apps/owner/src/app/signup/register/page.tsx +++ b/apps/owner/src/app/signup/register/page.tsx @@ -6,6 +6,8 @@ import { useRouter } from "next/navigation"; import type { StoreUpdateReqDTO, StoreLocationUpdateReqDTO, + StoreTag, + BankType, } from "@compasser/api"; import StoreNameField from "./_components/fields/StoreNameField"; @@ -19,13 +21,14 @@ import TagSection from "./_components/sections/TagSection"; import RegisterSubmitButton from "./_components/RegisterSubmitButton"; import BusinessHoursModal from "./_components/modals/BusinessHoursModal"; import RandomBoxModal from "./_components/modals/RandomBoxModal"; - +import AddressSearchBottomSheet from "./_components/AddressSearchBottomSheet"; +import type { AddressSearchItem } from "./_types/address-search"; import { useMyStoreQuery } from "@/shared/queries/query/useMyStoreQuery"; import { usePatchMyStoreMutation } from "@/shared/queries/mutation/auth/usePatchMyStoreMutation"; import { usePatchMyStoreLocationMutation } from "@/shared/queries/mutation/auth/usePatchMyStoreLocationMutation"; import { useRandomBoxListQuery } from "@/shared/queries/query/useRandomBoxListQuery"; import { useCreateRandomBoxMutation } from "@/shared/queries/mutation/auth/useCreateRandomBoxMutation"; -import { useUpdateRandomBoxMutation } from "@/shared/queries/mutation/auth/useUpdateRandomBoxMutation"; +// import { useUpdateRandomBoxMutation } from "@/shared/queries/mutation/auth/useUpdateRandomBoxMutation"; import { useDeleteRandomBoxMutation } from "@/shared/queries/mutation/auth/useDeleteRandomBoxMutation"; import { useStoreImageQuery } from "@/shared/queries/query/useStoreImageQuery"; import { useUploadStoreImageMutation } from "@/shared/queries/mutation/auth/useUploadStoreImageMutation"; @@ -33,6 +36,22 @@ import { useRemoveStoreImageMutation } from "@/shared/queries/mutation/auth/useR import { parseBusinessHours, EMPTY_BUSINESS_HOURS } from "./_utils/business-hours"; +const tagOptions = ["카페", "베이커리", "식당"] as const; + +type TagLabel = (typeof tagOptions)[number]; + +const tagMap: Record = { + 카페: "CAFE", + 베이커리: "BAKERY", + 식당: "RESTAURANT", +}; + +const reverseTagMap: Record = { + CAFE: "카페", + BAKERY: "베이커리", + RESTAURANT: "식당", +}; + export default function StoreRegisterPage() { const router = useRouter(); @@ -45,7 +64,7 @@ export default function StoreRegisterPage() { const patchMyStoreMutation = usePatchMyStoreMutation(); const patchMyStoreLocationMutation = usePatchMyStoreLocationMutation(); const createRandomBoxMutation = useCreateRandomBoxMutation(); - const updateRandomBoxMutation = useUpdateRandomBoxMutation(); + // const updateRandomBoxMutation = useUpdateRandomBoxMutation(); const deleteRandomBoxMutation = useDeleteRandomBoxMutation(); const uploadStoreImageMutation = useUploadStoreImageMutation(); const removeStoreImageMutation = useRemoveStoreImageMutation(); @@ -53,13 +72,13 @@ export default function StoreRegisterPage() { const [storeName, setStoreName] = useState(""); const [storeEmail, setStoreEmail] = useState(""); const [inputAddress, setInputAddress] = useState(""); - const [bankName, setBankName] = useState(""); + const [bankType, setBankType] = useState(""); + const [isAddressSearchOpen, setIsAddressSearchOpen] = useState(false); const [depositor, setDepositor] = useState(""); const [bankAccount, setBankAccount] = useState(""); const [businessHours, setBusinessHours] = useState(EMPTY_BUSINESS_HOURS); - const tagOptions = ["카페", "베이커리", "식당"] as const; - const [selectedTag, setSelectedTag] = useState<"" | "카페" | "베이커리" | "식당">(""); + const [selectedTag, setSelectedTag] = useState<"" | TagLabel>(""); const [selectedRandomBoxIds, setSelectedRandomBoxIds] = useState([]); const [isBusinessHoursModalOpen, setIsBusinessHoursModalOpen] = useState(false); const [isRandomBoxModalOpen, setIsRandomBoxModalOpen] = useState(false); @@ -69,12 +88,16 @@ export default function StoreRegisterPage() { const [imageRemoved, setImageRemoved] = useState(false); useEffect(() => { - if (!myStore) return; + if (!myStore) return; setStoreName(myStore.storeName ?? ""); - setStoreEmail(""); + setStoreEmail(myStore.storeEmail ?? ""); setInputAddress(myStore.inputAddress ?? ""); setBusinessHours(parseBusinessHours(myStore.businessHours)); + + if (myStore.tag) { + setSelectedTag(reverseTagMap[myStore.tag as StoreTag] ?? ""); + } }, [myStore]); useEffect(() => { @@ -83,6 +106,16 @@ export default function StoreRegisterPage() { }, [storeImage, imageRemoved]); const businessHoursRows = useMemo(() => { + const dayKeyMap = { + mon: "MON", + tue: "TUE", + wed: "WED", + thu: "THU", + fri: "FRI", + sat: "SAT", + sun: "SUN", + } as const; + const dayLabelMap = { mon: "월", tue: "화", @@ -96,8 +129,11 @@ export default function StoreRegisterPage() { const orderedDays = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const; return orderedDays.map((day) => { - const value = businessHours[day]; - const formatted = value === "closed" ? "휴무" : value || "-"; + const value = businessHours.weekly[dayKeyMap[day]]; + + const formatted = value.closed + ? "휴무" + : `${value.open || "-"} - ${value.close || "-"}`; return { dayLabel: dayLabelMap[day], @@ -106,9 +142,14 @@ export default function StoreRegisterPage() { }); }, [businessHours]); + const handleSelectAddress = (item: AddressSearchItem) => { + setInputAddress(item.roadAddress || item.lotNumberAddress || item.label); + setIsAddressSearchOpen(false); + }; + const toggleRandomBoxSelection = (id: number) => { setSelectedRandomBoxIds((prev) => - prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id] + prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id], ); }; @@ -117,8 +158,8 @@ export default function StoreRegisterPage() { await Promise.all( selectedRandomBoxIds.map((boxId) => - deleteRandomBoxMutation.mutateAsync({ storeId, boxId }) - ) + deleteRandomBoxMutation.mutateAsync({ storeId, boxId }), + ), ); setSelectedRandomBoxIds([]); @@ -130,34 +171,35 @@ export default function StoreRegisterPage() { price: number; buyLimit: number; content: string; + pickupTimeInfo: { + start: string; + end: string; + }; boxId?: number; }) => { if (!storeId) return; - if (form.boxId) { - await updateRandomBoxMutation.mutateAsync({ - storeId, - boxId: form.boxId, - body: { - boxName: form.boxName, - stock: form.stock, - price: form.price, - buyLimit: form.buyLimit, - content: form.content, - }, - }); - return; - } + const body = { + boxName: form.boxName, + stock: form.stock, + price: form.price, + buyLimit: form.buyLimit, + content: form.content, + pickupTimeInfo: form.pickupTimeInfo, + }; + + // if (form.boxId) { + // await updateRandomBoxMutation.mutateAsync({ + // storeId, + // boxId: form.boxId, + // body, + // }); + // return; + // } await createRandomBoxMutation.mutateAsync({ storeId, - body: { - boxName: form.boxName, - stock: form.stock, - price: form.price, - buyLimit: form.buyLimit, - content: form.content, - }, + body, }); }; @@ -188,10 +230,11 @@ export default function StoreRegisterPage() { const storePayload: StoreUpdateReqDTO = { storeName, storeEmail, - bankName, + bankType: bankType || undefined, depositor, bankAccount, businessHours, + tag: selectedTag ? tagMap[selectedTag] : undefined, }; const locationPayload: StoreLocationUpdateReqDTO = { @@ -222,13 +265,14 @@ export default function StoreRegisterPage() { {}} + onSearchAddress={() => setIsAddressSearchOpen(true)} /> + setBankType(value as BankType)} onChangeDepositor={setDepositor} onChangeBankAccount={setBankAccount} /> @@ -275,6 +319,12 @@ export default function StoreRegisterPage() { onClose={() => setIsRandomBoxModalOpen(false)} onSubmit={handleSubmitRandomBox} /> + + setIsAddressSearchOpen(false)} + onSelectAddress={handleSelectAddress} + /> ); } \ No newline at end of file diff --git a/apps/owner/src/shared/types/kakao.d.ts b/apps/owner/src/shared/types/kakao.d.ts new file mode 100644 index 0000000..47cbb48 --- /dev/null +++ b/apps/owner/src/shared/types/kakao.d.ts @@ -0,0 +1,116 @@ +export {}; + +declare global { + interface Window { + kakao: { + maps: { + load: (callback: () => void) => void; + + LatLng: new (latitude: number, longitude: number) => KakaoLatLng; + + Map: new ( + container: HTMLElement, + options: { + center: KakaoLatLng; + level: number; + } + ) => KakaoMapInstance; + + Marker: new (options: { + map?: KakaoMapInstance | null; + position: KakaoLatLng; + title?: string; + image?: KakaoMarkerImage; + }) => KakaoMarker; + + MarkerImage: new ( + src: string, + size: KakaoSize, + options?: { + offset?: KakaoPoint; + alt?: string; + shape?: string; + coords?: string; + spriteOrigin?: KakaoPoint; + spriteSize?: KakaoSize; + } + ) => KakaoMarkerImage; + + Size: new (width: number, height: number) => KakaoSize; + + Point: new (x: number, y: number) => KakaoPoint; + + event: { + addListener: ( + target: object, + type: string, + handler: (...args: unknown[]) => void + ) => void; + removeListener: ( + target: object, + type: string, + handler: (...args: unknown[]) => void + ) => void; + }; + + services: { + Geocoder: new () => { + addressSearch: ( + address: string, + callback: ( + result: KakaoAddressSearchResult[], + status: string + ) => void, + options?: { + page?: number; + size?: number; + analyze_type?: string; + } + ) => void; + }; + + Status: { + OK: string; + ZERO_RESULT: string; + ERROR: string; + }; + + AnalyzeType: { + SIMILAR: string; + EXACT: string; + }; + }; + }; + }; + } + + interface KakaoLatLng {} + + interface KakaoMapInstance { + relayout: () => void; + setCenter: (latlng: KakaoLatLng) => void; + } + + interface KakaoMarker { + setMap: (map: KakaoMapInstance | null) => void; + setPosition: (position: KakaoLatLng) => void; + } + + interface KakaoMarkerImage {} + + interface KakaoSize {} + + interface KakaoPoint {} + + interface KakaoAddressSearchResult { + address_name: string; + x: string; + y: string; + address?: { + address_name: string; + }; + road_address?: { + address_name: string; + }; + } +} \ No newline at end of file diff --git a/apps/owner/src/shared/utils/bank.ts b/apps/owner/src/shared/utils/bank.ts index 209edc4..b8cb52d 100644 --- a/apps/owner/src/shared/utils/bank.ts +++ b/apps/owner/src/shared/utils/bank.ts @@ -1,26 +1,34 @@ -export const bankNameOptions = [ - "국민은행", - "신한은행", - "우리은행", - "하나은행", - "농협은행", - "기업은행", - "카카오뱅크", - "토스뱅크", - "케이뱅크", - "새마을금고", - "수협은행", - "부산은행", - "대구은행", - "광주은행", - "전북은행", - "경남은행", +export const bankOptions = [ + { label: "국민은행", value: "KB" }, + { label: "신한은행", value: "SHINHAN" }, + { label: "우리은행", value: "WOORI" }, + { label: "하나은행", value: "HANA" }, + { label: "농협은행", value: "NH" }, + { label: "기업은행", value: "IBK" }, + { label: "카카오뱅크", value: "KAKAO" }, + { label: "토스뱅크", value: "TOSS" }, + { label: "케이뱅크", value: "K" }, + { label: "새마을금고", value: "SAEMAUL" }, + { label: "수협은행", value: "SUHYUP" }, + { label: "부산은행", value: "BUSAN" }, + { label: "대구은행", value: "DAEGU" }, + { label: "광주은행", value: "GWANGJU" }, + { label: "전북은행", value: "JEONBUK" }, + { label: "경남은행", value: "KYONGNAM" }, ] as const; -export const filterBankNames = (keyword: string) => { +export type BankLabel = (typeof bankOptions)[number]["label"]; +export type BankValue = (typeof bankOptions)[number]["value"]; + +export const filterBanks = (keyword: string) => { const normalized = keyword.trim(); - if (!normalized) return bankNameOptions; - return bankNameOptions.filter((bank) => bank.includes(normalized)); + if (!normalized) return bankOptions; + + return bankOptions.filter((bank) => bank.label.includes(normalized)); +}; + +export const getBankLabel = (value: string) => { + return bankOptions.find((bank) => bank.value === value)?.label ?? ""; }; export const normalizeAccountNumber = (value: string) => { diff --git a/packages/api/src/models/random-box.ts b/packages/api/src/models/random-box.ts index 64d66c5..2428930 100644 --- a/packages/api/src/models/random-box.ts +++ b/packages/api/src/models/random-box.ts @@ -1,3 +1,4 @@ +import { JsonValue } from "../core/types"; import type { SaleStatus } from "./common"; export interface RandomBoxCreateReqDTO { @@ -6,6 +7,7 @@ export interface RandomBoxCreateReqDTO { stock?: number; price?: number; buyLimit?: number; + pickupTimeInfo?: JsonValue; } export interface RandomBoxUpdateReqDTO extends RandomBoxCreateReqDTO { @@ -20,5 +22,6 @@ export interface RandomBoxRespDTO { price: number; buyLimit: number; content: string; - saleStatus: string; + saleStatus: SaleStatus | string; + pickupTimeInfo: string; } \ No newline at end of file diff --git a/packages/api/src/models/store.ts b/packages/api/src/models/store.ts index 96de341..80cf4c4 100644 --- a/packages/api/src/models/store.ts +++ b/packages/api/src/models/store.ts @@ -4,10 +4,11 @@ import type { StoreTag } from "./common"; export interface StoreUpdateReqDTO { storeName?: string; storeEmail?: string; - bankName?: string; + bankType?: string; depositor?: string; bankAccount?: string; businessHours?: JsonValue; + tag?: StoreTag; } export interface StoreLocationUpdateReqDTO { @@ -66,9 +67,9 @@ export interface SimpleStoreInfoDTO { storeId: number; tag: StoreTag; storeName: string; - storeEmail: string; roadAddress: string; jibunAddress: string; + storeEmail: string; businessHours?: JsonValue; }