fix: landing, geolocation, and description card

* feat: add step-specific DescriptionCard instructions before user input

Each flow page shows English guidance in muted instruction mode until the user makes a selection, then switches to dynamic summary copy.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: polish route page, map geolocation, and landing artwork

Replace landing growth SVGs with flow artwork, align Start Creating with FlowContinueBar, and search nearby florists from the user's current location.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

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 22:12:59 +09:00
committed by GitHub
parent 578e54efa6
commit e0f6058ff3
12 changed files with 259 additions and 63 deletions

View File

@@ -7,6 +7,8 @@
let {
title = 'Title',
description = 'Description Description Description',
/** @type {'instruction' | 'summary'} */
cardMode = 'summary',
/** @type {import('./artworkVariants.js').ArtworkVariant} */
variant = 'create1',
/** edit Continue 이후 확정된 꽃다발만 전달 (그 전에는 null → Vase) */
@@ -42,7 +44,7 @@
</div>
<div class="min-w-0 shrink-0 lg:w-full lg:flex lg:justify-center">
<DescriptionCard {title} {description} />
<DescriptionCard {title} {description} mode={cardMode} />
</div>
</div>
{#if comingSoon}

View File

@@ -1,8 +1,24 @@
<script>
let { title = 'Title', description = 'Description Description Description' } = $props();
let {
title = 'Title',
description = 'Description Description Description',
/** instruction: 입력 전 안내 톤 (muted) */
mode = 'summary'
} = $props();
</script>
<div class="w-64 max-w-full flex-none border border-line-strong bg-white px-4 py-3 shadow-sm lg:px-6 lg:py-5">
<h3 class="text-sm leading-snug font-semibold">{title}</h3>
<p class="mt-2 text-xs leading-relaxed">{description}</p>
<h3
class={['text-sm leading-snug font-semibold', mode === 'instruction' ? 'text-muted' : 'text-ink']}
>
{title}
</h3>
<p
class={[
'mt-2 text-xs',
mode === 'instruction' ? 'leading-snug text-muted' : 'leading-relaxed text-ink'
]}
>
{description}
</p>
</div>

View File

@@ -1,7 +1,9 @@
<script>
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Button from '$lib/components/ui/Button.svelte';
import FlowContinueBar, {
FLOW_CONTINUE_BUTTON
} from '$lib/components/ui/FlowContinueBar.svelte';
import GrowthMetaphorIllustration from '$lib/components/ui/landing/GrowthMetaphorIllustration.svelte';
function handleStart() {
@@ -10,7 +12,7 @@
</script>
<section
class="relative flex min-h-dvh flex-col bg-surface px-6 py-8 font-sans text-ink sm:px-10 sm:py-10 lg:px-14"
class="relative flex min-h-dvh flex-col bg-surface px-6 py-8 pb-[3.75rem] font-sans text-ink sm:px-10 sm:py-10 lg:px-14 lg:pb-8"
aria-label="Every bouquet starts with a muse — seed to bouquet growth metaphor"
>
<div class="mx-auto flex w-full max-w-6xl min-h-0 flex-1 flex-col justify-center">
@@ -21,16 +23,20 @@
</p>
</div>
<div class="mx-auto flex w-full max-w-6xl items-end justify-between gap-6 pb-2 pt-10">
<div class="min-w-0 text-left">
<p class="text-lg leading-none tracking-wide text-ink">AI Florist</p>
<h1 class="mt-2 text-4xl leading-none font-bold tracking-wide sm:text-5xl lg:text-6xl">
DearYou
</h1>
</div>
<div class="shrink-0 pb-1">
<Button onclick={handleStart}>start creating</Button>
</div>
<div class="mx-auto w-full max-w-6xl pt-10 pb-2">
<p class="text-lg leading-none tracking-wide text-ink">AI Florist</p>
<h1 class="mt-2 text-4xl leading-none font-bold tracking-wide sm:text-5xl lg:text-6xl">
Fleumuse
</h1>
</div>
<!--
create 등 플로우 페이지 FlowContinueBar와 동일 위치:
mobile — 하단 고정 / desktop — 우측 56% 패널 하단 오른쪽
-->
<FlowContinueBar class="lg:!fixed lg:top-auto lg:right-0 lg:bottom-8 lg:left-[44%]">
<button type="button" onclick={handleStart} class={FLOW_CONTINUE_BUTTON}>
Start Creating ->
</button>
</FlowContinueBar>
</section>

View File

@@ -2,6 +2,7 @@
import FloristOrderMessage from './FloristOrderMessage.svelte';
import KakaoMap from './KakaoMap.svelte';
import ShopList from './ShopList.svelte';
import { DEFAULT_MAP_CENTER } from '$lib/map/userLocation.js';
let {
shops = [],
@@ -12,14 +13,14 @@
fitBounds = false,
orderPlainText = '',
orderKoPlainText = '',
initialLat = DEFAULT_MAP_CENTER.lat,
initialLng = DEFAULT_MAP_CENTER.lng,
locationNotice = '',
onrefresh
} = $props();
const DEFAULT_LAT = 37.5665;
const DEFAULT_LNG = 126.978;
let mapCenterLat = $state(DEFAULT_LAT);
let mapCenterLng = $state(DEFAULT_LNG);
let mapCenterLat = $state(initialLat);
let mapCenterLng = $state(initialLng);
let panTarget = $state(null);
function handleCenterChange(lat, lng) {
@@ -46,6 +47,9 @@
Find a nearby florist
</h1>
<p class="mt-3 text-sm text-muted">Move the map, then refresh to search this area.</p>
{#if locationNotice}
<p class="mt-2 text-xs text-muted">{locationNotice}</p>
{/if}
{#if mock}
<p class="mt-2 text-xs text-muted">Showing sample shops (no Kakao API key).</p>
{/if}
@@ -63,8 +67,8 @@
<div class="flex min-h-0 flex-1 flex-col gap-6 px-6 pb-8 md:px-10 lg:flex-row lg:px-12 lg:pb-10">
<div class="relative flex min-h-64 flex-1 flex-col overflow-hidden border border-line lg:min-h-0">
<KakaoMap
initialLat={DEFAULT_LAT}
initialLng={DEFAULT_LNG}
initialLat={initialLat}
initialLng={initialLng}
{shops}
selectedId={selectedShopId}
{fitBounds}

View File

@@ -0,0 +1,27 @@
/** Artwork DescriptionCard — 단계별 기본 instruction (입력 전) */
/** @typedef {{ title: string, description: string }} ArtworkCardCopy */
/** @type {Record<'create' | 'message' | 'generating' | 'map', ArtworkCardCopy>} */
export const ARTWORK_CARD_DEFAULTS = {
create: {
title: 'Who is this bouquet for?',
description:
'Choose who will receive it, the occasion, and a style on the right. Adjust the budget if you like.'
},
message: {
title: 'Write a card message',
description:
'Type a short note or pick a preset on the right. It will appear on your bouquet card.'
},
generating: {
title: 'Crafting your bouquet',
description:
'We are turning their mood, photos, and message into a one-of-a-kind arrangement.'
},
map: {
title: 'Choose a florist',
description:
'Browse nearby shops on the map and select where you would like to place your order.'
}
};

View File

@@ -1,14 +1,31 @@
import seedSrc from '$lib/assets/landing/seed.svg';
import sproutSrc from '$lib/assets/landing/sprout.svg';
import flowerSrc from '$lib/assets/landing/flower.svg';
import bouquetSrc from '$lib/assets/landing/bouquet.svg';
import { ARTWORK_SRC } from '$lib/components/ui/Artwork/artworkVariants.js';
/** 랜딩 growth metaphor — ref/route illustration SVG 4단계 */
/** 랜딩 growth metaphor — artwork 2 → 3 → 5 → 6 순서 */
export const LANDING_GROWTH_STAGES = [
{ id: 'seed', src: seedSrc, heightClass: 'h-[2.125rem] sm:h-9', delayMs: 0 },
{ id: 'sprout', src: sproutSrc, heightClass: 'h-16 sm:h-20', delayMs: 520 },
{ id: 'flower', src: flowerSrc, heightClass: 'h-24 sm:h-28', delayMs: 1040 },
{ id: 'bouquet', src: bouquetSrc, heightClass: 'h-36 sm:h-44 lg:h-52', delayMs: 1560 }
{
id: 'create2',
src: ARTWORK_SRC.create2,
heightClass: 'h-16 sm:h-20',
delayMs: 0
},
{
id: 'upload1',
src: ARTWORK_SRC.upload1,
heightClass: 'h-24 sm:h-28',
delayMs: 520
},
{
id: 'message1',
src: ARTWORK_SRC.message1,
heightClass: 'h-32 sm:h-36 lg:h-40',
delayMs: 1040
},
{
id: 'generated',
src: ARTWORK_SRC.generated,
heightClass: 'h-36 sm:h-44 lg:h-52',
delayMs: 1560
}
];
export const LANDING_STAGE_GAP_MS = 520;

View File

@@ -0,0 +1,39 @@
/** 서울시청 — 위치 권한 거부·미지원 시 fallback */
export const DEFAULT_MAP_CENTER = { lat: 37.5665, lng: 126.978 };
/**
* @typedef {{ lat: number, lng: number, fromDevice: boolean }} UserMapCenter
*/
/**
* 브라우저 Geolocation API로 현재 위치를 가져옵니다.
* 실패 시 DEFAULT_MAP_CENTER를 반환합니다.
*
* @returns {Promise<UserMapCenter>}
*/
export function getUserMapCenter() {
return new Promise((resolve) => {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
resolve({ ...DEFAULT_MAP_CENTER, fromDevice: false });
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
resolve({
lat: position.coords.latitude,
lng: position.coords.longitude,
fromDevice: true
});
},
() => {
resolve({ ...DEFAULT_MAP_CENTER, fromDevice: false });
},
{
enableHighAccuracy: true,
timeout: 10_000,
maximumAge: 60_000
}
);
});
}

View File

@@ -15,6 +15,7 @@
isDevSeeded,
saveFlow
} from '$lib/flowerFlow/session.js';
import { ARTWORK_CARD_DEFAULTS } from '$lib/flowerFlow/artworkCardCopy.js';
// 항상 빈 폼으로 시작 — Dev Fill은 onMount에서 1회만 스냅샷 적용
let who = $state(null);
@@ -25,7 +26,7 @@
const hasAnySelection = $derived(who !== null || whatFor !== null || style !== null);
const artworkTitle = $derived.by(() => {
if (!hasAnySelection) return 'Title';
if (!hasAnySelection) return ARTWORK_CARD_DEFAULTS.create.title;
const occasion = whatFor ? `A ${whatFor} bouquet for` : 'A bouquet for';
return `${occasion} ${who ?? '...'}`;
});
@@ -34,10 +35,12 @@
const artworkDescription = $derived(
hasAnySelection
? `${style ?? '...'} style · ₩${budget.toLocaleString('ko-KR')} budget`
: 'Description Description Description'
? `${style ?? ''} style · ₩${budget.toLocaleString('ko-KR')} budget`
: ARTWORK_CARD_DEFAULTS.create.description
);
const artworkCardMode = $derived(hasAnySelection ? 'summary' : 'instruction');
onMount(() => {
const hadSnapshot = !!getFlowObject('devCreateSnapshot');
const snap = consumeDevCreateSnapshot();
@@ -87,7 +90,12 @@
<Header step={1} total={7} />
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork variant={artworkVariant} title={artworkTitle} description={artworkDescription} />
<Artwork
variant={artworkVariant}
title={artworkTitle}
description={artworkDescription}
cardMode={artworkCardMode}
/>
<section class="relative flex min-h-0 flex-1 flex-col pb-[3.75rem] lg:overflow-hidden lg:pb-8">
<div class="min-h-0 flex-1 overflow-y-auto">

View File

@@ -16,6 +16,7 @@
loadFlow,
saveFlow
} from '$lib/flowerFlow/session.js';
import { ARTWORK_CARD_DEFAULTS } from '$lib/flowerFlow/artworkCardCopy.js';
const MAX_RETRIES = 5;
const userInput = getFlowUserInput();
@@ -24,12 +25,20 @@
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';
if (!who && !whatFor) return ARTWORK_CARD_DEFAULTS.generating.title;
const occasion = whatFor ? `A ${whatFor} bouquet for` : 'A bouquet for';
return `${occasion} ${who ?? '...'}`;
});
const artworkDescription = $derived(cardMessage || '잠시 관리중 ~');
const artworkDescription = $derived(
cardMessage?.trim() || ARTWORK_CARD_DEFAULTS.generating.description
);
const artworkCardMode = $derived.by(() => {
const who = typeof userInput.relationship === 'string' ? userInput.relationship : null;
const whatFor = typeof userInput.occasion === 'string' ? userInput.occasion : null;
return who || whatFor || cardMessage?.trim() ? 'summary' : 'instruction';
});
/** @type {import('$lib/components/ui/Artwork/artworkVariants.js').ArtworkVariant} */
let artworkVariant = $state('create2');
@@ -212,7 +221,13 @@
<Header step={4} total={7} />
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork comingSoon variant={artworkVariant} title={artworkTitle} description={artworkDescription} />
<Artwork
comingSoon
variant={artworkVariant}
title={artworkTitle}
description={artworkDescription}
cardMode={artworkCardMode}
/>
<section class="relative flex min-h-0 flex-1 flex-col lg:overflow-y-auto">
<GenerationActivityFeed

View File

@@ -8,12 +8,11 @@
import { fetchJob, toDataUrl } from '$lib/flowerFlow/api.js';
import { buildFloristOrderMessage } from '$lib/flowerFlow/buildFloristOrderMessage.js';
import { getFlowObject, getFlowString } from '$lib/flowerFlow/session.js';
import { ARTWORK_CARD_DEFAULTS } from '$lib/flowerFlow/artworkCardCopy.js';
import { getUserMapCenter } from '$lib/map/userLocation.js';
const jobId = getFlowString('jobId');
const DEFAULT_LAT = 37.5665;
const DEFAULT_LNG = 126.978;
let shops = $state([]);
let loading = $state(true);
let error = $state('');
@@ -24,12 +23,24 @@
let orderPlainText = $state('');
let orderKoPlainText = $state('');
let selectedImage = $state(null);
let locationReady = $state(false);
let searchLat = $state(37.5665);
let searchLng = $state(126.978);
let locationNotice = $state('');
const sessionUserInput = getFlowObject('userInput') ?? {};
const artworkTitle = $derived(selectedShopId ? 'Ready to order' : 'Your bouquet');
const artworkTitle = $derived(
selectedShopId ? 'Ready to order' : ARTWORK_CARD_DEFAULTS.map.title
);
const artworkDescription = $derived(floristNote || 'Your selected bouquet design.');
const artworkDescription = $derived(
selectedShopId
? floristNote || 'Your selected bouquet design.'
: ARTWORK_CARD_DEFAULTS.map.description
);
const artworkCardMode = $derived(selectedShopId ? 'summary' : 'instruction');
const bouquetImageSrc = $derived(selectedImage ? toDataUrl(selectedImage) : null);
@@ -88,7 +99,16 @@
// job 없어도 지도·꽃집 검색은 계속
}
await loadShops(DEFAULT_LAT, DEFAULT_LNG, { fitBounds: true });
const center = await getUserMapCenter();
searchLat = center.lat;
searchLng = center.lng;
if (!center.fromDevice) {
locationNotice =
'Location access unavailable. Showing flower shops near Seoul City Hall instead.';
}
locationReady = true;
await loadShops(searchLat, searchLng, { fitBounds: true });
});
</script>
@@ -98,20 +118,34 @@
<Header step={7} total={7} />
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork title={artworkTitle} description={artworkDescription} imageSrc={bouquetImageSrc} />
<Artwork
title={artworkTitle}
description={artworkDescription}
imageSrc={bouquetImageSrc}
cardMode={artworkCardMode}
/>
<section class="relative flex min-h-0 flex-1 flex-col lg:overflow-y-auto">
<MapPanel
bind:selectedShopId
{shops}
{loading}
{error}
{mock}
{orderPlainText}
{orderKoPlainText}
fitBounds={fitMapBounds}
onrefresh={(lat, lng) => loadShops(lat, lng, { fitBounds: false })}
/>
{#if locationReady}
<MapPanel
bind:selectedShopId
initialLat={searchLat}
initialLng={searchLng}
{locationNotice}
{shops}
{loading}
{error}
{mock}
{orderPlainText}
{orderKoPlainText}
fitBounds={fitMapBounds}
onrefresh={(lat, lng) => loadShops(lat, lng, { fitBounds: false })}
/>
{:else}
<div class="flex flex-1 items-center justify-center px-6 py-16 text-sm text-muted">
Getting your location...
</div>
{/if}
</section>
</main>
</div>

View File

@@ -19,6 +19,7 @@
loadFlow,
saveFlow
} from '$lib/flowerFlow/session.js';
import { ARTWORK_CARD_DEFAULTS } from '$lib/flowerFlow/artworkCardCopy.js';
const userInput = getFlowUserInput();
@@ -27,11 +28,19 @@
let error = $state('');
let skipping = $state(false);
const artworkVariant = $derived(message.trim() ? 'message1' : 'upload2');
const hasMessage = $derived(message.trim().length > 0);
const artworkTitle = $derived(message ? 'Your message' : 'Title');
const artworkVariant = $derived(hasMessage ? 'message1' : 'upload2');
const artworkDescription = $derived(message || 'Description Description Description');
const artworkTitle = $derived(
hasMessage ? 'Your message' : ARTWORK_CARD_DEFAULTS.message.title
);
const artworkDescription = $derived(
hasMessage ? message.trim() : ARTWORK_CARD_DEFAULTS.message.description
);
const artworkCardMode = $derived(hasMessage ? 'summary' : 'instruction');
onMount(() => {
const hadSnapshot = !!getFlowObject('devMessageSnapshot');
@@ -107,7 +116,12 @@
<Header step={3} total={7} />
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork variant={artworkVariant} title={artworkTitle} description={artworkDescription} />
<Artwork
variant={artworkVariant}
title={artworkTitle}
description={artworkDescription}
cardMode={artworkCardMode}
/>
<section class="relative flex min-h-0 flex-1 flex-col pb-[3.75rem] lg:overflow-hidden lg:pb-8">
<div class="min-h-0 flex-1 overflow-y-auto">

View File

@@ -127,6 +127,15 @@
const artworkTitle = $derived(artworkCopy.title);
const artworkDescription = $derived(artworkCopy.description);
const artworkCardMode = $derived.by(() => {
if (mode === 'sns') return snsHasImage ? 'summary' : 'instruction';
const count = ['color', 'season', 'character', 'location'].filter(
(key) => moodboardTiles[key]
).length;
return count === 0 ? 'instruction' : 'summary';
});
/** create2(시작) → upload1(1장+) → upload2(전체 채움) */
const artworkVariant = $derived.by(() => {
if (mode === 'sns') {
@@ -193,7 +202,12 @@
<Header step={2} total={7} />
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork variant={artworkVariant} title={artworkTitle} description={artworkDescription} />
<Artwork
variant={artworkVariant}
title={artworkTitle}
description={artworkDescription}
cardMode={artworkCardMode}
/>
<section
class="relative flex min-h-0 flex-1 flex-col pt-4 pb-[3.75rem] lg:grid lg:grid-rows-[auto_minmax(0,1fr)_auto] lg:overflow-hidden lg:pt-6 lg:pb-8"