diff --git a/README.md b/README.md index d0b0297..7ddce31 100644 --- a/README.md +++ b/README.md @@ -1 +1,12 @@ -# FE \ No newline at end of file +# FE +#### 모노레포(도메인 기반) 구조 + +Compasser 프로젝트 프론트엔드 아키텍쳐입니다. +- Design System 구축 및 패키지화 + + image + +- 카페별 랜덤박스 결제, QR 기반 적립 + +image + diff --git a/apps/customer/next.config.ts b/apps/customer/next.config.ts index 66e1566..2fd9b78 100644 --- a/apps/customer/next.config.ts +++ b/apps/customer/next.config.ts @@ -6,3 +6,4 @@ const nextConfig: NextConfig = { }; export default nextConfig; + diff --git a/apps/customer/package.json b/apps/customer/package.json index f3c82db..3de07c4 100644 --- a/apps/customer/package.json +++ b/apps/customer/package.json @@ -8,6 +8,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { + "@compasser/api": "workspace:^", "@compasser/design-system": "workspace:*", "@tanstack/react-query": "^5.90.21", "next": "16.1.6", diff --git a/apps/customer/public/icons/pin-bakery.svg b/apps/customer/public/icons/pin-bakery.svg new file mode 100644 index 0000000..2c1d139 --- /dev/null +++ b/apps/customer/public/icons/pin-bakery.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/customer/public/icons/pin-cafe.svg b/apps/customer/public/icons/pin-cafe.svg new file mode 100644 index 0000000..a2a26e9 --- /dev/null +++ b/apps/customer/public/icons/pin-cafe.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/customer/public/icons/pin-restaurant.svg b/apps/customer/public/icons/pin-restaurant.svg new file mode 100644 index 0000000..785ed72 --- /dev/null +++ b/apps/customer/public/icons/pin-restaurant.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/customer/public/images/mock/store/store-1.jpg b/apps/customer/public/images/mock/store/store-1.jpg new file mode 100644 index 0000000..eb328f2 Binary files /dev/null and b/apps/customer/public/images/mock/store/store-1.jpg differ diff --git a/apps/customer/src/app/(tabs)/layout.tsx b/apps/customer/src/app/(tabs)/layout.tsx index 22f80ff..321895d 100644 --- a/apps/customer/src/app/(tabs)/layout.tsx +++ b/apps/customer/src/app/(tabs)/layout.tsx @@ -18,14 +18,13 @@ export default function TabsLayout({ children }: TabsLayoutProps) { const router = useRouter(); const pathname = usePathname(); - const activeKey = - pathname === "/main" - ? "home" - : pathname === "/order" - ? "order" - : pathname === "/mypage" - ? "my" - : "home"; + const activeKey = pathname.startsWith("/mypage") + ? "my" + : pathname.startsWith("/order") + ? "order" + : pathname.startsWith("/main") + ? "home" + : "home"; const handleTabChange = (key: string) => { if (key === "home") { @@ -44,8 +43,10 @@ export default function TabsLayout({ children }: TabsLayoutProps) { }; return ( -
-
{children}
+
+
+ {children} +
{ + 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/_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 fa435c8..6ccd953 100644 --- a/apps/customer/src/app/(tabs)/main/_components/KakaoMap.tsx +++ b/apps/customer/src/app/(tabs)/main/_components/KakaoMap.tsx @@ -2,16 +2,43 @@ import Script from "next/script"; import { useEffect, useRef, useState } from "react"; +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[]; } -export default function KakaoMap() { +const PIN_IMAGE_MAP: Record = { + 카페: "/icons/pin-cafe.svg", + 베이커리: "/icons/pin-bakery.svg", + 식당: "/icons/pin-restaurant.svg", +}; + +export default function KakaoMap({ selectedAddress, stores }: KakaoMapProps) { const mapRef = useRef(null); + const mapInstanceRef = useRef(null); + const markerRefs = useRef([]); + const selectedMarkerRef = useRef(null); + const [isLoaded, setIsLoaded] = useState(false); + const [selectedStoreId, setSelectedStoreId] = useState(null); + + const { data: simpleStore } = useStoreSimple(selectedStoreId); + + useEffect(() => { + if (window.kakao?.maps) { + setIsLoaded(true); + } + }, []); useEffect(() => { if (!isLoaded || !mapRef.current || !window.kakao?.maps) return; @@ -19,23 +46,139 @@ export default function KakaoMap() { window.kakao.maps.load(() => { if (!mapRef.current) return; - const options = { - center: new window.kakao.maps.LatLng(37.503206, 126.766872), - level: 4, - }; + const kakaoMaps = window.kakao.maps; + + if (!mapInstanceRef.current) { + mapInstanceRef.current = new kakaoMaps.Map(mapRef.current, { + center: new kakaoMaps.LatLng(37.4866, 126.8045), + level: 4, + }); + + kakaoMaps.event.addListener(mapInstanceRef.current, "click", () => { + setSelectedStoreId(null); + }); + } else { + mapInstanceRef.current.relayout(); + } + + markerRefs.current.forEach((marker) => marker.setMap(null)); + markerRefs.current = []; + + stores.forEach((store) => { + const markerImage = new kakaoMaps.MarkerImage( + PIN_IMAGE_MAP[mapServerTagToMainCategory(store.tag)], + new kakaoMaps.Size(32, 32), + { + offset: new kakaoMaps.Point(16, 16), + } + ); + + const marker = new kakaoMaps.Marker({ + map: mapInstanceRef.current, + position: new kakaoMaps.LatLng(store.latitude, store.longitude), + title: store.storeName, + image: markerImage, + }); + + kakaoMaps.event.addListener(marker, "click", () => { + setTimeout(() => { + 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); + } - new window.kakao.maps.Map(mapRef.current, options); + 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 ( <>