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>
12
.env.example
@@ -6,10 +6,16 @@ GEMINI_TEXT_MODEL=gemini-2.5-flash-lite
|
|||||||
# IMAGE_PROVIDER: openai | gemini | mock
|
# IMAGE_PROVIDER: openai | gemini | mock
|
||||||
# mock = instant placeholder images, zero API calls (develop without burning quota)
|
# mock = instant placeholder images, zero API calls (develop without burning quota)
|
||||||
IMAGE_PROVIDER=openai
|
IMAGE_PROVIDER=openai
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=your_openai_api_key_here
|
||||||
OPENAI_IMAGE_MODEL=gpt-image-1
|
|
||||||
OPENAI_IMAGE_SIZE=1024x1024
|
OPENAI_IMAGE_SIZE=1024x1024
|
||||||
GEMINI_IMAGE_MODEL=gemini-3.1-flash-image
|
GEMINI_IMAGE_MODEL=gemini-3.1-flash-image
|
||||||
|
|
||||||
# Kakao REST API (used later for /map)
|
# Kakao REST API (shop search for /map)
|
||||||
KAKAO_REST_API_KEY=
|
KAKAO_REST_API_KEY=
|
||||||
|
|
||||||
|
# Kakao Maps JavaScript key (map display on /map — public, client-side)
|
||||||
|
PUBLIC_KAKAO_MAP_KEY=
|
||||||
|
|
||||||
|
# Dev seed button: shown only when `npm run dev` (production build hides it).
|
||||||
|
# To mute during local dev, set DEV_SEED_MUTED = true in DevSeedButton.svelte.
|
||||||
|
# Replace static/dev/bouquet-{s,m,l}.jpg with real photos for richer UI previews.
|
||||||
|
|||||||
34
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export {};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
kakao: {
|
||||||
|
maps: {
|
||||||
|
load: (callback: () => void) => void;
|
||||||
|
LatLng: new (lat: number, lng: number) => unknown;
|
||||||
|
LatLngBounds: new () => { extend: (latlng: unknown) => void };
|
||||||
|
event: {
|
||||||
|
addListener: (target: unknown, type: string, handler: () => void) => void;
|
||||||
|
};
|
||||||
|
Map: new (
|
||||||
|
container: HTMLElement,
|
||||||
|
options: { center: unknown; level: number }
|
||||||
|
) => {
|
||||||
|
setBounds: (bounds: unknown) => void;
|
||||||
|
panTo: (latlng: unknown) => void;
|
||||||
|
relayout: () => void;
|
||||||
|
getCenter: () => { getLat: () => number; getLng: () => number };
|
||||||
|
};
|
||||||
|
Marker: new (options: { position: unknown; map: unknown }) => {
|
||||||
|
setMap: (map: unknown) => void;
|
||||||
|
setZIndex: (z: number) => void;
|
||||||
|
};
|
||||||
|
InfoWindow: new (options?: { removable?: boolean }) => {
|
||||||
|
open: (map: unknown, marker: unknown) => void;
|
||||||
|
close: () => void;
|
||||||
|
setContent: (content: string) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/lib/components/dev/DevSeedButton.svelte
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script>
|
||||||
|
import { dev } from '$app/environment';
|
||||||
|
import { seedDevFlow } from '$lib/flowerFlow/devSeed.js';
|
||||||
|
|
||||||
|
/** 나중에 mute 하려면 true 로 변경 */
|
||||||
|
const DEV_SEED_MUTED = false;
|
||||||
|
|
||||||
|
let loading = $state(false);
|
||||||
|
let message = $state('');
|
||||||
|
|
||||||
|
async function fillDevData() {
|
||||||
|
loading = true;
|
||||||
|
message = '';
|
||||||
|
|
||||||
|
const result = await seedDevFlow('result');
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
message = result.error;
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message = 'Filled';
|
||||||
|
loading = false;
|
||||||
|
// 페이지 상단 const jobId = getFlowString(...) 갱신을 위해 새로고침
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if dev && !DEV_SEED_MUTED}
|
||||||
|
<div class="dev-seed fixed bottom-4 left-4 z-50 flex flex-col items-start gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onclick={fillDevData}
|
||||||
|
class="rounded border border-dashed border-subtle/60 bg-surface/95 px-3 py-1.5 text-xs text-muted shadow-sm backdrop-blur hover:border-subtle hover:text-ink disabled:opacity-50"
|
||||||
|
title="AI 없이 더미 job + sessionStorage 채우기 (개발용)"
|
||||||
|
>
|
||||||
|
{loading ? 'Seeding…' : 'Dev: Fill data'}
|
||||||
|
</button>
|
||||||
|
{#if message && message !== 'Filled'}
|
||||||
|
<p class="max-w-48 rounded bg-surface/95 px-2 py-1 text-xs text-red-600 shadow-sm">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -3,7 +3,12 @@
|
|||||||
import Vase from './Vase.svelte';
|
import Vase from './Vase.svelte';
|
||||||
import DescriptionCard from './DescriptionCard.svelte';
|
import DescriptionCard from './DescriptionCard.svelte';
|
||||||
|
|
||||||
let { title = 'Title', description = 'Description Description Description' } = $props();
|
let {
|
||||||
|
title = 'Title',
|
||||||
|
description = 'Description Description Description',
|
||||||
|
/** options Continue 이후 확정된 꽃다발만 전달 (그 전에는 null → Vase) */
|
||||||
|
imageSrc = null
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -13,7 +18,17 @@
|
|||||||
<div
|
<div
|
||||||
class="mx-auto flex w-full max-w-100 flex-row items-center gap-12 px-6 py-5 lg:flex-1 lg:flex-col lg:items-center lg:justify-center lg:gap-10 lg:px-6 lg:py-12"
|
class="mx-auto flex w-full max-w-100 flex-row items-center gap-12 px-6 py-5 lg:flex-1 lg:flex-col lg:items-center lg:justify-center lg:gap-10 lg:px-6 lg:py-12"
|
||||||
>
|
>
|
||||||
<Vase />
|
{#if imageSrc}
|
||||||
|
<div class="mx-auto w-full max-w-24 shrink-0 overflow-hidden sm:max-w-28 lg:max-w-75">
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt="Selected bouquet"
|
||||||
|
class="aspect-[3/4] h-auto w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Vase />
|
||||||
|
{/if}
|
||||||
<DescriptionCard {title} {description} />
|
<DescriptionCard {title} {description} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
53
src/lib/components/ui/map/FloristOrderMessage.svelte
Normal 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>
|
||||||
235
src/lib/components/ui/map/KakaoMap.svelte
Normal 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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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>
|
||||||
93
src/lib/components/ui/map/MapPanel.svelte
Normal 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>
|
||||||
29
src/lib/components/ui/map/ShopList.svelte
Normal 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>
|
||||||
40
src/lib/components/ui/options/OptionCard.svelte
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script>
|
||||||
|
import { toDataUrl } from '$lib/flowerFlow/api.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
size,
|
||||||
|
image = null,
|
||||||
|
selectedSize = $bindable(null)
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const selected = $derived(selectedSize === size);
|
||||||
|
const dimmed = $derived(selectedSize !== null && selectedSize !== size);
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
selectedSize = size;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- 세로(4:5) 꽃다발 미리보기 — 클릭 시 해당 사이즈 선택 -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleClick}
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-label="Select size {size}"
|
||||||
|
class={[
|
||||||
|
'relative min-h-0 w-full cursor-pointer overflow-hidden bg-track text-left transition-all duration-300',
|
||||||
|
selected ? 'scale-[1.02] opacity-100 ring-2 ring-pill ring-inset' : '',
|
||||||
|
dimmed ? 'opacity-25' : 'opacity-100',
|
||||||
|
!dimmed && !selected ? 'hover:opacity-90' : ''
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div class="aspect-4/5 w-full">
|
||||||
|
{#if image}
|
||||||
|
<img
|
||||||
|
src={toDataUrl(image)}
|
||||||
|
alt="Bouquet option {size}"
|
||||||
|
class="pointer-events-none h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
49
src/lib/components/ui/options/OptionsPanel.svelte
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script>
|
||||||
|
import OptionGroup from '$lib/components/ui/create/OptionGroup.svelte';
|
||||||
|
import OptionCard from './OptionCard.svelte';
|
||||||
|
|
||||||
|
let { images = {}, loading = false, selectedSize = $bindable(null) } = $props();
|
||||||
|
|
||||||
|
const sizeOptions = ['S', 'M', 'L'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col justify-center px-6 py-10 md:px-12 lg:px-16 lg:py-12">
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex flex-1 items-center justify-center">
|
||||||
|
<p class="text-sm text-muted">Loading options...</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- options-reorganized.png: S/M/L 세로 이미지 가로 3열 -->
|
||||||
|
<div class="options-grid mb-10 min-h-0 w-full lg:mb-14">
|
||||||
|
{#each sizeOptions as size (size)}
|
||||||
|
<OptionCard {size} image={images[size]} bind:selectedSize />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OptionGroup
|
||||||
|
label="Choose the size of the bouquet"
|
||||||
|
options={sizeOptions}
|
||||||
|
selected={selectedSize}
|
||||||
|
onchange={(v) => (selectedSize = v)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.options-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
align-content: center;
|
||||||
|
max-height: min(70vh, 36rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.options-grid {
|
||||||
|
gap: 1rem;
|
||||||
|
max-height: min(65vh, 40rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import UploadTile from './UploadTile.svelte';
|
import UploadTile from './UploadTile.svelte';
|
||||||
|
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
|
||||||
|
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
|
||||||
|
|
||||||
let { primaryFile = $bindable(null) } = $props();
|
let { primaryFile = $bindable(null) } = $props();
|
||||||
|
|
||||||
@@ -12,36 +15,23 @@
|
|||||||
primaryFile = colorFile ?? seasonFile ?? characterFile ?? locationFile ?? null;
|
primaryFile = colorFile ?? seasonFile ?? characterFile ?? locationFile ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const tiles = [
|
onMount(async () => {
|
||||||
{
|
const devUpload = getFlowObject('devUpload');
|
||||||
key: 'color',
|
if (!isDevSeeded() || !devUpload?.active) return;
|
||||||
label: 'Color',
|
|
||||||
aspect: 'aspect-4/5',
|
const tiles = devUpload.moodboard;
|
||||||
bindFile: () => colorFile,
|
if (!tiles || typeof tiles !== 'object') return;
|
||||||
setFile: (v) => (colorFile = v)
|
|
||||||
},
|
try {
|
||||||
{
|
const files = await hydrateDevUpload(/** @type {Record<string, string>} */ (tiles));
|
||||||
key: 'season',
|
if (files.color) colorFile = files.color;
|
||||||
label: 'Season',
|
if (files.season) seasonFile = files.season;
|
||||||
aspect: 'aspect-4/3',
|
if (files.character) characterFile = files.character;
|
||||||
bindFile: () => seasonFile,
|
if (files.location) locationFile = files.location;
|
||||||
setFile: (v) => (seasonFile = v)
|
} catch {
|
||||||
},
|
// dev seed 실패 시 빈 타일 유지
|
||||||
{
|
|
||||||
key: 'character',
|
|
||||||
label: 'Character',
|
|
||||||
aspect: 'aspect-4/3',
|
|
||||||
bindFile: () => characterFile,
|
|
||||||
setFile: (v) => (characterFile = v)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'location',
|
|
||||||
label: 'Location',
|
|
||||||
aspect: 'aspect-4/5',
|
|
||||||
bindFile: () => locationFile,
|
|
||||||
setFile: (v) => (locationFile = v)
|
|
||||||
}
|
}
|
||||||
];
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="moodboard min-h-0 w-full flex-1">
|
<div class="moodboard min-h-0 w-full flex-1">
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import UploadTile from './UploadTile.svelte';
|
import UploadTile from './UploadTile.svelte';
|
||||||
|
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
|
||||||
|
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
|
||||||
|
|
||||||
let { primaryFile = $bindable(null) } = $props();
|
let { primaryFile = $bindable(null) } = $props();
|
||||||
|
|
||||||
@@ -9,6 +12,22 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
primaryFile = firstFile ?? secondFile ?? null;
|
primaryFile = firstFile ?? secondFile ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const devUpload = getFlowObject('devUpload');
|
||||||
|
if (!isDevSeeded() || !devUpload?.active) return;
|
||||||
|
|
||||||
|
const tiles = devUpload.sns;
|
||||||
|
if (!tiles || typeof tiles !== 'object') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await hydrateDevUpload(/** @type {Record<string, string>} */ (tiles));
|
||||||
|
if (files.first) firstFile = files.first;
|
||||||
|
if (files.second) secondFile = files.second;
|
||||||
|
} catch {
|
||||||
|
// dev seed 실패 시 빈 타일 유지
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="feed min-h-0 w-full flex-1">
|
<div class="feed min-h-0 w-full flex-1">
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
|
|
||||||
// A single click-to-upload slot: a light bordered placeholder when empty,
|
// A single click-to-upload slot: a light bordered placeholder when empty,
|
||||||
// the chosen image (cover) when filled. Layout (size / grid placement) is
|
// the chosen image (cover) when filled. Layout (size / grid placement) is
|
||||||
// supplied by the parent via `class` and `style` so the same tile works in
|
// supplied by the parent via `class` and `style` so the same tile works in
|
||||||
@@ -12,13 +10,19 @@
|
|||||||
function pick(event) {
|
function pick(event) {
|
||||||
const picked = event.currentTarget.files?.[0];
|
const picked = event.currentTarget.files?.[0];
|
||||||
if (!picked) return;
|
if (!picked) return;
|
||||||
if (preview) URL.revokeObjectURL(preview);
|
|
||||||
file = picked;
|
file = picked;
|
||||||
preview = URL.createObjectURL(picked);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(() => {
|
// 부모에서 File을 주입할 때(Dev seed 등) 미리보기 동기화
|
||||||
if (preview) URL.revokeObjectURL(preview);
|
$effect(() => {
|
||||||
|
if (!file) {
|
||||||
|
preview = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
preview = url;
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
15
src/lib/dev/fixtures.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/** create / message 단계용 더미 userInput */
|
||||||
|
export const DEV_USER_INPUT = {
|
||||||
|
relationship: 'Family',
|
||||||
|
occasion: 'Birthday',
|
||||||
|
style: 'Classic',
|
||||||
|
budget: 50_000
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEV_CARD_MESSAGE = 'Wishing you the happiest birthday — with love always.';
|
||||||
|
|
||||||
|
/** message Continue 후 userInput.notes 형태 */
|
||||||
|
export const DEV_USER_INPUT_WITH_NOTES = {
|
||||||
|
...DEV_USER_INPUT,
|
||||||
|
notes: `Card message: ${DEV_CARD_MESSAGE}`
|
||||||
|
};
|
||||||
32
src/lib/dev/hydrateUpload.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* static URL → File 변환 (upload UI 미리보기·primaryFile용)
|
||||||
|
* @param {string} url
|
||||||
|
* @param {string} filename
|
||||||
|
* @returns {Promise<File>}
|
||||||
|
*/
|
||||||
|
export async function urlToFile(url, filename) {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load dev upload image: ${url}`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
return new File([blob], filename, { type: blob.type || 'image/svg+xml' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, string>} tiles
|
||||||
|
* @returns {Promise<Record<string, File>>}
|
||||||
|
*/
|
||||||
|
export async function hydrateDevUpload(tiles) {
|
||||||
|
/** @type {Record<string, File>} */
|
||||||
|
const files = {};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Object.entries(tiles).map(async ([key, url]) => {
|
||||||
|
const ext = url.split('.').pop() ?? 'svg';
|
||||||
|
files[key] = await urlToFile(url, `dev-${key}.${ext}`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
12
src/lib/dev/uploadFixtures.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/** moodboard 4칸 + sns 2칸 더미 이미지 (static/dev/upload/) */
|
||||||
|
export const DEV_MOODBOARD_UPLOAD = {
|
||||||
|
color: '/dev/upload/color.svg',
|
||||||
|
season: '/dev/upload/season.svg',
|
||||||
|
character: '/dev/upload/character.svg',
|
||||||
|
location: '/dev/upload/location.svg'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEV_SNS_UPLOAD = {
|
||||||
|
first: '/dev/upload/sns-1.svg',
|
||||||
|
second: '/dev/upload/sns-2.svg'
|
||||||
|
};
|
||||||
73
src/lib/flowerFlow/buildFloristOrderMessage.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/** @typedef {import('$lib/server/flowerFlow/jobStore.js').UserInput} UserInput */
|
||||||
|
/** @typedef {import('$lib/server/flowerFlow/jobStore.js').MoodAnalysis} MoodAnalysis */
|
||||||
|
/** @typedef {import('$lib/server/flowerFlow/jobStore.js').BouquetRecipe} BouquetRecipe */
|
||||||
|
|
||||||
|
/** @typedef {{ text: string, highlight: boolean }} OrderMessageSegment */
|
||||||
|
|
||||||
|
/** @typedef {{ plainText: string, segments: OrderMessageSegment[] }} FloristOrderMessageResult */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} [items]
|
||||||
|
* @param {string} fallback
|
||||||
|
*/
|
||||||
|
function joinKeywords(items, fallback) {
|
||||||
|
return items?.length ? items.slice(0, 4).join(', ') : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* userInput?: Partial<UserInput> | null;
|
||||||
|
* moodAnalysis?: MoodAnalysis | null;
|
||||||
|
* recipe?: BouquetRecipe | null;
|
||||||
|
* }} input
|
||||||
|
* @returns {FloristOrderMessageResult}
|
||||||
|
*/
|
||||||
|
export function buildFloristOrderMessage(input) {
|
||||||
|
const { userInput, moodAnalysis, recipe } = input;
|
||||||
|
|
||||||
|
if (!recipe && !userInput?.relationship && !userInput?.occasion) {
|
||||||
|
return { plainText: '', segments: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const relationship = userInput?.relationship ?? 'someone special';
|
||||||
|
const occasion = userInput?.occasion ?? 'a special occasion';
|
||||||
|
const budget = userInput?.budget
|
||||||
|
? `₩${Number(userInput.budget).toLocaleString('en-US')}`
|
||||||
|
: 'a flexible range';
|
||||||
|
|
||||||
|
const moodFeel = joinKeywords(
|
||||||
|
[
|
||||||
|
...(moodAnalysis?.moodKeywords ?? []),
|
||||||
|
...(moodAnalysis?.styleImpression ?? []),
|
||||||
|
...(moodAnalysis?.textureKeywords ?? [])
|
||||||
|
],
|
||||||
|
'gentle and warm'
|
||||||
|
);
|
||||||
|
|
||||||
|
const colorTone = joinKeywords(
|
||||||
|
[...(moodAnalysis?.colorPalette ?? []), ...(recipe?.colors ?? [])],
|
||||||
|
'soft natural'
|
||||||
|
);
|
||||||
|
|
||||||
|
const plainText =
|
||||||
|
`Hello, I'd like to inquire about a flower order. ` +
|
||||||
|
`It's a bouquet for ${relationship} for ${occasion}, with a budget around ${budget}. ` +
|
||||||
|
`I'd like to gift something with a ${moodFeel} feel, using ${colorTone} tones. ` +
|
||||||
|
`Would a reservation be possible?`;
|
||||||
|
|
||||||
|
const segments = [
|
||||||
|
{ text: "Hello, I'd like to inquire about a flower order. It's a bouquet for ", highlight: false },
|
||||||
|
{ text: relationship, highlight: true },
|
||||||
|
{ text: ' for ', highlight: false },
|
||||||
|
{ text: occasion, highlight: true },
|
||||||
|
{ text: ', with a budget around ', highlight: false },
|
||||||
|
{ text: budget, highlight: true },
|
||||||
|
{ text: ". I'd like to gift something with a ", highlight: false },
|
||||||
|
{ text: moodFeel, highlight: true },
|
||||||
|
{ text: ' feel, using ', highlight: false },
|
||||||
|
{ text: colorTone, highlight: true },
|
||||||
|
{ text: ' tones. Would a reservation be possible?', highlight: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
return { plainText, segments };
|
||||||
|
}
|
||||||
60
src/lib/flowerFlow/devSeed.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { saveFlow } from './session.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {'options' | 'result'} [stage='result']
|
||||||
|
* @returns {Promise<{ ok: true } | { ok: false, error: string }>}
|
||||||
|
*/
|
||||||
|
export async function seedDevFlow(stage = 'result') {
|
||||||
|
const response = await fetch('/api/dev/seed-flow', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ stage })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: typeof data.error === 'string' ? data.error : 'Dev seed failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.session && typeof data.session === 'object') {
|
||||||
|
saveFlow(data.session);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 이미지 생성 없이 static/dev 더미 이미지를 job에 넣습니다.
|
||||||
|
* @param {string} jobId
|
||||||
|
* @returns {Promise<{ ok: true, data: Record<string, unknown> } | { ok: false, error: string }>}
|
||||||
|
*/
|
||||||
|
export async function skipDevImages(jobId) {
|
||||||
|
const response = await fetch('/api/dev/skip-images', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ jobId })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: typeof data.error === 'string' ? data.error : 'Skip images failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
saveFlow({
|
||||||
|
recipe: data.recipe,
|
||||||
|
moodAnalysis: data.moodAnalysis,
|
||||||
|
imagesJobId: jobId,
|
||||||
|
imagePrompt: data.imagePrompt,
|
||||||
|
mock: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, data };
|
||||||
|
}
|
||||||
@@ -28,8 +28,62 @@ export function getFlowString(key) {
|
|||||||
return typeof value === 'string' ? value : '';
|
return typeof value === 'string' ? value : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {string} key */
|
||||||
|
export function deleteFlowKey(key) {
|
||||||
|
const next = loadFlow();
|
||||||
|
delete next[key];
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
/** @param {string} key */
|
/** @param {string} key */
|
||||||
export function getFlowObject(key) {
|
export function getFlowObject(key) {
|
||||||
const value = loadFlow()[key];
|
const value = loadFlow()[key];
|
||||||
return value && typeof value === 'object' ? value : null;
|
return value && typeof value === 'object' ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dev Fill로 채워진 세션인지 */
|
||||||
|
export function isDevSeeded() {
|
||||||
|
return loadFlow().devSeeded === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* create/upload/message에서 쓰는 userInput (relationship, occasion 등).
|
||||||
|
* notes는 message Continue 이후에만 붙으므로 여기서는 제외합니다.
|
||||||
|
* @returns {Record<string, unknown>}
|
||||||
|
*/
|
||||||
|
export function getFlowUserInput() {
|
||||||
|
const input = getFlowObject('userInput');
|
||||||
|
if (!input) return {};
|
||||||
|
|
||||||
|
const { notes: _notes, ...createOnly } = input;
|
||||||
|
return createOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev Fill 직후 create에 1회만 더미 폼 적용. 없으면 null 반환.
|
||||||
|
* @returns {{ who: string | null, whatFor: string | null, style: string | null, budget: number } | null}
|
||||||
|
*/
|
||||||
|
export function consumeDevCreateSnapshot() {
|
||||||
|
const snap = getFlowObject('devCreateSnapshot');
|
||||||
|
deleteFlowKey('devCreateSnapshot');
|
||||||
|
if (!snap) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
who: typeof snap.relationship === 'string' ? snap.relationship : null,
|
||||||
|
whatFor: typeof snap.occasion === 'string' ? snap.occasion : null,
|
||||||
|
style: typeof snap.style === 'string' ? snap.style : null,
|
||||||
|
budget: typeof snap.budget === 'number' ? snap.budget : 50_000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev Fill 직후 message에 1회만 더미 카드 메시지 적용. 없으면 null.
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
export function consumeDevMessageSnapshot() {
|
||||||
|
const snap = getFlowObject('devMessageSnapshot');
|
||||||
|
deleteFlowKey('devMessageSnapshot');
|
||||||
|
if (!snap || typeof snap.text !== 'string') return null;
|
||||||
|
return snap.text;
|
||||||
|
}
|
||||||
|
|||||||
47
src/lib/server/dev/loadFixtureImages.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { mockGeneratedImage } from '$lib/server/gemini/mock.js';
|
||||||
|
|
||||||
|
/** @typedef {import('../flowerFlow/jobStore.js').BouquetSize} BouquetSize */
|
||||||
|
/** @typedef {import('../flowerFlow/jobStore.js').GeneratedImage} GeneratedImage */
|
||||||
|
|
||||||
|
const MIME_BY_EXT = {
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.svg': 'image/svg+xml'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* static/dev/bouquet-{size}.{jpg|png|svg} 를 읽습니다.
|
||||||
|
* 파일이 없으면 mock SVG로 대체합니다.
|
||||||
|
* @param {BouquetSize} size
|
||||||
|
* @returns {GeneratedImage}
|
||||||
|
*/
|
||||||
|
function loadFixtureImage(size) {
|
||||||
|
const baseDir = join(process.cwd(), 'static', 'dev');
|
||||||
|
const extensions = ['.jpg', '.jpeg', '.png', '.webp', '.svg'];
|
||||||
|
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const filePath = join(baseDir, `bouquet-${size.toLowerCase()}${ext}`);
|
||||||
|
if (!existsSync(filePath)) continue;
|
||||||
|
|
||||||
|
const mimeType = MIME_BY_EXT[ext] ?? 'application/octet-stream';
|
||||||
|
return {
|
||||||
|
mimeType,
|
||||||
|
base64: readFileSync(filePath).toString('base64')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return mockGeneratedImage(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Partial<Record<BouquetSize, GeneratedImage>>} */
|
||||||
|
export function loadDevBouquetImages() {
|
||||||
|
return {
|
||||||
|
S: loadFixtureImage('S'),
|
||||||
|
M: loadFixtureImage('M'),
|
||||||
|
L: loadFixtureImage('L')
|
||||||
|
};
|
||||||
|
}
|
||||||
43
src/lib/server/dev/seedJob.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { createJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
|
||||||
|
import {
|
||||||
|
mockFloristNote,
|
||||||
|
mockImagePrompt,
|
||||||
|
mockMoodAnalysis,
|
||||||
|
mockRecipe
|
||||||
|
} from '$lib/server/gemini/mock.js';
|
||||||
|
import { loadDevBouquetImages } from './loadFixtureImages.js';
|
||||||
|
|
||||||
|
/** @typedef {'options' | 'result'} DevSeedStage */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 없이 서버 job + sessionStorage용 payload를 한 번에 만듭니다.
|
||||||
|
* @param {Record<string, unknown>} userInput
|
||||||
|
* @param {DevSeedStage} [stage='result']
|
||||||
|
*/
|
||||||
|
export function seedDevJob(userInput, stage = 'result') {
|
||||||
|
const moodAnalysis = mockMoodAnalysis();
|
||||||
|
const recipe = mockRecipe(userInput);
|
||||||
|
const imagePrompt = mockImagePrompt(recipe);
|
||||||
|
const images = loadDevBouquetImages();
|
||||||
|
const floristNote = stage === 'result' ? mockFloristNote(recipe) : null;
|
||||||
|
|
||||||
|
const job = createJob(userInput);
|
||||||
|
updateJob(job.id, {
|
||||||
|
moodAnalysis,
|
||||||
|
recipe,
|
||||||
|
imagePrompt,
|
||||||
|
images,
|
||||||
|
...(stage === 'result' ? { selectedSize: 'M', floristNote } : {})
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobId: job.id,
|
||||||
|
moodAnalysis,
|
||||||
|
recipe,
|
||||||
|
imagePrompt,
|
||||||
|
images,
|
||||||
|
selectedSize: stage === 'result' ? 'M' : null,
|
||||||
|
floristNote,
|
||||||
|
mock: true
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
* @typedef {Object} UserInput
|
* @typedef {Object} UserInput
|
||||||
* @property {string} [relationship]
|
* @property {string} [relationship]
|
||||||
* @property {string} [occasion]
|
* @property {string} [occasion]
|
||||||
|
* @property {string} [style]
|
||||||
* @property {number} [budget]
|
* @property {number} [budget]
|
||||||
* @property {string} [season]
|
* @property {string} [season]
|
||||||
* @property {string} [notes]
|
* @property {string} [notes]
|
||||||
|
|||||||
@@ -6,13 +6,36 @@ import { getImageModel, isGeminiConfigured } from './client.js';
|
|||||||
import { mockGeneratedImage } from './mock.js';
|
import { mockGeneratedImage } from './mock.js';
|
||||||
import { generateOpenAIImage, isOpenAIConfigured } from '../openai/image.js';
|
import { generateOpenAIImage, isOpenAIConfigured } from '../openai/image.js';
|
||||||
|
|
||||||
|
/** S/M/L 공통 — recipe 구성 유지, 볼륨만 변경 */
|
||||||
|
const SIZE_CONSTRAINTS = `CRITICAL: This is a size variant of the SAME bouquet design.
|
||||||
|
- Use EXACTLY the same main flowers, sub flowers, greenery, colors, and wrapping from the recipe above.
|
||||||
|
- Do NOT add, remove, or substitute any flower types or colors.
|
||||||
|
- Do NOT change wrapping paper style, ribbon, or background.
|
||||||
|
- Only change the NUMBER OF STEMS and overall bouquet VOLUME/DENSITY.
|
||||||
|
- Same studio product photo style, front-facing bouquet, white/neutral background.`;
|
||||||
|
|
||||||
/** @type {Record<BouquetSize, string>} */
|
/** @type {Record<BouquetSize, string>} */
|
||||||
const SIZE_PROMPTS = {
|
const SIZE_PROMPTS = {
|
||||||
S: 'Create a small version with fewer flowers. Simple, delicate, and affordable.',
|
S: `SIZE: SMALL (S) — budget / compact version.
|
||||||
M: 'Create a medium version with a balanced amount of flowers and standard florist bouquet volume.',
|
- Slim, compact bouquet; smallest of the three size options.
|
||||||
L: 'Create a large version with more flowers, fuller volume, premium and abundant.'
|
- Fewer stems: roughly 40% of a standard bouquet (about 3-5 main flower blooms visible).
|
||||||
|
- Narrow silhouette, delicate and minimal volume.
|
||||||
|
- Wrapping paper proportionally smaller, hugging a small stem count.`,
|
||||||
|
M: `SIZE: MEDIUM (M) — standard version.
|
||||||
|
- Balanced, everyday florist bouquet; noticeably fuller than S.
|
||||||
|
- Moderate stems: roughly 70% of a premium bouquet (about 6-9 main flower blooms visible).
|
||||||
|
- Wider and rounder than S; filler and greenery scaled up proportionally.
|
||||||
|
- Standard wrapping volume, natural full-but-not-oversized shape.`,
|
||||||
|
L: `SIZE: LARGE (L) — premium / generous version.
|
||||||
|
- Most voluminous and dense; clearly larger than M.
|
||||||
|
- Abundant stems: full premium bouquet (about 10-15+ main flower blooms visible).
|
||||||
|
- Wide, lush, grand arrangement; maximum filler and greenery density.
|
||||||
|
- Largest wrapping spread, framing a generous bouquet.`
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @type {BouquetSize[]} */
|
||||||
|
const ALL_SIZES = ['S', 'M', 'L'];
|
||||||
|
|
||||||
export function getImageProvider() {
|
export function getImageProvider() {
|
||||||
const configured = env.IMAGE_PROVIDER?.trim().toLowerCase();
|
const configured = env.IMAGE_PROVIDER?.trim().toLowerCase();
|
||||||
if (configured === 'mock' || configured === 'openai' || configured === 'gemini') {
|
if (configured === 'mock' || configured === 'openai' || configured === 'gemini') {
|
||||||
@@ -33,7 +56,7 @@ export function isImageGenerationConfigured() {
|
|||||||
* @returns {Promise<GeneratedImage>}
|
* @returns {Promise<GeneratedImage>}
|
||||||
*/
|
*/
|
||||||
export async function generateBouquetImage(basePrompt, size) {
|
export async function generateBouquetImage(basePrompt, size) {
|
||||||
const prompt = `${basePrompt}\n\n${SIZE_PROMPTS[size]}\nKeep the same flower types, color palette, wrapping style, and mood.`;
|
const prompt = `${basePrompt}\n\n${SIZE_CONSTRAINTS}\n\n${SIZE_PROMPTS[size]}`;
|
||||||
const provider = getImageProvider();
|
const provider = getImageProvider();
|
||||||
|
|
||||||
// Explicit mock mode: develop the full flow without spending any image quota.
|
// Explicit mock mode: develop the full flow without spending any image quota.
|
||||||
@@ -75,11 +98,12 @@ export async function generateBouquetImage(basePrompt, size) {
|
|||||||
* @returns {Promise<Partial<Record<BouquetSize, GeneratedImage>>>}
|
* @returns {Promise<Partial<Record<BouquetSize, GeneratedImage>>>}
|
||||||
*/
|
*/
|
||||||
export async function generateAllSizeImages(basePrompt) {
|
export async function generateAllSizeImages(basePrompt) {
|
||||||
const image = await generateBouquetImage(basePrompt, 'M');
|
/** @type {Partial<Record<BouquetSize, GeneratedImage>>} */
|
||||||
|
const images = {};
|
||||||
|
|
||||||
return {
|
for (const size of ALL_SIZES) {
|
||||||
S: image,
|
images[size] = await generateBouquetImage(basePrompt, size);
|
||||||
M: image,
|
}
|
||||||
L: image
|
|
||||||
};
|
return images;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ Rules:
|
|||||||
- No fantasy colors or surreal shapes
|
- No fantasy colors or surreal shapes
|
||||||
- White background, soft natural lighting
|
- White background, soft natural lighting
|
||||||
- Korean florist style
|
- Korean florist style
|
||||||
|
- Describe bouquet composition only (flower types, colors, wrapping, mood)
|
||||||
|
- Do NOT specify bouquet size, stem count, or S/M/L — size variants are applied in a separate step
|
||||||
- Return plain text only, no markdown`;
|
- Return plain text only, no markdown`;
|
||||||
|
|
||||||
const result = await model.generateContent(prompt);
|
const result = await model.generateContent(prompt);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
import DevSeedButton from '$lib/components/dev/DevSeedButton.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -12,3 +13,4 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=Orbit&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Orbit&display=swap" rel="stylesheet" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
<DevSeedButton />
|
||||||
|
|||||||
57
src/routes/api/dev/seed-flow/+server.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { dev } from '$app/environment';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import {
|
||||||
|
DEV_CARD_MESSAGE,
|
||||||
|
DEV_USER_INPUT,
|
||||||
|
DEV_USER_INPUT_WITH_NOTES
|
||||||
|
} from '$lib/dev/fixtures.js';
|
||||||
|
import { DEV_MOODBOARD_UPLOAD, DEV_SNS_UPLOAD } from '$lib/dev/uploadFixtures.js';
|
||||||
|
import { seedDevJob } from '$lib/server/dev/seedJob.js';
|
||||||
|
|
||||||
|
/** @type {import('./$types').RequestHandler} */
|
||||||
|
export async function POST({ request }) {
|
||||||
|
if (!dev) {
|
||||||
|
return json({ error: 'Dev seed is only available in development.' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stage = 'result';
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
if (body.stage === 'options' || body.stage === 'result') {
|
||||||
|
stage = body.stage;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 기본값 사용
|
||||||
|
}
|
||||||
|
|
||||||
|
const seeded = seedDevJob(DEV_USER_INPUT_WITH_NOTES, stage);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
stage,
|
||||||
|
session: {
|
||||||
|
devSeeded: true,
|
||||||
|
/** create 페이지에서 1회만 적용 후 삭제 */
|
||||||
|
devCreateSnapshot: DEV_USER_INPUT,
|
||||||
|
userInput: DEV_USER_INPUT,
|
||||||
|
/** message 페이지에서 1회만 적용 후 삭제 */
|
||||||
|
devMessageSnapshot: { text: DEV_CARD_MESSAGE },
|
||||||
|
jobId: seeded.jobId,
|
||||||
|
moodAnalysis: seeded.moodAnalysis,
|
||||||
|
recipe: seeded.recipe,
|
||||||
|
imagePrompt: seeded.imagePrompt,
|
||||||
|
imagesJobId: seeded.jobId,
|
||||||
|
mock: true,
|
||||||
|
devUpload: {
|
||||||
|
active: true,
|
||||||
|
mode: 'moodboard',
|
||||||
|
moodboard: DEV_MOODBOARD_UPLOAD,
|
||||||
|
sns: DEV_SNS_UPLOAD
|
||||||
|
},
|
||||||
|
...(stage === 'result'
|
||||||
|
? { selectedSize: 'M', floristNote: seeded.floristNote }
|
||||||
|
: {})
|
||||||
|
},
|
||||||
|
// create 폼 초기값 참고용 (relationship/occasion/style/budget만)
|
||||||
|
formDefaults: DEV_USER_INPUT
|
||||||
|
});
|
||||||
|
}
|
||||||
43
src/routes/api/dev/skip-images/+server.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { dev } from '$app/environment';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { loadDevBouquetImages } from '$lib/server/dev/loadFixtureImages.js';
|
||||||
|
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
|
||||||
|
import { mockImagePrompt, mockMoodAnalysis, mockRecipe } from '$lib/server/gemini/mock.js';
|
||||||
|
import { readJsonBody } from '$lib/server/http.js';
|
||||||
|
|
||||||
|
/** @type {import('./$types').RequestHandler} */
|
||||||
|
export async function POST({ request }) {
|
||||||
|
if (!dev) {
|
||||||
|
return json({ error: 'Dev skip is only available in development.' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const jobId = typeof body.jobId === 'string' ? body.jobId : '';
|
||||||
|
|
||||||
|
if (!jobId) {
|
||||||
|
return json({ error: 'jobId is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = requireJob(jobId);
|
||||||
|
const moodAnalysis = job.moodAnalysis ?? mockMoodAnalysis();
|
||||||
|
const recipe = job.recipe ?? mockRecipe(job.userInput);
|
||||||
|
const imagePrompt = job.imagePrompt ?? mockImagePrompt(recipe);
|
||||||
|
const images = loadDevBouquetImages();
|
||||||
|
|
||||||
|
updateJob(jobId, { moodAnalysis, recipe, imagePrompt, images });
|
||||||
|
|
||||||
|
return json({
|
||||||
|
jobId,
|
||||||
|
moodAnalysis,
|
||||||
|
recipe,
|
||||||
|
imagePrompt,
|
||||||
|
images,
|
||||||
|
mock: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Skip images failed';
|
||||||
|
const status = message.includes('Job not found') ? 404 : 500;
|
||||||
|
return json({ error: message }, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/routes/api/map/shops/+server.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { json, toErrorResponse } from '$lib/server/http.js';
|
||||||
|
|
||||||
|
/** @type {import('./$types').RequestHandler} */
|
||||||
|
export async function GET({ url }) {
|
||||||
|
try {
|
||||||
|
const lat = Number(url.searchParams.get('lat') ?? '37.5665');
|
||||||
|
const lng = Number(url.searchParams.get('lng') ?? '126.978');
|
||||||
|
|
||||||
|
const key = env.KAKAO_REST_API_KEY;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return json({
|
||||||
|
mock: true,
|
||||||
|
shops: mockShops(lat, lng)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
query: '꽃집',
|
||||||
|
x: String(lng),
|
||||||
|
y: String(lat),
|
||||||
|
radius: '2000',
|
||||||
|
sort: 'distance'
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`https://dapi.kakao.com/v2/local/search/keyword.json?${query}`, {
|
||||||
|
headers: { Authorization: `KakaoAK ${key}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return json({ mock: true, shops: mockShops(lat, lng) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const shops = (data.documents ?? []).slice(0, 8).map((doc, index) => ({
|
||||||
|
id: doc.id ?? String(index),
|
||||||
|
name: doc.place_name,
|
||||||
|
address: doc.road_address_name || doc.address_name,
|
||||||
|
distance: doc.distance ? `${Math.round(Number(doc.distance))}m` : null,
|
||||||
|
lat: Number(doc.y),
|
||||||
|
lng: Number(doc.x),
|
||||||
|
phone: doc.phone
|
||||||
|
}));
|
||||||
|
|
||||||
|
return json({ mock: false, shops });
|
||||||
|
} catch (error) {
|
||||||
|
return toErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} lat
|
||||||
|
* @param {number} lng
|
||||||
|
*/
|
||||||
|
function mockShops(lat, lng) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'mock-1',
|
||||||
|
name: 'AI Florist Studio',
|
||||||
|
address: 'Sample address 123',
|
||||||
|
distance: '320m',
|
||||||
|
lat: lat + 0.002,
|
||||||
|
lng: lng + 0.001,
|
||||||
|
phone: '02-000-0001'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mock-2',
|
||||||
|
name: 'Bloom & Co.',
|
||||||
|
address: 'Sample address 456',
|
||||||
|
distance: '580m',
|
||||||
|
lat: lat - 0.001,
|
||||||
|
lng: lng + 0.002,
|
||||||
|
phone: '02-000-0002'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mock-3',
|
||||||
|
name: 'Morning Petal',
|
||||||
|
address: 'Sample address 789',
|
||||||
|
distance: '940m',
|
||||||
|
lat: lat + 0.001,
|
||||||
|
lng: lng - 0.002,
|
||||||
|
phone: '02-000-0003'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import Header from '$lib/components/ui/Header.svelte';
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
||||||
import ContextForm from '$lib/components/ui/create/ContextForm.svelte';
|
import ContextForm from '$lib/components/ui/create/ContextForm.svelte';
|
||||||
import { loadFlow, saveFlow } from '$lib/flowerFlow/session.js';
|
import {
|
||||||
|
consumeDevCreateSnapshot,
|
||||||
|
deleteFlowKey,
|
||||||
|
getFlowObject,
|
||||||
|
isDevSeeded,
|
||||||
|
saveFlow
|
||||||
|
} from '$lib/flowerFlow/session.js';
|
||||||
|
|
||||||
const savedInput = loadFlow().userInput ?? {};
|
// 항상 빈 폼으로 시작 — Dev Fill은 onMount에서 1회만 스냅샷 적용
|
||||||
|
let who = $state(null);
|
||||||
let who = $state(savedInput.relationship ?? null);
|
let whatFor = $state(null);
|
||||||
let whatFor = $state(savedInput.occasion ?? null);
|
let style = $state(null);
|
||||||
let style = $state(savedInput.style ?? null);
|
let budget = $state(50_000);
|
||||||
let budget = $state(savedInput.budget ?? 50_000);
|
|
||||||
|
|
||||||
const hasAnySelection = $derived(who !== null || whatFor !== null || style !== null);
|
const hasAnySelection = $derived(who !== null || whatFor !== null || style !== null);
|
||||||
|
|
||||||
@@ -27,7 +33,33 @@
|
|||||||
: 'Description Description Description'
|
: 'Description Description Description'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const hadSnapshot = !!getFlowObject('devCreateSnapshot');
|
||||||
|
const snap = consumeDevCreateSnapshot();
|
||||||
|
|
||||||
|
if (snap) {
|
||||||
|
who = snap.who;
|
||||||
|
whatFor = snap.whatFor;
|
||||||
|
style = snap.style;
|
||||||
|
budget = snap.budget;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 예전 세션에 devSeeded만 남은 경우 — 더미 폼 복원 차단
|
||||||
|
if (isDevSeeded() && !hadSnapshot) {
|
||||||
|
deleteFlowKey('devSeeded');
|
||||||
|
deleteFlowKey('devUpload');
|
||||||
|
deleteFlowKey('devMessageSnapshot');
|
||||||
|
deleteFlowKey('cardMessage');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function handleContinue() {
|
function handleContinue() {
|
||||||
|
deleteFlowKey('devUpload');
|
||||||
|
deleteFlowKey('devSeeded');
|
||||||
|
deleteFlowKey('devCreateSnapshot');
|
||||||
|
deleteFlowKey('devMessageSnapshot');
|
||||||
|
deleteFlowKey('cardMessage');
|
||||||
saveFlow({
|
saveFlow({
|
||||||
userInput: {
|
userInput: {
|
||||||
relationship: who ?? undefined,
|
relationship: who ?? undefined,
|
||||||
|
|||||||
@@ -111,6 +111,8 @@
|
|||||||
|
|
||||||
await goto(resolve('/options'));
|
await goto(resolve('/options'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
// The server lost this job (e.g. a dev-server restart wipes the in-memory
|
// The server lost this job (e.g. a dev-server restart wipes the in-memory
|
||||||
// job store). The stored jobId is dead, so retrying is pointless — clear
|
// job store). The stored jobId is dead, so retrying is pointless — clear
|
||||||
// the stale flow and send the user back to re-upload.
|
// the stale flow and send the user back to re-upload.
|
||||||
|
|||||||
@@ -1,16 +1,121 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import Header from '$lib/components/ui/Header.svelte';
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
import { getFlowString } from '$lib/flowerFlow/session.js';
|
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
||||||
|
import MapPanel from '$lib/components/ui/map/MapPanel.svelte';
|
||||||
|
import { fetchJob, toDataUrl } from '$lib/flowerFlow/api.js';
|
||||||
|
import { buildFloristOrderMessage } from '$lib/flowerFlow/buildFloristOrderMessage.js';
|
||||||
|
import { getFlowObject, getFlowString } from '$lib/flowerFlow/session.js';
|
||||||
|
|
||||||
const jobId = getFlowString('jobId');
|
const jobId = getFlowString('jobId');
|
||||||
|
|
||||||
|
const DEFAULT_LAT = 37.5665;
|
||||||
|
const DEFAULT_LNG = 126.978;
|
||||||
|
|
||||||
|
let shops = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let mock = $state(false);
|
||||||
|
let selectedShopId = $state(null);
|
||||||
|
let floristNote = $state('');
|
||||||
|
let fitMapBounds = $state(true);
|
||||||
|
let orderPlainText = $state('');
|
||||||
|
let orderSegments = $state([]);
|
||||||
|
let selectedImage = $state(null);
|
||||||
|
|
||||||
|
const sessionUserInput = getFlowObject('userInput') ?? {};
|
||||||
|
|
||||||
|
const artworkTitle = $derived(selectedShopId ? 'Ready to order' : 'Your bouquet');
|
||||||
|
|
||||||
|
const artworkDescription = $derived(
|
||||||
|
floristNote || 'Your selected bouquet design.'
|
||||||
|
);
|
||||||
|
|
||||||
|
// options Continue(최종 사이즈 확정) 이후 selectedSize가 있을 때만 이미지 표시
|
||||||
|
const bouquetImageSrc = $derived(selectedImage ? toDataUrl(selectedImage) : null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} lat
|
||||||
|
* @param {number} lng
|
||||||
|
* @param {{ fitBounds?: boolean }} [options]
|
||||||
|
*/
|
||||||
|
async function loadShops(lat, lng, options = {}) {
|
||||||
|
const { fitBounds = false } = options;
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
fitMapBounds = fitBounds;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
lat: String(lat),
|
||||||
|
lng: String(lng)
|
||||||
|
});
|
||||||
|
const response = await fetch(`/api/map/shops?${params}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(typeof data.error === 'string' ? data.error : 'Shop search failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
shops = data.shops ?? [];
|
||||||
|
mock = Boolean(data.mock);
|
||||||
|
selectedShopId = shops.length > 0 ? shops[0].id : null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : 'Failed to load shops';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!jobId) {
|
||||||
|
await goto(resolve('/create'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const job = await fetchJob(jobId);
|
||||||
|
floristNote = job.floristNote ?? '';
|
||||||
|
// options에서 S/M/L 선택 후 Continue 한 경우에만 job.selectedSize 존재
|
||||||
|
selectedImage = job.selectedSize ? (job.images?.[job.selectedSize] ?? null) : null;
|
||||||
|
|
||||||
|
const order = buildFloristOrderMessage({
|
||||||
|
userInput: { ...sessionUserInput, ...job.userInput },
|
||||||
|
moodAnalysis: job.moodAnalysis,
|
||||||
|
recipe: job.recipe
|
||||||
|
});
|
||||||
|
orderPlainText = order.plainText;
|
||||||
|
orderSegments = order.segments;
|
||||||
|
} catch {
|
||||||
|
// job 없어도 지도·꽃집 검색은 계속
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadShops(DEFAULT_LAT, DEFAULT_LNG, { fitBounds: true });
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-dvh bg-surface text-ink">
|
<div
|
||||||
|
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
||||||
|
>
|
||||||
<Header step={7} total={7} />
|
<Header step={7} total={7} />
|
||||||
|
|
||||||
<main class="mx-auto max-w-xl px-6 py-10">
|
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||||
<h1 class="mb-2 text-2xl">Map</h1>
|
<Artwork title={artworkTitle} description={artworkDescription} imageSrc={bouquetImageSrc} />
|
||||||
<p class="mb-4 text-sm text-muted">Nearby flower shops will appear here in the next step.</p>
|
|
||||||
<p class="text-sm text-muted">Current job: {jobId || 'none'}</p>
|
<section class="relative flex min-h-0 flex-1 flex-col lg:overflow-y-auto">
|
||||||
|
<MapPanel
|
||||||
|
bind:selectedShopId
|
||||||
|
{shops}
|
||||||
|
{loading}
|
||||||
|
{error}
|
||||||
|
{mock}
|
||||||
|
{orderPlainText}
|
||||||
|
{orderSegments}
|
||||||
|
fitBounds={fitMapBounds}
|
||||||
|
onrefresh={(lat, lng) => loadShops(lat, lng, { fitBounds: false })}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,50 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { dev } from '$app/environment';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import Header from '$lib/components/ui/Header.svelte';
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
||||||
import MessageForm from '$lib/components/ui/message/MessageForm.svelte';
|
import MessageForm from '$lib/components/ui/message/MessageForm.svelte';
|
||||||
import { getFlowObject, loadFlow, saveFlow } from '$lib/flowerFlow/session.js';
|
import { skipDevImages } from '$lib/flowerFlow/devSeed.js';
|
||||||
|
import {
|
||||||
|
consumeDevMessageSnapshot,
|
||||||
|
deleteFlowKey,
|
||||||
|
getFlowObject,
|
||||||
|
getFlowUserInput,
|
||||||
|
isDevSeeded,
|
||||||
|
loadFlow,
|
||||||
|
saveFlow
|
||||||
|
} from '$lib/flowerFlow/session.js';
|
||||||
|
|
||||||
const flow = loadFlow();
|
const userInput = getFlowUserInput();
|
||||||
const userInput = getFlowObject('userInput') ?? {};
|
|
||||||
|
|
||||||
let message = $state(typeof flow.cardMessage === 'string' ? flow.cardMessage : '');
|
// 항상 빈 메시지로 시작 — Dev Fill은 onMount에서 1회만 스냅샷 적용
|
||||||
|
let message = $state('');
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
let skipping = $state(false);
|
||||||
|
|
||||||
const artworkTitle = $derived(message ? 'Your message' : 'Title');
|
const artworkTitle = $derived(message ? 'Your message' : 'Title');
|
||||||
|
|
||||||
const artworkDescription = $derived(message || 'Description Description Description');
|
const artworkDescription = $derived(message || 'Description Description Description');
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const hadSnapshot = !!getFlowObject('devMessageSnapshot');
|
||||||
|
const snap = consumeDevMessageSnapshot();
|
||||||
|
|
||||||
|
if (snap) {
|
||||||
|
message = snap;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 예전 세션에 devSeeded / cardMessage만 남은 경우 — 더미 메시지 복원 차단
|
||||||
|
if (isDevSeeded() && !hadSnapshot) {
|
||||||
|
deleteFlowKey('devSeeded');
|
||||||
|
deleteFlowKey('devUpload');
|
||||||
|
deleteFlowKey('cardMessage');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function handleContinue() {
|
function handleContinue() {
|
||||||
const current = loadFlow();
|
const current = loadFlow();
|
||||||
if (!current.jobId) {
|
if (!current.jobId) {
|
||||||
@@ -24,16 +53,42 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedNotes = [userInput.notes, message ? `Card message: ${message}` : '']
|
saveMessageToFlow();
|
||||||
.filter(Boolean)
|
goto(resolve('/generating'));
|
||||||
.join('\n\n');
|
}
|
||||||
|
|
||||||
|
function saveMessageToFlow() {
|
||||||
|
const mergedNotes = message ? `Card message: ${message}` : '';
|
||||||
|
|
||||||
saveFlow({
|
saveFlow({
|
||||||
cardMessage: message,
|
cardMessage: message,
|
||||||
userInput: { ...userInput, notes: mergedNotes || undefined }
|
userInput: { ...userInput, notes: mergedNotes || undefined }
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
goto(resolve('/generating'));
|
async function skipWithDummyImages() {
|
||||||
|
const current = loadFlow();
|
||||||
|
const jobId = typeof current.jobId === 'string' ? current.jobId : '';
|
||||||
|
|
||||||
|
if (!jobId) {
|
||||||
|
error = 'Please upload an image first.';
|
||||||
|
goto(resolve('/upload'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
skipping = true;
|
||||||
|
error = '';
|
||||||
|
saveMessageToFlow();
|
||||||
|
|
||||||
|
const result = await skipDevImages(jobId);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
skipping = false;
|
||||||
|
error = result.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await goto(resolve('/options'));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -60,6 +115,17 @@
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if dev}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={skipping}
|
||||||
|
onclick={skipWithDummyImages}
|
||||||
|
class="w-full rounded border border-dashed border-subtle/60 px-4 py-2.5 text-xs text-muted hover:border-subtle hover:text-ink disabled:opacity-50"
|
||||||
|
title="AI 생성 없이 더미 이미지로 options로 이동 (개발용)"
|
||||||
|
>
|
||||||
|
{skipping ? 'Skipping…' : 'Dev: Skip to options (dummy images)'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={handleContinue}
|
onclick={handleContinue}
|
||||||
|
|||||||
@@ -3,56 +3,66 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import Header from '$lib/components/ui/Header.svelte';
|
import Header from '$lib/components/ui/Header.svelte';
|
||||||
import { fetchJob, selectOption, toDataUrl } from '$lib/flowerFlow/api.js';
|
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
|
||||||
|
import OptionsPanel from '$lib/components/ui/options/OptionsPanel.svelte';
|
||||||
|
import { fetchJob, selectOption } from '$lib/flowerFlow/api.js';
|
||||||
import { getFlowString } from '$lib/flowerFlow/session.js';
|
import { getFlowString } from '$lib/flowerFlow/session.js';
|
||||||
|
|
||||||
const jobId = getFlowString('jobId');
|
const jobId = getFlowString('jobId');
|
||||||
|
|
||||||
// Images are large base64 blobs that don't fit in sessionStorage, so they live
|
|
||||||
// server-side. Fetch them by jobId rather than reading them from the flow store.
|
|
||||||
let images = $state({});
|
let images = $state({});
|
||||||
|
let recipe = $state(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let loadingSize = $state(null);
|
let loadingSize = $state(null);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
let selectedSize = $state(null);
|
||||||
|
|
||||||
const options = [
|
const artworkTitle = $derived(recipe?.concept ?? 'Title');
|
||||||
{ size: 'S', label: 'Small', description: 'Simple, delicate, affordable' },
|
|
||||||
{ size: 'M', label: 'Medium', description: 'Balanced standard bouquet volume' },
|
const artworkDescription = $derived(
|
||||||
{ size: 'L', label: 'Large', description: 'Fuller, premium and abundant' }
|
recipe?.mainFlowers?.length
|
||||||
];
|
? `${recipe.mainFlowers.join(', ')} · ${recipe.wrapping ?? 'Custom wrap'}`
|
||||||
|
: 'Description Description Description'
|
||||||
|
);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
await goto(resolve('/create'));
|
// 와이어프레임 확인용: job 없어도 레이아웃은 보여 줌
|
||||||
|
loading = false;
|
||||||
|
error = 'Start from /create and complete the flow to see generated bouquets.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const job = await fetchJob(jobId);
|
const job = await fetchJob(jobId);
|
||||||
if (!job.images?.M) {
|
if (!job.images?.M) {
|
||||||
// Not generated yet — (re)run generation.
|
|
||||||
await goto(resolve('/generating'));
|
await goto(resolve('/generating'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
images = job.images;
|
images = job.images;
|
||||||
|
recipe = job.recipe ?? null;
|
||||||
loading = false;
|
loading = false;
|
||||||
} catch {
|
} catch {
|
||||||
// Job missing on the server (e.g. a dev-server restart wiped it) — restart.
|
|
||||||
await goto(resolve('/generating'));
|
await goto(resolve('/generating'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function choose(size) {
|
async function handleContinue() {
|
||||||
if (!jobId) {
|
if (!selectedSize) {
|
||||||
await goto(resolve('/create'));
|
error = 'Choose a bouquet size to continue.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadingSize = size;
|
if (!jobId) {
|
||||||
|
error = 'Complete create → upload → message → generating first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingSize = selectedSize;
|
||||||
error = '';
|
error = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await selectOption(jobId, /** @type {'S'|'M'|'L'} */ (size));
|
await selectOption(jobId, /** @type {'S'|'M'|'L'} */ (selectedSize));
|
||||||
await goto(resolve('/result'));
|
await goto(resolve('/result'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : 'Selection failed';
|
error = err instanceof Error ? err.message : 'Selection failed';
|
||||||
@@ -61,47 +71,34 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-dvh bg-surface text-ink">
|
<div
|
||||||
|
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
|
||||||
|
>
|
||||||
<Header step={5} total={7} />
|
<Header step={5} total={7} />
|
||||||
|
|
||||||
<main class="mx-auto max-w-5xl px-6 py-10">
|
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||||
<h1 class="mb-2 text-2xl">Choose your bouquet size</h1>
|
<Artwork title={artworkTitle} description={artworkDescription} />
|
||||||
<p class="mb-8 text-sm text-muted">Pick one of the generated options.</p>
|
|
||||||
|
|
||||||
{#if error}
|
<section class="relative flex min-h-0 flex-1 flex-col pb-[4.75rem] lg:overflow-y-auto lg:pb-0">
|
||||||
<p class="mb-4 text-sm text-red-600">{error}</p>
|
<OptionsPanel {images} {loading} bind:selectedSize />
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if loading}
|
<div
|
||||||
<p class="text-sm text-muted">Loading options...</p>
|
class="fixed right-0 bottom-0 left-0 z-20 space-y-2 px-4 pb-5 lg:absolute lg:right-8 lg:bottom-8 lg:left-auto lg:w-72 lg:px-0"
|
||||||
{/if}
|
>
|
||||||
|
{#if error}
|
||||||
<div class="grid gap-6 md:grid-cols-3" class:hidden={loading}>
|
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
|
||||||
{#each options as option (option.size)}
|
{error}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={Boolean(loadingSize)}
|
disabled={!selectedSize || Boolean(loadingSize)}
|
||||||
onclick={() => choose(option.size)}
|
onclick={handleContinue}
|
||||||
class="border border-line bg-track p-4 text-left transition hover:border-line-strong disabled:opacity-50"
|
class="w-full bg-pill px-4 py-3 text-sm text-surface disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<div class="mb-4 aspect-[3/4] overflow-hidden bg-surface">
|
{loadingSize ? 'Selecting...' : 'Continue to result'}
|
||||||
{#if images[option.size]}
|
|
||||||
<img
|
|
||||||
src={toDataUrl(images[option.size])}
|
|
||||||
alt="{option.label} bouquet option"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="flex h-full items-center justify-center text-sm text-muted">No image</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<h2 class="text-lg">{option.label}</h2>
|
|
||||||
<p class="mt-1 text-sm text-muted">{option.description}</p>
|
|
||||||
<p class="mt-4 text-sm">
|
|
||||||
{loadingSize === option.size ? 'Selecting...' : 'Select this option'}
|
|
||||||
</p>
|
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,18 +6,40 @@
|
|||||||
import MoodboardGrid from '$lib/components/ui/upload/MoodboardGrid.svelte';
|
import MoodboardGrid from '$lib/components/ui/upload/MoodboardGrid.svelte';
|
||||||
import SnsFeedUpload from '$lib/components/ui/upload/SnsFeedUpload.svelte';
|
import SnsFeedUpload from '$lib/components/ui/upload/SnsFeedUpload.svelte';
|
||||||
import { analyzeMood } from '$lib/flowerFlow/api.js';
|
import { analyzeMood } from '$lib/flowerFlow/api.js';
|
||||||
import { getFlowObject, saveFlow } from '$lib/flowerFlow/session.js';
|
import {
|
||||||
|
deleteFlowKey,
|
||||||
|
getFlowUserInput,
|
||||||
|
isDevSeeded,
|
||||||
|
loadFlow,
|
||||||
|
saveFlow
|
||||||
|
} from '$lib/flowerFlow/session.js';
|
||||||
|
|
||||||
let mode = $state('moodboard');
|
const savedFlow = loadFlow();
|
||||||
|
const userInput = getFlowUserInput();
|
||||||
|
|
||||||
|
const devUpload = savedFlow.devUpload;
|
||||||
|
let mode = $state(
|
||||||
|
isDevSeeded() && devUpload?.active && typeof devUpload.mode === 'string'
|
||||||
|
? devUpload.mode
|
||||||
|
: 'moodboard'
|
||||||
|
);
|
||||||
let primaryFile = $state(null);
|
let primaryFile = $state(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
const userInput = getFlowObject('userInput') ?? {};
|
|
||||||
|
|
||||||
async function continueToMessage() {
|
async function continueToMessage() {
|
||||||
error = '';
|
error = '';
|
||||||
|
|
||||||
|
const flow = loadFlow();
|
||||||
|
if (flow.jobId && flow.moodAnalysis) {
|
||||||
|
// Dev Fill 후 바로 message로 넘어갈 때 더미 플래그가 남지 않도록 정리
|
||||||
|
deleteFlowKey('devUpload');
|
||||||
|
deleteFlowKey('devSeeded');
|
||||||
|
deleteFlowKey('cardMessage');
|
||||||
|
await goto(resolve('/message'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!primaryFile) {
|
if (!primaryFile) {
|
||||||
error = 'Upload at least one image to continue.';
|
error = 'Upload at least one image to continue.';
|
||||||
return;
|
return;
|
||||||
@@ -27,6 +49,10 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await analyzeMood(primaryFile, userInput);
|
const result = await analyzeMood(primaryFile, userInput);
|
||||||
|
deleteFlowKey('devUpload');
|
||||||
|
deleteFlowKey('devSeeded');
|
||||||
|
deleteFlowKey('devMessageSnapshot');
|
||||||
|
deleteFlowKey('cardMessage');
|
||||||
saveFlow({
|
saveFlow({
|
||||||
jobId: result.jobId,
|
jobId: result.jobId,
|
||||||
moodAnalysis: result.moodAnalysis,
|
moodAnalysis: result.moodAnalysis,
|
||||||
|
|||||||
14
static/dev/bouquet-l.svg
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="768" height="1024" viewBox="0 0 768 1024">
|
||||||
|
<rect width="768" height="1024" fill="#ebe4dc"/>
|
||||||
|
<rect x="40" y="680" width="688" height="240" rx="12" fill="#d4cbc2"/>
|
||||||
|
<text x="384" y="640" text-anchor="middle" font-size="28" fill="#8b7355" font-family="Georgia, serif">Large bouquet</text>
|
||||||
|
<circle cx="240" cy="390" r="54" fill="#f4a6b8"/>
|
||||||
|
<circle cx="330" cy="350" r="68" fill="#f8c8d4"/>
|
||||||
|
<circle cx="420" cy="340" r="72" fill="#efb4c4"/>
|
||||||
|
<circle cx="510" cy="380" r="58" fill="#ffd6e0"/>
|
||||||
|
<circle cx="560" cy="450" r="50" fill="#ffeef2"/>
|
||||||
|
<circle cx="200" cy="440" r="48" fill="#f8d0da"/>
|
||||||
|
<circle cx="280" cy="480" r="44" fill="#ffd6e0"/>
|
||||||
|
<rect x="360" y="420" width="48" height="300" rx="8" fill="#4d6f53"/>
|
||||||
|
<text x="384" y="960" text-anchor="middle" font-size="36" fill="#6b5b53" font-family="Arial">L</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 886 B |
12
static/dev/bouquet-m.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="768" height="1024" viewBox="0 0 768 1024">
|
||||||
|
<rect width="768" height="1024" fill="#f0ebe6"/>
|
||||||
|
<rect x="60" y="700" width="648" height="220" rx="12" fill="#ddd4cb"/>
|
||||||
|
<text x="384" y="660" text-anchor="middle" font-size="28" fill="#8b7355" font-family="Georgia, serif">Medium bouquet</text>
|
||||||
|
<circle cx="280" cy="400" r="52" fill="#f4a6b8"/>
|
||||||
|
<circle cx="360" cy="360" r="64" fill="#f8c8d4"/>
|
||||||
|
<circle cx="440" cy="380" r="58" fill="#efb4c4"/>
|
||||||
|
<circle cx="500" cy="430" r="46" fill="#ffd6e0"/>
|
||||||
|
<circle cx="260" cy="450" r="42" fill="#ffeef2"/>
|
||||||
|
<rect x="364" y="440" width="40" height="280" rx="8" fill="#5a7d60"/>
|
||||||
|
<text x="384" y="960" text-anchor="middle" font-size="36" fill="#6b5b53" font-family="Arial">M</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 783 B |
10
static/dev/bouquet-s.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="768" height="1024" viewBox="0 0 768 1024">
|
||||||
|
<rect width="768" height="1024" fill="#f5ebe3"/>
|
||||||
|
<rect x="80" y="720" width="608" height="200" rx="12" fill="#e8ddd4"/>
|
||||||
|
<text x="384" y="680" text-anchor="middle" font-size="28" fill="#8b7355" font-family="Georgia, serif">Small bouquet</text>
|
||||||
|
<circle cx="320" cy="420" r="48" fill="#f4a6b8"/>
|
||||||
|
<circle cx="384" cy="380" r="56" fill="#f8c8d4"/>
|
||||||
|
<circle cx="448" cy="420" r="44" fill="#efb4c4"/>
|
||||||
|
<rect x="368" y="460" width="32" height="260" rx="8" fill="#6b8f71"/>
|
||||||
|
<text x="384" y="960" text-anchor="middle" font-size="36" fill="#6b5b53" font-family="Arial">S</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 678 B |
6
static/dev/upload/character.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
|
||||||
|
<rect width="400" height="300" fill="#e8e0f4"/>
|
||||||
|
<circle cx="200" cy="110" r="55" fill="#c4b5d8"/>
|
||||||
|
<rect x="175" y="165" width="50" height="70" rx="20" fill="#9a8ab8"/>
|
||||||
|
<text x="200" y="270" text-anchor="middle" font-size="22" fill="#6b5a82" font-family="Arial">Character</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 381 B |
7
static/dev/upload/color.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="500" viewBox="0 0 400 500">
|
||||||
|
<rect width="400" height="500" fill="#f8d4dc"/>
|
||||||
|
<circle cx="120" cy="180" r="40" fill="#e8899a"/>
|
||||||
|
<circle cx="200" cy="150" r="50" fill="#f4a6b8"/>
|
||||||
|
<circle cx="280" cy="190" r="38" fill="#efb4c4"/>
|
||||||
|
<text x="200" y="420" text-anchor="middle" font-size="22" fill="#8b5a62" font-family="Arial">Color</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 409 B |
6
static/dev/upload/location.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="500" viewBox="0 0 400 500">
|
||||||
|
<rect width="400" height="500" fill="#d4e4f0"/>
|
||||||
|
<rect x="80" y="280" width="240" height="120" rx="8" fill="#8ab4cc"/>
|
||||||
|
<polygon points="200,80 260,200 140,200" fill="#5a8aaa"/>
|
||||||
|
<text x="200" y="450" text-anchor="middle" font-size="22" fill="#3d6a82" font-family="Arial">Location</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 388 B |
6
static/dev/upload/season.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
|
||||||
|
<rect width="400" height="300" fill="#dce8d0"/>
|
||||||
|
<circle cx="200" cy="120" r="70" fill="#f5e6a8"/>
|
||||||
|
<rect x="160" y="180" width="80" height="60" rx="8" fill="#6b8f71"/>
|
||||||
|
<text x="200" y="270" text-anchor="middle" font-size="22" fill="#4a6b4e" font-family="Arial">Season</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 377 B |
4
static/dev/upload/sns-1.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="500" viewBox="0 0 400 500">
|
||||||
|
<rect width="400" height="500" fill="#f5ebe3"/>
|
||||||
|
<text x="200" y="260" text-anchor="middle" font-size="24" fill="#8b7355" font-family="Georgia">SNS feed 1</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 260 B |
4
static/dev/upload/sns-2.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="500" viewBox="0 0 400 500">
|
||||||
|
<rect width="400" height="500" fill="#ebe4dc"/>
|
||||||
|
<text x="200" y="260" text-anchor="middle" font-size="24" fill="#8b7355" font-family="Georgia">SNS feed 2</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 260 B |