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 구축 및 패키지화
+
+
+
+- 카페별 랜덤박스 결제, QR 기반 적립
+
+
+
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 (
<>