From 6449b5303613d732ed9d5a1eeab561c8ddb04885 Mon Sep 17 00:00:00 2001 From: taegeon2 Date: Mon, 27 Apr 2026 03:34:17 +0900 Subject: [PATCH] =?UTF-8?q?[FEAT/#97]=20=EB=AA=A8=EB=93=A0=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EB=A7=B5=20=ED=83=AD=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(protected)/admin/map-search.tsx | 5 + src/app/(protected)/partner/map-search.tsx | 5 + src/app/(protected)/student/map-search.tsx | 5 + src/entities/store/index.ts | 8 + src/entities/store/model/types.ts | 46 +++++ src/features/map-search/index.ts | 1 + src/features/map-search/model/useMapSearch.ts | 129 ++++++++++++++ src/pages/admin/map/ui/AdminMapPage.tsx | 12 +- src/pages/map-search/index.ts | 1 + src/pages/map-search/ui/MapSearchPage.tsx | 159 ++++++++++++++++++ src/pages/partner/map/ui/PartnerMapPage.tsx | 12 +- src/pages/student/map/ui/StudentMapPage.tsx | 12 +- src/shared/assets/icons/index.ts | 1 + src/shared/assets/icons/location.svg | 3 + src/shared/lib/hooks/useDebounce.ts | 12 ++ src/shared/ui/summary-card/SummaryCard.tsx | 2 +- src/widgets/map/index.ts | 7 + src/widgets/map/ui/AdminStoreCard.tsx | 83 +++++++++ src/widgets/map/ui/MapSearchBar.tsx | 34 ++++ src/widgets/map/ui/MapView.tsx | 5 + src/widgets/map/ui/SearchResultCard.tsx | 25 +++ src/widgets/map/ui/StudentStoreCard.tsx | 60 +++++++ src/widgets/map/ui/index.ts | 5 + 23 files changed, 622 insertions(+), 10 deletions(-) create mode 100644 src/app/(protected)/admin/map-search.tsx create mode 100644 src/app/(protected)/partner/map-search.tsx create mode 100644 src/app/(protected)/student/map-search.tsx create mode 100644 src/entities/store/index.ts create mode 100644 src/entities/store/model/types.ts create mode 100644 src/features/map-search/index.ts create mode 100644 src/features/map-search/model/useMapSearch.ts create mode 100644 src/pages/map-search/index.ts create mode 100644 src/pages/map-search/ui/MapSearchPage.tsx create mode 100644 src/shared/assets/icons/location.svg create mode 100644 src/shared/lib/hooks/useDebounce.ts create mode 100644 src/widgets/map/index.ts create mode 100644 src/widgets/map/ui/AdminStoreCard.tsx create mode 100644 src/widgets/map/ui/MapSearchBar.tsx create mode 100644 src/widgets/map/ui/MapView.tsx create mode 100644 src/widgets/map/ui/SearchResultCard.tsx create mode 100644 src/widgets/map/ui/StudentStoreCard.tsx create mode 100644 src/widgets/map/ui/index.ts diff --git a/src/app/(protected)/admin/map-search.tsx b/src/app/(protected)/admin/map-search.tsx new file mode 100644 index 0000000..d157a1b --- /dev/null +++ b/src/app/(protected)/admin/map-search.tsx @@ -0,0 +1,5 @@ +import { MapSearchPage } from "@/pages/map-search"; + +export default function AdminMapSearchScreen() { + return ; +} diff --git a/src/app/(protected)/partner/map-search.tsx b/src/app/(protected)/partner/map-search.tsx new file mode 100644 index 0000000..e636a5d --- /dev/null +++ b/src/app/(protected)/partner/map-search.tsx @@ -0,0 +1,5 @@ +import { MapSearchPage } from "@/pages/map-search"; + +export default function PartnerMapSearchScreen() { + return ; +} diff --git a/src/app/(protected)/student/map-search.tsx b/src/app/(protected)/student/map-search.tsx new file mode 100644 index 0000000..3cd4b32 --- /dev/null +++ b/src/app/(protected)/student/map-search.tsx @@ -0,0 +1,5 @@ +import { MapSearchPage } from "@/pages/map-search"; + +export default function StudentMapSearchScreen() { + return ; +} diff --git a/src/entities/store/index.ts b/src/entities/store/index.ts new file mode 100644 index 0000000..ce00438 --- /dev/null +++ b/src/entities/store/index.ts @@ -0,0 +1,8 @@ +export type { + AdminStoreCardData, + PopularStore, + SearchResultStore, + Store, + StoreMarker, + StudentStoreCardData, +} from "./model/types"; diff --git a/src/entities/store/model/types.ts b/src/entities/store/model/types.ts new file mode 100644 index 0000000..6dd6fc1 --- /dev/null +++ b/src/entities/store/model/types.ts @@ -0,0 +1,46 @@ +export interface Store { + id: string; + name: string; + address: string; + latitude: number; + longitude: number; +} + +export interface StoreMarker extends Store { + count?: number; +} + +export interface StudentStoreCardData extends Store { + imageUri?: string; + rating: number; + reviewCount: number; + priceRange?: string; +} + +export interface AdminStoreCardData extends Store { + imageUri?: string; + partnershipStartDate?: string; + partnershipEndDate?: string; + isPartner: boolean; +} + +export interface PopularStore { + id: string; + name: string; + category?: string; +} + +export interface SearchResultStore { + id: string; + name: string; + imageUri?: string; + // student view + tag?: string; + benefit?: string; + // admin / partner view + address?: string; + // partner-specific + isPartner?: boolean; + partnershipStartDate?: string; + partnershipEndDate?: string; +} diff --git a/src/features/map-search/index.ts b/src/features/map-search/index.ts new file mode 100644 index 0000000..7150301 --- /dev/null +++ b/src/features/map-search/index.ts @@ -0,0 +1 @@ +export { usePopularStores, useSearchStores } from "./model/useMapSearch"; diff --git a/src/features/map-search/model/useMapSearch.ts b/src/features/map-search/model/useMapSearch.ts new file mode 100644 index 0000000..b0b822a --- /dev/null +++ b/src/features/map-search/model/useMapSearch.ts @@ -0,0 +1,129 @@ +import { useQuery } from "@tanstack/react-query"; + +import type { PopularStore, SearchResultStore } from "@/entities/store"; + +const MOCK_POPULAR_STORES: PopularStore[] = [ + { id: "1", name: "역전할머니맥주" }, + { id: "2", name: "취향" }, + { id: "3", name: "Bread & co" }, + { id: "4", name: "인쌩맥주" }, + { id: "5", name: "리얼후라이" }, + { id: "6", name: "이자카야 젠" }, + { id: "7", name: "상도로 3가" }, + { id: "8", name: "인쌩맥주" }, +]; + +const MOCK_SEARCH_STORES: SearchResultStore[] = [ + { + id: "1", + name: "역전할머니맥주 숭실대점", + tag: "IT대 학생회", + benefit: "4인이상 식사시, 음료제공", + address: "서울 동작구 상도로 369", + isPartner: true, + partnershipStartDate: "2025.02.24", + partnershipEndDate: "2025.06.15", + }, + { + id: "2", + name: "취향", + tag: "총학생회", + benefit: "4인이상 식사시, 음료제공", + address: "서울 동작구 사당로 36-1", + isPartner: true, + partnershipStartDate: "2025.03.01", + partnershipEndDate: "2025.08.31", + }, + { + id: "3", + name: "Bread & co", + tag: "공대 학생회", + benefit: "음료 10% 할인", + address: "서울 동작구 상도로 312", + isPartner: false, + }, + { + id: "4", + name: "인쌩맥주", + tag: "생활관", + benefit: "빵 구매 시 아메리카노 제공", + address: "서울 동작구 상도로 407", + isPartner: true, + partnershipStartDate: "2025.01.15", + partnershipEndDate: "2025.07.14", + }, + { + id: "5", + name: "리얼후라이", + tag: "총학생회", + benefit: "세트 메뉴 10% 할인", + address: "서울 동작구 상도로 391", + isPartner: false, + }, + { + id: "6", + name: "이자카야 젠", + tag: "IT대 학생회", + benefit: "2인 이상 방문 시 음료 1잔 제공", + address: "서울 동작구 사당로 34", + isPartner: false, + }, + { + id: "7", + name: "상도로 3가", + tag: "생활관", + benefit: "식사 후 음료 50% 할인", + address: "서울 동작구 상도로 298", + isPartner: true, + partnershipStartDate: "2025.04.01", + partnershipEndDate: "2025.09.30", + }, + { + id: "8", + name: "역전 테스트", + tag: "IT대 학생회", + benefit: "테스트용 혜택입니다", + address: "서울 동작구 상도로 99", + isPartner: false, + }, +]; + +const fetchPopularStores = async (): Promise => { + // TODO: replace with actual API call + // return apiClient.get("/stores/popular").then(res => res.data); + return new Promise((resolve) => { + setTimeout(() => resolve(MOCK_POPULAR_STORES), 300); + }); +}; + +const fetchSearchStores = async ( + query: string, +): Promise => { + // TODO: replace with actual API call + // return apiClient.get("/stores/search", { params: { q: query } }).then(res => res.data); + return new Promise((resolve) => { + setTimeout(() => { + const results = MOCK_SEARCH_STORES.filter((store) => + store.name.toLowerCase().includes(query.toLowerCase()), + ); + resolve(results); + }, 300); + }); +}; + +export function usePopularStores() { + return useQuery({ + queryKey: ["popularStores"], + queryFn: fetchPopularStores, + staleTime: 1000 * 60 * 5, + }); +} + +export function useSearchStores(query: string) { + return useQuery({ + queryKey: ["storeSearch", query], + queryFn: () => fetchSearchStores(query), + enabled: query.trim().length > 0, + staleTime: 1000 * 60, + }); +} diff --git a/src/pages/admin/map/ui/AdminMapPage.tsx b/src/pages/admin/map/ui/AdminMapPage.tsx index 8ae7a1a..12b1151 100644 --- a/src/pages/admin/map/ui/AdminMapPage.tsx +++ b/src/pages/admin/map/ui/AdminMapPage.tsx @@ -1,9 +1,15 @@ -import { Text, View } from "react-native"; +import { router } from "expo-router"; +import { View } from "react-native"; + +import { MapSearchBar, MapView } from "@/widgets/map"; export function AdminMapPage() { return ( - - 관리자 맵 + + + router.push("/(protected)/admin/map-search")} + /> ); } diff --git a/src/pages/map-search/index.ts b/src/pages/map-search/index.ts new file mode 100644 index 0000000..3b41b00 --- /dev/null +++ b/src/pages/map-search/index.ts @@ -0,0 +1 @@ +export { MapSearchPage } from "./ui/MapSearchPage"; diff --git a/src/pages/map-search/ui/MapSearchPage.tsx b/src/pages/map-search/ui/MapSearchPage.tsx new file mode 100644 index 0000000..d9085b5 --- /dev/null +++ b/src/pages/map-search/ui/MapSearchPage.tsx @@ -0,0 +1,159 @@ +import { useRouter } from "expo-router"; +import { useRef, useState } from "react"; +import { + FlatList, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import type { PopularStore, SearchResultStore } from "@/entities/store"; +import { usePopularStores, useSearchStores } from "@/features/map-search"; +import { BackArrowIcon, CloseIcon, LocationIcon } from "@/shared/assets/icons"; +import { useDebounce } from "@/shared/lib/hooks/useDebounce"; +import { shadows } from "@/shared/styles/shadows"; +import { colorTokens } from "@/shared/styles/tokens"; +import { SearchResultCard } from "@/widgets/map"; + +type Role = "student" | "admin" | "partner"; + +interface MapSearchPageProps { + userRole: Role; +} + +export function MapSearchPage({ userRole: role }: MapSearchPageProps) { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const debouncedQuery = useDebounce(query, 300); + + const isSearching = debouncedQuery.trim().length > 0; + const showPopular = !isSearching && role !== "partner"; + + const { data: popularStores = [] } = usePopularStores(); + const { data: searchResults = [], isLoading: isSearchLoading } = + useSearchStores(debouncedQuery.trim()); + + return ( + + + router.back()} hitSlop={8}> + + + + + + {query.length > 0 && ( + setQuery("")} hitSlop={8}> + + + )} + + + + {isSearching ? ( + !isSearchLoading && searchResults.length === 0 ? ( + + + 검색결과를 찾지 못했어요! + + + { + "매장을 찾지 못해 페이지를 표시할 수 없어요.\n이용에 불편을 드려 죄송합니다." + } + + + ) : !isSearchLoading && searchResults.length > 0 ? ( + item.id} + renderItem={({ item }: { item: SearchResultStore }) => ( + + )} + ItemSeparatorComponent={SearchResultSeparator} + ListHeaderComponent={ + + {searchResults.length}개의 검색 결과가 있습니다 + + } + contentContainerStyle={{ paddingHorizontal: 14 }} + /> + ) : null + ) : ( + showPopular && + popularStores.length > 0 && ( + + + {"🔥 지금 많이 찾는 "} + 제휴 + {" 매장"} + + + {popularStores.map((store, index) => ( + setQuery(store.name)} + /> + ))} + + + ) + )} + + ); +} + +function SearchResultSeparator() { + return ( + + + + ); +} + +function PopularStoreRow({ + store, + rank, + onPress, +}: { + store: PopularStore; + rank: number; + onPress: () => void; +}) { + return ( + + + {rank} + + + {store.name} + + + ); +} diff --git a/src/pages/partner/map/ui/PartnerMapPage.tsx b/src/pages/partner/map/ui/PartnerMapPage.tsx index e6d7108..4e6ce87 100644 --- a/src/pages/partner/map/ui/PartnerMapPage.tsx +++ b/src/pages/partner/map/ui/PartnerMapPage.tsx @@ -1,9 +1,15 @@ -import { Text, View } from "react-native"; +import { router } from "expo-router"; +import { View } from "react-native"; + +import { MapSearchBar, MapView } from "@/widgets/map"; export function PartnerMapPage() { return ( - - 제휴업체 맵 + + + router.push("/(protected)/partner/map-search")} + /> ); } diff --git a/src/pages/student/map/ui/StudentMapPage.tsx b/src/pages/student/map/ui/StudentMapPage.tsx index 4105b5b..fadf603 100644 --- a/src/pages/student/map/ui/StudentMapPage.tsx +++ b/src/pages/student/map/ui/StudentMapPage.tsx @@ -1,9 +1,15 @@ -import { Text, View } from "react-native"; +import { router } from "expo-router"; +import { View } from "react-native"; + +import { MapSearchBar, MapView } from "@/widgets/map"; export function StudentMapPage() { return ( - - 학생 맵 + + + router.push("/(protected)/student/map-search")} + /> ); } diff --git a/src/shared/assets/icons/index.ts b/src/shared/assets/icons/index.ts index 5bc1e74..925fff9 100644 --- a/src/shared/assets/icons/index.ts +++ b/src/shared/assets/icons/index.ts @@ -13,6 +13,7 @@ export { default as HeadphoneIcon } from "./headphone-icon.svg"; export { default as InfoFillIcon } from "./info-fill-icon.svg"; export { default as InfoIcon } from "./info-icon.svg"; export { default as ListIcon } from "./list-icon.svg"; +export { default as LocationIcon } from "./location.svg"; export { default as LoginCheckIcon } from "./login-check-icon.svg"; export { default as LoginNoIcon } from "./login-no-icon.svg"; export { default as Logo } from "./logo.svg"; diff --git a/src/shared/assets/icons/location.svg b/src/shared/assets/icons/location.svg new file mode 100644 index 0000000..3781353 --- /dev/null +++ b/src/shared/assets/icons/location.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/lib/hooks/useDebounce.ts b/src/shared/lib/hooks/useDebounce.ts new file mode 100644 index 0000000..e059794 --- /dev/null +++ b/src/shared/lib/hooks/useDebounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export function useDebounce(value: T, delay = 300): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/shared/ui/summary-card/SummaryCard.tsx b/src/shared/ui/summary-card/SummaryCard.tsx index b40faf2..f343964 100644 --- a/src/shared/ui/summary-card/SummaryCard.tsx +++ b/src/shared/ui/summary-card/SummaryCard.tsx @@ -69,7 +69,7 @@ export const SummaryCard = memo( {actionLabel ? ( diff --git a/src/widgets/map/index.ts b/src/widgets/map/index.ts new file mode 100644 index 0000000..7b14e6e --- /dev/null +++ b/src/widgets/map/index.ts @@ -0,0 +1,7 @@ +export { + AdminStoreCard, + MapSearchBar, + MapView, + SearchResultCard, + StudentStoreCard, +} from "./ui"; diff --git a/src/widgets/map/ui/AdminStoreCard.tsx b/src/widgets/map/ui/AdminStoreCard.tsx new file mode 100644 index 0000000..71d0669 --- /dev/null +++ b/src/widgets/map/ui/AdminStoreCard.tsx @@ -0,0 +1,83 @@ +import { Image, Pressable, Text, View } from "react-native"; + +import type { SearchResultStore } from "@/entities/store"; + +interface AdminStoreCardProps { + store: SearchResultStore; + onActionPress?: () => void; +} + +export function AdminStoreCard({ store, onActionPress }: AdminStoreCardProps) { + const isPartner = store.isPartner ?? false; + const dateRange = + isPartner && store.partnershipStartDate && store.partnershipEndDate + ? `${store.partnershipStartDate} ~ ${store.partnershipEndDate}` + : undefined; + + return ( + + + + {/* Title + status/address */} + + + {store.name} + + {isPartner ? ( + + + + 제휴중 + + + {dateRange && ( + + {dateRange} + + )} + + ) : store.address ? ( + + {store.address} + + ) : null} + + + {/* Action button */} + + + {isPartner ? "제휴 계약서 보기" : "문의하기"} + + + + + ); +} + +function StoreImage({ uri }: { uri?: string }) { + return ( + + {uri ? ( + + ) : null} + + ); +} diff --git a/src/widgets/map/ui/MapSearchBar.tsx b/src/widgets/map/ui/MapSearchBar.tsx new file mode 100644 index 0000000..298fcfb --- /dev/null +++ b/src/widgets/map/ui/MapSearchBar.tsx @@ -0,0 +1,34 @@ +import { Pressable, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { LocationIcon } from "@/shared/assets/icons"; +import { shadows } from "@/shared/styles/shadows"; + +interface MapSearchBarProps { + placeholder?: string; + onPress: () => void; +} + +export function MapSearchBar({ + placeholder = "찾으시는 제휴 가게가 없나요?", + onPress, +}: MapSearchBarProps) { + const insets = useSafeAreaInsets(); + + return ( + + + + + {placeholder} + + + + ); +} diff --git a/src/widgets/map/ui/MapView.tsx b/src/widgets/map/ui/MapView.tsx new file mode 100644 index 0000000..46f83e9 --- /dev/null +++ b/src/widgets/map/ui/MapView.tsx @@ -0,0 +1,5 @@ +import { View } from "react-native"; + +export function MapView() { + return ; +} diff --git a/src/widgets/map/ui/SearchResultCard.tsx b/src/widgets/map/ui/SearchResultCard.tsx new file mode 100644 index 0000000..bcf643a --- /dev/null +++ b/src/widgets/map/ui/SearchResultCard.tsx @@ -0,0 +1,25 @@ +import type { SearchResultStore } from "@/entities/store"; + +import { AdminStoreCard } from "./AdminStoreCard"; +import { StudentStoreCard } from "./StudentStoreCard"; + +type Role = "student" | "admin" | "partner"; + +interface SearchResultCardProps { + store: SearchResultStore; + role: Role; + onPress?: () => void; + onActionPress?: () => void; +} + +export function SearchResultCard({ + store, + role, + onPress, + onActionPress, +}: SearchResultCardProps) { + if (role === "student") { + return ; + } + return ; +} diff --git a/src/widgets/map/ui/StudentStoreCard.tsx b/src/widgets/map/ui/StudentStoreCard.tsx new file mode 100644 index 0000000..765962d --- /dev/null +++ b/src/widgets/map/ui/StudentStoreCard.tsx @@ -0,0 +1,60 @@ +import { Image, Pressable, Text, View } from "react-native"; + +import type { SearchResultStore } from "@/entities/store"; + +interface StudentStoreCardProps { + store: SearchResultStore; + onPress?: () => void; +} + +export function StudentStoreCard({ store, onPress }: StudentStoreCardProps) { + return ( + + + + + + {store.name} + + + {store.tag && ( + + + {store.tag} + + + )} + {store.benefit && ( + + {store.benefit} + + )} + + + + + ); +} + +function StoreImage({ uri }: { uri?: string }) { + return ( + + {uri ? ( + + ) : null} + + ); +} diff --git a/src/widgets/map/ui/index.ts b/src/widgets/map/ui/index.ts new file mode 100644 index 0000000..4c8b6ec --- /dev/null +++ b/src/widgets/map/ui/index.ts @@ -0,0 +1,5 @@ +export { AdminStoreCard } from "./AdminStoreCard"; +export { MapSearchBar } from "./MapSearchBar"; +export { MapView } from "./MapView"; +export { SearchResultCard } from "./SearchResultCard"; +export { StudentStoreCard } from "./StudentStoreCard";