diff --git a/public/repair-marker.svg b/public/repair-marker.svg
new file mode 100644
index 00000000..da102fbd
--- /dev/null
+++ b/public/repair-marker.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/app/root.tsx b/src/app/root.tsx
index 1994e571..7f1ee3a9 100644
--- a/src/app/root.tsx
+++ b/src/app/root.tsx
@@ -11,15 +11,22 @@ export const links = () => [
{ rel: 'manifest', href: '/site.webmanifest' },
];
+const KAKAO_SDK_URL_BASE = 'https://dapi.kakao.com/v2/maps/sdk.js';
+const kakaoMapKey = import.meta.env.VITE_KAKAO_JS_KEY;
+const kakaoMapSrc = kakaoMapKey
+ ? `${KAKAO_SDK_URL_BASE}?appkey=${kakaoMapKey}&libraries=services&autoload=false`
+ : null;
+
export default function Root() {
return (
-
+
+ {kakaoMapSrc && }
diff --git a/src/app/routes.ts b/src/app/routes.ts
index a645b788..13ee0947 100644
--- a/src/app/routes.ts
+++ b/src/app/routes.ts
@@ -9,6 +9,7 @@ export default [
route('buy/:id', 'routes/(main)/buy.detail.tsx'),
route('sell', 'routes/(main)/sell.tsx'),
route('sell/confirm', 'routes/(main)/sell.confirm.tsx'),
+ route('repair', 'routes/(main)/repair.tsx'),
route('mypage', 'routes/(main)/mypage.tsx'),
route('chatbot', 'routes/(main)/chatbot.tsx'),
route('mypage/settings', 'routes/(main)/mypage.settings.tsx'),
diff --git a/src/app/routes/(main)/repair.tsx b/src/app/routes/(main)/repair.tsx
new file mode 100644
index 00000000..59d89fd7
--- /dev/null
+++ b/src/app/routes/(main)/repair.tsx
@@ -0,0 +1,3 @@
+import { RepairPage } from '@pages/repair';
+
+export default RepairPage;
diff --git a/src/pages/repair/index.ts b/src/pages/repair/index.ts
new file mode 100644
index 00000000..f751eee0
--- /dev/null
+++ b/src/pages/repair/index.ts
@@ -0,0 +1 @@
+export { default as RepairPage } from './ui/RepairPage';
diff --git a/src/pages/repair/model/types.ts b/src/pages/repair/model/types.ts
new file mode 100644
index 00000000..406e5bc5
--- /dev/null
+++ b/src/pages/repair/model/types.ts
@@ -0,0 +1,84 @@
+export type RepairShop = {
+ id: string;
+ name: string;
+ address: string;
+ lat: number;
+ lng: number;
+ phone?: string;
+ distance?: number;
+ placeUrl?: string;
+};
+
+export type KakaoAddressResult = {
+ x: string;
+ y: string;
+};
+
+export type KakaoPlace = {
+ id: string;
+ place_name: string;
+ road_address_name: string;
+ address_name: string;
+ y: string;
+ x: string;
+ phone: string;
+ distance?: string;
+ place_url?: string;
+};
+
+export type KakaoPagination = {
+ hasNextPage: boolean;
+ nextPage: () => void;
+};
+
+export type KakaoMapInstance = {
+ setCenter: (latLng: unknown) => void;
+ setBounds: (bounds: unknown) => void;
+ panTo: (latLng: unknown) => void;
+};
+
+export type KakaoMarker = {
+ setMap: (map: KakaoMapInstance | null) => void;
+};
+
+export type KakaoOverlay = {
+ setMap: (map: KakaoMapInstance | null) => void;
+};
+
+export type KakaoLatLngBounds = {
+ extend: (latLng: unknown) => void;
+};
+
+export type KakaoMapsServices = {
+ Geocoder: new () => {
+ addressSearch: (query: string, cb: (result: unknown[], status: string) => void) => void;
+ };
+ Places: new () => {
+ keywordSearch: (
+ keyword: string,
+ cb: (data: unknown[], status: string, pagination: KakaoPagination) => void,
+ options: { location: unknown; radius: number }
+ ) => void;
+ };
+ Status: { OK: string };
+};
+
+export type KakaoMaps = {
+ LatLng: new (lat: number, lng: number) => unknown;
+ Map: new (container: HTMLElement, options: { center: unknown; level: number }) => KakaoMapInstance;
+ CustomOverlay: new (options: {
+ yAnchor: number;
+ zIndex: number;
+ content?: string;
+ position?: unknown;
+ clickable?: boolean;
+ }) => KakaoOverlay;
+ Marker: new (options: { map: KakaoMapInstance; position: unknown; image?: unknown }) => KakaoMarker;
+ MarkerImage: new (src: string, size: unknown, options: { offset: unknown }) => unknown;
+ Size: new (width: number, height: number) => unknown;
+ Point: new (x: number, y: number) => unknown;
+ LatLngBounds: new () => KakaoLatLngBounds;
+ event: { addListener: (target: unknown, event: string, handler: () => void) => void };
+ load?: (callback: () => void) => void;
+ services?: KakaoMapsServices;
+};
diff --git a/src/pages/repair/model/useRepairMap.ts b/src/pages/repair/model/useRepairMap.ts
new file mode 100644
index 00000000..fe99e576
--- /dev/null
+++ b/src/pages/repair/model/useRepairMap.ts
@@ -0,0 +1,149 @@
+import { useEffect, useRef, useState } from 'react';
+import type { KakaoMapInstance, KakaoMaps, KakaoMarker, KakaoOverlay, RepairShop } from './types';
+
+const getKakaoMaps = () => (window as Window & { kakao?: { maps?: KakaoMaps } }).kakao?.maps;
+
+const buildOverlayContent = (shop: RepairShop) => {
+ const phoneLink = shop.phone ? `전화` : '';
+ const routeLink = `https://map.kakao.com/link/to/${encodeURIComponent(shop.name)},${shop.lat},${shop.lng}`;
+ const detailLink = shop.placeUrl ? `상세` : '';
+ const actions = [phoneLink, detailLink, `길찾기`]
+ .filter(Boolean)
+ .join(' · ');
+
+ return `
+
+
+
${shop.name}
+
${shop.address}
+
${actions}
+
+
+
+
+ `;
+};
+
+export const useRepairMap = () => {
+ const mapRef = useRef(null);
+ const mapInstanceRef = useRef(null);
+ const markersRef = useRef([]);
+ const overlayRef = useRef(null);
+ const [isMapReady, setIsMapReady] = useState(false);
+
+ useEffect(() => {
+ const maps = getKakaoMaps();
+
+ if (!mapRef.current) {
+ return;
+ }
+
+ const initMap = () => {
+ if (!maps || !mapRef.current) {
+ return;
+ }
+
+ const center = new maps.LatLng(37.5665, 126.978);
+ const options = { center, level: 4 };
+
+ const map = new maps.Map(mapRef.current, options);
+ mapInstanceRef.current = map;
+ overlayRef.current = new maps.CustomOverlay({ yAnchor: 1, zIndex: 10, clickable: true });
+ maps.event.addListener(map, 'click', () => {
+ overlayRef.current?.setMap(null);
+ });
+ setIsMapReady(true);
+ };
+
+ if (maps?.load) {
+ maps.load(initMap);
+ return;
+ }
+
+ const timer = window.setInterval(() => {
+ if (maps?.load) {
+ window.clearInterval(timer);
+ maps.load(initMap);
+ }
+ }, 50);
+
+ return () => window.clearInterval(timer);
+ }, []);
+
+ const clearMarkers = () => {
+ markersRef.current.forEach((marker) => marker.setMap(null));
+ markersRef.current = [];
+ };
+
+ const setCenter = (lat: number, lng: number) => {
+ const maps = getKakaoMaps();
+ const map = mapInstanceRef.current;
+ if (!maps || !map) {
+ return;
+ }
+ map.setCenter(new maps.LatLng(lat, lng));
+ };
+
+ const openOverlayForShop = (shop: RepairShop) => {
+ const maps = getKakaoMaps();
+ const map = mapInstanceRef.current;
+ if (!maps || !map) {
+ return;
+ }
+
+ const position = new maps.LatLng(shop.lat, shop.lng);
+ map.panTo(position);
+ overlayRef.current?.setMap(null);
+
+ const overlay = new maps.CustomOverlay({
+ content: buildOverlayContent(shop),
+ position,
+ yAnchor: 1,
+ zIndex: 10,
+ clickable: true,
+ });
+
+ overlay.setMap(map);
+ overlayRef.current = overlay;
+ };
+
+ const setMarkers = (shops: RepairShop[]) => {
+ const maps = getKakaoMaps();
+ const map = mapInstanceRef.current;
+ if (!maps || !map) {
+ return;
+ }
+
+ clearMarkers();
+ if (shops.length === 0) {
+ return;
+ }
+
+ const markerImage = new maps.MarkerImage('/repair-marker.svg', new maps.Size(36, 48), {
+ offset: new maps.Point(18, 48),
+ });
+
+ const bounds = new maps.LatLngBounds();
+ shops.forEach((shop) => {
+ const position = new maps.LatLng(shop.lat, shop.lng);
+ const marker = new maps.Marker({ map, position, image: markerImage });
+ maps.event.addListener(marker, 'click', () => {
+ openOverlayForShop(shop);
+ });
+
+ markersRef.current.push(marker);
+ bounds.extend(position);
+ });
+
+ map.setBounds(bounds);
+ };
+
+ return {
+ mapRef,
+ isMapReady,
+ setCenter,
+ setMarkers,
+ clearMarkers,
+ openOverlayForShop,
+ };
+};
diff --git a/src/pages/repair/model/useRepairSearch.ts b/src/pages/repair/model/useRepairSearch.ts
new file mode 100644
index 00000000..887ace6c
--- /dev/null
+++ b/src/pages/repair/model/useRepairSearch.ts
@@ -0,0 +1,132 @@
+import { useState } from 'react';
+import { useRepairMap } from './useRepairMap';
+import type { KakaoAddressResult, KakaoMaps, KakaoPagination, KakaoPlace, RepairShop } from './types';
+
+const SEARCH_KEYWORDS = [
+ '아이폰 수리',
+ '갤럭시 수리',
+ '삼성폰 수리',
+ '애플 서비스센터',
+ '애플 수리',
+ '삼성전자 서비스센터',
+ '휴대폰 수리',
+ '핸드폰 수리',
+ '액정',
+];
+
+const SEARCH_RADIUS_METERS = 5000;
+
+export const useRepairSearch = () => {
+ const { mapRef, isMapReady, setCenter, setMarkers, clearMarkers, openOverlayForShop } = useRepairMap();
+ const [shops, setShops] = useState([]);
+ const [isSearching, setIsSearching] = useState(false);
+ const [hasSearched, setHasSearched] = useState(false);
+ const [errorMessage, setErrorMessage] = useState(null);
+
+ const handleSearch = (query: string) => {
+ const kakao = (window as Window & { kakao?: { maps?: KakaoMaps } }).kakao;
+
+ if (!kakao?.maps?.services || !isMapReady) {
+ return;
+ }
+ if (!query.trim()) {
+ return;
+ }
+
+ setIsSearching(true);
+ setHasSearched(true);
+ setErrorMessage(null);
+
+ const geocoder = new kakao.maps.services.Geocoder();
+
+ geocoder.addressSearch(query, (result: unknown[], status: string) => {
+ const [firstResult] = result as KakaoAddressResult[];
+ if (status !== kakao.maps.services.Status.OK || !firstResult) {
+ clearMarkers();
+ setShops([]);
+ setIsSearching(false);
+ setErrorMessage('검색에 실패했습니다.');
+ return;
+ }
+
+ const { x, y } = firstResult;
+ setCenter(Number(y), Number(x));
+
+ const searchByKeyword = (keyword: string) =>
+ new Promise((resolve) => {
+ const collected: RepairShop[] = [];
+ const places = new kakao.maps.services.Places();
+ const center = new kakao.maps.LatLng(y, x);
+
+ const handleResult = (data: unknown[], placesStatus: string, pagination: KakaoPagination) => {
+ const placeResults = data as KakaoPlace[];
+ if (placesStatus === kakao.maps.services.Status.OK && data?.length) {
+ placeResults.forEach((place) => {
+ collected.push({
+ id: place.id,
+ name: place.place_name,
+ address: place.road_address_name || place.address_name,
+ lat: Number(place.y),
+ lng: Number(place.x),
+ phone: place.phone,
+ distance: place.distance ? Number(place.distance) : undefined,
+ placeUrl: place.place_url,
+ });
+ });
+ }
+
+ if (pagination?.hasNextPage) {
+ pagination.nextPage();
+ return;
+ }
+
+ resolve(collected);
+ };
+
+ places.keywordSearch(keyword, handleResult, { location: center, radius: SEARCH_RADIUS_METERS });
+ });
+
+ Promise.all(SEARCH_KEYWORDS.map((keyword) => searchByKeyword(keyword)))
+ .then((results) => {
+ clearMarkers();
+
+ const uniqueMap = new Map();
+ results.flat().forEach((shop) => {
+ if (!uniqueMap.has(shop.id)) {
+ uniqueMap.set(shop.id, shop);
+ }
+ });
+
+ const nextShops = Array.from(uniqueMap.values());
+
+ if (nextShops.length === 0) {
+ setShops([]);
+ setIsSearching(false);
+ return;
+ }
+
+ setMarkers(nextShops);
+
+ nextShops.sort((a, b) => (a.distance ?? Number.MAX_VALUE) - (b.distance ?? Number.MAX_VALUE));
+ setShops(nextShops);
+ setIsSearching(false);
+ })
+ .catch(() => {
+ clearMarkers();
+ setShops([]);
+ setIsSearching(false);
+ setErrorMessage('검색에 실패했습니다.');
+ });
+ });
+ };
+
+ return {
+ mapRef,
+ shops,
+ isSearching,
+ hasSearched,
+ errorMessage,
+ handleSearch,
+ openOverlayForShop,
+ };
+};
diff --git a/src/pages/repair/ui/RepairPage.tsx b/src/pages/repair/ui/RepairPage.tsx
new file mode 100644
index 00000000..d5b53ccc
--- /dev/null
+++ b/src/pages/repair/ui/RepairPage.tsx
@@ -0,0 +1,67 @@
+import { SearchBar } from '@shared/ui/SearchBar';
+import { RepairShopCard } from './RepairShopCard';
+import { useRepairSearch } from '../model/useRepairSearch';
+
+const MessageText = ({ children }: { children: string }) => (
+
+ {children}
+
+);
+
+const RepairPage = () => {
+ const { mapRef, shops, isSearching, hasSearched, errorMessage, handleSearch, openOverlayForShop } = useRepairSearch();
+
+ return (
+
+
+
+
+
+ 총 {shops.length}개
+
+
+
+
+
+ {isSearching && 검색 중...}
+ {!isSearching && errorMessage && {errorMessage}}
+ {!isSearching && !errorMessage && hasSearched && shops.length === 0 && (
+ 주변에 수리점이 없습니다.
+ )}
+ {!isSearching && !errorMessage && !hasSearched && (
+ 주소를 입력하면 주변 수리점을 보여드려요.
+ )}
+ {!isSearching &&
+ !errorMessage &&
+ shops.map((shop) => (
+ openOverlayForShop(shop)}
+ />
+ ))}
+
+
+ );
+};
+
+export default RepairPage;
diff --git a/src/pages/repair/ui/RepairShopCard.tsx b/src/pages/repair/ui/RepairShopCard.tsx
new file mode 100644
index 00000000..d866e780
--- /dev/null
+++ b/src/pages/repair/ui/RepairShopCard.tsx
@@ -0,0 +1,102 @@
+import { Button } from '@shared/ui/Button/Button';
+import { FavoriteButton } from '@shared/ui/FavoriteButton';
+
+export type RepairShopCardProps = {
+ name: string;
+ address: string;
+ favoriteActive?: boolean;
+ phone?: string;
+ lat?: number;
+ lng?: number;
+ placeUrl?: string;
+ onContact?: () => void;
+ onFindRoute?: () => void;
+ onSelect?: () => void;
+};
+
+export const RepairShopCard = ({
+ name,
+ address,
+ favoriteActive = false,
+ phone,
+ lat,
+ lng,
+ placeUrl,
+ onContact,
+ onFindRoute,
+ onSelect,
+}: RepairShopCardProps) => {
+ const handleContact = () => {
+ if (onContact) {
+ onContact();
+ return;
+ }
+ if (phone) {
+ window.location.href = `tel:${phone}`;
+ }
+ };
+
+ const handleFindRoute = () => {
+ if (onFindRoute) {
+ onFindRoute();
+ return;
+ }
+ if (lat !== undefined && lng !== undefined) {
+ const url = `https://map.kakao.com/link/to/${encodeURIComponent(name)},${lat},${lng}`;
+ window.open(url, '_blank', 'noopener,noreferrer');
+ return;
+ }
+ if (placeUrl) {
+ window.open(placeUrl, '_blank', 'noopener,noreferrer');
+ }
+ };
+
+ const handleContactClick: React.MouseEventHandler = (event) => {
+ event.stopPropagation();
+ handleContact();
+ };
+
+ const handleFindRouteClick: React.MouseEventHandler = (event) => {
+ event.stopPropagation();
+ handleFindRoute();
+ };
+
+ const handleCardKeyDown: React.KeyboardEventHandler = (event) => {
+ if (!onSelect) {
+ return;
+ }
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ onSelect();
+ }
+ };
+
+ return (
+
+
+ {name}
+
+ {address}
+
+
+
+
+
event.stopPropagation()} />
+
+
+
+
+
+
+ );
+};
diff --git a/src/shared/ui/Button/Button.variants.ts b/src/shared/ui/Button/Button.variants.ts
index a7497200..730004e3 100644
--- a/src/shared/ui/Button/Button.variants.ts
+++ b/src/shared/ui/Button/Button.variants.ts
@@ -50,6 +50,41 @@ export const buttonVariants = tv({
'focus:border-gray-600',
'focus:text-gray-600',
+ /* disabled */
+ 'disabled:border-gray-300',
+ 'disabled:text-gray-400',
+ 'disabled:cursor-not-allowed',
+ ],
+ light: [
+ /* default */
+ 'bg-white text-gray-900',
+
+ /* hover */
+ 'hover:bg-gray-100',
+
+ /* focus */
+ 'focus:bg-gray-200 focus:text-gray-900',
+
+ /* disabled */
+ 'disabled:bg-gray-100',
+ 'disabled:text-gray-400',
+ 'disabled:cursor-not-allowed',
+ ],
+ lightOutline: [
+ /* default */
+ 'bg-transparent',
+ 'border-2',
+ 'border-white',
+ 'text-white',
+
+ /* hover */
+ 'hover:border-gray-200',
+ 'hover:text-gray-200',
+
+ /* focus */
+ 'focus:border-gray-300',
+ 'focus:text-gray-300',
+
/* disabled */
'disabled:border-gray-300',
'disabled:text-gray-400',
diff --git a/src/shared/ui/FavoriteButton/FavoriteButton.tsx b/src/shared/ui/FavoriteButton/FavoriteButton.tsx
index 0edb5407..61e0e3f2 100644
--- a/src/shared/ui/FavoriteButton/FavoriteButton.tsx
+++ b/src/shared/ui/FavoriteButton/FavoriteButton.tsx
@@ -1,6 +1,6 @@
import { HeartIcon } from '@shared/assets/icons';
import { cn } from '@shared/utils/cn';
-import { useState } from 'react';
+import { useState, type ComponentPropsWithoutRef } from 'react';
import { favoriteButtonVariants } from './FavoriteButton.variants';
export interface FavoriteButtonProps {
@@ -8,6 +8,7 @@ export interface FavoriteButtonProps {
onToggle?: (isActive: boolean) => void;
ariaLabel?: string;
variant?: 'default' | 'inverse';
+ onClick?: ComponentPropsWithoutRef<'button'>['onClick'];
}
export const FavoriteButton = ({
@@ -15,12 +16,14 @@ export const FavoriteButton = ({
onToggle,
ariaLabel = '찜',
variant = 'default',
+ onClick,
}: FavoriteButtonProps) => {
const [isActive, setActive] = useState(defaultActive);
const styles = favoriteButtonVariants({ variant });
- const handleClick = () => {
+ const handleClick: ComponentPropsWithoutRef<'button'>['onClick'] = (event) => {
+ onClick?.(event);
const nextActive = !isActive;
setActive(nextActive);
onToggle?.(nextActive);
diff --git a/src/shared/ui/SearchBar/SearchBar.tsx b/src/shared/ui/SearchBar/SearchBar.tsx
index abcdf447..97de6d6f 100644
--- a/src/shared/ui/SearchBar/SearchBar.tsx
+++ b/src/shared/ui/SearchBar/SearchBar.tsx
@@ -7,9 +7,10 @@ export interface SearchBarProps {
onSearch: (query: string) => void;
value?: string;
onChange?: (value: string) => void;
+ className?: string;
}
-export const SearchBar = ({ placeholder, onSearch, value, onChange }: SearchBarProps) => {
+export const SearchBar = ({ placeholder, onSearch, value, onChange, className }: SearchBarProps) => {
const [internalQuery, setInternalQuery] = useState('');
const [isFocused, setFocused] = useState(false);
@@ -37,7 +38,7 @@ export const SearchBar = ({ placeholder, onSearch, value, onChange }: SearchBarP
};
return (
-
+