From 922320d59a2467f0cc8256a31efff8bbac490649 Mon Sep 17 00:00:00 2001 From: Chaewon Lee Date: Wed, 10 Jun 2026 08:56:07 +0900 Subject: [PATCH] feat: add options/map flow, dev seed, and artwork fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Options page, Kakao map with florist order message, dev tooling, and create/message dummy gating — without secrets in .env.example. Co-authored-by: 이지은 Co-authored-by: Cursor --- .env.example | 12 +- src/app.d.ts | 34 +++ src/lib/components/dev/DevSeedButton.svelte | 47 ++++ src/lib/components/ui/Artwork/Artwork.svelte | 19 +- .../ui/map/FloristOrderMessage.svelte | 53 ++++ src/lib/components/ui/map/KakaoMap.svelte | 235 ++++++++++++++++++ src/lib/components/ui/map/MapPanel.svelte | 93 +++++++ src/lib/components/ui/map/ShopList.svelte | 29 +++ .../components/ui/options/OptionCard.svelte | 40 +++ .../components/ui/options/OptionsPanel.svelte | 49 ++++ .../components/ui/upload/MoodboardGrid.svelte | 48 ++-- .../components/ui/upload/SnsFeedUpload.svelte | 19 ++ .../components/ui/upload/UploadTile.svelte | 16 +- src/lib/dev/fixtures.js | 15 ++ src/lib/dev/hydrateUpload.js | 32 +++ src/lib/dev/uploadFixtures.js | 12 + .../flowerFlow/buildFloristOrderMessage.js | 73 ++++++ src/lib/flowerFlow/devSeed.js | 60 +++++ src/lib/flowerFlow/session.js | 54 ++++ src/lib/server/dev/loadFixtureImages.js | 47 ++++ src/lib/server/dev/seedJob.js | 43 ++++ src/lib/server/flowerFlow/jobStore.js | 1 + src/lib/server/gemini/image.js | 44 +++- src/lib/server/gemini/text.js | 2 + src/routes/+layout.svelte | 2 + src/routes/api/dev/seed-flow/+server.js | 57 +++++ src/routes/api/dev/skip-images/+server.js | 43 ++++ src/routes/api/map/shops/+server.js | 86 +++++++ src/routes/create/+page.svelte | 46 +++- src/routes/generating/+page.svelte | 2 + src/routes/map/+page.svelte | 117 ++++++++- src/routes/message/+page.svelte | 82 +++++- src/routes/options/+page.svelte | 97 ++++---- src/routes/upload/+page.svelte | 34 ++- static/dev/bouquet-l.svg | 14 ++ static/dev/bouquet-m.svg | 12 + static/dev/bouquet-s.svg | 10 + static/dev/upload/character.svg | 6 + static/dev/upload/color.svg | 7 + static/dev/upload/location.svg | 6 + static/dev/upload/season.svg | 6 + static/dev/upload/sns-1.svg | 4 + static/dev/upload/sns-2.svg | 4 + 43 files changed, 1587 insertions(+), 125 deletions(-) create mode 100644 src/app.d.ts create mode 100644 src/lib/components/dev/DevSeedButton.svelte create mode 100644 src/lib/components/ui/map/FloristOrderMessage.svelte create mode 100644 src/lib/components/ui/map/KakaoMap.svelte create mode 100644 src/lib/components/ui/map/MapPanel.svelte create mode 100644 src/lib/components/ui/map/ShopList.svelte create mode 100644 src/lib/components/ui/options/OptionCard.svelte create mode 100644 src/lib/components/ui/options/OptionsPanel.svelte create mode 100644 src/lib/dev/fixtures.js create mode 100644 src/lib/dev/hydrateUpload.js create mode 100644 src/lib/dev/uploadFixtures.js create mode 100644 src/lib/flowerFlow/buildFloristOrderMessage.js create mode 100644 src/lib/flowerFlow/devSeed.js create mode 100644 src/lib/server/dev/loadFixtureImages.js create mode 100644 src/lib/server/dev/seedJob.js create mode 100644 src/routes/api/dev/seed-flow/+server.js create mode 100644 src/routes/api/dev/skip-images/+server.js create mode 100644 src/routes/api/map/shops/+server.js create mode 100644 static/dev/bouquet-l.svg create mode 100644 static/dev/bouquet-m.svg create mode 100644 static/dev/bouquet-s.svg create mode 100644 static/dev/upload/character.svg create mode 100644 static/dev/upload/color.svg create mode 100644 static/dev/upload/location.svg create mode 100644 static/dev/upload/season.svg create mode 100644 static/dev/upload/sns-1.svg create mode 100644 static/dev/upload/sns-2.svg diff --git a/.env.example b/.env.example index 3a8214c..1654657 100644 --- a/.env.example +++ b/.env.example @@ -6,10 +6,16 @@ GEMINI_TEXT_MODEL=gemini-2.5-flash-lite # IMAGE_PROVIDER: openai | gemini | mock # mock = instant placeholder images, zero API calls (develop without burning quota) IMAGE_PROVIDER=openai -OPENAI_API_KEY= -OPENAI_IMAGE_MODEL=gpt-image-1 +OPENAI_API_KEY=your_openai_api_key_here OPENAI_IMAGE_SIZE=1024x1024 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 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. diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..ecf2c13 --- /dev/null +++ b/src/app.d.ts @@ -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; + }; + }; + }; + } +} diff --git a/src/lib/components/dev/DevSeedButton.svelte b/src/lib/components/dev/DevSeedButton.svelte new file mode 100644 index 0000000..7aad45a --- /dev/null +++ b/src/lib/components/dev/DevSeedButton.svelte @@ -0,0 +1,47 @@ + + +{#if dev && !DEV_SEED_MUTED} +
+ + {#if message && message !== 'Filled'} +

+ {message} +

+ {/if} +
+{/if} diff --git a/src/lib/components/ui/Artwork/Artwork.svelte b/src/lib/components/ui/Artwork/Artwork.svelte index 521e03f..7f907e6 100644 --- a/src/lib/components/ui/Artwork/Artwork.svelte +++ b/src/lib/components/ui/Artwork/Artwork.svelte @@ -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();
- + {#if imageSrc} +
+ Selected bouquet +
+ {:else} + + {/if}
diff --git a/src/lib/components/ui/map/FloristOrderMessage.svelte b/src/lib/components/ui/map/FloristOrderMessage.svelte new file mode 100644 index 0000000..6c173b0 --- /dev/null +++ b/src/lib/components/ui/map/FloristOrderMessage.svelte @@ -0,0 +1,53 @@ + + +
+ {#if hasMessage} +

+ {#each segments as segment, index (index)} + {#if segment.highlight} + {'{'}{segment.text}{'}'} + {:else} + {segment.text} + {/if} + {/each} +

+ {:else} +

Complete the flow to generate your order message.

+ {/if} + + +
diff --git a/src/lib/components/ui/map/KakaoMap.svelte b/src/lib/components/ui/map/KakaoMap.svelte new file mode 100644 index 0000000..ed94709 --- /dev/null +++ b/src/lib/components/ui/map/KakaoMap.svelte @@ -0,0 +1,235 @@ + + +
+
+ + {#if mapError} +
+ {mapError} +
+ {:else if !mapReady} +
+ Loading map... +
+ {/if} +
diff --git a/src/lib/components/ui/map/MapPanel.svelte b/src/lib/components/ui/map/MapPanel.svelte new file mode 100644 index 0000000..e5ec2f6 --- /dev/null +++ b/src/lib/components/ui/map/MapPanel.svelte @@ -0,0 +1,93 @@ + + +
+
+

+ Find a nearby florist +

+

Move the map, then refresh to search this area.

+ {#if mock} +

Showing sample shops (no Kakao API key).

+ {/if} +
+
+ +
+ +
+ + {#if error} +

{error}

+ {/if} + +
+
+ + +
+ +
+ {#if loading && shops.length === 0} +

Searching for flower shops...

+ {:else} + + {/if} +
+
+
diff --git a/src/lib/components/ui/map/ShopList.svelte b/src/lib/components/ui/map/ShopList.svelte new file mode 100644 index 0000000..0a50fdf --- /dev/null +++ b/src/lib/components/ui/map/ShopList.svelte @@ -0,0 +1,29 @@ + + +
+ {#each shops as shop (shop.id)} + + {:else} +

No flower shops found nearby.

+ {/each} +
diff --git a/src/lib/components/ui/options/OptionCard.svelte b/src/lib/components/ui/options/OptionCard.svelte new file mode 100644 index 0000000..bfe5580 --- /dev/null +++ b/src/lib/components/ui/options/OptionCard.svelte @@ -0,0 +1,40 @@ + + + + diff --git a/src/lib/components/ui/options/OptionsPanel.svelte b/src/lib/components/ui/options/OptionsPanel.svelte new file mode 100644 index 0000000..4f9626e --- /dev/null +++ b/src/lib/components/ui/options/OptionsPanel.svelte @@ -0,0 +1,49 @@ + + +
+ {#if loading} +
+

Loading options...

+
+ {:else} + +
+ {#each sizeOptions as size (size)} + + {/each} +
+ + (selectedSize = v)} + /> + {/if} +
+ + diff --git a/src/lib/components/ui/upload/MoodboardGrid.svelte b/src/lib/components/ui/upload/MoodboardGrid.svelte index 8f5150e..4a95b57 100644 --- a/src/lib/components/ui/upload/MoodboardGrid.svelte +++ b/src/lib/components/ui/upload/MoodboardGrid.svelte @@ -1,5 +1,8 @@
diff --git a/src/lib/components/ui/upload/SnsFeedUpload.svelte b/src/lib/components/ui/upload/SnsFeedUpload.svelte index 5258dde..e501dec 100644 --- a/src/lib/components/ui/upload/SnsFeedUpload.svelte +++ b/src/lib/components/ui/upload/SnsFeedUpload.svelte @@ -1,5 +1,8 @@
diff --git a/src/lib/components/ui/upload/UploadTile.svelte b/src/lib/components/ui/upload/UploadTile.svelte index 795fa68..8c93c55 100644 --- a/src/lib/components/ui/upload/UploadTile.svelte +++ b/src/lib/components/ui/upload/UploadTile.svelte @@ -1,6 +1,4 @@ diff --git a/src/lib/dev/fixtures.js b/src/lib/dev/fixtures.js new file mode 100644 index 0000000..1f85f31 --- /dev/null +++ b/src/lib/dev/fixtures.js @@ -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}` +}; diff --git a/src/lib/dev/hydrateUpload.js b/src/lib/dev/hydrateUpload.js new file mode 100644 index 0000000..9cbedda --- /dev/null +++ b/src/lib/dev/hydrateUpload.js @@ -0,0 +1,32 @@ +/** + * static URL → File 변환 (upload UI 미리보기·primaryFile용) + * @param {string} url + * @param {string} filename + * @returns {Promise} + */ +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} tiles + * @returns {Promise>} + */ +export async function hydrateDevUpload(tiles) { + /** @type {Record} */ + 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; +} diff --git a/src/lib/dev/uploadFixtures.js b/src/lib/dev/uploadFixtures.js new file mode 100644 index 0000000..c307c29 --- /dev/null +++ b/src/lib/dev/uploadFixtures.js @@ -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' +}; diff --git a/src/lib/flowerFlow/buildFloristOrderMessage.js b/src/lib/flowerFlow/buildFloristOrderMessage.js new file mode 100644 index 0000000..ddc5e24 --- /dev/null +++ b/src/lib/flowerFlow/buildFloristOrderMessage.js @@ -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 | 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 }; +} diff --git a/src/lib/flowerFlow/devSeed.js b/src/lib/flowerFlow/devSeed.js new file mode 100644 index 0000000..02f1620 --- /dev/null +++ b/src/lib/flowerFlow/devSeed.js @@ -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 } | { 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 }; +} diff --git a/src/lib/flowerFlow/session.js b/src/lib/flowerFlow/session.js index dc6e8a4..a6d2a98 100644 --- a/src/lib/flowerFlow/session.js +++ b/src/lib/flowerFlow/session.js @@ -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} + */ +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; +} diff --git a/src/lib/server/dev/loadFixtureImages.js b/src/lib/server/dev/loadFixtureImages.js new file mode 100644 index 0000000..1f288cc --- /dev/null +++ b/src/lib/server/dev/loadFixtureImages.js @@ -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>} */ +export function loadDevBouquetImages() { + return { + S: loadFixtureImage('S'), + M: loadFixtureImage('M'), + L: loadFixtureImage('L') + }; +} diff --git a/src/lib/server/dev/seedJob.js b/src/lib/server/dev/seedJob.js new file mode 100644 index 0000000..37a297c --- /dev/null +++ b/src/lib/server/dev/seedJob.js @@ -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} 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 + }; +} diff --git a/src/lib/server/flowerFlow/jobStore.js b/src/lib/server/flowerFlow/jobStore.js index d0fd633..bd499bc 100644 --- a/src/lib/server/flowerFlow/jobStore.js +++ b/src/lib/server/flowerFlow/jobStore.js @@ -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] diff --git a/src/lib/server/gemini/image.js b/src/lib/server/gemini/image.js index 06bd773..4335944 100644 --- a/src/lib/server/gemini/image.js +++ b/src/lib/server/gemini/image.js @@ -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} */ 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} */ 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>>} */ export async function generateAllSizeImages(basePrompt) { - const image = await generateBouquetImage(basePrompt, 'M'); + /** @type {Partial>} */ + const images = {}; - return { - S: image, - M: image, - L: image - }; + for (const size of ALL_SIZES) { + images[size] = await generateBouquetImage(basePrompt, size); + } + + return images; } diff --git a/src/lib/server/gemini/text.js b/src/lib/server/gemini/text.js index 94f3e84..87d1bc7 100644 --- a/src/lib/server/gemini/text.js +++ b/src/lib/server/gemini/text.js @@ -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); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2154f43..0c1bdb9 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,6 +1,7 @@ @@ -12,3 +13,4 @@ {@render children()} + diff --git a/src/routes/api/dev/seed-flow/+server.js b/src/routes/api/dev/seed-flow/+server.js new file mode 100644 index 0000000..33459a2 --- /dev/null +++ b/src/routes/api/dev/seed-flow/+server.js @@ -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 + }); +} diff --git a/src/routes/api/dev/skip-images/+server.js b/src/routes/api/dev/skip-images/+server.js new file mode 100644 index 0000000..ef250bb --- /dev/null +++ b/src/routes/api/dev/skip-images/+server.js @@ -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); + } +} diff --git a/src/routes/api/map/shops/+server.js b/src/routes/api/map/shops/+server.js new file mode 100644 index 0000000..853d6c8 --- /dev/null +++ b/src/routes/api/map/shops/+server.js @@ -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' + } + ]; +} diff --git a/src/routes/create/+page.svelte b/src/routes/create/+page.svelte index dfe864b..987f2b4 100644 --- a/src/routes/create/+page.svelte +++ b/src/routes/create/+page.svelte @@ -1,17 +1,23 @@ -
+
-
-

Map

-

Nearby flower shops will appear here in the next step.

-

Current job: {jobId || 'none'}

+
+ + +
+ loadShops(lat, lng, { fitBounds: false })} + /> +
diff --git a/src/routes/message/+page.svelte b/src/routes/message/+page.svelte index 3735ded..774607a 100644 --- a/src/routes/message/+page.svelte +++ b/src/routes/message/+page.svelte @@ -1,21 +1,50 @@ @@ -60,6 +115,17 @@ {error}

{/if} + {#if dev} + + {/if} - {/each} -
+
+
diff --git a/src/routes/upload/+page.svelte b/src/routes/upload/+page.svelte index feea9b5..1469887 100644 --- a/src/routes/upload/+page.svelte +++ b/src/routes/upload/+page.svelte @@ -6,18 +6,40 @@ import MoodboardGrid from '$lib/components/ui/upload/MoodboardGrid.svelte'; import SnsFeedUpload from '$lib/components/ui/upload/SnsFeedUpload.svelte'; 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 loading = $state(false); let error = $state(''); - const userInput = getFlowObject('userInput') ?? {}; - async function continueToMessage() { 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) { error = 'Upload at least one image to continue.'; return; @@ -27,6 +49,10 @@ try { const result = await analyzeMood(primaryFile, userInput); + deleteFlowKey('devUpload'); + deleteFlowKey('devSeeded'); + deleteFlowKey('devMessageSnapshot'); + deleteFlowKey('cardMessage'); saveFlow({ jobId: result.jobId, moodAnalysis: result.moodAnalysis, diff --git a/static/dev/bouquet-l.svg b/static/dev/bouquet-l.svg new file mode 100644 index 0000000..7ec52db --- /dev/null +++ b/static/dev/bouquet-l.svg @@ -0,0 +1,14 @@ + + + + Large bouquet + + + + + + + + + L + diff --git a/static/dev/bouquet-m.svg b/static/dev/bouquet-m.svg new file mode 100644 index 0000000..7d1ce85 --- /dev/null +++ b/static/dev/bouquet-m.svg @@ -0,0 +1,12 @@ + + + + Medium bouquet + + + + + + + M + diff --git a/static/dev/bouquet-s.svg b/static/dev/bouquet-s.svg new file mode 100644 index 0000000..5d0d22b --- /dev/null +++ b/static/dev/bouquet-s.svg @@ -0,0 +1,10 @@ + + + + Small bouquet + + + + + S + diff --git a/static/dev/upload/character.svg b/static/dev/upload/character.svg new file mode 100644 index 0000000..58ac0a3 --- /dev/null +++ b/static/dev/upload/character.svg @@ -0,0 +1,6 @@ + + + + + Character + diff --git a/static/dev/upload/color.svg b/static/dev/upload/color.svg new file mode 100644 index 0000000..c2b8b82 --- /dev/null +++ b/static/dev/upload/color.svg @@ -0,0 +1,7 @@ + + + + + + Color + diff --git a/static/dev/upload/location.svg b/static/dev/upload/location.svg new file mode 100644 index 0000000..55bb142 --- /dev/null +++ b/static/dev/upload/location.svg @@ -0,0 +1,6 @@ + + + + + Location + diff --git a/static/dev/upload/season.svg b/static/dev/upload/season.svg new file mode 100644 index 0000000..7ed2a52 --- /dev/null +++ b/static/dev/upload/season.svg @@ -0,0 +1,6 @@ + + + + + Season + diff --git a/static/dev/upload/sns-1.svg b/static/dev/upload/sns-1.svg new file mode 100644 index 0000000..5f32f8f --- /dev/null +++ b/static/dev/upload/sns-1.svg @@ -0,0 +1,4 @@ + + + SNS feed 1 + diff --git a/static/dev/upload/sns-2.svg b/static/dev/upload/sns-2.svg new file mode 100644 index 0000000..6ef0199 --- /dev/null +++ b/static/dev/upload/sns-2.svg @@ -0,0 +1,4 @@ + + + SNS feed 2 +