From 80b84bd2edbb8c10660bd234046591f3fd872740 Mon Sep 17 00:00:00 2001 From: Chaewon Lee Date: Sun, 14 Jun 2026 09:43:35 +0900 Subject: [PATCH] feat: add generating image page, artwork, and map translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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: Cursor * with generating page + art work --------- Co-authored-by: 이지은 Co-authored-by: Cursor --- src/lib/assets/artwork/1.create1.svg | 6 + src/lib/assets/artwork/2.create2.svg | 24 +++ src/lib/assets/artwork/3.upload1.svg | 34 +++++ src/lib/assets/artwork/4.upload2.svg | 39 +++++ src/lib/assets/artwork/5.message1.svg | 50 +++++++ src/lib/assets/artwork/6.generated.svg | 50 +++++++ src/lib/components/ui/Artwork/Artwork.svelte | 51 ++++--- .../ui/Artwork/ComingSoonTape.svelte | 11 ++ src/lib/components/ui/Artwork/Vase.svelte | 13 +- .../components/ui/Artwork/artworkVariants.js | 32 ++++ .../generating/GenerationActivityFeed.svelte | 64 ++++++++ .../ui/generating/GenerationStepItem.svelte | 41 ++++++ .../ui/generating/generationSteps.js | 12 ++ .../ui/map/FloristOrderMessage.svelte | 106 ++++++++++---- src/lib/components/ui/map/MapPanel.svelte | 4 +- .../components/ui/upload/MoodboardGrid.svelte | 8 +- .../components/ui/upload/SnsFeedUpload.svelte | 7 +- .../flowerFlow/buildFloristOrderMessage.js | 34 ++++- src/lib/flowerFlow/generatingArtworkCycle.js | 44 ++++++ src/lib/flowerFlow/generationProgress.js | 127 ++++++++++++++++ src/routes/create/+page.svelte | 4 +- src/routes/generating/+page.svelte | 137 ++++++++++++------ src/routes/map/+page.svelte | 6 +- src/routes/message/+page.svelte | 4 +- src/routes/upload/+page.svelte | 53 ++++++- 25 files changed, 851 insertions(+), 110 deletions(-) create mode 100644 src/lib/assets/artwork/1.create1.svg create mode 100644 src/lib/assets/artwork/2.create2.svg create mode 100644 src/lib/assets/artwork/3.upload1.svg create mode 100644 src/lib/assets/artwork/4.upload2.svg create mode 100644 src/lib/assets/artwork/5.message1.svg create mode 100644 src/lib/assets/artwork/6.generated.svg create mode 100644 src/lib/components/ui/Artwork/ComingSoonTape.svelte create mode 100644 src/lib/components/ui/Artwork/artworkVariants.js create mode 100644 src/lib/components/ui/generating/GenerationActivityFeed.svelte create mode 100644 src/lib/components/ui/generating/GenerationStepItem.svelte create mode 100644 src/lib/components/ui/generating/generationSteps.js create mode 100644 src/lib/flowerFlow/generatingArtworkCycle.js create mode 100644 src/lib/flowerFlow/generationProgress.js diff --git a/src/lib/assets/artwork/1.create1.svg b/src/lib/assets/artwork/1.create1.svg new file mode 100644 index 0000000..0d8cc50 --- /dev/null +++ b/src/lib/assets/artwork/1.create1.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/lib/assets/artwork/2.create2.svg b/src/lib/assets/artwork/2.create2.svg new file mode 100644 index 0000000..4dffee0 --- /dev/null +++ b/src/lib/assets/artwork/2.create2.svg @@ -0,0 +1,24 @@ + + + + + + + + + +
+
+
+ + + + + + + + + + + +
diff --git a/src/lib/assets/artwork/3.upload1.svg b/src/lib/assets/artwork/3.upload1.svg new file mode 100644 index 0000000..d782d14 --- /dev/null +++ b/src/lib/assets/artwork/3.upload1.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + +
diff --git a/src/lib/assets/artwork/4.upload2.svg b/src/lib/assets/artwork/4.upload2.svg new file mode 100644 index 0000000..2a2b368 --- /dev/null +++ b/src/lib/assets/artwork/4.upload2.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + +
diff --git a/src/lib/assets/artwork/5.message1.svg b/src/lib/assets/artwork/5.message1.svg new file mode 100644 index 0000000..57cc865 --- /dev/null +++ b/src/lib/assets/artwork/5.message1.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + +
+
+
+
+
+
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/lib/assets/artwork/6.generated.svg b/src/lib/assets/artwork/6.generated.svg new file mode 100644 index 0000000..0350f5b --- /dev/null +++ b/src/lib/assets/artwork/6.generated.svg @@ -0,0 +1,50 @@ + + + + + + + + + + +
+
+
+
+
+
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/lib/components/ui/Artwork/Artwork.svelte b/src/lib/components/ui/Artwork/Artwork.svelte index 7f907e6..4a0ab29 100644 --- a/src/lib/components/ui/Artwork/Artwork.svelte +++ b/src/lib/components/ui/Artwork/Artwork.svelte @@ -2,33 +2,50 @@ // The exhibited artwork — always shown on the left, acting like a step indicator. import Vase from './Vase.svelte'; import DescriptionCard from './DescriptionCard.svelte'; + import ComingSoonTape from './ComingSoonTape.svelte'; let { title = 'Title', description = 'Description Description Description', - /** options Continue 이후 확정된 꽃다발만 전달 (그 전에는 null → Vase) */ - imageSrc = null + /** @type {import('./artworkVariants.js').ArtworkVariant} */ + variant = 'create1', + /** edit Continue 이후 확정된 꽃다발만 전달 (그 전에는 null → Vase) */ + imageSrc = null, + /** generating 단계: 작품 중앙 Coming Soon 밴드 */ + comingSoon = false } = $props();
- +
- {#if imageSrc} -
- Selected bouquet -
- {:else} - - {/if} - +
+ {#if imageSrc} +
+ Selected bouquet +
+ {:else} + + {/if} +
+ +
+ +
+ {#if comingSoon} + + {/if}
diff --git a/src/lib/components/ui/Artwork/ComingSoonTape.svelte b/src/lib/components/ui/Artwork/ComingSoonTape.svelte new file mode 100644 index 0000000..91629d8 --- /dev/null +++ b/src/lib/components/ui/Artwork/ComingSoonTape.svelte @@ -0,0 +1,11 @@ + + diff --git a/src/lib/components/ui/Artwork/Vase.svelte b/src/lib/components/ui/Artwork/Vase.svelte index 4a41340..97da57c 100644 --- a/src/lib/components/ui/Artwork/Vase.svelte +++ b/src/lib/components/ui/Artwork/Vase.svelte @@ -1,11 +1,16 @@ diff --git a/src/lib/components/ui/Artwork/artworkVariants.js b/src/lib/components/ui/Artwork/artworkVariants.js new file mode 100644 index 0000000..cdfaa28 --- /dev/null +++ b/src/lib/components/ui/Artwork/artworkVariants.js @@ -0,0 +1,32 @@ +import create1 from '$lib/assets/artwork/1.create1.svg'; +import create2 from '$lib/assets/artwork/2.create2.svg'; +import upload1 from '$lib/assets/artwork/3.upload1.svg'; +import upload2 from '$lib/assets/artwork/4.upload2.svg'; +import message1 from '$lib/assets/artwork/5.message1.svg'; +import generated from '$lib/assets/artwork/6.generated.svg'; + +/** @typedef {'create1' | 'create2' | 'upload1' | 'upload2' | 'message1' | 'generated'} ArtworkVariant */ + +/** @type {Record} */ +export const ARTWORK_SRC = { + create1, + create2, + upload1, + upload2, + message1, + generated +}; + +/** generating 페이지 순환 프레임 */ +export const GENERATING_ARTWORK_CYCLE = /** @type {const} */ ([ + 'create2', + 'upload1', + 'upload2', + 'message1', + 'generated' +]); + +/** @param {ArtworkVariant} [variant='create1'] */ +export function getArtworkSrc(variant = 'create1') { + return ARTWORK_SRC[variant] ?? ARTWORK_SRC.create1; +} diff --git a/src/lib/components/ui/generating/GenerationActivityFeed.svelte b/src/lib/components/ui/generating/GenerationActivityFeed.svelte new file mode 100644 index 0000000..a906dc8 --- /dev/null +++ b/src/lib/components/ui/generating/GenerationActivityFeed.svelte @@ -0,0 +1,64 @@ + + +
+
+

+ Creating your bouquet... +

+ {#if retryLabel} +

{retryLabel}

+ {/if} +
+ +
    + {#each GENERATION_STEPS as label, index (label)} + + {/each} +
+ + {#if error} +
+

+ {error} +

+
+ {#if canRetry} + + {/if} + +
+
+ {/if} +
diff --git a/src/lib/components/ui/generating/GenerationStepItem.svelte b/src/lib/components/ui/generating/GenerationStepItem.svelte new file mode 100644 index 0000000..6b527d5 --- /dev/null +++ b/src/lib/components/ui/generating/GenerationStepItem.svelte @@ -0,0 +1,41 @@ + + +
  • + + {label} +
  • + + diff --git a/src/lib/components/ui/generating/generationSteps.js b/src/lib/components/ui/generating/generationSteps.js new file mode 100644 index 0000000..cf795e9 --- /dev/null +++ b/src/lib/components/ui/generating/generationSteps.js @@ -0,0 +1,12 @@ +/** AI activity feed 단계 라벨 (generating 페이지) */ +export const GENERATION_STEPS = [ + 'Understanding who this bouquet is for', + 'Discovering their mood and aesthetic', + 'Finding flowers that match their personality', + 'Exploring meaningful flower symbolism', + 'Designing the bouquet composition', + 'Choosing the wrapping style', + 'Adding the finishing ribbon' +]; + +export const GENERATION_STEP_COUNT = GENERATION_STEPS.length; diff --git a/src/lib/components/ui/map/FloristOrderMessage.svelte b/src/lib/components/ui/map/FloristOrderMessage.svelte index 6c173b0..854785c 100644 --- a/src/lib/components/ui/map/FloristOrderMessage.svelte +++ b/src/lib/components/ui/map/FloristOrderMessage.svelte @@ -1,20 +1,49 @@ -
    +
    {#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.

    +

    Complete the flow to generate your order message.

    {/if} - +
    + + +
    + + +
    +
    diff --git a/src/lib/components/ui/map/MapPanel.svelte b/src/lib/components/ui/map/MapPanel.svelte index e5ec2f6..3e26f93 100644 --- a/src/lib/components/ui/map/MapPanel.svelte +++ b/src/lib/components/ui/map/MapPanel.svelte @@ -11,7 +11,7 @@ mock = false, fitBounds = false, orderPlainText = '', - orderSegments = [], + orderKoPlainText = '', onrefresh } = $props(); @@ -53,7 +53,7 @@
    - +
    {#if error} diff --git a/src/lib/components/ui/upload/MoodboardGrid.svelte b/src/lib/components/ui/upload/MoodboardGrid.svelte index ffb11d1..a1f2653 100644 --- a/src/lib/components/ui/upload/MoodboardGrid.svelte +++ b/src/lib/components/ui/upload/MoodboardGrid.svelte @@ -4,7 +4,7 @@ import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js'; import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js'; - let { primaryFile = $bindable(null), caption = 'build their moodboard!' } = $props(); + let { primaryFile = $bindable(null), caption = 'build their moodboard!', filledCount = $bindable(0), allFilled = $bindable(false) } = $props(); let colorFile = $state(null); let seasonFile = $state(null); @@ -16,6 +16,12 @@ if (primaryFile !== next) primaryFile = next; }); + $effect(() => { + const count = [colorFile, seasonFile, characterFile, locationFile].filter(Boolean).length; + filledCount = count; + allFilled = count === 4; + }); + onMount(async () => { const devUpload = getFlowObject('devUpload'); if (!isDevSeeded() || !devUpload?.active) return; diff --git a/src/lib/components/ui/upload/SnsFeedUpload.svelte b/src/lib/components/ui/upload/SnsFeedUpload.svelte index 586e164..5f73edc 100644 --- a/src/lib/components/ui/upload/SnsFeedUpload.svelte +++ b/src/lib/components/ui/upload/SnsFeedUpload.svelte @@ -4,7 +4,7 @@ import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js'; import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js'; - let { primaryFile = $bindable(null), caption = 'upload their feed!' } = $props(); + let { primaryFile = $bindable(null), caption = 'upload their feed!', filledCount = $bindable(0), allFilled = $bindable(false) } = $props(); let firstFile = $state(null); @@ -13,6 +13,11 @@ if (primaryFile !== next) primaryFile = next; }); + $effect(() => { + filledCount = firstFile ? 1 : 0; + allFilled = Boolean(firstFile); + }); + onMount(async () => { const devUpload = getFlowObject('devUpload'); if (!isDevSeeded() || !devUpload?.active) return; diff --git a/src/lib/flowerFlow/buildFloristOrderMessage.js b/src/lib/flowerFlow/buildFloristOrderMessage.js index ddc5e24..7942cbc 100644 --- a/src/lib/flowerFlow/buildFloristOrderMessage.js +++ b/src/lib/flowerFlow/buildFloristOrderMessage.js @@ -4,7 +4,11 @@ /** @typedef {{ text: string, highlight: boolean }} OrderMessageSegment */ -/** @typedef {{ plainText: string, segments: OrderMessageSegment[] }} FloristOrderMessageResult */ +/** @typedef {{ plainText: string, segments: OrderMessageSegment[] }} OrderMessageLocale */ + +/** @typedef {OrderMessageLocale & { ko: OrderMessageLocale }} FloristOrderMessageResult */ + +const EMPTY_LOCALE = /** @type {OrderMessageLocale} */ ({ plainText: '', segments: [] }); /** * @param {string[]} [items] @@ -26,7 +30,7 @@ export function buildFloristOrderMessage(input) { const { userInput, moodAnalysis, recipe } = input; if (!recipe && !userInput?.relationship && !userInput?.occasion) { - return { plainText: '', segments: [] }; + return { ...EMPTY_LOCALE, ko: { ...EMPTY_LOCALE } }; } const relationship = userInput?.relationship ?? 'someone special'; @@ -69,5 +73,29 @@ export function buildFloristOrderMessage(input) { { text: ' tones. Would a reservation be possible?', highlight: false } ]; - return { plainText, segments }; + const koPlainText = + `안녕하세요, 꽃 주문 문의드립니다. ` + + `${relationship}에게 ${occasion} 꽃다발을 준비하고 싶습니다. 예산은 약 ${budget}입니다. ` + + `${moodFeel}한 분위기로, ${colorTone} 톤으로 선물하고 싶습니다. ` + + `예약 가능할까요?`; + + const koSegments = [ + { text: '안녕하세요, 꽃 주문 문의드립니다. ', highlight: false }, + { text: relationship, highlight: true }, + { text: '에게 ', highlight: false }, + { text: occasion, highlight: true }, + { text: ' 꽃다발을 준비하고 싶습니다. 예산은 약 ', highlight: false }, + { text: budget, highlight: true }, + { text: '입니다. ', highlight: false }, + { text: moodFeel, highlight: true }, + { text: '한 분위기로, ', highlight: false }, + { text: colorTone, highlight: true }, + { text: ' 톤으로 선물하고 싶습니다. 예약 가능할까요?', highlight: false } + ]; + + return { + plainText, + segments, + ko: { plainText: koPlainText, segments: koSegments } + }; } diff --git a/src/lib/flowerFlow/generatingArtworkCycle.js b/src/lib/flowerFlow/generatingArtworkCycle.js new file mode 100644 index 0000000..785d34a --- /dev/null +++ b/src/lib/flowerFlow/generatingArtworkCycle.js @@ -0,0 +1,44 @@ +import { GENERATING_ARTWORK_CYCLE } from '$lib/components/ui/Artwork/artworkVariants.js'; + +const FRAME_MS = 700; + +/** + * generating 페이지 artwork 순환 (create2 → … → generated) + * @param {(variant: import('$lib/components/ui/Artwork/artworkVariants.js').ArtworkVariant) => void} onVariantChange + */ +export function createGeneratingArtworkCycle(onVariantChange) { + let frameIndex = 0; + /** @type {ReturnType | null} */ + let timer = null; + let disposed = false; + + function emitCurrent() { + onVariantChange(GENERATING_ARTWORK_CYCLE[frameIndex]); + } + + function start() { + stop(); + disposed = false; + frameIndex = 0; + emitCurrent(); + timer = setInterval(() => { + if (disposed) return; + frameIndex = (frameIndex + 1) % GENERATING_ARTWORK_CYCLE.length; + emitCurrent(); + }, FRAME_MS); + } + + function stop() { + if (timer) { + clearInterval(timer); + timer = null; + } + } + + function dispose() { + disposed = true; + stop(); + } + + return { start, stop, dispose }; +} diff --git a/src/lib/flowerFlow/generationProgress.js b/src/lib/flowerFlow/generationProgress.js new file mode 100644 index 0000000..cf25a74 --- /dev/null +++ b/src/lib/flowerFlow/generationProgress.js @@ -0,0 +1,127 @@ +import { GENERATION_STEP_COUNT } from '$lib/components/ui/generating/generationSteps.js'; + +/** 실제 API 기준 예상 총 소요 시간 (ms) — 7단계 균등 분배 */ +export const DEFAULT_ESTIMATED_MS = 40_000; + +/** mock/dev: 7단계가 눈에 보이도록 짧게 */ +export const MOCK_ESTIMATED_MS = 6_000; + +/** stepInterval 하한 */ +export const MIN_STEP_MS = 500; + +/** API 조기 완료 시 남은 단계 catch-up 간격 */ +export const CATCHUP_STEP_MS = 250; + +/** @param {number} ms */ +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * generating UI 7단계 시간 기반 진행 컨트롤러. + * activeStepIndex: 0–6 = 해당 단계 active, GENERATION_STEP_COUNT = 전부 완료. + * + * @param {(index: number) => void} onStepChange + */ +export function createGenerationProgress(onStepChange) { + /** @type {ReturnType | null} */ + let stepTimer = null; + let disposed = false; + let currentIndex = 0; + let capped = false; + + function emit(index) { + currentIndex = index; + onStepChange(index); + } + + function clearStepTimer() { + if (stepTimer) { + clearTimeout(stepTimer); + stepTimer = null; + } + } + + function dispose() { + disposed = true; + clearStepTimer(); + } + + function reset() { + clearStepTimer(); + disposed = false; + capped = false; + currentIndex = 0; + emit(0); + } + + /** + * @param {number} estimatedMs + * @returns {number} + */ + function stepIntervalMs(estimatedMs) { + return Math.max(MIN_STEP_MS, Math.floor(estimatedMs / GENERATION_STEP_COUNT)); + } + + /** + * @param {number} estimatedMs + */ + function scheduleNextStep(estimatedMs) { + clearStepTimer(); + if (disposed || capped) return; + + stepTimer = setTimeout(() => { + if (disposed || capped) return; + + if (currentIndex < GENERATION_STEP_COUNT - 1) { + emit(currentIndex + 1); + + if (currentIndex >= GENERATION_STEP_COUNT - 1) { + capped = true; + return; + } + + scheduleNextStep(estimatedMs); + } + }, stepIntervalMs(estimatedMs)); + } + + /** + * @param {{ estimatedMs?: number }} [options] + */ + function begin(options = {}) { + const estimatedMs = options.estimatedMs ?? DEFAULT_ESTIMATED_MS; + reset(); + emit(0); + scheduleNextStep(estimatedMs); + } + + /** 전 단계 완료 */ + function completeAll() { + clearStepTimer(); + capped = true; + emit(GENERATION_STEP_COUNT); + } + + /** API 완료 시 — 남은 단계 catch-up 후 completeAll */ + async function finishWhenReady() { + clearStepTimer(); + + while (!disposed && currentIndex < GENERATION_STEP_COUNT - 1) { + emit(currentIndex + 1); + await wait(CATCHUP_STEP_MS); + } + + if (!disposed) { + completeAll(); + } + } + + return { + begin, + completeAll, + finishWhenReady, + reset, + dispose + }; +} diff --git a/src/routes/create/+page.svelte b/src/routes/create/+page.svelte index 987f2b4..7ed1805 100644 --- a/src/routes/create/+page.svelte +++ b/src/routes/create/+page.svelte @@ -27,6 +27,8 @@ return `${occasion} ${who ?? '...'}`; }); + const artworkVariant = $derived(hasAnySelection ? 'create2' : 'create1'); + const artworkDescription = $derived( hasAnySelection ? `${style ?? '—'} style · ₩${budget.toLocaleString('ko-KR')} budget` @@ -82,7 +84,7 @@
    - +
    diff --git a/src/routes/generating/+page.svelte b/src/routes/generating/+page.svelte index 75790fc..1608d39 100644 --- a/src/routes/generating/+page.svelte +++ b/src/routes/generating/+page.svelte @@ -3,25 +3,62 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import Header from '$lib/components/ui/Header.svelte'; + import Artwork from '$lib/components/ui/Artwork/Artwork.svelte'; + import GenerationActivityFeed from '$lib/components/ui/generating/GenerationActivityFeed.svelte'; import { buildRecipe, generateImages } from '$lib/flowerFlow/api.js'; - import { clearFlow, getFlowObject, loadFlow, saveFlow } from '$lib/flowerFlow/session.js'; + import { createGenerationProgress, DEFAULT_ESTIMATED_MS, MOCK_ESTIMATED_MS } from '$lib/flowerFlow/generationProgress.js'; + import { createGeneratingArtworkCycle } from '$lib/flowerFlow/generatingArtworkCycle.js'; + import { + clearFlow, + getFlowObject, + getFlowString, + getFlowUserInput, + loadFlow, + saveFlow + } from '$lib/flowerFlow/session.js'; const MAX_RETRIES = 5; + const userInput = getFlowUserInput(); + const cardMessage = getFlowString('cardMessage'); - let status = $state('Preparing bouquet recipe...'); + const artworkTitle = $derived.by(() => { + const who = typeof userInput.relationship === 'string' ? userInput.relationship : null; + const whatFor = typeof userInput.occasion === 'string' ? userInput.occasion : null; + if (!who && !whatFor) return 'Your bouquet'; + const occasion = whatFor ? `A ${whatFor} bouquet for` : 'A bouquet for'; + return `${occasion} ${who ?? '...'}`; + }); + + const artworkDescription = $derived(cardMessage || '잠시 관리중 ~'); + + /** @type {import('$lib/components/ui/Artwork/artworkVariants.js').ArtworkVariant} */ + let artworkVariant = $state('create2'); + + let activeStepIndex = $state(0); + let retryLabel = $state(''); let error = $state(''); let canRetry = $state(false); let active = true; + /** @type {ReturnType | null} */ + let progress = null; + /** @type {ReturnType | null} */ + let artworkCycle = null; + + function startArtworkCycle() { + artworkCycle?.dispose(); + artworkCycle = createGeneratingArtworkCycle((variant) => { + artworkVariant = variant; + }); + artworkCycle.start(); + } /** @param {number} ms */ function wait(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolveWait) => setTimeout(resolveWait, ms)); } /** - * Read the structured fields the server now sends. Falls back to message - * sniffing only if an older/unstructured error slips through. * @param {any} err */ function classify(err) { @@ -42,9 +79,6 @@ } /** - * Run a task with a finite, classified retry policy: permanent errors stop - * immediately, transient ones retry up to MAX_RETRIES respecting the - * server-provided delay, and the real error is surfaced either way. * @template T * @param {string} label * @param {() => Promise} task @@ -55,8 +89,8 @@ while (active) { try { - status = - attempt === 0 ? label : `Retrying ${label.toLowerCase()} (${attempt}/${MAX_RETRIES})...`; + retryLabel = + attempt === 0 ? '' : `Retrying ${label.toLowerCase()} (${attempt}/${MAX_RETRIES})…`; error = ''; return await task(); } catch (err) { @@ -68,7 +102,7 @@ attempt += 1; const seconds = Math.round(retryAfterMs / 1000); - status = `AI provider is busy. Retrying in ${seconds}s (${attempt}/${MAX_RETRIES})...`; + retryLabel = `AI provider is busy. Retrying in ${seconds}s (${attempt}/${MAX_RETRIES})…`; await wait(retryAfterMs); } } @@ -77,10 +111,15 @@ } async function runGeneration() { + if (!progress) return; + canRetry = false; + error = ''; + retryLabel = ''; + const flow = loadFlow(); const jobId = typeof flow.jobId === 'string' ? flow.jobId : ''; - const userInput = getFlowObject('userInput') ?? {}; + const sessionUserInput = getFlowObject('userInput') ?? {}; if (!jobId) { await goto(resolve('/create')); @@ -88,21 +127,27 @@ } try { + const estimatedMs = flow.mock ? MOCK_ESTIMATED_MS : DEFAULT_ESTIMATED_MS; + progress.begin({ estimatedMs }); + const existingRecipe = getFlowObject('recipe'); if (!existingRecipe) { - const recipeResult = await runWithRetry('Building bouquet recipe...', () => - buildRecipe(jobId, userInput) + const recipeResult = await runWithRetry('Building bouquet recipe', () => + buildRecipe(jobId, sessionUserInput) ); saveFlow({ recipe: recipeResult.recipe }); } - const imageResult = await runWithRetry('Generating bouquet image...', () => + const imageResult = await runWithRetry('Generating bouquet image', () => generateImages(jobId) ); // Do NOT persist the multi-MB base64 images in sessionStorage — Safari caps // it at ~5MB and throws "QuotaExceededError: The quota has been exceeded." - // The images already live in Supabase Storage via the job; the options + // The images already live in Supabase Storage via the job; the edit // and result pages fetch them by jobId. We only keep lightweight metadata here. + + await progress.finishWhenReady(); + saveFlow({ imagesJobId: jobId, imagePrompt: imageResult.imagePrompt, @@ -119,61 +164,65 @@ const stale = code === 'job_not_found' || (err && typeof err === 'object' && err.status === 404); if (stale) { - // Keep the user's entered context (relationship/occasion/etc.), drop the - // dead job, and re-upload to mint a fresh one. - const userInput = getFlowObject('userInput'); + const preservedInput = getFlowObject('userInput'); clearFlow(); - if (userInput) saveFlow({ userInput }); - error = ''; - status = 'This session expired. Starting over...'; + if (preservedInput) saveFlow({ userInput: preservedInput }); + retryLabel = ''; await goto(resolve('/upload')); return; } const { permanent } = classify(err); error = err instanceof Error ? err.message : 'Generation failed'; - status = permanent ? 'Generation is blocked.' : 'Still failing after several retries.'; + retryLabel = permanent ? 'Generation is blocked.' : 'Still failing after several retries.'; canRetry = true; + progress?.reset(); } } function retry() { if (!active) return; + startArtworkCycle(); runGeneration(); } + function backToMessage() { + goto(resolve('/message')); + } + onMount(() => { active = true; + progress = createGenerationProgress((index) => { + activeStepIndex = index; + }); + startArtworkCycle(); runGeneration(); + return () => { active = false; + progress?.dispose(); + artworkCycle?.dispose(); }; }); -
    +
    -
    -

    Generating

    -

    {status}

    +
    + - {#if error} -

    {error}

    -
    - {#if canRetry} - - {/if} - -
    - {/if} +
    + +
    diff --git a/src/routes/map/+page.svelte b/src/routes/map/+page.svelte index eff4b5a..13780ac 100644 --- a/src/routes/map/+page.svelte +++ b/src/routes/map/+page.svelte @@ -22,7 +22,7 @@ let floristNote = $state(''); let fitMapBounds = $state(true); let orderPlainText = $state(''); - let orderSegments = $state([]); + let orderKoPlainText = $state(''); let selectedImage = $state(null); const sessionUserInput = getFlowObject('userInput') ?? {}; @@ -83,7 +83,7 @@ recipe: job.recipe }); orderPlainText = order.plainText; - orderSegments = order.segments; + orderKoPlainText = order.ko.plainText; } catch { // job 없어도 지도·꽃집 검색은 계속 } @@ -108,7 +108,7 @@ {error} {mock} {orderPlainText} - {orderSegments} + {orderKoPlainText} fitBounds={fitMapBounds} onrefresh={(lat, lng) => loadShops(lat, lng, { fitBounds: false })} /> diff --git a/src/routes/message/+page.svelte b/src/routes/message/+page.svelte index fb308c8..e3c25b5 100644 --- a/src/routes/message/+page.svelte +++ b/src/routes/message/+page.svelte @@ -24,6 +24,8 @@ let error = $state(''); let skipping = $state(false); + const artworkVariant = $derived(message.trim() ? 'message1' : 'upload2'); + const artworkTitle = $derived(message ? 'Your message' : 'Title'); const artworkDescription = $derived(message || 'Description Description Description'); @@ -102,7 +104,7 @@
    - +
    diff --git a/src/routes/upload/+page.svelte b/src/routes/upload/+page.svelte index 82387fb..25dfee3 100644 --- a/src/routes/upload/+page.svelte +++ b/src/routes/upload/+page.svelte @@ -24,6 +24,8 @@ : 'moodboard' ); let primaryFile = $state(null); + let filledCount = $state(0); + let allFilled = $state(false); let loading = $state(false); let error = $state(''); @@ -34,6 +36,37 @@ return 'their'; }); + const hasUserContext = $derived( + Boolean(userInput.relationship || userInput.occasion || userInput.style) + ); + + const artworkTitle = $derived.by(() => { + const who = userInput.relationship; + const whatFor = userInput.occasion; + if (!hasUserContext) return 'Title'; + const occasion = whatFor ? `A ${whatFor} bouquet for` : 'A bouquet for'; + return `${occasion} ${who ?? '...'}`; + }); + + const artworkDescription = $derived( + hasUserContext + ? `${userInput.style ?? '—'} style · ₩${Number(userInput.budget ?? 50_000).toLocaleString('ko-KR')} budget` + : 'Description Description Description' + ); + + /** create2(시작) → upload1(1장+) → upload2(전체 채움) */ + const artworkVariant = $derived.by(() => { + if (allFilled) return 'upload2'; + if (filledCount > 0) return 'upload1'; + return 'create2'; + }); + + $effect(() => { + void mode; + filledCount = 0; + allFilled = false; + }); + async function continueToMessage() { error = ''; @@ -84,16 +117,26 @@ >
    -
    - +
    +
    {#if mode === 'moodboard'} - + {:else} - + {/if}