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,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}

View File

@@ -3,7 +3,12 @@
import Vase from './Vase.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>
<section
@@ -13,7 +18,17 @@
<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"
>
<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} />
</div>
</section>

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>

View 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>

View 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>

View File

@@ -1,5 +1,8 @@
<script>
import { onMount } from '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();
@@ -12,36 +15,23 @@
primaryFile = colorFile ?? seasonFile ?? characterFile ?? locationFile ?? null;
});
const tiles = [
{
key: 'color',
label: 'Color',
aspect: 'aspect-4/5',
bindFile: () => colorFile,
setFile: (v) => (colorFile = v)
},
{
key: 'season',
label: 'Season',
aspect: 'aspect-4/3',
bindFile: () => seasonFile,
setFile: (v) => (seasonFile = v)
},
{
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)
onMount(async () => {
const devUpload = getFlowObject('devUpload');
if (!isDevSeeded() || !devUpload?.active) return;
const tiles = devUpload.moodboard;
if (!tiles || typeof tiles !== 'object') return;
try {
const files = await hydrateDevUpload(/** @type {Record<string, string>} */ (tiles));
if (files.color) colorFile = files.color;
if (files.season) seasonFile = files.season;
if (files.character) characterFile = files.character;
if (files.location) locationFile = files.location;
} catch {
// dev seed 실패 시 빈 타일 유지
}
];
});
</script>
<div class="moodboard min-h-0 w-full flex-1">

View File

@@ -1,5 +1,8 @@
<script>
import { onMount } from '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();
@@ -9,6 +12,22 @@
$effect(() => {
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>
<div class="feed min-h-0 w-full flex-1">

View File

@@ -1,6 +1,4 @@
<script>
import { onDestroy } from 'svelte';
// A single click-to-upload slot: a light bordered placeholder when empty,
// 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
@@ -12,13 +10,19 @@
function pick(event) {
const picked = event.currentTarget.files?.[0];
if (!picked) return;
if (preview) URL.revokeObjectURL(preview);
file = picked;
preview = URL.createObjectURL(picked);
}
onDestroy(() => {
if (preview) URL.revokeObjectURL(preview);
// 부모에서 File을 주입할 때(Dev seed 등) 미리보기 동기화
$effect(() => {
if (!file) {
preview = null;
return;
}
const url = URL.createObjectURL(file);
preview = url;
return () => URL.revokeObjectURL(url);
});
</script>

15
src/lib/dev/fixtures.js Normal file
View 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}`
};

View 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;
}

View 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'
};

View 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 };
}

View 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 };
}

View File

@@ -28,8 +28,62 @@ export function getFlowString(key) {
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 */
export function getFlowObject(key) {
const value = loadFlow()[key];
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;
}

View 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')
};
}

View 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
};
}

View File

@@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto';
* @typedef {Object} UserInput
* @property {string} [relationship]
* @property {string} [occasion]
* @property {string} [style]
* @property {number} [budget]
* @property {string} [season]
* @property {string} [notes]

View File

@@ -6,13 +6,36 @@ import { getImageModel, isGeminiConfigured } from './client.js';
import { mockGeneratedImage } from './mock.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>} */
const SIZE_PROMPTS = {
S: 'Create a small version with fewer flowers. Simple, delicate, and affordable.',
M: 'Create a medium version with a balanced amount of flowers and standard florist bouquet volume.',
L: 'Create a large version with more flowers, fuller volume, premium and abundant.'
S: `SIZE: SMALL (S) — budget / compact version.
- Slim, compact bouquet; smallest of the three size options.
- 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() {
const configured = env.IMAGE_PROVIDER?.trim().toLowerCase();
if (configured === 'mock' || configured === 'openai' || configured === 'gemini') {
@@ -33,7 +56,7 @@ export function isImageGenerationConfigured() {
* @returns {Promise<GeneratedImage>}
*/
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();
// 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>>>}
*/
export async function generateAllSizeImages(basePrompt) {
const image = await generateBouquetImage(basePrompt, 'M');
/** @type {Partial<Record<BouquetSize, GeneratedImage>>} */
const images = {};
return {
S: image,
M: image,
L: image
};
for (const size of ALL_SIZES) {
images[size] = await generateBouquetImage(basePrompt, size);
}
return images;
}

View File

@@ -75,6 +75,8 @@ Rules:
- No fantasy colors or surreal shapes
- White background, soft natural lighting
- 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`;
const result = await model.generateContent(prompt);