diff --git a/.env.example b/.env.example index dba20ae..a55dd77 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,19 @@ DEFAULT_TIMEZONE=UTC # Map link providers (google, naver, kakao, ...) MAP_LINK_PROVIDERS=google,naver +# User-facing map tile provider — osm (default, OSS-safe) | kakao | google +# `osm` requires no key; Leaflet + OpenStreetMap tiles. +# `kakao` requires KAKAO_MAP_APP_KEY (JavaScript key, issued at https://developers.kakao.com/). +# The key's allowed domains (Web platform → Site Domain) must include every origin +# the app runs on: e.g. http://localhost:3000 for dev, https://your.domain for prod. +# `google` is a future stub — selecting it throws until an adapter lands. +MAP_PROVIDER=osm +KAKAO_MAP_APP_KEY='' +# REST API key (server-side only) — enables POI name autocomplete on map click. +# Issue from the same Kakao Developers app under 앱 키 → REST API 키. +KAKAO_MAP_REST_KEY='' +GOOGLE_MAPS_API_KEY='' + # PostHog (optional) POSTHOG_KEY=phc_xxx diff --git a/src/components/LeafletMap.tsx b/src/components/LeafletMap.tsx index 5988c4b..a17a6a0 100644 --- a/src/components/LeafletMap.tsx +++ b/src/components/LeafletMap.tsx @@ -1,35 +1,11 @@ import { useEffect, useRef, useState } from "react"; import { cn } from "~/lib/utils"; +import { buildMarkerArtwork, buildSelectedPinArtwork } from "~/components/maps/icon"; +import type { UserFacingMapProps } from "~/components/maps/types"; -export type MarkerColor = "blue" | "red" | "green" | "gold" | "gray"; +export type { MapMarker, MarkerColor } from "~/components/maps/types"; -export type MapMarker = { - lat: number; - lng: number; - label: string; - id: string; - color?: MarkerColor; - glyph?: string | null; - highlighted?: boolean; -}; - -type CircleOverlay = { - center: [number, number]; - radiusKm: number; -}; - -type LeafletMapProps = { - center?: [number, number]; - zoom?: number; - markers?: MapMarker[]; - circle?: CircleOverlay; - fitToMarkers?: boolean; - onMapClick?: (lat: number, lng: number) => void; - onMarkerClick?: (marker: MapMarker) => void; - onZoomEnd?: (zoom: number) => void; - height?: string; - className?: string; -}; +type LeafletMapProps = UserFacingMapProps; export function LeafletMap({ center = [37.5665, 126.978], // Seoul default @@ -117,89 +93,25 @@ export function LeafletMap({ gpsMarkerRef.current = null; } - // Colored marker icon factory - const MARKER_COLORS: Record = { - blue: { - text: "#1e3a8a", - }, - red: { - text: "#991b1b", - }, - green: { - text: "#166534", - }, - gold: { - text: "#92400e", - }, - gray: { - text: "#374151", - }, - }; - const MARKER_BORDER = "#6b7280"; - function makeSelectedPin() { - const svg = ` - - - - - - - - - - `; + function toDivIcon(artwork: ReturnType, popupOffset: number) { return L.divIcon({ - html: svg, - iconSize: [28, 42], - iconAnchor: [14, 42], - popupAnchor: [0, -36], - className: "", - }); - } - function makeIcon(color: MarkerColor, glyph?: string | null, highlighted = false) { - const palette = MARKER_COLORS[color]; - const isHighlighted = highlighted && !!glyph; - const width = isHighlighted ? 44 : 44; - const height = isHighlighted ? 48 : 48; - const iconAnchorX = 22; - const iconAnchorY = 48; - const shadowId = isHighlighted ? "marker-shadow-active" : "marker-shadow"; - const strokeColor = isHighlighted ? "#ef4444" : MARKER_BORDER; - const fillColor = "#ffffff"; - const glyphMarkup = glyph - ? `${glyph}` - : ""; - const svg = ` - - - - - - - - - - - - - ${glyphMarkup} - `; - return L.divIcon({ - html: svg, - iconSize: [width, height], - iconAnchor: [iconAnchorX, iconAnchorY], - popupAnchor: [0, isHighlighted ? -50 : -42], + html: artwork.svg, + iconSize: [artwork.width, artwork.height], + iconAnchor: [artwork.anchorX, artwork.anchorY], + popupAnchor: [0, popupOffset], className: "", }); } // Add new markers for (const marker of markers) { - const icon = marker.glyph - ? makeIcon(marker.color ?? "blue", marker.glyph, marker.highlighted) - : marker.highlighted - ? makeSelectedPin() - : undefined; + let icon: any; + if (marker.glyph) { + const artwork = buildMarkerArtwork(marker.glyph, marker.highlighted); + icon = toDivIcon(artwork, marker.highlighted ? -50 : -42); + } else if (marker.highlighted) { + icon = toDivIcon(buildSelectedPinArtwork(), -36); + } const m = L.marker([marker.lat, marker.lng], icon ? { icon } : {}) .addTo(mapRef.current) .bindTooltip(marker.label); @@ -250,7 +162,9 @@ export function LeafletMap({ // Place a green marker at the user's position if (gpsMarkerRef.current) gpsMarkerRef.current.remove(); - gpsMarkerRef.current = L.marker([lat, lng], { icon: makeIcon("green") }) + gpsMarkerRef.current = L.marker([lat, lng], { + icon: toDivIcon(buildMarkerArtwork(null, false), -42), + }) .addTo(mapRef.current) .bindPopup("You are here") .openPopup(); diff --git a/src/components/MapSearchOverlay.tsx b/src/components/MapSearchOverlay.tsx new file mode 100644 index 0000000..9d44761 --- /dev/null +++ b/src/components/MapSearchOverlay.tsx @@ -0,0 +1,165 @@ +import { useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Search } from "lucide-react"; +import { useMapConfig } from "~/components/maps/config"; +import { cn } from "~/lib/utils"; +import { zoomToRadius } from "~/lib/place"; + +export type PoiCandidate = { + externalId: string; + source: "kakao"; + name: string; + address: string | null; + roadAddress: string | null; + lat: number; + lng: number; + categoryName: string | null; + distanceMeters: number | null; +}; + +type Props = { + /** Bias the search around this coord. Usually the current map center or the user's GPS. */ + biasLat: number | null; + biasLng: number | null; + /** Current map zoom — used to scale the search radius. + Higher zoom (more detail) ⇒ smaller radius so suggestions stay relevant. */ + biasZoom?: number; + onPick: (candidate: PoiCandidate) => void; + placeholder?: string; + className?: string; +}; + +// Kakao keyword-search radius in meters. zoomToRadius() is tuned tight for the +// nearby-check-in circle ("what's literally around me"); POI search wants a +// wider net that feels like the visible map viewport, so we 2× it and cap at +// Kakao's max of 20 km. +function zoomToRadiusMeters(zoom?: number): number { + if (zoom == null) return 5000; + return Math.min(20000, Math.round(zoomToRadius(zoom) * 2000)); +} + +export function MapSearchOverlay({ + biasLat, + biasLng, + biasZoom, + onPick, + placeholder = "Search places…", + className, +}: Props) { + const { data: mapConfig } = useMapConfig(); + const [value, setValue] = useState(""); + const [debounced, setDebounced] = useState(""); + const [open, setOpen] = useState(false); + const wrapperRef = useRef(null); + + useEffect(() => { + const t = setTimeout(() => setDebounced(value), 250); + return () => clearTimeout(t); + }, [value]); + + const enabled = + mapConfig?.provider === "kakao" + && biasLat != null + && biasLng != null + && debounced.trim().length >= 1; + + const radiusMeters = zoomToRadiusMeters(biasZoom); + const { data, isFetching, error } = useQuery<{ candidates: PoiCandidate[] }>({ + queryKey: ["poi-search", debounced, biasLat, biasLng, radiusMeters], + queryFn: async () => { + const r = await fetch( + `/api/places/poi-search?q=${encodeURIComponent(debounced.trim())}&lat=${biasLat}&lng=${biasLng}&radius=${radiusMeters}`, + ); + if (!r.ok) { + const body = (await r.json().catch(() => null)) as { error?: string } | null; + throw new Error(body?.error ?? `HTTP ${r.status}`); + } + return r.json(); + }, + enabled, + staleTime: 60_000, + retry: false, + }); + + useEffect(() => { + function onClickAway(e: MouseEvent) { + if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false); + } + document.addEventListener("mousedown", onClickAway); + return () => document.removeEventListener("mousedown", onClickAway); + }, []); + + if (mapConfig?.provider !== "kakao") return null; + + const candidates = data?.candidates ?? []; + // Always show the dropdown once the user has typed + is focused, so empty + // or error states give visual feedback instead of silence. + const showDropdown = open && enabled; + + return ( +
+
+ + { + setValue(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + placeholder={placeholder} + autoComplete="off" + className="h-9 w-full rounded-md border border-border bg-white pl-9 pr-3 text-sm text-foreground shadow-md outline-none placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring/30" + style={{ backgroundColor: "#ffffff" }} + /> +
+ {showDropdown && ( +
    + {isFetching && candidates.length === 0 && ( +
  • Searching…
  • + )} + {error && !isFetching && ( +
  • + {error instanceof Error ? error.message : "Search failed"} +
  • + )} + {candidates.map((c) => ( +
  • + +
  • + ))} + {!isFetching && !error && candidates.length === 0 && ( +
  • + No matches nearby. Try a different term or zoom out. +
  • + )} +
+ )} +
+ ); +} diff --git a/src/components/PlacePicker.tsx b/src/components/PlacePicker.tsx index d899932..392a017 100644 --- a/src/components/PlacePicker.tsx +++ b/src/components/PlacePicker.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useRef, useMemo } from "react"; import { Input } from "~/components/ui/input"; import { Button } from "~/components/ui/button"; -import { LeafletMap, type MapMarker } from "~/components/LeafletMap"; +import { UserFacingMap, type MapMarker } from "~/components/maps"; +import { MapSearchOverlay } from "~/components/MapSearchOverlay"; import { formatDistance, type NearbyPlace, @@ -146,6 +147,9 @@ export function PlacePicker({ value, onChange, groupActorId }: PlacePickerProps) const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(""); + // Current map zoom — used to scale the POI-search radius in the overlay. + const [mapZoom, setMapZoom] = useState(16); + // Name-only mode: null = disabled, string = active with value const [nameOnlyValue, setNameOnlyValue] = useState(null); @@ -317,7 +321,7 @@ export function PlacePicker({ value, onChange, groupActorId }: PlacePickerProps) {value.latitude && value.longitude && ( - - {/* Map — always visible */} - + {/* Map — always visible. Search overlay sits on top for organizers + to find places by name; falls back silently when not Kakao. */} +
+ + { + setMapLat(c.lat.toFixed(6)); + setMapLng(c.lng.toFixed(6)); + setMapName(c.name); + setMapCategoryId(""); + setCreateError(""); + }} + placeholder="Search places…" + /> +
{/* New place creation (when user clicked on map) */} {mapLat && mapLng && ( diff --git a/src/components/maps/UserFacingMap.tsx b/src/components/maps/UserFacingMap.tsx new file mode 100644 index 0000000..5eea2c9 --- /dev/null +++ b/src/components/maps/UserFacingMap.tsx @@ -0,0 +1,48 @@ +import { cn } from "~/lib/utils"; +import { useMapConfig } from "./config"; +import { LeafletAdapter } from "./adapters/LeafletAdapter"; +import { KakaoAdapter } from "./adapters/KakaoAdapter"; +import { GoogleAdapter } from "./adapters/GoogleAdapter"; +import type { UserFacingMapProps } from "./types"; + +export function UserFacingMap(props: UserFacingMapProps) { + const { data: config, isLoading, error } = useMapConfig(); + const height = props.height ?? "300px"; + + if (isLoading || !config) { + return ( +
+ ); + } + + if (error) { + return ( +
+

Map unavailable

+

{error instanceof Error ? error.message : String(error)}

+
+ ); + } + + switch (config.provider) { + case "kakao": + if (!config.kakaoAppKey) { + throw new Error("MAP_PROVIDER=kakao but kakaoAppKey is missing from /api/map-config response."); + } + return ; + case "google": + return ; + case "osm": + default: + return ; + } +} diff --git a/src/components/maps/adapters/GoogleAdapter.tsx b/src/components/maps/adapters/GoogleAdapter.tsx new file mode 100644 index 0000000..ee7bad5 --- /dev/null +++ b/src/components/maps/adapters/GoogleAdapter.tsx @@ -0,0 +1,8 @@ +import type { ReactElement } from "react"; +import type { UserFacingMapProps } from "../types"; + +export function GoogleAdapter(_props: UserFacingMapProps): ReactElement { + throw new Error( + "GoogleAdapter is not implemented yet. Set MAP_PROVIDER to 'osm' or 'kakao'.", + ); +} diff --git a/src/components/maps/adapters/KakaoAdapter.tsx b/src/components/maps/adapters/KakaoAdapter.tsx new file mode 100644 index 0000000..75c193a --- /dev/null +++ b/src/components/maps/adapters/KakaoAdapter.tsx @@ -0,0 +1,232 @@ +import { useEffect, useRef, useState } from "react"; +import { cn } from "~/lib/utils"; +import { buildMarkerArtwork, buildSelectedPinArtwork, toDataUri } from "../icon"; +import type { UserFacingMapProps } from "../types"; + +type KakaoAdapterProps = UserFacingMapProps & { appKey: string }; + +let sdkLoadPromise: Promise | null = null; + +function loadKakaoSdk(appKey: string): Promise { + if (sdkLoadPromise) return sdkLoadPromise; + sdkLoadPromise = new Promise((resolve, reject) => { + if (typeof window === "undefined") { + reject(new Error("Kakao SDK cannot load in non-browser context")); + return; + } + const w = window as unknown as { kakao?: { maps?: { load?: (cb: () => void) => void } } }; + if (w.kakao?.maps?.load) { + w.kakao.maps.load(() => resolve()); + return; + } + const existing = document.querySelector('script[data-kakao-sdk="true"]'); + const script = existing ?? document.createElement("script"); + if (!existing) { + script.dataset.kakaoSdk = "true"; + script.async = true; + script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${encodeURIComponent(appKey)}&autoload=false`; + document.head.appendChild(script); + } + script.addEventListener("load", () => { + const kw = window as unknown as { kakao: { maps: { load: (cb: () => void) => void } } }; + kw.kakao.maps.load(() => resolve()); + }); + script.addEventListener("error", () => reject(new Error("Failed to load Kakao Maps SDK"))); + }); + return sdkLoadPromise; +} + +// Leaflet zoom → Kakao level. Kakao is inverse (1 closest, 14 farthest). +function zoomToLevel(zoom: number): number { + return Math.max(1, Math.min(14, Math.round(20 - zoom))); +} +function levelToZoom(level: number): number { + return 20 - level; +} + +export function KakaoAdapter({ + appKey, + center = [37.5665, 126.978], + zoom = 13, + markers = [], + fitToMarkers = true, + onMapClick, + onMarkerClick, + onZoomEnd, + circle, + height = "300px", + className, +}: KakaoAdapterProps) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const markersRef = useRef([]); + const circleRef = useRef(null); + const gpsMarkerRef = useRef(null); + const gpsRequestedRef = useRef(false); + const onMapClickRef = useRef(onMapClick); + onMapClickRef.current = onMapClick; + const onMarkerClickRef = useRef(onMarkerClick); + onMarkerClickRef.current = onMarkerClick; + const onZoomEndRef = useRef(onZoomEnd); + onZoomEndRef.current = onZoomEnd; + const prevViewRef = useRef<{ center?: [number, number]; zoom?: number }>({}); + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + useEffect(() => { + if (!isClient || !containerRef.current) return; + let cancelled = false; + + (async () => { + await loadKakaoSdk(appKey); + if (cancelled || !containerRef.current) return; + const kakao = (window as any).kakao; + + if (!mapRef.current) { + mapRef.current = new kakao.maps.Map(containerRef.current, { + center: new kakao.maps.LatLng(center[0], center[1]), + level: zoomToLevel(zoom), + }); + kakao.maps.event.addListener(mapRef.current, "click", (e: any) => { + onMapClickRef.current?.(e.latLng.getLat(), e.latLng.getLng()); + }); + kakao.maps.event.addListener(mapRef.current, "zoom_changed", () => { + onZoomEndRef.current?.(levelToZoom(mapRef.current.getLevel())); + }); + } + + // Clear existing markers (including GPS marker when real markers arrive) + for (const m of markersRef.current) m.setMap(null); + markersRef.current = []; + if (markers.length > 0 && gpsMarkerRef.current) { + gpsMarkerRef.current.setMap(null); + gpsMarkerRef.current = null; + } + + function toMarkerImage(artwork: ReturnType) { + return new kakao.maps.MarkerImage( + toDataUri(artwork.svg), + new kakao.maps.Size(artwork.width, artwork.height), + { offset: new kakao.maps.Point(artwork.anchorX, artwork.anchorY) }, + ); + } + + for (const marker of markers) { + let image: any; + if (marker.glyph) { + image = toMarkerImage(buildMarkerArtwork(marker.glyph, marker.highlighted)); + } else if (marker.highlighted) { + image = toMarkerImage(buildSelectedPinArtwork()); + } + const kmarker = new kakao.maps.Marker({ + position: new kakao.maps.LatLng(marker.lat, marker.lng), + map: mapRef.current, + title: marker.label, + ...(image ? { image } : {}), + }); + if (onMarkerClickRef.current) { + kakao.maps.event.addListener(kmarker, "click", () => { + onMarkerClickRef.current?.(marker); + }); + } + markersRef.current.push(kmarker); + } + + if (circleRef.current) { + circleRef.current.setMap(null); + circleRef.current = null; + } + if (circle) { + circleRef.current = new kakao.maps.Circle({ + center: new kakao.maps.LatLng(circle.center[0], circle.center[1]), + radius: circle.radiusKm * 1000, + strokeWeight: 1.5, + strokeColor: "#3b82f6", + strokeOpacity: 0.8, + strokeStyle: "dashed", + fillColor: "#3b82f6", + fillOpacity: 0.06, + }); + circleRef.current.setMap(mapRef.current); + } + + if (fitToMarkers && markers.length > 1) { + const bounds = new kakao.maps.LatLngBounds(); + for (const m of markers) bounds.extend(new kakao.maps.LatLng(m.lat, m.lng)); + mapRef.current.setBounds(bounds); + } else if (fitToMarkers && markers.length === 1) { + mapRef.current.setCenter(new kakao.maps.LatLng(markers[0].lat, markers[0].lng)); + const currentLevel = mapRef.current.getLevel(); + const targetLevel = zoomToLevel(zoom); + if (targetLevel < currentLevel) mapRef.current.setLevel(targetLevel); + } else if (!fitToMarkers) { + const prev = prevViewRef.current; + if ( + !prev.center + || prev.center[0] !== center[0] + || prev.center[1] !== center[1] + || prev.zoom !== zoom + ) { + mapRef.current.setCenter(new kakao.maps.LatLng(center[0], center[1])); + mapRef.current.setLevel(zoomToLevel(zoom)); + prevViewRef.current = { center, zoom }; + } + } else if (markers.length === 0 && !gpsRequestedRef.current) { + gpsRequestedRef.current = true; + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (pos) => { + const { latitude: lat, longitude: lng } = pos.coords; + if (!mapRef.current) return; + mapRef.current.setCenter(new kakao.maps.LatLng(lat, lng)); + mapRef.current.setLevel(zoomToLevel(zoom)); + if (gpsMarkerRef.current) gpsMarkerRef.current.setMap(null); + gpsMarkerRef.current = new kakao.maps.Marker({ + position: new kakao.maps.LatLng(lat, lng), + map: mapRef.current, + image: toMarkerImage(buildMarkerArtwork(null, false)), + title: "You are here", + }); + onMapClickRef.current?.(lat, lng); + }, + () => {}, + { enableHighAccuracy: false, timeout: 5000 }, + ); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [isClient, appKey, markers, center, zoom, fitToMarkers, circle]); + + useEffect(() => { + if (!containerRef.current || !mapRef.current) return; + const ro = new ResizeObserver(() => { + mapRef.current?.relayout?.(); + }); + ro.observe(containerRef.current); + return () => ro.disconnect(); + }, [isClient]); + + if (!isClient) { + return ( +
+ ); + } + + return ( +
+ ); +} diff --git a/src/components/maps/adapters/LeafletAdapter.tsx b/src/components/maps/adapters/LeafletAdapter.tsx new file mode 100644 index 0000000..cd0d8dc --- /dev/null +++ b/src/components/maps/adapters/LeafletAdapter.tsx @@ -0,0 +1,6 @@ +import { LeafletMap } from "~/components/LeafletMap"; +import type { UserFacingMapProps } from "../types"; + +export function LeafletAdapter(props: UserFacingMapProps) { + return ; +} diff --git a/src/components/maps/config.ts b/src/components/maps/config.ts new file mode 100644 index 0000000..4fa2fac --- /dev/null +++ b/src/components/maps/config.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import type { MapConfig } from "./types"; + +export function useMapConfig() { + return useQuery({ + queryKey: ["map-config"], + queryFn: async () => { + const r = await fetch("/api/map-config"); + if (!r.ok) { + const body = (await r.json().catch(() => null)) as { error?: string } | null; + throw new Error(body?.error ?? `Failed to load map config (HTTP ${r.status})`); + } + return r.json(); + }, + staleTime: Infinity, + retry: false, + }); +} diff --git a/src/components/maps/icon.ts b/src/components/maps/icon.ts new file mode 100644 index 0000000..588f788 --- /dev/null +++ b/src/components/maps/icon.ts @@ -0,0 +1,60 @@ +const MARKER_BORDER = "#6b7280"; + +export type MarkerArtwork = { + svg: string; + width: number; + height: number; + anchorX: number; + anchorY: number; +}; + +export function buildSelectedPinArtwork(): MarkerArtwork { + const svg = ` + + + + + + + + + + `; + return { svg, width: 28, height: 42, anchorX: 14, anchorY: 42 }; +} + +export function buildMarkerArtwork( + glyph?: string | null, + highlighted = false, +): MarkerArtwork { + const isHighlighted = highlighted && !!glyph; + const width = 44; + const height = 48; + const shadowId = isHighlighted ? "marker-shadow-active" : "marker-shadow"; + const strokeColor = isHighlighted ? "#ef4444" : MARKER_BORDER; + const fillColor = "#ffffff"; + const glyphMarkup = glyph + ? `${glyph}` + : ""; + const strokeWidth = isHighlighted ? 2.25 : 1.5; + const svg = ` + + + + + + + + + + + + + ${glyphMarkup} + `; + return { svg, width, height, anchorX: 22, anchorY: 48 }; +} + +export function toDataUri(svg: string): string { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} diff --git a/src/components/maps/index.ts b/src/components/maps/index.ts new file mode 100644 index 0000000..8f9fbe7 --- /dev/null +++ b/src/components/maps/index.ts @@ -0,0 +1,2 @@ +export { UserFacingMap } from "./UserFacingMap"; +export type { MapMarker, MarkerColor, MapProvider, UserFacingMapProps, MapConfig } from "./types"; diff --git a/src/components/maps/types.ts b/src/components/maps/types.ts new file mode 100644 index 0000000..792487e --- /dev/null +++ b/src/components/maps/types.ts @@ -0,0 +1,37 @@ +export type MapProvider = "osm" | "kakao" | "google"; + +export type MarkerColor = "blue" | "red" | "green" | "gold" | "gray"; + +export type MapMarker = { + lat: number; + lng: number; + label: string; + id: string; + color?: MarkerColor; + glyph?: string | null; + highlighted?: boolean; +}; + +export type CircleOverlay = { + center: [number, number]; + radiusKm: number; +}; + +export type UserFacingMapProps = { + center?: [number, number]; + zoom?: number; + markers?: MapMarker[]; + circle?: CircleOverlay; + fitToMarkers?: boolean; + onMapClick?: (lat: number, lng: number) => void; + onMarkerClick?: (marker: MapMarker) => void; + onZoomEnd?: (zoom: number) => void; + height?: string; + className?: string; +}; + +export type MapConfig = { + provider: MapProvider; + kakaoAppKey?: string; + googleApiKey?: string; +}; diff --git a/src/lib/place.ts b/src/lib/place.ts index cb4f935..9362056 100644 --- a/src/lib/place.ts +++ b/src/lib/place.ts @@ -49,3 +49,13 @@ export function formatDistance(km: number): string { if (km < 1) return `${Math.round(km * 1000)}m`; return `${km.toFixed(1)}km`; } + +/** Tier-based zoom → radius mapping (km) shared across nearby-places and POI search. */ +export function zoomToRadius(zoom: number): number { + if (zoom >= 18) return 0.2; + if (zoom >= 16) return 0.5; + if (zoom >= 14) return 1; + if (zoom >= 12) return 2; + if (zoom >= 10) return 5; + return 10; +} diff --git a/src/routes/events/$eventId/index.tsx b/src/routes/events/$eventId/index.tsx index f9f2423..943e492 100644 --- a/src/routes/events/$eventId/index.tsx +++ b/src/routes/events/$eventId/index.tsx @@ -14,7 +14,7 @@ import { type ThreadMessage, } from "~/hooks/useEventDetail"; import { z } from "zod"; -import { LeafletMap } from "~/components/LeafletMap"; +import { UserFacingMap } from "~/components/maps"; import { getEventMeta as fetchEventMeta } from "~/server/services/events"; import { useEventCategoryMap } from "~/hooks/useEventCategories"; import { renderMarkdown } from "~/lib/markdown"; @@ -416,7 +416,7 @@ function EventDetailPage() { onClick={() => setMapOpen(true)} className="w-full rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary/50 transition-shadow" > - {event.placeAddress} )} - - = 18) return 0.2; - if (zoom >= 16) return 0.5; - if (zoom >= 14) return 1; - if (zoom >= 12) return 2; - if (zoom >= 10) return 5; - return 10; -} - - type CheckinConfirmation = { placeName: string; placeEmoji: string | null; @@ -219,6 +211,13 @@ function CheckinsPage() { // Map marker click const handleMarkerClick = (marker: MapMarker) => { + // Synthetic pin for an unsaved location — open the check-in dialog so + // the user can name and categorize the new place, rather than routing + // to /places/new (which isn't a real place id). + if (marker.id === "new") { + setDialogOpen(true); + return; + } const place = nearbyPlaces.find((p) => p.id === marker.id); if (place) selectPlace(place); else navigate({ to: "/places/$placeId", params: { placeId: marker.id } }); @@ -415,17 +414,38 @@ function CheckinsPage() { )}
- {/* Map */} -
- +
+ +
+ { + setCheckinLat(c.lat.toFixed(6)); + setCheckinLng(c.lng.toFixed(6)); + setCheckinName(c.name); + setSelectedPlace(null); + setCheckinError(""); + setPinnedLocation({ lat: c.lat.toFixed(6), lng: c.lng.toFixed(6) }); + setMapCenter([c.lat, c.lng]); + setMapZoom(16); + setDialogOpen(true); + }} + placeholder="Search places…" />
diff --git a/src/server-entry.ts b/src/server-entry.ts index eb3415c..25aeb6e 100644 --- a/src/server-entry.ts +++ b/src/server-entry.ts @@ -42,6 +42,7 @@ import { GET as placeDetail } from "./server/controllers/places/detail"; import { POST as checkinPlace } from "./server/controllers/places/checkin"; import { GET as placeCheckins } from "./server/controllers/places/checkins"; import { GET as nearbyPlaces } from "./server/controllers/places/nearby"; +import { GET as poiSearch } from "./server/controllers/places/poi-search"; import { POST as findOrCreatePlace } from "./server/controllers/places/find-or-create"; import { GET as listPlaceCategories } from "./server/controllers/places/categories"; import { GET as listEventCategories } from "./server/controllers/events/categories"; @@ -70,6 +71,8 @@ import { GET as listAdminEvents, PATCH as updateAdminEvent } from "./server/cont import { GET as listCountries, PUT as importCountries, DELETE as clearCountries } from "./server/controllers/admin/countries"; import { GET as listPublicCountries } from "./server/controllers/countries/list"; import { GET as getCarouselSlides } from "./server/controllers/carousel"; +import { GET as getMapConfig } from "./server/controllers/map-config/get"; +import { env } from "./server/env"; import { POST as trackBannerClick } from "./server/controllers/banner-click"; import { POST as webfingerLookup } from "./server/controllers/api/webfinger"; import { POST as instanceLookup } from "./server/controllers/api/instance-lookup"; @@ -115,27 +118,30 @@ const app = createApp({ onError }); app.use(integrateFederation(federation, () => undefined)); // Security headers +const mapProviderScriptSrc = + env.mapProvider === "kakao" + ? " https://dapi.kakao.com https://t1.daumcdn.net" + : ""; + +const cspHeader = [ + "default-src 'self'", + `script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com https://us-assets.i.posthog.com${mapProviderScriptSrc}`, + "style-src 'self' 'unsafe-inline' https://unpkg.com", + "img-src 'self' data: blob: https:", + "font-src 'self'", + "connect-src 'self' https:", + "frame-ancestors 'self'", + "base-uri 'self'", + "form-action 'self'", +].join("; "); + app.use(defineEventHandler((event) => { setResponseHeader(event, "X-Frame-Options", "SAMEORIGIN"); setResponseHeader(event, "X-Content-Type-Options", "nosniff"); setResponseHeader(event, "Referrer-Policy", "strict-origin-when-cross-origin"); setResponseHeader(event, "Permissions-Policy", "camera=(), microphone=(), geolocation=(self)"); setResponseHeader(event, "Strict-Transport-Security", "max-age=31536000; includeSubDomains"); - setResponseHeader( - event, - "Content-Security-Policy", - [ - "default-src 'self'", - "script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com https://us-assets.i.posthog.com", - "style-src 'self' 'unsafe-inline' https://unpkg.com", - "img-src 'self' data: blob: https:", - "font-src 'self'", - "connect-src 'self' https:", - "frame-ancestors 'self'", - "base-uri 'self'", - "form-action 'self'", - ].join("; "), - ); + setResponseHeader(event, "Content-Security-Policy", cspHeader); })); // Start the MiAuth session cleanup interval @@ -744,6 +750,10 @@ apiRouter.get("/places/nearby", defineEventHandler(async (event) => { return nearbyPlaces({ request: toWebRequest(event) }); })); +apiRouter.get("/places/poi-search", defineEventHandler(async (event) => { + return poiSearch({ request: toWebRequest(event) }); +})); + apiRouter.get("/places/:placeId", defineEventHandler(async (event) => { const request = toWebRequest(event); const placeId = event.context.params?.placeId; @@ -952,6 +962,10 @@ apiRouter.get("/home/carousel", defineEventHandler(async (event) => { return getCarouselSlides({ request: toWebRequest(event) }); })); +apiRouter.get("/map-config", defineEventHandler(async () => { + return getMapConfig(); +})); + apiRouter.post("/banner-clicks", defineEventHandler(async (event) => { return trackBannerClick({ request: toWebRequest(event) }); })); diff --git a/src/server/controllers/map-config/get.ts b/src/server/controllers/map-config/get.ts new file mode 100644 index 0000000..0330adb --- /dev/null +++ b/src/server/controllers/map-config/get.ts @@ -0,0 +1,32 @@ +import { env } from "~/server/env"; + +export const GET = async () => { + const provider = env.mapProvider; + + if (provider !== "osm" && provider !== "kakao" && provider !== "google") { + return Response.json( + { error: `Invalid MAP_PROVIDER: ${String(provider)}. Expected osm | kakao | google.` }, + { status: 500 }, + ); + } + + if (provider === "kakao" && !env.kakaoMapAppKey) { + return Response.json( + { error: "MAP_PROVIDER=kakao requires KAKAO_MAP_APP_KEY to be set." }, + { status: 500 }, + ); + } + + if (provider === "google" && !env.googleMapsApiKey) { + return Response.json( + { error: "MAP_PROVIDER=google requires GOOGLE_MAPS_API_KEY to be set." }, + { status: 500 }, + ); + } + + return Response.json({ + provider, + kakaoAppKey: provider === "kakao" ? env.kakaoMapAppKey : undefined, + googleApiKey: provider === "google" ? env.googleMapsApiKey : undefined, + }); +}; diff --git a/src/server/controllers/places/poi-search.ts b/src/server/controllers/places/poi-search.ts new file mode 100644 index 0000000..2a97de4 --- /dev/null +++ b/src/server/controllers/places/poi-search.ts @@ -0,0 +1,34 @@ +import { PoiSearchError, searchPois } from "~/server/services/poi-search"; + +export const GET = async ({ request }: { request: Request }) => { + const url = new URL(request.url); + const q = url.searchParams.get("q")?.trim() ?? ""; + const latStr = url.searchParams.get("lat"); + const lngStr = url.searchParams.get("lng"); + const radiusStr = url.searchParams.get("radius"); + + if (q.length < 1) { + return Response.json({ candidates: [] }); + } + + const lat = latStr ? Number.parseFloat(latStr) : NaN; + const lng = lngStr ? Number.parseFloat(lngStr) : NaN; + if (!Number.isFinite(lat) || !Number.isFinite(lng)) { + return Response.json( + { error: "lat and lng query params are required and must be numbers" }, + { status: 400 }, + ); + } + + const radius = radiusStr ? Number.parseInt(radiusStr, 10) : undefined; + + try { + const candidates = await searchPois({ q, lat, lng, radius }); + return Response.json({ candidates }); + } catch (e) { + if (e instanceof PoiSearchError) { + return Response.json({ error: e.message }, { status: e.status }); + } + throw e; + } +}; diff --git a/src/server/env.ts b/src/server/env.ts index f2f5b3e..3159ac5 100644 --- a/src/server/env.ts +++ b/src/server/env.ts @@ -41,6 +41,12 @@ export const env = { // Map link providers (comma-separated: kakao,naver,google) mapLinkProviders: (process.env.MAP_LINK_PROVIDERS ?? "").split(",").map(s => s.trim()).filter(Boolean), + // User-facing map tile provider — osm (default, OSS-safe) | kakao | google + mapProvider: (process.env.MAP_PROVIDER ?? "osm") as "osm" | "kakao" | "google", + kakaoMapAppKey: process.env.KAKAO_MAP_APP_KEY || undefined, + kakaoMapRestKey: process.env.KAKAO_MAP_REST_KEY || undefined, + googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY || undefined, + // PostHog (optional) posthogKey: process.env.POSTHOG_KEY || undefined, posthogHost: process.env.POSTHOG_HOST || undefined, diff --git a/src/server/services/poi-search/index.ts b/src/server/services/poi-search/index.ts new file mode 100644 index 0000000..4c719f2 --- /dev/null +++ b/src/server/services/poi-search/index.ts @@ -0,0 +1,61 @@ +import { env } from "~/server/env"; +import { kakaoKeywordSearch } from "./kakao"; +import { PoiSearchError, type PoiCandidate, type PoiSearchParams } from "./types"; + +export { PoiSearchError } from "./types"; +export type { PoiCandidate, PoiSearchParams } from "./types"; + +const CACHE_TTL_MS = 5 * 60 * 1000; +const CACHE_MAX = 100; + +type CacheEntry = { value: PoiCandidate[]; expiresAt: number }; +const cache = new Map(); + +function cacheKey(params: PoiSearchParams): string { + return [ + params.q.trim().toLowerCase(), + params.lat.toFixed(3), + params.lng.toFixed(3), + params.radius ?? 2000, + ].join("|"); +} + +function cacheGet(key: string): PoiCandidate[] | null { + const entry = cache.get(key); + if (!entry) return null; + if (entry.expiresAt < Date.now()) { + cache.delete(key); + return null; + } + // Refresh insertion order (LRU touch) + cache.delete(key); + cache.set(key, entry); + return entry.value; +} + +function cacheSet(key: string, value: PoiCandidate[]): void { + if (cache.size >= CACHE_MAX) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS }); +} + +export async function searchPois( + params: PoiSearchParams, +): Promise { + if (env.mapProvider !== "kakao") { + throw new PoiSearchError( + `POI search not available for MAP_PROVIDER=${env.mapProvider}`, + 501, + ); + } + + const key = cacheKey(params); + const cached = cacheGet(key); + if (cached) return cached; + + const result = await kakaoKeywordSearch(params); + cacheSet(key, result); + return result; +} diff --git a/src/server/services/poi-search/kakao.ts b/src/server/services/poi-search/kakao.ts new file mode 100644 index 0000000..87b1182 --- /dev/null +++ b/src/server/services/poi-search/kakao.ts @@ -0,0 +1,63 @@ +import { env } from "~/server/env"; +import { PoiSearchError, type PoiCandidate, type PoiSearchParams } from "./types"; + +type KakaoKeywordDoc = { + id: string; + place_name: string; + address_name: string | null; + road_address_name: string | null; + x: string; + y: string; + category_name: string | null; + distance: string | null; +}; + +type KakaoKeywordResponse = { + documents?: KakaoKeywordDoc[]; +}; + +export async function kakaoKeywordSearch( + params: PoiSearchParams, +): Promise { + const restKey = env.kakaoMapRestKey; + if (!restKey) { + throw new PoiSearchError( + "KAKAO_MAP_REST_KEY is required when MAP_PROVIDER=kakao", + 500, + ); + } + + const url = new URL("https://dapi.kakao.com/v2/local/search/keyword.json"); + url.searchParams.set("query", params.q); + url.searchParams.set("x", String(params.lng)); + url.searchParams.set("y", String(params.lat)); + url.searchParams.set("radius", String(params.radius ?? 2000)); + url.searchParams.set("sort", "distance"); + url.searchParams.set("size", "10"); + + const res = await fetch(url.toString(), { + headers: { Authorization: `KakaoAK ${restKey}` }, + }); + + if (!res.ok) { + throw new PoiSearchError( + `Kakao local search failed: HTTP ${res.status}`, + 502, + ); + } + + const body = (await res.json()) as KakaoKeywordResponse; + const docs = body.documents ?? []; + + return docs.map((d) => ({ + externalId: d.id, + source: "kakao" as const, + name: d.place_name, + address: d.address_name || null, + roadAddress: d.road_address_name || null, + lat: Number.parseFloat(d.y), + lng: Number.parseFloat(d.x), + categoryName: d.category_name || null, + distanceMeters: d.distance ? Number.parseInt(d.distance, 10) : null, + })); +} diff --git a/src/server/services/poi-search/types.ts b/src/server/services/poi-search/types.ts new file mode 100644 index 0000000..92e1b61 --- /dev/null +++ b/src/server/services/poi-search/types.ts @@ -0,0 +1,28 @@ +export type PoiCandidate = { + externalId: string; + source: "kakao"; + name: string; + address: string | null; + roadAddress: string | null; + lat: number; + lng: number; + categoryName: string | null; + distanceMeters: number | null; +}; + +export type PoiSearchParams = { + q: string; + lat: number; + lng: number; + radius?: number; +}; + +export class PoiSearchError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + this.name = "PoiSearchError"; + } +}