+
{#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}