feat: add options/map flow, dev seed, and artwork fixes

Options page, Kakao map with florist order message, dev tooling, and
create/message dummy gating — without secrets in .env.example.

Co-authored-by: 이지은 <ijieun@ijieun-ui-MacBookPro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Chaewon Lee
2026-06-10 08:56:07 +09:00
committed by GitHub
parent d8f93f4c17
commit 922320d59a
43 changed files with 1587 additions and 125 deletions

View File

@@ -0,0 +1,53 @@
<script>
/** @typedef {{ text: string, highlight: boolean }} OrderMessageSegment */
let {
plainText = '',
segments = /** @type {OrderMessageSegment[]} */ ([])
} = $props();
let copied = $state(false);
const hasMessage = $derived(Boolean(plainText?.trim()));
async function handleCopy() {
if (!hasMessage) return;
try {
await navigator.clipboard.writeText(plainText);
copied = true;
setTimeout(() => {
copied = false;
}, 2000);
} catch {
// 클립보드 API 미지원 환경
}
}
</script>
<div class="flex items-start justify-between gap-3">
{#if hasMessage}
<p class="min-w-0 flex-1 text-sm leading-relaxed text-muted">
{#each segments as segment, index (index)}
{#if segment.highlight}
<span class="text-pill">{'{'}</span><span class="font-medium text-ink"
>{segment.text}</span
><span class="text-pill">{'}'}</span>
{:else}
{segment.text}
{/if}
{/each}
</p>
{:else}
<p class="text-sm text-muted">Complete the flow to generate your order message.</p>
{/if}
<button
type="button"
disabled={!hasMessage}
onclick={handleCopy}
class="shrink-0 rounded bg-pill px-3 py-1.5 text-xs text-surface disabled:opacity-40"
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>

View File

@@ -0,0 +1,235 @@
<script>
import { onMount } from 'svelte';
import { env } from '$env/dynamic/public';
let {
initialLat = 37.5665,
initialLng = 126.978,
shops = [],
selectedId = null,
fitBounds = false,
panTo = null,
oncenterchange,
onselect
} = $props();
let container = $state(null);
let mapReady = $state(false);
let mapError = $state('');
const mapKey = env.PUBLIC_KAKAO_MAP_KEY;
/** @type {ReturnType<typeof window.kakao.maps.Map> | null} */
let mapInstance = $state(null);
/** @type {ReturnType<typeof window.kakao.maps.InfoWindow> | null} */
let infoWindow = null;
/** @type {Map<string, { marker: ReturnType<typeof window.kakao.maps.Marker>; shop: (typeof shops)[number] }>} */
let shopMarkerMap = new Map();
function relayoutMap() {
mapInstance?.relayout?.();
}
/**
* @param {string} value
*/
function escapeHtml(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
/**
* @param {(typeof shops)[number]} shop
*/
function buildInfoContent(shop) {
const distance = shop.distance
? `<p style="margin:4px 0 0;font-size:12px;color:#666">${escapeHtml(shop.distance)}</p>`
: '';
const phone = shop.phone
? `<p style="margin:4px 0 0;font-size:12px;color:#666">${escapeHtml(shop.phone)}</p>`
: '';
return `<div style="padding:15px 12px;min-width:200px;max-width:240px;font-family:system-ui,sans-serif;line-height:1.4">
<p style="margin:0;font-size:15px;font-weight:600;color:#1a1a1a">${escapeHtml(shop.name)}</p>
<p style="margin:4px 0 0;font-size:13px;color:#555">${escapeHtml(shop.address)}</p>
${distance}
${phone}
</div>`;
}
/**
* @param {string | null} shopId
*/
function showInfoForShop(shopId) {
const map = mapInstance;
if (!map || !mapReady || !window.kakao?.maps || !infoWindow) return;
for (const [id, { marker }] of shopMarkerMap) {
marker.setZIndex(id === shopId ? 2 : 0);
}
if (!shopId) {
infoWindow.close();
return;
}
const entry = shopMarkerMap.get(shopId);
if (!entry) {
infoWindow.close();
return;
}
infoWindow.setContent(buildInfoContent(entry.shop));
infoWindow.open(map, entry.marker);
}
onMount(() => {
let cancelled = false;
(async () => {
if (!mapKey) {
mapError = 'Set PUBLIC_KAKAO_MAP_KEY in .env to show the map.';
return;
}
try {
await loadKakaoScript(mapKey);
if (cancelled || !container || !window.kakao?.maps) return;
const center = new window.kakao.maps.LatLng(initialLat, initialLng);
const map = new window.kakao.maps.Map(container, { center, level: 5 });
mapInstance = map;
infoWindow = new window.kakao.maps.InfoWindow({ removable: true });
mapReady = true;
window.kakao.maps.event.addListener(map, 'idle', () => {
const c = map.getCenter();
oncenterchange?.(c.getLat(), c.getLng());
});
requestAnimationFrame(() => relayoutMap());
} catch (err) {
if (!cancelled) {
mapError = err instanceof Error ? err.message : 'Failed to load map';
}
}
})();
return () => {
cancelled = true;
infoWindow?.close();
infoWindow = null;
for (const { marker } of shopMarkerMap.values()) {
marker.setMap(null);
}
shopMarkerMap.clear();
mapInstance = null;
mapReady = false;
};
});
$effect(() => {
const map = mapInstance;
if (!map || !mapReady || !window.kakao?.maps) return;
const shopList = shops;
const shouldFit = fitBounds;
infoWindow?.close();
for (const { marker } of shopMarkerMap.values()) {
marker.setMap(null);
}
shopMarkerMap.clear();
const bounds = new window.kakao.maps.LatLngBounds();
let hasMarker = false;
for (const shop of shopList) {
const shopLat = Number(shop.lat);
const shopLng = Number(shop.lng);
if (!Number.isFinite(shopLat) || !Number.isFinite(shopLng)) continue;
const position = new window.kakao.maps.LatLng(shopLat, shopLng);
const marker = new window.kakao.maps.Marker({
position,
map
});
window.kakao.maps.event.addListener(marker, 'click', () => {
onselect?.(shop.id);
});
shopMarkerMap.set(shop.id, { marker, shop });
bounds.extend(position);
hasMarker = true;
}
if (hasMarker && shouldFit) {
map.setBounds(bounds);
}
requestAnimationFrame(() => relayoutMap());
});
// 마커·리스트 선택 시 정보창 표시
$effect(() => {
const id = selectedId;
const shopList = shops;
if (!mapReady || shopList.length === 0) return;
showInfoForShop(id);
});
// 리스트에서 가게 선택 시에만 이동 (panTo가 바뀔 때)
$effect(() => {
const map = mapInstance;
const target = panTo;
if (!map || !mapReady || !window.kakao?.maps || !target) return;
const centerLat = Number(target.lat);
const centerLng = Number(target.lng);
if (!Number.isFinite(centerLat) || !Number.isFinite(centerLng)) return;
map.panTo(new window.kakao.maps.LatLng(centerLat, centerLng));
});
/**
* @param {string} key
*/
function loadKakaoScript(key) {
return new Promise((resolve, reject) => {
if (window.kakao?.maps) {
window.kakao.maps.load(resolve);
return;
}
const script = document.createElement('script');
script.src = `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${key}&autoload=false`;
script.onload = () => window.kakao.maps.load(resolve);
script.onerror = () => reject(new Error('Kakao Maps script failed to load'));
document.head.appendChild(script);
});
}
</script>
<div class="relative min-h-64 flex-1 overflow-hidden bg-track lg:min-h-0">
<div bind:this={container} class="absolute inset-0"></div>
{#if mapError}
<div
class="absolute inset-0 flex items-center justify-center bg-track px-6 text-center text-sm text-muted"
>
{mapError}
</div>
{:else if !mapReady}
<div
class="absolute inset-0 flex items-center justify-center bg-track text-sm text-muted"
>
Loading map...
</div>
{/if}
</div>

View File

@@ -0,0 +1,93 @@
<script>
import FloristOrderMessage from './FloristOrderMessage.svelte';
import KakaoMap from './KakaoMap.svelte';
import ShopList from './ShopList.svelte';
let {
shops = [],
loading = false,
error = '',
selectedShopId = $bindable(null),
mock = false,
fitBounds = false,
orderPlainText = '',
orderSegments = [],
onrefresh
} = $props();
const DEFAULT_LAT = 37.5665;
const DEFAULT_LNG = 126.978;
let mapCenterLat = $state(DEFAULT_LAT);
let mapCenterLng = $state(DEFAULT_LNG);
let panTarget = $state(null);
function handleCenterChange(lat, lng) {
mapCenterLat = lat;
mapCenterLng = lng;
}
function handleShopSelect(id) {
selectedShopId = id;
const shop = shops.find((s) => s.id === id);
if (shop?.lat != null && shop?.lng != null) {
panTarget = { lat: shop.lat, lng: shop.lng };
}
}
function handleRefresh() {
onrefresh?.(mapCenterLat, mapCenterLng);
}
</script>
<div class="flex min-h-0 flex-1 flex-col">
<header class="shrink-0 px-6 py-8 md:px-10 lg:px-12 lg:py-10">
<h1 class="text-3xl leading-relaxed font-light text-muted md:text-4xl lg:text-[2.75rem]">
Find a nearby florist
</h1>
<p class="mt-3 text-sm text-muted">Move the map, then refresh to search this area.</p>
{#if mock}
<p class="mt-2 text-xs text-muted">Showing sample shops (no Kakao API key).</p>
{/if}
<div class="mt-6 border-b border-pill lg:mt-8"></div>
</header>
<div class="shrink-0 px-6 pb-4 md:px-10 lg:px-12">
<FloristOrderMessage plainText={orderPlainText} segments={orderSegments} />
</div>
{#if error}
<p class="px-6 text-sm text-red-600 md:px-10 lg:px-12">{error}</p>
{/if}
<div class="flex min-h-0 flex-1 flex-col gap-6 px-6 pb-8 md:px-10 lg:flex-row lg:px-12 lg:pb-10">
<div class="relative flex min-h-64 flex-1 flex-col overflow-hidden border border-line lg:min-h-0">
<KakaoMap
initialLat={DEFAULT_LAT}
initialLng={DEFAULT_LNG}
{shops}
selectedId={selectedShopId}
{fitBounds}
panTo={panTarget}
oncenterchange={handleCenterChange}
onselect={handleShopSelect}
/>
<button
type="button"
disabled={loading}
onclick={handleRefresh}
class="absolute top-3 right-3 z-10 rounded bg-surface/95 px-3 py-2 text-xs text-ink shadow-md ring-1 ring-black/10 backdrop-blur hover:bg-surface disabled:opacity-50"
>
{loading ? 'Searching...' : 'Refresh'}
</button>
</div>
<div class="w-full shrink-0 lg:w-72 lg:overflow-y-auto">
{#if loading && shops.length === 0}
<p class="text-sm text-muted">Searching for flower shops...</p>
{:else}
<ShopList shops={shops} bind:selectedId={selectedShopId} onselect={handleShopSelect} />
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,29 @@
<script>
let { shops = [], selectedId = $bindable(null), onselect } = $props();
</script>
<div class="flex flex-col gap-2">
{#each shops as shop (shop.id)}
<button
type="button"
onclick={() => onselect(shop.id)}
class={[
'rounded border px-4 py-3 text-left transition-colors',
selectedId === shop.id
? 'border-pill bg-pill text-surface'
: 'border-line-strong bg-surface text-ink hover:border-ink/30'
]}
>
<p class="text-sm font-medium">{shop.name}</p>
<p class="mt-1 text-xs opacity-80">{shop.address}</p>
{#if shop.distance}
<p class="mt-1 text-xs opacity-70">{shop.distance}</p>
{/if}
{#if selectedId === shop.id && shop.phone}
<p class="mt-1 text-xs opacity-70">{shop.phone}</p>
{/if}
</button>
{:else}
<p class="text-sm text-muted">No flower shops found nearby.</p>
{/each}
</div>