feat: add generating image page, artwork, and map translation

* 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 <cursoragent@cursor.com>

* with generating page + art work

---------

Co-authored-by: 이지은 <ijieun@ijieun-ui-MacBookPro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Chaewon Lee
2026-06-14 09:43:35 +09:00
committed by GitHub
parent 921dfd55f4
commit 80b84bd2ed
25 changed files with 851 additions and 110 deletions

View File

@@ -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();
</script>
<section
class="flex w-full shrink-0 flex-col border-b border-line lg:min-h-0 lg:w-[44%] lg:shrink-0 lg:overflow-y-auto lg:border-r lg:border-b-0"
class="relative flex w-full shrink-0 flex-col border-b border-line lg:min-h-0 lg:h-full lg:w-[44%] lg:shrink-0 lg:overflow-y-auto lg:border-r lg:border-b-0"
>
<!-- mobile: compact row · desktop: centered column -->
<!--
mobile: row · desktop: 꽃 슬롯 높이 고정 → 설명 카드 길이와 무관하게 Y·크기 유지
-->
<div
class="mx-auto flex w-full max-w-100 flex-row items-center gap-12 px-6 py-5 lg:flex-1 lg:flex-col lg:items-center lg:justify-center lg:gap-10 lg:px-6 lg:py-12"
class="mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-row items-start gap-8 px-6 py-6 lg:flex-col lg:items-center lg:justify-start lg:gap-4 lg:px-6 lg:pb-8 lg:pt-[calc(50%-5rem)]"
>
{#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
class="flex h-[11rem] shrink-0 items-end justify-center sm:h-[13rem] lg:h-[min(24rem,36vh)] lg:w-full"
>
{#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 {variant} />
{/if}
</div>
<div class="min-w-0 shrink-0 lg:w-full lg:flex lg:justify-center">
<DescriptionCard {title} {description} />
</div>
</div>
{#if comingSoon}
<ComingSoonTape />
{/if}
</section>

View File

@@ -0,0 +1,11 @@
<!-- generating: 왼쪽 섹션 전체 가로 + blur, vase 높이 중앙 -->
<div
class="pointer-events-none absolute inset-x-0 top-[calc(50%-1.25rem)] z-30 -translate-y-1/2"
aria-hidden="true"
>
<div
class="w-full border-y border-subtle/30 bg-muted/45 py-2.5 text-center backdrop-blur-md lg:py-3"
>
<p class="text-sm font-light tracking-wide text-ink lg:text-base">Coming Soon</p>
</div>
</div>

View File

@@ -1,11 +1,16 @@
<script>
import vaseIllustration from '$lib/assets/vase-illustration.svg';
import { getArtworkSrc } from './artworkVariants.js';
/** @type {import('./artworkVariants.js').ArtworkVariant} */
let { variant = 'create1' } = $props();
const src = $derived(getArtworkSrc(variant));
</script>
<img
src={vaseIllustration}
{src}
alt=""
class="mx-auto h-auto w-full max-w-24 shrink-0 sm:max-w-28 lg:max-w-75"
width="320"
height="452"
width="328"
height="443"
/>

View File

@@ -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<ArtworkVariant, string>} */
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;
}

View File

@@ -0,0 +1,64 @@
<script>
import GenerationStepItem from './GenerationStepItem.svelte';
import { GENERATION_STEPS, GENERATION_STEP_COUNT } from './generationSteps.js';
let {
/** 현재 active 단계 (06). GENERATION_STEP_COUNT이면 전부 완료 */
activeStepIndex = 0,
error = '',
retryLabel = '',
canRetry = false,
onRetry = () => {},
onBack = () => {}
} = $props();
/**
* @param {number} index
* @returns {'completed' | 'active' | 'pending'}
*/
function stepStatus(index) {
if (activeStepIndex >= GENERATION_STEP_COUNT) return 'completed';
if (index < activeStepIndex) return 'completed';
if (index === activeStepIndex) return 'active';
return 'pending';
}
</script>
<div class="flex flex-1 flex-col justify-center px-6 py-10 md:px-12 lg:px-16 lg:py-16">
<header class="mb-10 space-y-3 lg:mb-14">
<h1 class="text-3xl leading-relaxed font-light text-muted md:text-4xl lg:text-[2.75rem]">
Creating your bouquet...
</h1>
{#if retryLabel}
<p class="text-sm text-muted">{retryLabel}</p>
{/if}
</header>
<ol class="space-y-4 lg:space-y-5" aria-label="Bouquet creation progress">
{#each GENERATION_STEPS as label, index (label)}
<GenerationStepItem {label} status={stepStatus(index)} />
{/each}
</ol>
{#if error}
<div class="mt-10 space-y-4">
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
{error}
</p>
<div class="flex flex-wrap gap-3">
{#if canRetry}
<button type="button" class="bg-pill px-4 py-2 text-sm text-surface" onclick={onRetry}>
Try again
</button>
{/if}
<button
type="button"
class="border border-pill px-4 py-2 text-sm text-ink"
onclick={onBack}
>
Back to message
</button>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,41 @@
<script>
/** @type {'completed' | 'active' | 'pending'} */
let { label, status = 'pending' } = $props();
</script>
<li
class={[
'flex items-start gap-3 text-lg tracking-wide transition-all duration-500 md:text-xs',
status === 'completed' && 'step-completed text-ink opacity-100',
status === 'active' && 'text-ink',
status === 'pending' && 'text-muted/50'
]}
>
<span class="mt-0.5 w-5 shrink-0 text-center leading-none" aria-hidden="true">
{#if status === 'completed'}
<span class="inline-block"></span>
{:else if status === 'active'}
<span class="inline-block animate-pulse"></span>
{:else}
<span class="inline-block"></span>
{/if}
</span>
<span class="leading-snug">{label}</span>
</li>
<style>
.step-completed {
animation: stepFadeIn 0.5s ease-out;
}
@keyframes stepFadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -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;

View File

@@ -1,20 +1,49 @@
<script>
/** @typedef {{ text: string, highlight: boolean }} OrderMessageSegment */
let {
plainText = '',
segments = /** @type {OrderMessageSegment[]} */ ([])
enPlainText = '',
koPlainText = ''
} = $props();
/** @type {'ko' | 'en'} */
let activeLang = $state('ko');
let textEn = $state('');
let textKo = $state('');
let seeded = $state(false);
let copied = $state(false);
const hasMessage = $derived(Boolean(plainText?.trim()));
$effect(() => {
if (!seeded && (enPlainText || koPlainText)) {
textEn = enPlainText;
textKo = koPlainText;
seeded = true;
}
});
const activeText = $derived(activeLang === 'ko' ? textKo : textEn);
const hasMessage = $derived(
Boolean(activeText?.trim()) || Boolean(textEn?.trim()) || Boolean(textKo?.trim())
);
/** @param {Event & { currentTarget: HTMLTextAreaElement }} event */
function handleInput(event) {
const value = event.currentTarget.value;
if (activeLang === 'ko') {
textKo = value;
} else {
textEn = value;
}
}
/** @param {'ko' | 'en'} lang */
function setLanguage(lang) {
activeLang = lang;
}
async function handleCopy() {
if (!hasMessage) return;
if (!activeText.trim()) return;
try {
await navigator.clipboard.writeText(plainText);
await navigator.clipboard.writeText(activeText);
copied = true;
setTimeout(() => {
copied = false;
@@ -25,29 +54,50 @@
}
</script>
<div class="flex items-start justify-between gap-3">
<div class="flex items-start 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>
<textarea
class="min-h-[5.5rem] min-w-0 flex-1 resize-y rounded border border-line bg-transparent px-3 py-2 text-sm leading-relaxed text-muted focus:border-line-strong focus:outline-none"
rows={4}
value={activeText}
oninput={handleInput}
aria-label={activeLang === 'ko' ? '꽃집 주문 멘트 (한국어)' : 'Florist order message (English)'}
></textarea>
{:else}
<p class="text-sm text-muted">Complete the flow to generate your order message.</p>
<p class="min-w-0 flex-1 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 class="flex shrink-0 flex-col items-stretch gap-2">
<button
type="button"
disabled={!hasMessage}
onclick={handleCopy}
class="rounded bg-pill px-3 py-1.5 text-xs text-surface disabled:opacity-40"
>
{copied ? 'Copied!' : 'Copy'}
</button>
<div class="flex gap-1">
<button
type="button"
disabled={!hasMessage}
onclick={() => setLanguage('ko')}
class="flex-1 rounded border px-2 py-1.5 text-xs disabled:opacity-40 {activeLang === 'ko'
? 'border-pill bg-pill text-surface'
: 'border-line text-muted hover:border-line-strong'}"
>
Kor
</button>
<button
type="button"
disabled={!hasMessage}
onclick={() => setLanguage('en')}
class="flex-1 rounded border px-2 py-1.5 text-xs disabled:opacity-40 {activeLang === 'en'
? 'border-pill bg-pill text-surface'
: 'border-line text-muted hover:border-line-strong'}"
>
Eng
</button>
</div>
</div>
</div>

View File

@@ -11,7 +11,7 @@
mock = false,
fitBounds = false,
orderPlainText = '',
orderSegments = [],
orderKoPlainText = '',
onrefresh
} = $props();
@@ -53,7 +53,7 @@
</header>
<div class="shrink-0 px-6 pb-4 md:px-10 lg:px-12">
<FloristOrderMessage plainText={orderPlainText} segments={orderSegments} />
<FloristOrderMessage enPlainText={orderPlainText} koPlainText={orderKoPlainText} />
</div>
{#if error}

View File

@@ -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;

View File

@@ -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;