From b653af1e43ef2e2ab70cbc9c68732f63fe837d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B3=A0=EB=AF=BC=EA=B7=A0?= <97932282+skyblue1232@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:45:33 +0900 Subject: [PATCH 1/5] chore/packages --- .../design-system/src/components/BottomSheet/BottomSheet.tsx | 2 +- packages/design-system/src/components/Header/Header.tsx | 2 ++ packages/design-system/src/components/Header/Header.types.ts | 1 + packages/design-system/src/icons/generated/iconNames.ts | 1 + packages/design-system/src/icons/generated/spriteSymbols.ts | 2 +- packages/design-system/src/icons/source/Pin.svg | 1 + packages/design-system/src/libs/utils.ts | 1 + packages/tailwind-config/theme.css | 2 ++ 8 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 packages/design-system/src/icons/source/Pin.svg diff --git a/packages/design-system/src/components/BottomSheet/BottomSheet.tsx b/packages/design-system/src/components/BottomSheet/BottomSheet.tsx index ea0228f..378bade 100644 --- a/packages/design-system/src/components/BottomSheet/BottomSheet.tsx +++ b/packages/design-system/src/components/BottomSheet/BottomSheet.tsx @@ -37,7 +37,7 @@ export const BottomSheet = ({
{ value, placeholder = "위치를 입력해주세요", onChange, + onInputClick, onMapPinClick, onAlarmClick, mapPinIconName = "MapPin", @@ -39,6 +40,7 @@ export const Header = (props: HeaderProps) => { name={inputName} value={value} onChange={onChange} + onClick={onInputClick} placeholder={placeholder} disabled={inputDisabled} readOnly={inputReadOnly} diff --git a/packages/design-system/src/components/Header/Header.types.ts b/packages/design-system/src/components/Header/Header.types.ts index fa9958f..a3fa356 100644 --- a/packages/design-system/src/components/Header/Header.types.ts +++ b/packages/design-system/src/components/Header/Header.types.ts @@ -20,6 +20,7 @@ export interface LocationSearchHeaderProps extends HeaderBaseProps { value?: string; placeholder?: string; onChange?: React.ChangeEventHandler; + onInputClick?: React.MouseEventHandler; onMapPinClick?: React.MouseEventHandler; onAlarmClick?: React.MouseEventHandler; mapPinIconName?: IconName; diff --git a/packages/design-system/src/icons/generated/iconNames.ts b/packages/design-system/src/icons/generated/iconNames.ts index 942fb9b..83ca4d0 100644 --- a/packages/design-system/src/icons/generated/iconNames.ts +++ b/packages/design-system/src/icons/generated/iconNames.ts @@ -26,6 +26,7 @@ export const iconNames = [ "Notice", "Order", "OwnerSelect", + "Pin", "Plus", "ProfileCharacter", "RadioActive", diff --git a/packages/design-system/src/icons/generated/spriteSymbols.ts b/packages/design-system/src/icons/generated/spriteSymbols.ts index bda40e4..e167496 100644 --- a/packages/design-system/src/icons/generated/spriteSymbols.ts +++ b/packages/design-system/src/icons/generated/spriteSymbols.ts @@ -1,2 +1,2 @@ // 이 파일은 자동 생성 파일입니다. (직접 수정 금지) -export const spriteSymbols = ""; +export const spriteSymbols = ""; diff --git a/packages/design-system/src/icons/source/Pin.svg b/packages/design-system/src/icons/source/Pin.svg new file mode 100644 index 0000000..5e6945e --- /dev/null +++ b/packages/design-system/src/icons/source/Pin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/design-system/src/libs/utils.ts b/packages/design-system/src/libs/utils.ts index c05146e..a6a73aa 100644 --- a/packages/design-system/src/libs/utils.ts +++ b/packages/design-system/src/libs/utils.ts @@ -22,6 +22,7 @@ const twMerge = extendTailwindMerge({ "primary-variant", "primary-button", "secondary", + "secondary-variant", "accent", "default", "inverse", diff --git a/packages/tailwind-config/theme.css b/packages/tailwind-config/theme.css index 1132ff5..d6b15e6 100644 --- a/packages/tailwind-config/theme.css +++ b/packages/tailwind-config/theme.css @@ -31,6 +31,8 @@ @utility border-primary { border-color: var(--color-primary); } @utility border-primary-variant { border-color: var(--color-primary-variant); } +@utility border-secondary { border-color: var(--color-secondary); } +@utility border-secondary-variant { border-color: var(--color-secondary-variant); } @utility border-accent { border-color: var(--color-accent); } @utility border-gray-500 { border-color: var(--color-gray-500); } @utility border-gray-200 { border-color: var(--color-gray-200); } From 5a5a71f4176fc20eea70021e4cfb3fcb4f638c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B3=A0=EB=AF=BC=EA=B7=A0?= <97932282+skyblue1232@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:46:11 +0900 Subject: [PATCH 2/5] api/main-store --- .../(tabs)/main/_apis/searchAddressByKakao.ts | 72 +++++++++++ .../(tabs)/main/store/_types/store-detail.ts | 29 +---- apps/customer/src/shared/api/api.ts | 4 +- .../queries/query/store/useStoreDetail.ts | 11 ++ .../queries/query/store/useStoreSimple.ts | 11 ++ .../shared/queries/query/store/useStores.ts | 15 +++ .../queries/query/store/useStoresByAddress.ts | 16 +++ apps/customer/src/shared/types/kakao.d.ts | 116 ++++++++++++++++++ packages/api/src/models/store.ts | 23 +++- 9 files changed, 267 insertions(+), 30 deletions(-) create mode 100644 apps/customer/src/app/(tabs)/main/_apis/searchAddressByKakao.ts create mode 100644 apps/customer/src/shared/queries/query/store/useStoreDetail.ts create mode 100644 apps/customer/src/shared/queries/query/store/useStoreSimple.ts create mode 100644 apps/customer/src/shared/queries/query/store/useStores.ts create mode 100644 apps/customer/src/shared/queries/query/store/useStoresByAddress.ts create mode 100644 apps/customer/src/shared/types/kakao.d.ts diff --git a/apps/customer/src/app/(tabs)/main/_apis/searchAddressByKakao.ts b/apps/customer/src/app/(tabs)/main/_apis/searchAddressByKakao.ts new file mode 100644 index 0000000..79ace98 --- /dev/null +++ b/apps/customer/src/app/(tabs)/main/_apis/searchAddressByKakao.ts @@ -0,0 +1,72 @@ +"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 || + !window.kakao.maps || + !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: KakaoAddressSearchResult, index: number) => { + 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/customer/src/app/(tabs)/main/store/_types/store-detail.ts b/apps/customer/src/app/(tabs)/main/store/_types/store-detail.ts index cddc6bb..631a202 100644 --- a/apps/customer/src/app/(tabs)/main/store/_types/store-detail.ts +++ b/apps/customer/src/app/(tabs)/main/store/_types/store-detail.ts @@ -5,31 +5,4 @@ export type DayKey = | "thu" | "fri" | "sat" - | "sun"; - -export interface StoreBusinessHour { - open: string; - close: string; -} - -export interface StoreMenuItem { - id: number; - name: string; - remainingCount: number; - pickupStartTime: string; - pickupEndTime: string; - price: number; - originalPrice?: number; - imageUrl: string; -} - -export interface StoreDetailItem { - id: number; - storeName: string; - roadAddress: string; - lotAddress: string; - email: string; - thumbnailImageUrl: string; - businessHours: Record; - menus: StoreMenuItem[]; -} \ No newline at end of file + | "sun"; \ No newline at end of file diff --git a/apps/customer/src/shared/api/api.ts b/apps/customer/src/shared/api/api.ts index f23758c..ee646dd 100644 --- a/apps/customer/src/shared/api/api.ts +++ b/apps/customer/src/shared/api/api.ts @@ -3,6 +3,7 @@ import { createCompasserApi, createAuthModule, + createStoreModule, type TokenPair, type TokenStore, } from "@compasser/api"; @@ -36,4 +37,5 @@ export const compasserApi = createCompasserApi({ tokenStore, }); -export const authModule = createAuthModule(compasserApi); \ No newline at end of file +export const authModule = createAuthModule(compasserApi); +export const storeModule = createStoreModule(compasserApi); \ No newline at end of file diff --git a/apps/customer/src/shared/queries/query/store/useStoreDetail.ts b/apps/customer/src/shared/queries/query/store/useStoreDetail.ts new file mode 100644 index 0000000..b5af05a --- /dev/null +++ b/apps/customer/src/shared/queries/query/store/useStoreDetail.ts @@ -0,0 +1,11 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { storeModule } from "@/shared/api/api"; + +export function useStoreDetail(storeId: number) { + return useQuery({ + ...storeModule.queries.detail(storeId), + enabled: Boolean(storeId), + }); +} \ No newline at end of file diff --git a/apps/customer/src/shared/queries/query/store/useStoreSimple.ts b/apps/customer/src/shared/queries/query/store/useStoreSimple.ts new file mode 100644 index 0000000..7e6c9bc --- /dev/null +++ b/apps/customer/src/shared/queries/query/store/useStoreSimple.ts @@ -0,0 +1,11 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { storeModule } from "@/shared/api/api"; + +export function useStoreSimple(storeId: number | null) { + return useQuery({ + ...storeModule.queries.simple(storeId ?? 0), + enabled: Boolean(storeId), + }); +} \ No newline at end of file diff --git a/apps/customer/src/shared/queries/query/store/useStores.ts b/apps/customer/src/shared/queries/query/store/useStores.ts new file mode 100644 index 0000000..99db86b --- /dev/null +++ b/apps/customer/src/shared/queries/query/store/useStores.ts @@ -0,0 +1,15 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { storeModule } from "@/shared/api/api"; + +export function useStores(userLat: number | null, userLon: number | null, page = 0) { + return useQuery({ + ...storeModule.queries.list({ + userLat: userLat ?? 0, + userLon: userLon ?? 0, + page, + }), + enabled: userLat !== null && userLon !== null, + }); +} \ No newline at end of file diff --git a/apps/customer/src/shared/queries/query/store/useStoresByAddress.ts b/apps/customer/src/shared/queries/query/store/useStoresByAddress.ts new file mode 100644 index 0000000..fcc4ec4 --- /dev/null +++ b/apps/customer/src/shared/queries/query/store/useStoresByAddress.ts @@ -0,0 +1,16 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { storeModule } from "@/shared/api/api"; + +export function useStoresByAddress(address: string, page = 0) { + const trimmedAddress = address.trim(); + + return useQuery({ + ...storeModule.queries.byAddress({ + address: trimmedAddress, + page, + }), + enabled: Boolean(trimmedAddress), + }); +} \ No newline at end of file diff --git a/apps/customer/src/shared/types/kakao.d.ts b/apps/customer/src/shared/types/kakao.d.ts new file mode 100644 index 0000000..47cbb48 --- /dev/null +++ b/apps/customer/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/packages/api/src/models/store.ts b/packages/api/src/models/store.ts index acf42a9..ba4d2c9 100644 --- a/packages/api/src/models/store.ts +++ b/packages/api/src/models/store.ts @@ -14,10 +14,28 @@ export interface StoreLocationUpdateReqDTO { inputAddress?: string; } +export interface StoreImageDTO { + id: number; + imageUrl: string; + createdAt?: string; +} + +export interface RandomBoxRespDTO { + boxId: number; + storeId: number; + boxName: string; + stock: number; + price: number; + buyLimit: number; + content: string; + saleStatus: string; +} + export interface StoreRespDTO { storeId: number; storeManagerId: number; storeName: string; + storeEmail: string; storeDetails: string; inputAddress: string; roadAddress: string; @@ -26,6 +44,8 @@ export interface StoreRespDTO { longitude: number; businessHours?: JsonValue; tag: StoreTag; + images: StoreImageDTO[]; + randomBoxes: RandomBoxRespDTO[]; } export interface GetStoreReqDTO { @@ -46,6 +66,7 @@ export interface SimpleStoreInfoDTO { storeName: string; roadAddress: string; jibunAddress: string; + storeEmail: string; businessHours?: JsonValue; } @@ -67,4 +88,4 @@ export interface StoreAddressListParams { export type StoreListResponse = ApiResponse; export type StoreDetailResponse = ApiResponse; export type StoreSimpleResponse = ApiResponse; -export type StoreByAddressResponse = ApiResponse>; \ No newline at end of file +export type StoreByAddressResponse = ApiResponse; \ No newline at end of file From a7b3f5ba1783f9c2c8b6f04499e2fe42323d98e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B3=A0=EB=AF=BC=EA=B7=A0?= <97932282+skyblue1232@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:46:51 +0900 Subject: [PATCH 3/5] feat/address-search --- .../_components/AddressSearchBottomSheet.tsx | 146 +++++++++++++++++ .../app/(tabs)/main/_components/KakaoMap.tsx | 126 +++++++++++--- .../(tabs)/main/_components/MainListView.tsx | 98 ++++------- .../(tabs)/main/_components/MainMapView.tsx | 16 +- .../main/_components/MainPageClient.tsx | 155 +++++++++++++++++- .../(tabs)/main/_components/MainStoreCard.tsx | 85 +++++++--- .../app/(tabs)/main/_types/address-search.ts | 8 + .../src/app/(tabs)/main/store/[id]/page.tsx | 6 +- 8 files changed, 521 insertions(+), 119 deletions(-) create mode 100644 apps/customer/src/app/(tabs)/main/_components/AddressSearchBottomSheet.tsx create mode 100644 apps/customer/src/app/(tabs)/main/_types/address-search.ts diff --git a/apps/customer/src/app/(tabs)/main/_components/AddressSearchBottomSheet.tsx b/apps/customer/src/app/(tabs)/main/_components/AddressSearchBottomSheet.tsx new file mode 100644 index 0000000..971d11e --- /dev/null +++ b/apps/customer/src/app/(tabs)/main/_components/AddressSearchBottomSheet.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { BottomSheet, Input, Icon } 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 handleQuickSelect = () => { + onSelectAddress({ + id: "catholic-univ", + label: "가톨릭대 주변 탐색", + lotNumberAddress: "경기도 부천시 원미구 역곡동 일대", + roadAddress: "가톨릭대학교 성심교정 주변", + longitude: 126.8016, + latitude: 37.4875, + }); + }; + + const handleSelectItem = (item: AddressSearchItem) => { + onSelectAddress(item); + }; + + 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/customer/src/app/(tabs)/main/_components/KakaoMap.tsx b/apps/customer/src/app/(tabs)/main/_components/KakaoMap.tsx index 215873b..6ccd953 100644 --- a/apps/customer/src/app/(tabs)/main/_components/KakaoMap.tsx +++ b/apps/customer/src/app/(tabs)/main/_components/KakaoMap.tsx @@ -2,14 +2,19 @@ import Script from "next/script"; import { useEffect, useRef, useState } from "react"; -import { MOCK_MAIN_STORE_LIST } from "../_constants/MockMainList"; -import type { MainCategory, MainStoreItem } from "../_types/main-list"; +import type { + GetStoreReqDTO, + SimpleStoreInfoDTO, + StoreTag, +} from "@compasser/api"; +import type { MainCategory } from "../_types/main-list"; +import type { AddressSearchItem } from "../_types/address-search"; +import { useStoreSimple } from "@/shared/queries/query/store/useStoreSimple"; import MapStoreBottomSheet from "./MapStoreBottomSheet"; -declare global { - interface Window { - kakao: any; - } +interface KakaoMapProps { + selectedAddress: AddressSearchItem | null; + stores: GetStoreReqDTO[]; } const PIN_IMAGE_MAP: Record = { @@ -18,12 +23,16 @@ const PIN_IMAGE_MAP: Record = { 식당: "/icons/pin-restaurant.svg", }; -export default function KakaoMap() { +export default function KakaoMap({ selectedAddress, stores }: KakaoMapProps) { const mapRef = useRef(null); - const mapInstanceRef = useRef(null); - const markerRefs = useRef([]); + const mapInstanceRef = useRef(null); + const markerRefs = useRef([]); + const selectedMarkerRef = useRef(null); + const [isLoaded, setIsLoaded] = useState(false); - const [selectedStore, setSelectedStore] = useState(null); + const [selectedStoreId, setSelectedStoreId] = useState(null); + + const { data: simpleStore } = useStoreSimple(selectedStoreId); useEffect(() => { if (window.kakao?.maps) { @@ -46,7 +55,7 @@ export default function KakaoMap() { }); kakaoMaps.event.addListener(mapInstanceRef.current, "click", () => { - setSelectedStore(null); + setSelectedStoreId(null); }); } else { mapInstanceRef.current.relayout(); @@ -55,13 +64,13 @@ export default function KakaoMap() { markerRefs.current.forEach((marker) => marker.setMap(null)); markerRefs.current = []; - MOCK_MAIN_STORE_LIST.forEach((store) => { + stores.forEach((store) => { const markerImage = new kakaoMaps.MarkerImage( - PIN_IMAGE_MAP[store.primaryCategory], + PIN_IMAGE_MAP[mapServerTagToMainCategory(store.tag)], new kakaoMaps.Size(32, 32), { offset: new kakaoMaps.Point(16, 16), - }, + } ); const marker = new kakaoMaps.Marker({ @@ -73,36 +82,103 @@ export default function KakaoMap() { kakaoMaps.event.addListener(marker, "click", () => { setTimeout(() => { - setSelectedStore(store); + setSelectedStoreId(store.storeId); }, 0); }); markerRefs.current.push(marker); }); + + if (selectedAddress) { + const selectedPosition = new kakaoMaps.LatLng( + selectedAddress.latitude, + selectedAddress.longitude + ); + + mapInstanceRef.current.setCenter(selectedPosition); + + if (selectedMarkerRef.current) { + selectedMarkerRef.current.setMap(null); + } + + selectedMarkerRef.current = new kakaoMaps.Marker({ + map: mapInstanceRef.current, + position: selectedPosition, + }); + } else if (stores.length > 0) { + const firstStore = stores[0]; + mapInstanceRef.current.setCenter( + new kakaoMaps.LatLng(firstStore.latitude, firstStore.longitude) + ); + } }); - }, [isLoaded]); + }, [isLoaded, stores, selectedAddress]); return ( <>