diff --git a/packages/web/app/[locale]/(shell)/page.tsx b/packages/web/app/[locale]/(shell)/page.tsx index c77378f0..89968a3f 100644 --- a/packages/web/app/[locale]/(shell)/page.tsx +++ b/packages/web/app/[locale]/(shell)/page.tsx @@ -1,30 +1,20 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import { HomeShell } from "@/lib/components/home/HomeShell"; +import { HomeDiscovery } from "@/lib/components/home/HomeDiscovery"; +import { DiscoverySkeleton } from "@/lib/components/home/DiscoverySkeleton"; import type { - HomeData, - HomeLookCard, HeroDecodeBandData, - MoodRail, - CuratedRail, + HeroSlide, } from "@/lib/components/home/types"; -import type { PaginatedResponsePostListItem } from "@/lib/api/generated/models"; -import type { PaginatedResponsePostListItemDataItem } from "@/lib/api/generated/models"; -import type { WarehouseProfilesResponse } from "@/lib/api/generated/models"; import type { - OnboardingPoolResponse, - OnboardingPoolItem, -} from "@/lib/api/generated/models"; -import type { - CurationListResponse, - FeedItem, + PaginatedResponsePostListItem, + StyleSampleItem, } from "@/lib/api/generated/models"; import { setRequestLocale } from "next-intl/server"; import { serverApiGet } from "@/lib/api/server-instance"; import { isLocale, type Locale } from "@/lib/i18n/locale"; -import { getPostMatch } from "@/lib/api/adapters/postMatch"; import { colorNameToHex } from "@/lib/components/home/styleColors"; -import type { HeroSlide } from "@/lib/components/home/types"; -import type { StyleSampleItem } from "@/lib/api/generated/models"; import { buildLocaleAlternates } from "@/lib/seo/locale-alternates"; // 이전엔 이 페이지에 metadata export가 전혀 없어서 root layout의 canonical(항상 @@ -44,116 +34,9 @@ export async function generateMetadata({ }; } -/** "Browse by mood" 표시 순서 + 라벨 — onboarding pool 의 mood_key 기준. 데이터에 - * 없는 무드는 렌더 안 됨(정직). 라벨은 홈의 다른 하드코딩 영어 섹션명과 일관되게 - * 매력적인 표현으로(예: retro_vintage → "Y2K & Retro"). */ -const MOOD_RAILS: { key: string; title: string }[] = [ - { key: "street", title: "Street" }, - { key: "minimal", title: "Minimal" }, - { key: "casual_daily", title: "Casual" }, - { key: "chic_edgy", title: "Chic & Edgy" }, - { key: "retro_vintage", title: "Y2K & Retro" }, - { key: "preppy", title: "Preppy" }, - { key: "sporty", title: "Sporty" }, - { key: "classic_elegant", title: "Classic Elegant" }, - { key: "lovely_feminine", title: "Lovely Feminine" }, -]; - -/** Shorthand for post list item from REST API */ -type ApiPost = PaginatedResponsePostListItemDataItem & { - style_tags?: unknown; -}; - -interface ArtistProfileEntry { - name: string; - profileImageUrl: string | null; -} - const proxyImg = (url: string) => `/api/v1/image-proxy?url=${encodeURIComponent(url)}`; -/** REST API fetch — no direct DB fallback */ -async function fetchPosts(params: string, locale?: Locale): Promise { - try { - const res = await serverApiGet( - `/api/v1/posts?${params}`, - { locale } - ); - return res.data ?? []; - } catch { - return []; - } -} - -/** Build artist/group name → profile lookup from Rust API warehouse/profiles */ -async function fetchArtistProfileMap( - locale?: Locale -): Promise> { - const map = new Map(); - try { - const res = await serverApiGet( - "/api/v1/warehouse/profiles", - { locale } - ); - const addEntity = ( - name_ko: string | null | undefined, - name_en: string | null | undefined, - profile_image_url: string | null | undefined - ) => { - const displayName = name_en || name_ko || ""; - if (!displayName) return; - const entry: ArtistProfileEntry = { - name: displayName, - profileImageUrl: profile_image_url ?? null, - }; - if (name_ko) map.set(name_ko.toLowerCase(), entry); - if (name_en) map.set(name_en.toLowerCase(), entry); - }; - for (const a of res.artists ?? []) - addEntity(a.name_ko, a.name_en, a.profile_image_url); - for (const g of res.groups ?? []) - addEntity(g.name_ko, g.name_en, g.profile_image_url); - } catch { - /* warehouse profiles unavailable — use raw names */ - } - return map; -} - -/** 무드별 룩 풀 — Style DNA 온보딩 picker 와 같은 public 풀(무드별 균형 버킷). - * 로그아웃 Home 의 "Browse by mood" 밀도 소스(무드당 ~10룩, 정직하게 채워짐). */ -async function fetchMoodPool( - limit = 120, - locale?: Locale -): Promise { - try { - const res = await serverApiGet( - `/api/v1/onboarding/pool?limit=${limit}`, - { locale } - ); - return res.items ?? []; - } catch { - return []; - } -} - -/** decoded 큐레이션 에이전트(#791) — 주 1회 배치가 `curations`/`curation_posts` - * 를 채운다(Fully Decoded/Weekly Edit/Seasonal Edit). 하드코딩 필터/카피 없음 — - * 여기서는 배치가 이미 쓴 것을 그대로 읽어 렌더한다. 룩 0인 큐레이션은 배치가 - * 아예 안 쓰거나(표본 부족 self-hide) posts 가 빈 배열이라 자연히 제외된다. */ -async function fetchCurations( - locale?: Locale -): Promise { - try { - const res = await serverApiGet( - "/api/v1/feed/curations?include_posts=true", - { locale } - ); - return res.data ?? []; - } catch { - return []; - } -} - /** * Hero carousel slides with a real per-post style read (mood/color), via the * public `style-samples` endpoint (derived server-side from cody_analysis). No @@ -187,6 +70,22 @@ async function fetchHeroSlides( } } +/** style-samples 가 비면 hero 가 스캔-온리 단일 슬라이드로라도 뜨도록 인기 포스트 + * 이미지 1장만 가져온다(정상 케이스에선 style-samples 가 채워져 호출되지 않음). + * discovery 의 popularPosts 는 이제 HomeDiscovery(스트리밍) 소관이라 hero fallback + * 은 여기서 독립적으로, 필요할 때만 조회한다. */ +async function fetchHeroFallbackImage(locale?: Locale): Promise { + try { + const res = await serverApiGet( + "/api/v1/posts?sort=popular&per_page=1", + { locale } + ); + return res.data?.[0]?.image_url ?? null; + } catch { + return null; + } +} + export default async function Home({ params, }: { @@ -202,123 +101,26 @@ export default async function Home({ ? rawLocale : undefined; - const [ - popularPosts, - recentPosts, - moodPool, - artistProfileMap, - curations, - slides, - ] = await Promise.all([ - fetchPosts("sort=popular&per_page=30", locale), - fetchPosts("sort=recent&per_page=24", locale), - fetchMoodPool(120, locale), - fetchArtistProfileMap(locale), - fetchCurations(locale), - // Hero decode carousel: posts with a real style read (mood/color), via - // the public style-samples endpoint (no direct DB access — #661). Fetched - // alongside the rest so the above-the-fold hero doesn't wait on a - // separate round-trip after the other 5 calls resolve. - fetchHeroSlides(6, locale), - ]); - - function enrichArtistName(name: string | null | undefined): string { - if (!name) return ""; - return artistProfileMap.get(name.toLowerCase())?.name ?? name; - } - - function toLookCard(post: ApiPost): HomeLookCard { - const artistName = enrichArtistName(post.artist_name || post.group_name); - return { - id: post.id, - imageUrl: proxyImg(post.image_url), - title: post.title || artistName || "Untitled look", - href: `/posts/${post.id}`, - artistName: artistName || undefined, - // List model carries no detected-item count yet — omit (no fake counts). - match: getPostMatch(post), // null until #646 lands - }; - } - - // Fallback when no style reads exist yet — single scan-only slide. + // above-the-fold hero(style-samples)만 await 한다 → 아래 디스커버리(popular/ + // recent/mood/warehouse/curations 등 느린 fetch)를 기다리지 않고 즉시 페인트. + // 디스커버리는 안의 HomeDiscovery 로 독립 스트리밍된다(#848 AC4). + const slides = await fetchHeroSlides(6, locale); if (slides.length === 0) { - const fallback = popularPosts[0]; - if (fallback) { - slides.push({ imageUrl: proxyImg(fallback.image_url), styleRead: null }); + const fallbackImg = await fetchHeroFallbackImage(locale); + if (fallbackImg) { + slides.push({ imageUrl: proxyImg(fallbackImg), styleRead: null }); } } - const hero: HeroDecodeBandData = { slides }; - // 로드 불가능한 이미지(로컬 시드의 mock 상대경로 등) 카드는 거른다 — 깨진 - // placeholder 대신 미노출(정직). prod 이미지는 전부 절대 URL 이라 무해. - const isRenderable = (p: { image_url: string }) => - typeof p.image_url === "string" && p.image_url.startsWith("http"); - - function feedItemToLookCard(item: FeedItem): HomeLookCard { - const artistName = enrichArtistName(item.artist_name || item.group_name); - return { - id: item.id, - imageUrl: proxyImg(item.image_url), - title: artistName || "Look", - href: `/posts/${item.id}`, - artistName: artistName || undefined, - match: getPostMatch(item), - }; - } - - // Trending(인기) + Fresh(최신) — 작은 코퍼스에서 두 레일이 같은 룩으로 겹치지 - // 않게 Fresh 는 Trending 에 이미 뜬 id 를 제외한다(중복 나열 방지). - const trending = popularPosts - .filter(isRenderable) - .slice(0, 12) - .map(toLookCard); - const trendingIds = new Set(trending.map((c) => c.id)); - const fresh = recentPosts - .filter(isRenderable) - .filter((p) => !trendingIds.has(p.id)) - .slice(0, 12) - .map(toLookCard); - - // Browse by mood — onboarding pool 을 mood_key 로 버킷팅. 카드는 이미지-온리라 - // post_id/image_url 만 있으면 충분. 데이터 있는 무드만(MOOD_RAILS 순서 유지). - const poolToCard = (item: OnboardingPoolItem): HomeLookCard => ({ - id: item.post_id, - imageUrl: proxyImg(item.image_url), - title: item.title || "Look", - href: `/posts/${item.post_id}`, - }); - const byMood = new Map(); - for (const item of moodPool) { - if (!item.image_url?.startsWith("http")) continue; - const arr = byMood.get(item.mood_key) ?? []; - arr.push(poolToCard(item)); - byMood.set(item.mood_key, arr); - } - const moodRails: MoodRail[] = MOOD_RAILS.filter( - (m) => (byMood.get(m.key)?.length ?? 0) > 0 - ).map((m) => ({ - moodKey: m.key, - title: m.title, - looks: byMood.get(m.key)!, - })); - - // decoded 큐레이션 레일(#791) — 배치가 curations/curation_posts 에 이미 써둔 - // 걸 그대로 카드로 변환. 룩 0인 레일은 미포함(정직 self-hide, 표본 부족 시 - // 배치가 애초에 안 쓰거나 posts 가 빈 배열). - const curated: CuratedRail[] = curations - .map((c) => ({ - key: c.slug ?? c.id, - title: c.title, - description: c.description ?? "", - looks: (c.posts ?? []) - .filter(isRenderable) - .slice(0, 12) - .map(feedItemToLookCard), - })) - .filter((rail) => rail.looks.length > 0); - - const home: HomeData = { hero, trending, fresh, moodRails, curated }; - - return ; + return ( + }> + + + } + /> + ); } diff --git a/packages/web/lib/components/home/DiscoveryBrowse.tsx b/packages/web/lib/components/home/DiscoveryBrowse.tsx new file mode 100644 index 00000000..24363c8a --- /dev/null +++ b/packages/web/lib/components/home/DiscoveryBrowse.tsx @@ -0,0 +1,74 @@ +import { SectionCarousel } from "./SectionCarousel"; +import { FeaturedCuratedGrid } from "./FeaturedCuratedGrid"; +import { WeeklyEditSection } from "./WeeklyEditSection"; +import { RecommendedLookCard } from "./LookCards"; +import { BrowseByMoodSection } from "./BrowseByMoodSection"; +import { TrendingRail } from "./TrendingRail"; +import type { HomeLookCard, MoodRail, CuratedRail } from "./types"; + +interface DiscoveryBrowseProps { + /** 글로벌 트렌딩 seed(sort=popular) — TrendingRail 이 로그인 유저에겐 DNA 블렌드로 + * 스왑, 게스트에겐 이 값을 그대로 노출한다. */ + trending: HomeLookCard[]; + fresh: HomeLookCard[]; + moodRails: MoodRail[]; + curated: CuratedRail[]; +} + +/** + * 로그아웃/무신호 Home 의 디스커버리 밀도(#786 후속, raf: "돈키호테식으로 풍부하게"). + * 섹션마다 배치(캐러셀 vs 그리드, 균등 vs 피처드)를 다르게 줘 리듬을 만든다(raf: + * "너무 다 같은 레이아웃으로 되어있어서 단조롭게 느껴진다"): Trending Style(캐러셀) + * → Complete Looks(Look Set 완성도, 피처드 그리드) → Fresh Decodes(캐러셀) → Weekly + * Edit(#791 큐레이션 에이전트가 자율 선정, 1~2 레일 스플릿/피처드) → Seasonal + * Edit(캐러셀) → Browse by mood(칩 필터+그리드). 전부 public 데이터 + 정직한 + * self-hide(빈 레일 미렌더). + * + * server 컴포넌트 — 클라이언트 leaf(TrendingRail/SectionCarousel 등)를 조합만 한다. + * HomeDiscovery 가 Suspense 안에서 이 트리를 스트리밍한다(#848 AC4). + */ +export function DiscoveryBrowse({ + trending, + fresh, + moodRails, + curated, +}: DiscoveryBrowseProps) { + const completeLooks = curated.find((r) => r.key === "complete-looks"); + const weeklyEditRails = curated.filter((r) => + r.key.startsWith("weekly-edit-") + ); + const seasonal = curated.find((r) => r.key === "seasonal-edit"); + + return ( + <> + {/* Trending — 로그인 유저는 /feed/trending(DNA 블렌드)으로 스왑되고, 게스트는 + * server seed(글로벌 popular)를 본다. 빈/무신호 처리는 TrendingRail 내부. */} + + + {completeLooks && } + + {fresh.length > 0 && ( + + {fresh.map((card) => ( + + ))} + + )} + + + + {seasonal && ( + + {seasonal.looks.map((card) => ( + + ))} + + )} + + {moodRails.length > 0 && } + + ); +} diff --git a/packages/web/lib/components/home/DiscoverySkeleton.tsx b/packages/web/lib/components/home/DiscoverySkeleton.tsx new file mode 100644 index 00000000..6a054dfc --- /dev/null +++ b/packages/web/lib/components/home/DiscoverySkeleton.tsx @@ -0,0 +1,26 @@ +/** + * HomeDiscovery(트렌딩·큐레이션·무드 레일) 스트리밍 중 표시되는 플레이스홀더. + * hero 는 이미 페인트된 상태에서 아래 디스커버리 섹션만 이 스켈레톤으로 채워졌다가 + * 서버 데이터가 도착하면 교체된다(#848 AC4 — hero-first 스트리밍). + */ +export function DiscoverySkeleton() { + return ( +