From 111c315cb40208fbc1592b8419c1b12e08de367b Mon Sep 17 00:00:00 2001
From: CIOI
Date: Sat, 11 Jul 2026 21:35:48 +0900
Subject: [PATCH 01/10] feat(web): add catalog artist/brand discovery pages
(#886)
Expose public catalog entity APIs, enrich Explore entity search with profile images and counts, and add /artists/[id] and /brands/[id] detail pages with compact headers and brand descriptions.
Co-authored-by: Cursor
---
.../backfill_artist_profile_image_url.py | 280 +++++++++++
packages/api-server/openapi.json | 463 ++++++++++++++++++
.../api-server/src/batch/entity_reindex.rs | 99 +++-
.../api-server/src/batch/home_curation.rs | 1 +
.../src/domains/catalog_entities/dto.rs | 74 +++
.../src/domains/catalog_entities/handlers.rs | 134 +++++
.../src/domains/catalog_entities/mod.rs | 5 +
.../src/domains/catalog_entities/service.rs | 242 +++++++++
packages/api-server/src/domains/mod.rs | 1 +
packages/api-server/src/domains/posts/dto.rs | 4 +
.../api-server/src/domains/posts/handlers.rs | 1 +
.../api-server/src/domains/posts/service.rs | 34 ++
.../api-server/src/domains/posts/tests.rs | 6 +
packages/api-server/src/domains/search/dto.rs | 15 +
.../api-server/src/domains/search/service.rs | 161 +++++-
.../src/domains/solutions/service.rs | 75 +++
packages/api-server/src/openapi.rs | 10 +
packages/api-server/src/router.rs | 1 +
.../[locale]/(shell)/artists/[id]/page.tsx | 15 +
.../app/[locale]/(shell)/brands/[id]/page.tsx | 15 +
.../(shell)/explore/ExploreClient.tsx | 8 +-
.../components/artists/ArtistDetailClient.tsx | 163 ++++++
.../components/brands/BrandDetailClient.tsx | 295 +++++++++++
.../components/explore/ArtistResultCard.tsx | 54 ++
.../components/explore/ArtistResultsPanel.tsx | 82 ++++
.../components/explore/BrandResultCard.tsx | 48 ++
.../components/explore/BrandResultsPanel.tsx | 59 +++
.../__tests__/SearchSuggestions.test.tsx | 14 +
packages/web/lib/components/shell/shellNav.ts | 2 +
.../web/lib/hooks/useCatalogEntitySearch.ts | 39 ++
30 files changed, 2388 insertions(+), 12 deletions(-)
create mode 100644 packages/ai-server/scripts/backfill_artist_profile_image_url.py
create mode 100644 packages/api-server/src/domains/catalog_entities/dto.rs
create mode 100644 packages/api-server/src/domains/catalog_entities/handlers.rs
create mode 100644 packages/api-server/src/domains/catalog_entities/mod.rs
create mode 100644 packages/api-server/src/domains/catalog_entities/service.rs
create mode 100644 packages/web/app/[locale]/(shell)/artists/[id]/page.tsx
create mode 100644 packages/web/app/[locale]/(shell)/brands/[id]/page.tsx
create mode 100644 packages/web/lib/components/artists/ArtistDetailClient.tsx
create mode 100644 packages/web/lib/components/brands/BrandDetailClient.tsx
create mode 100644 packages/web/lib/components/explore/ArtistResultCard.tsx
create mode 100644 packages/web/lib/components/explore/ArtistResultsPanel.tsx
create mode 100644 packages/web/lib/components/explore/BrandResultCard.tsx
create mode 100644 packages/web/lib/components/explore/BrandResultsPanel.tsx
create mode 100644 packages/web/lib/hooks/useCatalogEntitySearch.ts
diff --git a/packages/ai-server/scripts/backfill_artist_profile_image_url.py b/packages/ai-server/scripts/backfill_artist_profile_image_url.py
new file mode 100644
index 000000000..6e8a89da6
--- /dev/null
+++ b/packages/ai-server/scripts/backfill_artist_profile_image_url.py
@@ -0,0 +1,280 @@
+#!/usr/bin/env python3
+# pyright: reportMissingImports=false
+"""Copy instagram_accounts.profile_image_url → artists.profile_image_url (1회성).
+
+artists.profile_image_url 이 비어 있고, 연결된 Instagram 계정에만 이미지가 있을 때
+primary_instagram_account_id 우선, 없으면 artist_id 링크 중 최신 row 를 사용한다.
+
+Usage:
+ cd packages/ai-server
+
+ uv run python scripts/backfill_artist_profile_image_url.py \\
+ --db-env ../../.env.backend.prod --dry-run
+
+ uv run python scripts/backfill_artist_profile_image_url.py \\
+ --db-env ../../.env.backend.prod --apply
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import logging
+import os
+import sys
+from pathlib import Path
+from typing import Any, Optional
+
+logger = logging.getLogger("backfill-artist-profile-image")
+
+
+def _parse_env_file(path: Path) -> dict[str, str]:
+ if not path.exists():
+ raise FileNotFoundError(f"env file not found: {path}")
+ out: dict[str, str] = {}
+ for raw in path.read_text().splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, _, val = line.partition("=")
+ key = key.strip()
+ if not key:
+ continue
+ for marker in (" #", "\t#", " #"):
+ idx = val.find(marker)
+ if idx != -1:
+ val = val[:idx]
+ break
+ out[key] = val.strip().strip('"').strip("'")
+ return out
+
+
+def _resolve_env(path: Optional[str], keys: list[str]) -> dict[str, str]:
+ if path:
+ parsed = _parse_env_file(Path(path).expanduser().resolve())
+ return {k: parsed[k] for k in keys if k in parsed}
+ return {k: os.environ[k] for k in keys if k in os.environ}
+
+
+def _mask_host(db_url: str) -> str:
+ try:
+ return db_url.split("@", 1)[1]
+ except IndexError:
+ return ""
+
+
+def _is_local(db_url: str) -> bool:
+ return _mask_host(db_url).startswith(("127.0.0.1", "localhost", "::1"))
+
+
+def _is_supabase_pooler(db_url: str) -> bool:
+ return "pooler.supabase.com" in db_url
+
+
+def _prefer_transaction_pooler(db_url: str, *, session_pooler: bool) -> str:
+ if session_pooler or not _is_supabase_pooler(db_url):
+ return db_url
+ if ":5432/" in db_url:
+ return db_url.replace(":5432/", ":6543/", 1)
+ return db_url
+
+
+async def _connect(
+ db_url: str,
+ *,
+ session_pooler: bool = False,
+ connect_retries: int = 4,
+):
+ import asyncpg
+
+ resolved = _prefer_transaction_pooler(db_url, session_pooler=session_pooler)
+ ssl = None if _is_local(resolved) else "require"
+ kwargs: dict[str, Any] = {}
+ if _is_supabase_pooler(resolved):
+ kwargs["statement_cache_size"] = 0
+
+ delay = 1.0
+ last_exc: Optional[Exception] = None
+ for attempt in range(connect_retries + 1):
+ try:
+ conn = await asyncpg.connect(resolved, ssl=ssl, **kwargs)
+ if resolved != db_url:
+ logger.info(
+ "using transaction pooler %s (session :5432 → :6543)",
+ _mask_host(resolved),
+ )
+ return conn
+ except Exception as exc: # noqa: BLE001
+ last_exc = exc
+ msg = str(exc).lower()
+ pool_full = "max clients" in msg or "too many clients" in msg
+ if attempt < connect_retries and (pool_full or "timeout" in msg):
+ logger.warning("connect failed (attempt %s): %s", attempt + 1, exc)
+ await asyncio.sleep(delay)
+ delay = min(delay * 2, 8.0)
+ continue
+ if pool_full:
+ logger.error(
+ "Supabase session pool exhausted. Stop `just local-be` or use --db-url."
+ )
+ raise
+ raise last_exc # type: ignore[misc]
+
+
+_COUNT_SQL = """
+SELECT COUNT(*)::bigint
+ FROM public.artists a
+ LEFT JOIN public.instagram_accounts primary_ia
+ ON primary_ia.id = a.primary_instagram_account_id
+ WHERE (a.profile_image_url IS NULL OR btrim(a.profile_image_url) = '')
+ AND COALESCE(
+ NULLIF(btrim(primary_ia.profile_image_url), ''),
+ (
+ SELECT NULLIF(btrim(ia2.profile_image_url), '')
+ FROM public.instagram_accounts ia2
+ WHERE ia2.artist_id = a.id
+ AND ia2.profile_image_url IS NOT NULL
+ AND btrim(ia2.profile_image_url) <> ''
+ ORDER BY ia2.updated_at DESC
+ LIMIT 1
+ )
+ ) IS NOT NULL
+"""
+
+_PREVIEW_SQL = """
+SELECT
+ a.id,
+ COALESCE(a.name_ko, a.name_en, a.name ->> 'ko', a.name ->> 'en') AS name,
+ COALESCE(
+ NULLIF(btrim(primary_ia.profile_image_url), ''),
+ (
+ SELECT NULLIF(btrim(ia2.profile_image_url), '')
+ FROM public.instagram_accounts ia2
+ WHERE ia2.artist_id = a.id
+ AND ia2.profile_image_url IS NOT NULL
+ AND btrim(ia2.profile_image_url) <> ''
+ ORDER BY ia2.updated_at DESC
+ LIMIT 1
+ )
+ ) AS source_url,
+ CASE
+ WHEN primary_ia.profile_image_url IS NOT NULL
+ AND btrim(primary_ia.profile_image_url) <> ''
+ THEN 'primary_instagram_account_id'
+ ELSE 'artist_id_link'
+ END AS source
+FROM public.artists a
+LEFT JOIN public.instagram_accounts primary_ia
+ ON primary_ia.id = a.primary_instagram_account_id
+WHERE (a.profile_image_url IS NULL OR btrim(a.profile_image_url) = '')
+ AND COALESCE(
+ NULLIF(btrim(primary_ia.profile_image_url), ''),
+ (
+ SELECT NULLIF(btrim(ia2.profile_image_url), '')
+ FROM public.instagram_accounts ia2
+ WHERE ia2.artist_id = a.id
+ AND ia2.profile_image_url IS NOT NULL
+ AND btrim(ia2.profile_image_url) <> ''
+ ORDER BY ia2.updated_at DESC
+ LIMIT 1
+ )
+ ) IS NOT NULL
+ORDER BY a.updated_at DESC
+LIMIT $1
+"""
+
+_UPDATE_SQL = """
+UPDATE public.artists a
+ SET profile_image_url = src.url,
+ updated_at = NOW()
+ FROM (
+ SELECT
+ a.id,
+ COALESCE(
+ NULLIF(btrim(primary_ia.profile_image_url), ''),
+ (
+ SELECT NULLIF(btrim(ia2.profile_image_url), '')
+ FROM public.instagram_accounts ia2
+ WHERE ia2.artist_id = a.id
+ AND ia2.profile_image_url IS NOT NULL
+ AND btrim(ia2.profile_image_url) <> ''
+ ORDER BY ia2.updated_at DESC
+ LIMIT 1
+ )
+ ) AS url
+ FROM public.artists a
+ LEFT JOIN public.instagram_accounts primary_ia
+ ON primary_ia.id = a.primary_instagram_account_id
+ WHERE (a.profile_image_url IS NULL OR btrim(a.profile_image_url) = '')
+ ) src
+ WHERE a.id = src.id
+ AND src.url IS NOT NULL
+"""
+
+
+async def run(args: argparse.Namespace) -> int:
+ db_cfg = _resolve_env(args.db_env, ["OPERATION_DATABASE_URL"])
+ db_url = (
+ args.db_url
+ or db_cfg.get("OPERATION_DATABASE_URL", "")
+ or os.environ.get("OPERATION_DATABASE_URL", "")
+ )
+ if not db_url:
+ logger.error("OPERATION_DATABASE_URL missing — pass --db-env or --db-url")
+ return 2
+
+ if not args.apply and not args.dry_run:
+ logger.error("Specify --dry-run or --apply")
+ return 2
+
+ logger.info("db host: %s", _mask_host(db_url))
+ conn = await _connect(db_url, session_pooler=args.session_pooler)
+ try:
+ total = await conn.fetchval(_COUNT_SQL)
+ logger.info("artists to backfill: %d", total)
+ if total == 0:
+ return 0
+
+ preview_rows = await conn.fetch(_PREVIEW_SQL, args.preview_limit)
+ for row in preview_rows:
+ logger.info(
+ "%s (%s) ← %s | %s",
+ row["name"] or row["id"],
+ str(row["id"])[:8],
+ row["source"],
+ (row["source_url"] or "")[:80],
+ )
+ if total > len(preview_rows):
+ logger.info("... and %d more", total - len(preview_rows))
+
+ if args.dry_run:
+ logger.info("dry-run complete — no rows updated")
+ return 0
+
+ result = await conn.execute(_UPDATE_SQL)
+ logger.info("apply complete: %s", result)
+ remaining = await conn.fetchval(_COUNT_SQL)
+ logger.info("remaining artists needing backfill: %d", remaining)
+ return 0
+ finally:
+ await conn.close()
+
+
+def main() -> None:
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--db-env", default="", help="Env file with OPERATION_DATABASE_URL")
+ parser.add_argument("--db-url", default="", help="OPERATION_DATABASE_URL override")
+ parser.add_argument(
+ "--session-pooler",
+ action="store_true",
+ help="Use Supabase session pooler (:5432); default is transaction pooler (:6543)",
+ )
+ parser.add_argument("--preview-limit", type=int, default=10, help="Sample rows in logs")
+ parser.add_argument("--dry-run", action="store_true", help="Preview only")
+ parser.add_argument("--apply", action="store_true", help="Write to OPERATION_DATABASE_URL")
+ raise SystemExit(asyncio.run(run(parser.parse_args())))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/packages/api-server/openapi.json b/packages/api-server/openapi.json
index e7bde3951..c0e9ef563 100644
--- a/packages/api-server/openapi.json
+++ b/packages/api-server/openapi.json
@@ -1766,6 +1766,52 @@
]
}
},
+ "/api/v1/artists/{id}": {
+ "get": {
+ "tags": [
+ "catalog-entities"
+ ],
+ "operationId": "get_artist",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "description": "Artist ID",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ }
+ },
+ {
+ "name": "locale",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Artist detail",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ArtistDetail"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Artist not found"
+ }
+ }
+ }
+ },
"/api/v1/auth/password/reset": {
"post": {
"tags": [
@@ -1991,6 +2037,184 @@
}
}
},
+ "/api/v1/brands/{id}": {
+ "get": {
+ "tags": [
+ "catalog-entities"
+ ],
+ "operationId": "get_brand",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "description": "Brand ID",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ }
+ },
+ {
+ "name": "locale",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Brand detail",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BrandDetail"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Brand not found"
+ }
+ }
+ }
+ },
+ "/api/v1/brands/{id}/looks": {
+ "get": {
+ "tags": [
+ "catalog-entities"
+ ],
+ "operationId": "list_brand_looks",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "description": "Brand ID",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ }
+ },
+ {
+ "name": "page",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int64",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "per_page",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int64",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "locale",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Brand looks",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PaginatedResponse_PostListItem"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Brand not found"
+ }
+ }
+ }
+ },
+ "/api/v1/brands/{id}/solutions": {
+ "get": {
+ "tags": [
+ "catalog-entities"
+ ],
+ "operationId": "list_brand_solutions",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "description": "Brand ID",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ }
+ },
+ {
+ "name": "page",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int64",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "per_page",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "int64",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "locale",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Brand solutions",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PaginatedResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Brand not found"
+ }
+ }
+ }
+ },
"/api/v1/catalog/items/{item_id}": {
"get": {
"tags": [
@@ -2749,6 +2973,16 @@
"format": "uuid"
}
},
+ {
+ "name": "brand_id",
+ "in": "query",
+ "description": "브랜드 ID 필터 (active primary solution FK)",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ }
+ },
{
"name": "sort",
"in": "query",
@@ -7116,6 +7350,72 @@
}
}
},
+ "ArtistDetail": {
+ "type": "object",
+ "required": [
+ "id",
+ "name",
+ "look_count",
+ "decoded_count",
+ "created_at",
+ "updated_at"
+ ],
+ "properties": {
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "decoded_count": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "group_names": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "instagram_username": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "look_count": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "name": {
+ "type": "string"
+ },
+ "name_en": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "name_ko": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "profile_image_url": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
"AuthTokenResponse": {
"type": "object",
"required": [
@@ -7352,6 +7652,99 @@
"shopper"
]
},
+ "BrandContentQuery": {
+ "type": "object",
+ "properties": {
+ "locale": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "page": {
+ "type": "integer",
+ "format": "int64",
+ "minimum": 0
+ },
+ "per_page": {
+ "type": "integer",
+ "format": "int64",
+ "minimum": 0
+ }
+ }
+ },
+ "BrandDetail": {
+ "type": "object",
+ "required": [
+ "id",
+ "name",
+ "product_count",
+ "look_count",
+ "created_at",
+ "updated_at"
+ ],
+ "properties": {
+ "country_of_origin": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "description": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "instagram_username": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "logo_image_url": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "look_count": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "name": {
+ "type": "string"
+ },
+ "name_en": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "name_ko": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "product_count": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
"BuildStyleDnaDto": {
"type": "object",
"description": "Style DNA 빌드 요청 — 유저가 picks 단계에서 고른 룩(post) id 들.\n최소 5개 (콜드스타트 온보딩 풀 픽 + 본인 룩 공통). cody_analysis 없는 post 는\n서버가 거른다(가짜 데이터 금지).",
@@ -7424,6 +7817,17 @@
}
}
},
+ "CatalogEntityQuery": {
+ "type": "object",
+ "properties": {
+ "locale": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
"CatalogItemDetail": {
"type": "object",
"description": "카탈로그 아이템 단건 상세 — 아이템 detail 페이지(#786 후속: 아이템 좋아요 →\n개인화) 용. 목록/유사도 카드(CatalogSimItem)보다 필드가 조금 더 많다\n(affiliate_url 별도 노출 — product_url 이 없을 때 FE 가 대체 링크로 쓸 수 있게).",
@@ -9262,11 +9666,33 @@
"type": "string",
"description": "엔티티 타입 (artist | brand | group)"
},
+ "group_names": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "연결된 그룹 표시명 목록"
+ },
"id": {
"type": "string",
"format": "uuid",
"description": "엔티티 UUID"
},
+ "logo_image_url": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "브랜드 로고 이미지 URL"
+ },
+ "look_count": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "format": "int64",
+ "description": "관련 look 수"
+ },
"name": {
"type": "string",
"description": "표시명 (locale 기준)"
@@ -9284,6 +9710,21 @@
"null"
],
"description": "한국어 이름"
+ },
+ "product_count": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "format": "int64",
+ "description": "관련 product/solution 수"
+ },
+ "profile_image_url": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "아티스트 프로필 이미지 URL"
}
}
},
@@ -10406,6 +10847,24 @@
}
}
},
+ "PaginatedResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "pagination"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/SolutionListItem"
+ }
+ },
+ "pagination": {
+ "$ref": "#/components/schemas/PaginationMeta"
+ }
+ }
+ },
"PaginatedResponse_AdminSpotListItem": {
"type": "object",
"required": [
@@ -15817,6 +16276,10 @@
"name": "search",
"description": "Search and feed"
},
+ {
+ "name": "catalog-entities",
+ "description": "Catalog artist and brand discovery"
+ },
{
"name": "rankings",
"description": "Rankings"
diff --git a/packages/api-server/src/batch/entity_reindex.rs b/packages/api-server/src/batch/entity_reindex.rs
index e078d5692..4f2ca2421 100644
--- a/packages/api-server/src/batch/entity_reindex.rs
+++ b/packages/api-server/src/batch/entity_reindex.rs
@@ -28,6 +28,8 @@ fn entity_to_document(
name_json: Option<&Value>,
name_en: Option,
name_ko: Option,
+ profile_image_url: Option,
+ logo_image_url: Option,
aliases: &[String],
) -> Value {
let (en, ko) = entity_names(name_json, name_en, name_ko);
@@ -66,6 +68,8 @@ fn entity_to_document(
"name_ko": ko,
"name_en": en,
"name_ja": ja,
+ "profile_image_url": profile_image_url,
+ "logo_image_url": logo_image_url,
"aliases": aliases,
})
}
@@ -99,17 +103,50 @@ pub async fn upsert_entity_search_doc(
EntityKind::Artist => entities::Artists::find_by_id(entity_id)
.one(db)
.await
- .map(|o| o.map(|m| (m.name, m.name_en, m.name_ko, m.aliases))),
+ .map(|o| {
+ o.map(|m| {
+ (
+ m.name,
+ m.name_en,
+ m.name_ko,
+ m.profile_image_url,
+ None,
+ m.aliases,
+ )
+ })
+ }),
EntityKind::Brand => entities::Brands::find_by_id(entity_id)
.one(db)
.await
- .map(|o| o.map(|m| (m.name, m.name_en, m.name_ko, m.aliases))),
+ .map(|o| {
+ o.map(|m| {
+ (
+ m.name,
+ m.name_en,
+ m.name_ko,
+ None,
+ m.logo_image_url,
+ m.aliases,
+ )
+ })
+ }),
EntityKind::Group => entities::Groups::find_by_id(entity_id)
.one(db)
.await
- .map(|o| o.map(|m| (m.name, m.name_en, m.name_ko, m.aliases))),
+ .map(|o| {
+ o.map(|m| {
+ (
+ m.name,
+ m.name_en,
+ m.name_ko,
+ m.profile_image_url,
+ None,
+ m.aliases,
+ )
+ })
+ }),
};
- let (name, name_en, name_ko, aliases) = match row {
+ let (name, name_en, name_ko, profile_image_url, logo_image_url, aliases) = match row {
Ok(Some(t)) => t,
Ok(None) => {
warn!(kind = kind.as_str(), entity_id = %entity_id, "Entity not found for search doc upsert");
@@ -129,6 +166,8 @@ pub async fn upsert_entity_search_doc(
name.as_ref().map(|v| v as &Value),
name_en,
name_ko,
+ profile_image_url,
+ logo_image_url,
&aliases,
);
let pk = format!("{}_{}", kind.as_str(), entity_id);
@@ -157,6 +196,8 @@ pub async fn run(state: Arc) -> Result<(), Box) -> Result<(), Box) -> Result<(), Box PostListQuery {
user_id: None,
artist_id: None,
group_id: None,
+ brand_id: None,
sort: "popular".to_string(),
page: 1,
per_page,
diff --git a/packages/api-server/src/domains/catalog_entities/dto.rs b/packages/api-server/src/domains/catalog_entities/dto.rs
new file mode 100644
index 000000000..294b260a3
--- /dev/null
+++ b/packages/api-server/src/domains/catalog_entities/dto.rs
@@ -0,0 +1,74 @@
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+use utoipa::{IntoParams, ToSchema};
+use uuid::Uuid;
+
+use crate::{domains::solutions::dto::SolutionListItem, utils::pagination::PaginatedResponse};
+
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct ArtistDetail {
+ pub id: Uuid,
+ pub name: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub name_ko: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub name_en: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub profile_image_url: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub instagram_username: Option,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub group_names: Vec,
+ pub look_count: i64,
+ pub decoded_count: i64,
+ pub created_at: DateTime,
+ pub updated_at: DateTime,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct BrandDetail {
+ pub id: Uuid,
+ pub name: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub name_ko: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub name_en: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub logo_image_url: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub instagram_username: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub country_of_origin: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub description: Option,
+ pub product_count: i64,
+ pub look_count: i64,
+ pub created_at: DateTime,
+ pub updated_at: DateTime,
+}
+
+#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
+pub struct CatalogEntityQuery {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub locale: Option,
+}
+
+#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
+pub struct BrandContentQuery {
+ #[serde(default = "default_page")]
+ pub page: u64,
+ #[serde(default = "default_per_page")]
+ pub per_page: u64,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub locale: Option,
+}
+
+fn default_page() -> u64 {
+ 1
+}
+
+fn default_per_page() -> u64 {
+ 20
+}
+
+pub type BrandSolutionsResponse = PaginatedResponse;
diff --git a/packages/api-server/src/domains/catalog_entities/handlers.rs b/packages/api-server/src/domains/catalog_entities/handlers.rs
new file mode 100644
index 000000000..5b5045f70
--- /dev/null
+++ b/packages/api-server/src/domains/catalog_entities/handlers.rs
@@ -0,0 +1,134 @@
+use axum::{
+ extract::{Path, Query, State},
+ routing::get,
+ Json, Router,
+};
+use uuid::Uuid;
+
+use crate::{
+ config::AppState, error::AppResult, market::RequestMarket, utils::pagination::PaginatedResponse,
+};
+
+use super::{
+ dto::{
+ ArtistDetail, BrandContentQuery, BrandDetail, BrandSolutionsResponse, CatalogEntityQuery,
+ },
+ service,
+};
+
+#[utoipa::path(
+ get,
+ path = "/api/v1/artists/{id}",
+ tag = "catalog-entities",
+ params(
+ ("id" = Uuid, Path, description = "Artist ID"),
+ CatalogEntityQuery
+ ),
+ responses(
+ (status = 200, description = "Artist detail", body = ArtistDetail),
+ (status = 404, description = "Artist not found")
+ )
+)]
+pub async fn get_artist(
+ State(state): State,
+ Path(id): Path,
+ Query(query): Query,
+) -> AppResult> {
+ Ok(Json(
+ service::get_artist_detail(state.db.as_ref(), id, query.locale.as_deref()).await?,
+ ))
+}
+
+#[utoipa::path(
+ get,
+ path = "/api/v1/brands/{id}",
+ tag = "catalog-entities",
+ params(
+ ("id" = Uuid, Path, description = "Brand ID"),
+ CatalogEntityQuery
+ ),
+ responses(
+ (status = 200, description = "Brand detail", body = BrandDetail),
+ (status = 404, description = "Brand not found")
+ )
+)]
+pub async fn get_brand(
+ State(state): State,
+ Path(id): Path,
+ Query(query): Query,
+) -> AppResult> {
+ Ok(Json(
+ service::get_brand_detail(state.db.as_ref(), id, query.locale.as_deref()).await?,
+ ))
+}
+
+#[utoipa::path(
+ get,
+ path = "/api/v1/brands/{id}/solutions",
+ tag = "catalog-entities",
+ params(
+ ("id" = Uuid, Path, description = "Brand ID"),
+ BrandContentQuery
+ ),
+ responses(
+ (status = 200, description = "Brand solutions", body = BrandSolutionsResponse),
+ (status = 404, description = "Brand not found")
+ )
+)]
+pub async fn list_brand_solutions(
+ State(state): State,
+ RequestMarket(market): RequestMarket,
+ Path(id): Path,
+ Query(query): Query,
+) -> AppResult> {
+ Ok(Json(
+ service::list_brand_solutions(
+ state.db.as_ref(),
+ state.assets_db.as_ref(),
+ id,
+ query,
+ &market,
+ )
+ .await?,
+ ))
+}
+
+#[utoipa::path(
+ get,
+ path = "/api/v1/brands/{id}/looks",
+ tag = "catalog-entities",
+ params(
+ ("id" = Uuid, Path, description = "Brand ID"),
+ BrandContentQuery
+ ),
+ responses(
+ (status = 200, description = "Brand looks", body = PaginatedResponse),
+ (status = 404, description = "Brand not found")
+ )
+)]
+pub async fn list_brand_looks(
+ State(state): State,
+ Path(id): Path,
+ Query(query): Query,
+) -> AppResult>> {
+ Ok(Json(
+ service::list_brand_looks(state.db.as_ref(), id, query).await?,
+ ))
+}
+
+pub fn artists_router() -> Router {
+ Router::new().route("/{id}", get(get_artist))
+}
+
+pub fn brands_router() -> Router {
+ Router::new()
+ .route("/{id}", get(get_brand))
+ .route("/{id}/solutions", get(list_brand_solutions))
+ .route("/{id}/looks", get(list_brand_looks))
+}
+
+pub fn router() -> Router {
+ Router::new()
+ .nest("/artists", artists_router())
+ .nest("/brands", brands_router())
+}
diff --git a/packages/api-server/src/domains/catalog_entities/mod.rs b/packages/api-server/src/domains/catalog_entities/mod.rs
new file mode 100644
index 000000000..530e6ac8b
--- /dev/null
+++ b/packages/api-server/src/domains/catalog_entities/mod.rs
@@ -0,0 +1,5 @@
+pub mod dto;
+pub mod handlers;
+pub mod service;
+
+pub use handlers::router;
diff --git a/packages/api-server/src/domains/catalog_entities/service.rs b/packages/api-server/src/domains/catalog_entities/service.rs
new file mode 100644
index 000000000..6f2ca2648
--- /dev/null
+++ b/packages/api-server/src/domains/catalog_entities/service.rs
@@ -0,0 +1,242 @@
+use sea_orm::{
+ ColumnTrait, DatabaseBackend, DatabaseConnection, EntityTrait, FromQueryResult, PaginatorTrait,
+ QueryFilter, Statement,
+};
+use uuid::Uuid;
+
+use crate::{
+ domains::{
+ posts::{dto::PostListQuery, service as posts_service},
+ solutions::service as solutions_service,
+ },
+ entities,
+ error::{AppError, AppResult},
+ utils::pagination::{PaginatedResponse, Pagination},
+};
+
+use super::dto::{ArtistDetail, BrandContentQuery, BrandDetail, BrandSolutionsResponse};
+
+#[derive(Debug, FromQueryResult)]
+struct ArtistDetailRow {
+ id: Uuid,
+ name: String,
+ name_ko: Option,
+ name_en: Option,
+ profile_image_url: Option,
+ instagram_username: Option,
+ group_names: Vec,
+ look_count: i64,
+ decoded_count: i64,
+ created_at: chrono::DateTime,
+ updated_at: chrono::DateTime,
+}
+
+#[derive(Debug, FromQueryResult)]
+struct BrandDetailRow {
+ id: Uuid,
+ name: String,
+ name_ko: Option,
+ name_en: Option,
+ logo_image_url: Option,
+ instagram_username: Option,
+ country_of_origin: Option,
+ description: Option,
+ product_count: i64,
+ look_count: i64,
+ created_at: chrono::DateTime,
+ updated_at: chrono::DateTime,
+}
+
+pub async fn get_artist_detail(
+ db: &DatabaseConnection,
+ id: Uuid,
+ locale: Option<&str>,
+) -> AppResult {
+ let locale = normalize_locale(locale);
+ let sql = r#"
+ SELECT
+ a.id,
+ COALESCE(a.name ->> $2, a.name_ko, a.name_en, a.name ->> 'ko', a.name ->> 'en') AS name,
+ a.name_ko,
+ a.name_en,
+ a.profile_image_url,
+ ia.username AS instagram_username,
+ COALESCE(
+ array_remove(array_agg(DISTINCT COALESCE(g.name ->> $2, g.name_ko, g.name_en)), NULL),
+ ARRAY[]::text[]
+ ) AS group_names,
+ COUNT(DISTINCT p.id) FILTER (
+ WHERE p.status = 'active'
+ AND (p.post_type IS NULL OR p.post_type <> 'try')
+ AND (p.post_type IS NULL OR p.post_type <> 'decode')
+ )::bigint AS look_count,
+ COUNT(DISTINCT p.id) FILTER (
+ WHERE p.status = 'active'
+ AND p.post_type = 'decode'
+ )::bigint AS decoded_count,
+ a.created_at,
+ a.updated_at
+ FROM public.artists a
+ LEFT JOIN public.instagram_accounts ia ON ia.id = a.primary_instagram_account_id
+ LEFT JOIN public.group_members gm ON gm.artist_id = a.id AND COALESCE(gm.is_active, true)
+ LEFT JOIN public.groups g ON g.id = gm.group_id
+ LEFT JOIN public.posts p ON p.artist_id = a.id
+ WHERE a.id = $1
+ GROUP BY a.id, ia.username
+ "#;
+
+ let row = ArtistDetailRow::find_by_statement(Statement::from_sql_and_values(
+ DatabaseBackend::Postgres,
+ sql,
+ vec![id.into(), locale.into()],
+ ))
+ .one(db)
+ .await
+ .map_err(AppError::DatabaseError)?
+ .ok_or_else(|| AppError::NotFound(format!("Artist not found: {}", id)))?;
+
+ Ok(ArtistDetail {
+ id: row.id,
+ name: row.name,
+ name_ko: row.name_ko,
+ name_en: row.name_en,
+ profile_image_url: row.profile_image_url,
+ instagram_username: row.instagram_username,
+ group_names: row.group_names,
+ look_count: row.look_count,
+ decoded_count: row.decoded_count,
+ created_at: row.created_at.with_timezone(&chrono::Utc),
+ updated_at: row.updated_at.with_timezone(&chrono::Utc),
+ })
+}
+
+pub async fn get_brand_detail(
+ db: &DatabaseConnection,
+ id: Uuid,
+ locale: Option<&str>,
+) -> AppResult {
+ let locale = normalize_locale(locale);
+ let sql = r#"
+ SELECT
+ b.id,
+ COALESCE(b.name ->> $2, b.name_ko, b.name_en, b.name ->> 'ko', b.name ->> 'en') AS name,
+ b.name_ko,
+ b.name_en,
+ b.logo_image_url,
+ ia.username AS instagram_username,
+ COALESCE(
+ b.metadata ->> 'country_of_origin',
+ b.metadata ->> 'country',
+ b.metadata ->> 'country_code'
+ ) AS country_of_origin,
+ COALESCE(
+ NULLIF(btrim(b.metadata -> 'brand_identity' -> 'summary' ->> $2), ''),
+ NULLIF(btrim(b.metadata ->> 'description'), '')
+ ) AS description,
+ COUNT(DISTINCT s.id) FILTER (WHERE s.status = 'active')::bigint AS product_count,
+ COUNT(DISTINCT p.id) FILTER (
+ WHERE p.status = 'active'
+ AND (p.post_type IS NULL OR p.post_type <> 'try')
+ AND (p.post_type IS NULL OR p.post_type <> 'decode')
+ )::bigint AS look_count,
+ b.created_at,
+ b.updated_at
+ FROM public.brands b
+ LEFT JOIN public.instagram_accounts ia ON ia.id = b.primary_instagram_account_id
+ LEFT JOIN public.solutions s ON s.brand_id = b.id
+ LEFT JOIN public.spots sp ON sp.id = s.spot_id
+ LEFT JOIN public.posts p ON p.id = sp.post_id
+ WHERE b.id = $1
+ GROUP BY b.id, ia.username
+ "#;
+
+ let row = BrandDetailRow::find_by_statement(Statement::from_sql_and_values(
+ DatabaseBackend::Postgres,
+ sql,
+ vec![id.into(), locale.into()],
+ ))
+ .one(db)
+ .await
+ .map_err(AppError::DatabaseError)?
+ .ok_or_else(|| AppError::NotFound(format!("Brand not found: {}", id)))?;
+
+ Ok(BrandDetail {
+ id: row.id,
+ name: row.name,
+ name_ko: row.name_ko,
+ name_en: row.name_en,
+ logo_image_url: row.logo_image_url,
+ instagram_username: row.instagram_username,
+ country_of_origin: row.country_of_origin,
+ description: row.description,
+ product_count: row.product_count,
+ look_count: row.look_count,
+ created_at: row.created_at.with_timezone(&chrono::Utc),
+ updated_at: row.updated_at.with_timezone(&chrono::Utc),
+ })
+}
+
+pub async fn list_brand_solutions(
+ db: &DatabaseConnection,
+ assets_db: &DatabaseConnection,
+ brand_id: Uuid,
+ query: BrandContentQuery,
+ market: &str,
+) -> AppResult {
+ ensure_brand_exists(db, brand_id).await?;
+ let pagination = Pagination::new(query.page, query.per_page);
+ solutions_service::list_solutions_by_brand_id(db, assets_db, brand_id, pagination, market).await
+}
+
+pub async fn list_brand_looks(
+ db: &DatabaseConnection,
+ brand_id: Uuid,
+ query: BrandContentQuery,
+) -> AppResult> {
+ ensure_brand_exists(db, brand_id).await?;
+ posts_service::list_posts(
+ db,
+ PostListQuery {
+ artist_name: None,
+ group_name: None,
+ context: None,
+ mood: None,
+ category: None,
+ user_id: None,
+ artist_id: None,
+ group_id: None,
+ brand_id: Some(brand_id),
+ sort: "recent".to_string(),
+ page: query.page,
+ per_page: query.per_page,
+ has_solutions: None,
+ has_magazine: None,
+ color: None,
+ season: None,
+ min_solutions: None,
+ complete_outfit: None,
+ include_magazine_items: None,
+ viewer_id: None,
+ },
+ )
+ .await
+}
+
+async fn ensure_brand_exists(db: &DatabaseConnection, brand_id: Uuid) -> AppResult<()> {
+ let exists = entities::Brands::find()
+ .filter(entities::brands::Column::Id.eq(brand_id))
+ .count(db)
+ .await
+ .map_err(AppError::DatabaseError)?;
+ if exists == 0 {
+ return Err(AppError::NotFound(format!("Brand not found: {}", brand_id)));
+ }
+ Ok(())
+}
+
+fn normalize_locale(locale: Option<&str>) -> &'static str {
+ match locale {
+ Some("en") => "en",
+ _ => "ko",
+ }
+}
diff --git a/packages/api-server/src/domains/mod.rs b/packages/api-server/src/domains/mod.rs
index 00b94df99..91e18d2ca 100644
--- a/packages/api-server/src/domains/mod.rs
+++ b/packages/api-server/src/domains/mod.rs
@@ -6,6 +6,7 @@ pub mod admin;
pub mod auth;
pub mod badges;
pub mod catalog;
+pub mod catalog_entities;
pub mod categories;
pub mod comments;
pub mod content_studio;
diff --git a/packages/api-server/src/domains/posts/dto.rs b/packages/api-server/src/domains/posts/dto.rs
index 621c85124..9e825a6fc 100644
--- a/packages/api-server/src/domains/posts/dto.rs
+++ b/packages/api-server/src/domains/posts/dto.rs
@@ -244,6 +244,10 @@ pub struct PostListQuery {
#[serde(skip_serializing_if = "Option::is_none")]
pub group_id: Option,
+ /// 브랜드 ID 필터 (warehouse FK, active solution 기준)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub brand_id: Option,
+
/// 정렬 방식
#[serde(default = "default_sort")]
pub sort: String, // 'recent' | 'popular' | 'trending'
diff --git a/packages/api-server/src/domains/posts/handlers.rs b/packages/api-server/src/domains/posts/handlers.rs
index 3eac9deca..a8c358767 100644
--- a/packages/api-server/src/domains/posts/handlers.rs
+++ b/packages/api-server/src/domains/posts/handlers.rs
@@ -213,6 +213,7 @@ pub async fn create_post_with_solutions(
("user_id" = Option, Query, description = "사용자 ID 필터"),
("artist_id" = Option, Query, description = "아티스트 ID 필터 (warehouse FK)"),
("group_id" = Option, Query, description = "그룹 ID 필터 (warehouse FK)"),
+ ("brand_id" = Option, Query, description = "브랜드 ID 필터 (active primary solution FK)"),
("sort" = Option, Query, description = "정렬: recent | popular | trending"),
("page" = Option, Query, description = "페이지 번호"),
("per_page" = Option, Query, description = "페이지당 개수"),
diff --git a/packages/api-server/src/domains/posts/service.rs b/packages/api-server/src/domains/posts/service.rs
index 0df3fc458..c7b78e1f3 100644
--- a/packages/api-server/src/domains/posts/service.rs
+++ b/packages/api-server/src/domains/posts/service.rs
@@ -1706,6 +1706,29 @@ fn build_post_list_select(
select = select.filter(Column::GroupId.eq(group_id));
}
+ if let Some(brand_id) = query.brand_id {
+ use sea_orm::sea_query::{Alias, Query as SeaQuery};
+ let subquery = SeaQuery::select()
+ .distinct()
+ .column((Alias::new("spots"), Alias::new("post_id")))
+ .from(Alias::new("spots"))
+ .inner_join(
+ Alias::new("solutions"),
+ sea_orm::sea_query::Expr::col((Alias::new("solutions"), Alias::new("spot_id")))
+ .equals((Alias::new("spots"), Alias::new("id"))),
+ )
+ .and_where(
+ sea_orm::sea_query::Expr::col((Alias::new("solutions"), Alias::new("status")))
+ .eq(crate::constants::solution_status::ACTIVE),
+ )
+ .and_where(
+ sea_orm::sea_query::Expr::col((Alias::new("solutions"), Alias::new("brand_id")))
+ .eq(brand_id),
+ )
+ .to_owned();
+ select = select.filter(Column::Id.in_subquery(subquery));
+ }
+
if let Some(ref context) = query.context {
select = select.filter(Column::Context.eq(context));
}
@@ -5395,6 +5418,7 @@ mod tests {
user_id: None,
artist_id: None,
group_id: None,
+ brand_id: None,
sort: "recent".to_string(),
page: 1,
per_page: 20,
@@ -5442,6 +5466,7 @@ mod tests {
user_id: None,
artist_id: None,
group_id: None,
+ brand_id: None,
sort: "popular".to_string(),
page: 1,
per_page: 20,
@@ -5485,6 +5510,7 @@ mod tests {
user_id: Some(Uuid::new_v4()),
artist_id: Some(Uuid::new_v4()),
group_id: Some(Uuid::new_v4()),
+ brand_id: None,
sort: "popular".to_string(),
page: 1,
per_page: 20,
@@ -5526,6 +5552,7 @@ mod tests {
user_id: None,
artist_id: None,
group_id: None,
+ brand_id: None,
sort: "trending".to_string(),
page: 1,
per_page: 20,
@@ -5572,6 +5599,7 @@ mod tests {
user_id: Some(Uuid::new_v4()),
artist_id: None,
group_id: None,
+ brand_id: None,
sort: "recent".to_string(),
page: 1,
per_page: 20,
@@ -5618,6 +5646,7 @@ mod tests {
user_id: None,
artist_id: None,
group_id: None,
+ brand_id: None,
sort: "popular".to_string(),
page: 1,
per_page: 20,
@@ -5663,6 +5692,7 @@ mod tests {
user_id: None,
artist_id: None,
group_id: None,
+ brand_id: None,
sort: "trending".to_string(),
page: 1,
per_page: 20,
@@ -5907,6 +5937,7 @@ mod tests {
user_id: None,
artist_id: None,
group_id: None,
+ brand_id: None,
sort: "recent".to_string(),
page: 1,
per_page: 20,
@@ -5951,6 +5982,7 @@ mod tests {
user_id: None,
artist_id: None,
group_id: None,
+ brand_id: None,
sort: "recent".to_string(),
page: 1,
per_page: 20,
@@ -6015,6 +6047,7 @@ mod tests {
user_id: None,
artist_id: None,
group_id: None,
+ brand_id: None,
sort: "recent".to_string(),
page: 1,
per_page: 20,
@@ -6145,6 +6178,7 @@ mod tests {
user_id: None,
artist_id: None,
group_id: None,
+ brand_id: None,
sort: "recent".to_string(),
page: 1,
per_page: 20,
diff --git a/packages/api-server/src/domains/posts/tests.rs b/packages/api-server/src/domains/posts/tests.rs
index 56c087b62..9278bc5d7 100644
--- a/packages/api-server/src/domains/posts/tests.rs
+++ b/packages/api-server/src/domains/posts/tests.rs
@@ -409,6 +409,7 @@ mod tests {
user_id: None,
artist_id: None,
group_id: None,
+ brand_id: None,
sort: "recent".to_string(),
page: 1,
per_page: 20,
@@ -700,6 +701,7 @@ mod tests {
user_id: Some(test_uuid(10)),
artist_id: Some(test_uuid(20)),
group_id: Some(test_uuid(30)),
+ brand_id: None,
sort: "popular".to_string(),
page: 1,
per_page: 20,
@@ -746,6 +748,7 @@ mod tests {
user_id: None,
artist_id: None,
group_id: None,
+ brand_id: None,
sort: "trending".to_string(),
page: 1,
per_page: 20,
@@ -1252,6 +1255,7 @@ mod tests {
user_id: None,
artist_id: None,
group_id: None,
+ brand_id: None,
sort: "recent".to_string(),
page: 1,
per_page: 20,
@@ -1474,6 +1478,7 @@ mod tests {
user_id: None,
artist_id: None,
group_id: None,
+ brand_id: None,
sort: "xxx-unknown".to_string(),
page: 1,
per_page: 20,
@@ -1703,6 +1708,7 @@ mod tests {
user_id: None,
artist_id: None,
group_id: None,
+ brand_id: None,
sort: "recent".to_string(),
page: 1,
per_page: 20,
diff --git a/packages/api-server/src/domains/search/dto.rs b/packages/api-server/src/domains/search/dto.rs
index 64afd3057..dfb5b5932 100644
--- a/packages/api-server/src/domains/search/dto.rs
+++ b/packages/api-server/src/domains/search/dto.rs
@@ -277,6 +277,21 @@ pub struct EntitySearchHit {
/// 영어 이름
#[serde(skip_serializing_if = "Option::is_none")]
pub name_en: Option,
+ /// 아티스트 프로필 이미지 URL
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub profile_image_url: Option,
+ /// 브랜드 로고 이미지 URL
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub logo_image_url: Option,
+ /// 연결된 그룹 표시명 목록
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub group_names: Vec,
+ /// 관련 look 수
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub look_count: Option,
+ /// 관련 product/solution 수
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub product_count: Option,
}
/// 엔티티 검색 응답
diff --git a/packages/api-server/src/domains/search/service.rs b/packages/api-server/src/domains/search/service.rs
index 8d039fe02..f35b66f29 100644
--- a/packages/api-server/src/domains/search/service.rs
+++ b/packages/api-server/src/domains/search/service.rs
@@ -3,8 +3,8 @@
//! 검색 비즈니스 로직
use sea_orm::{
- ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, FromQueryResult, QueryFilter,
- QueryOrder, QuerySelect, Set, Statement,
+ ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait,
+ FromQueryResult, QueryFilter, QueryOrder, QuerySelect, Set, Statement,
};
use uuid::Uuid;
@@ -383,6 +383,19 @@ impl SearchService {
name,
name_ko,
name_en,
+ profile_image_url: doc["profile_image_url"].as_str().map(str::to_string),
+ logo_image_url: doc["logo_image_url"].as_str().map(str::to_string),
+ group_names: doc["group_names"]
+ .as_array()
+ .map(|values| {
+ values
+ .iter()
+ .filter_map(|value| value.as_str().map(str::to_string))
+ .collect()
+ })
+ .unwrap_or_default(),
+ look_count: doc["look_count"].as_i64(),
+ product_count: doc["product_count"].as_i64(),
})
}
@@ -404,10 +417,11 @@ impl SearchService {
let empty = vec![];
let hits = results["hits"].as_array().unwrap_or(&empty);
- let data: Vec = hits
+ let mut data: Vec = hits
.iter()
.filter_map(|h| Self::map_entity_hit(h, locale))
.collect();
+ Self::enrich_entity_hits(state.db.as_ref(), &mut data, locale).await?;
Ok(EntitySearchResponse {
data,
@@ -415,6 +429,147 @@ impl SearchService {
took_ms: start.elapsed().as_millis() as u64,
})
}
+
+ async fn enrich_entity_hits(
+ db: &DatabaseConnection,
+ hits: &mut [EntitySearchHit],
+ locale: &str,
+ ) -> AppResult<()> {
+ let artist_ids: Vec = hits
+ .iter()
+ .filter(|hit| hit.entity_type == "artist")
+ .map(|hit| hit.id)
+ .collect();
+ let brand_ids: Vec = hits
+ .iter()
+ .filter(|hit| hit.entity_type == "brand")
+ .map(|hit| hit.id)
+ .collect();
+
+ let mut artists = HashMap::new();
+ if !artist_ids.is_empty() {
+ let artist_placeholders: Vec =
+ (1..=artist_ids.len()).map(|i| format!("${}", i)).collect();
+ let locale_placeholder = artist_ids.len() + 1;
+ let mut values: Vec = artist_ids.into_iter().map(Into::into).collect();
+ values.push(locale.into());
+ let rows = db
+ .query_all(Statement::from_sql_and_values(
+ sea_orm::DatabaseBackend::Postgres,
+ format!(
+ r#"
+ SELECT
+ a.id,
+ a.profile_image_url,
+ COALESCE(
+ array_remove(array_agg(DISTINCT COALESCE(g.name ->> ${}, g.name_ko, g.name_en)), NULL),
+ ARRAY[]::text[]
+ ) AS group_names,
+ COUNT(DISTINCT p.id) FILTER (
+ WHERE p.status = 'active'
+ AND (p.post_type IS NULL OR p.post_type <> 'try')
+ AND (p.post_type IS NULL OR p.post_type <> 'decode')
+ )::bigint AS look_count
+ FROM public.artists a
+ LEFT JOIN public.group_members gm ON gm.artist_id = a.id AND COALESCE(gm.is_active, true)
+ LEFT JOIN public.groups g ON g.id = gm.group_id
+ LEFT JOIN public.posts p ON p.artist_id = a.id
+ WHERE a.id IN ({})
+ GROUP BY a.id
+ "#,
+ locale_placeholder,
+ artist_placeholders.join(", ")
+ ),
+ values,
+ ))
+ .await
+ .map_err(AppError::DatabaseError)?;
+ for row in rows {
+ let id: Uuid = row.try_get("", "id").map_err(AppError::DatabaseError)?;
+ let profile_image_url: Option = row
+ .try_get("", "profile_image_url")
+ .map_err(AppError::DatabaseError)?;
+ let group_names: Vec = row
+ .try_get("", "group_names")
+ .map_err(AppError::DatabaseError)?;
+ let look_count: i64 = row
+ .try_get("", "look_count")
+ .map_err(AppError::DatabaseError)?;
+ artists.insert(id, (profile_image_url, group_names, look_count));
+ }
+ }
+
+ let mut brands = HashMap::new();
+ if !brand_ids.is_empty() {
+ let brand_placeholders: Vec =
+ (1..=brand_ids.len()).map(|i| format!("${}", i)).collect();
+ let values: Vec = brand_ids.into_iter().map(Into::into).collect();
+ let rows = db
+ .query_all(Statement::from_sql_and_values(
+ sea_orm::DatabaseBackend::Postgres,
+ format!(
+ r#"
+ SELECT
+ b.id,
+ b.logo_image_url,
+ COUNT(DISTINCT s.id) FILTER (WHERE s.status = 'active')::bigint AS product_count,
+ COUNT(DISTINCT p.id) FILTER (
+ WHERE p.status = 'active'
+ AND (p.post_type IS NULL OR p.post_type <> 'try')
+ AND (p.post_type IS NULL OR p.post_type <> 'decode')
+ )::bigint AS look_count
+ FROM public.brands b
+ LEFT JOIN public.solutions s ON s.brand_id = b.id
+ LEFT JOIN public.spots sp ON sp.id = s.spot_id
+ LEFT JOIN public.posts p ON p.id = sp.post_id
+ WHERE b.id IN ({})
+ GROUP BY b.id
+ "#,
+ brand_placeholders.join(", ")
+ ),
+ values,
+ ))
+ .await
+ .map_err(AppError::DatabaseError)?;
+ for row in rows {
+ let id: Uuid = row.try_get("", "id").map_err(AppError::DatabaseError)?;
+ let logo_image_url: Option = row
+ .try_get("", "logo_image_url")
+ .map_err(AppError::DatabaseError)?;
+ let product_count: i64 = row
+ .try_get("", "product_count")
+ .map_err(AppError::DatabaseError)?;
+ let look_count: i64 = row
+ .try_get("", "look_count")
+ .map_err(AppError::DatabaseError)?;
+ brands.insert(id, (logo_image_url, product_count, look_count));
+ }
+ }
+
+ for hit in hits {
+ match hit.entity_type.as_str() {
+ "artist" => {
+ if let Some((profile_image_url, group_names, look_count)) = artists.get(&hit.id)
+ {
+ hit.profile_image_url =
+ hit.profile_image_url.clone().or(profile_image_url.clone());
+ hit.group_names = group_names.clone();
+ hit.look_count = Some(*look_count);
+ }
+ }
+ "brand" => {
+ if let Some((logo_image_url, product_count, look_count)) = brands.get(&hit.id) {
+ hit.logo_image_url = hit.logo_image_url.clone().or(logo_image_url.clone());
+ hit.product_count = Some(*product_count);
+ hit.look_count = Some(*look_count);
+ }
+ }
+ _ => {}
+ }
+ }
+
+ Ok(())
+ }
}
#[cfg(test)]
diff --git a/packages/api-server/src/domains/solutions/service.rs b/packages/api-server/src/domains/solutions/service.rs
index f12567d6d..843eab25d 100644
--- a/packages/api-server/src/domains/solutions/service.rs
+++ b/packages/api-server/src/domains/solutions/service.rs
@@ -166,6 +166,81 @@ pub async fn list_solutions_by_spot_id(
Ok(items)
}
+/// 브랜드 ID로 공개 Solution 목록 조회 (채택 > 검증 > 최신순)
+pub async fn list_solutions_by_brand_id(
+ db: &DatabaseConnection,
+ assets_db: &sea_orm::DatabaseConnection,
+ brand_id: Uuid,
+ pagination: crate::utils::pagination::Pagination,
+ user_market: &str,
+) -> AppResult> {
+ let select = Solutions::find()
+ .filter(Column::BrandId.eq(brand_id))
+ .filter(Column::Status.eq(crate::constants::solution_status::ACTIVE))
+ .order_by(Column::IsAdopted, Order::Desc)
+ .order_by(Column::IsVerified, Order::Desc)
+ .order_by_desc(Column::CreatedAt);
+
+ let total = select
+ .clone()
+ .count(db)
+ .await
+ .map_err(AppError::DatabaseError)?;
+
+ let solutions = select
+ .offset(pagination.offset())
+ .limit(pagination.limit())
+ .find_also_related(users::Entity)
+ .all(db)
+ .await
+ .map_err(AppError::DatabaseError)?;
+
+ let rows: Vec<(Uuid, Option, Option)> = solutions
+ .iter()
+ .map(|(sol, _)| {
+ (
+ sol.id,
+ sol.catalog_item_id.clone(),
+ sol.affiliate_url.clone(),
+ )
+ })
+ .collect();
+ let resolved = super::affiliate_resolve::resolve_many(assets_db, &rows, user_market).await;
+
+ let mut items = Vec::new();
+ for (solution, user) in solutions {
+ let Some(user) = user else {
+ tracing::warn!("Solution {} has no associated user", solution.id);
+ continue;
+ };
+ let aff = resolved.get(&solution.id);
+ items.push(SolutionListItem {
+ id: solution.id,
+ brand_id: solution.brand_id,
+ user: user.into(),
+ match_type: solution.match_type,
+ link_type: solution.link_type,
+ title: solution.title,
+ metadata: solution.metadata,
+ thumbnail_url: solution.thumbnail_url,
+ original_url: solution.original_url,
+ affiliate_url: aff.and_then(|r| r.affiliate_url.clone()),
+ vote_stats: VoteStatsDto {
+ accurate: solution.accurate_count,
+ different: solution.different_count,
+ },
+ is_verified: solution.is_verified,
+ is_adopted: solution.is_adopted,
+ created_at: solution.created_at.with_timezone(&chrono::Utc),
+ served_market: aff.and_then(|r| r.served_market.clone()),
+ });
+ }
+
+ Ok(crate::utils::pagination::PaginatedResponse::new(
+ items, pagination, total,
+ ))
+}
+
/// Solution ID로 조회
pub async fn get_solution_by_id(
db: &DatabaseConnection,
diff --git a/packages/api-server/src/openapi.rs b/packages/api-server/src/openapi.rs
index c6599c55d..4418ce93e 100644
--- a/packages/api-server/src/openapi.rs
+++ b/packages/api-server/src/openapi.rs
@@ -51,6 +51,10 @@ use utoipa::OpenApi;
crate::domains::catalog::handlers::similar_items,
// 아이템 detail 페이지(#786 후속: 아이템 좋아요 → 개인화).
crate::domains::catalog::handlers::get_item,
+ crate::domains::catalog_entities::handlers::get_artist,
+ crate::domains::catalog_entities::handlers::get_brand,
+ crate::domains::catalog_entities::handlers::list_brand_solutions,
+ crate::domains::catalog_entities::handlers::list_brand_looks,
crate::domains::posts::handlers::get_post,
crate::domains::posts::handlers::update_post,
crate::domains::posts::handlers::delete_post,
@@ -182,6 +186,7 @@ use utoipa::OpenApi;
(name = "feed", description = "Home feed and curations"),
(name = "subcategories", description = "Subcategories"),
(name = "search", description = "Search and feed"),
+ (name = "catalog-entities", description = "Catalog artist and brand discovery"),
(name = "rankings", description = "Rankings"),
(name = "badges", description = "Badges"),
(name = "earnings", description = "Earnings and settlements"),
@@ -312,6 +317,11 @@ use utoipa::OpenApi;
crate::domains::search::dto::EntitySearchQuery,
crate::domains::search::dto::EntitySearchResponse,
crate::domains::search::dto::EntitySearchHit,
+ crate::domains::catalog_entities::dto::ArtistDetail,
+ crate::domains::catalog_entities::dto::BrandDetail,
+ crate::domains::catalog_entities::dto::CatalogEntityQuery,
+ crate::domains::catalog_entities::dto::BrandContentQuery,
+ crate::domains::catalog_entities::dto::BrandSolutionsResponse,
// Rankings 도메인 DTO
crate::domains::rankings::dto::RankingPeriodQuery,
crate::domains::rankings::dto::RankingListResponse,
diff --git a/packages/api-server/src/router.rs b/packages/api-server/src/router.rs
index ba78bcba9..a07ace977 100644
--- a/packages/api-server/src/router.rs
+++ b/packages/api-server/src/router.rs
@@ -20,6 +20,7 @@ pub fn build_api_router(state: AppState) -> Router {
.nest("/tries", domains::vton::tries_router(config.clone()))
.nest("/posts", domains::posts::router(config.clone()))
.nest("/catalog", domains::catalog::router())
+ .merge(domains::catalog_entities::router())
.nest("/spots", domains::spots::router(config.clone()))
.nest("/search", domains::search::router(config.clone()))
.nest("/feed", domains::feed::router(config.clone()))
diff --git a/packages/web/app/[locale]/(shell)/artists/[id]/page.tsx b/packages/web/app/[locale]/(shell)/artists/[id]/page.tsx
new file mode 100644
index 000000000..4bccc2841
--- /dev/null
+++ b/packages/web/app/[locale]/(shell)/artists/[id]/page.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from "next";
+import { ArtistDetailClient } from "@/lib/components/artists/ArtistDetailClient";
+
+type Props = {
+ params: Promise<{ id: string; locale: string }>;
+};
+
+export const metadata: Metadata = {
+ title: "Artist",
+};
+
+export default async function ArtistDetailPage({ params }: Props) {
+ const { id } = await params;
+ return ;
+}
diff --git a/packages/web/app/[locale]/(shell)/brands/[id]/page.tsx b/packages/web/app/[locale]/(shell)/brands/[id]/page.tsx
new file mode 100644
index 000000000..566058782
--- /dev/null
+++ b/packages/web/app/[locale]/(shell)/brands/[id]/page.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from "next";
+import { BrandDetailClient } from "@/lib/components/brands/BrandDetailClient";
+
+type Props = {
+ params: Promise<{ id: string; locale: string }>;
+};
+
+export const metadata: Metadata = {
+ title: "Brand",
+};
+
+export default async function BrandDetailPage({ params }: Props) {
+ const { id } = await params;
+ return ;
+}
diff --git a/packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx b/packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx
index 6b33bd403..8e3842d11 100644
--- a/packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx
+++ b/packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx
@@ -27,6 +27,8 @@ import {
type ExploreCategoryId,
} from "@/lib/components/explore/exploreFilters";
import { ComingSoonState } from "@/lib/components/explore/ComingSoonState";
+import { ArtistResultsPanel } from "@/lib/components/explore/ArtistResultsPanel";
+import { BrandResultsPanel } from "@/lib/components/explore/BrandResultsPanel";
import { LoadingSpinner } from "@/lib/design-system/loading-spinner";
import { SearchSuggestions } from "@/lib/components/search/SearchSuggestions";
import type { GridItem } from "@/lib/components/ThiingsGrid";
@@ -328,7 +330,11 @@ export function ExploreClient({
{/* Entity tabs */}
- {entity !== "looks" ? (
+ {entity === "artists" ? (
+
+ ) : entity === "brands" ? (
+
+ ) : entity !== "looks" ? (
) : (
<>
diff --git a/packages/web/lib/components/artists/ArtistDetailClient.tsx b/packages/web/lib/components/artists/ArtistDetailClient.tsx
new file mode 100644
index 000000000..87f4cef2d
--- /dev/null
+++ b/packages/web/lib/components/artists/ArtistDetailClient.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import Image from "next/image";
+import { Link } from "@/i18n/navigation";
+import { ArrowLeft, Instagram, UserRound } from "lucide-react";
+import { useLocale } from "next-intl";
+import { useGetArtist } from "@/lib/api/generated/catalog-entities/catalog-entities";
+import { useListPosts } from "@/lib/api/generated/posts/posts";
+import { ExploreCardCell, ExploreSkeletonCell } from "@/lib/components/explore";
+import { mapPostListItemToGridItem } from "@/lib/utils/post-grid-item-mapper";
+
+const proxyImg = (url: string) =>
+ url.startsWith("http")
+ ? `/api/v1/image-proxy?url=${encodeURIComponent(url)}`
+ : url;
+
+export function ArtistDetailClient({ artistId }: { artistId: string }) {
+ const locale = useLocale();
+ const {
+ data: artist,
+ isLoading: isArtistLoading,
+ isError: isArtistError,
+ } = useGetArtist(artistId, { locale });
+ const { data: looks, isLoading: isLooksLoading } = useListPosts(
+ { artist_id: artistId, sort: "recent", per_page: 24 },
+ { query: { staleTime: 60_000 } }
+ );
+
+ if (isArtistLoading) {
+ return ;
+ }
+
+ if (isArtistError || !artist) {
+ return (
+
+
Artist not found
+
+ Back to Explore
+
+
+ );
+ }
+
+ const gridItems = (looks?.data ?? []).map(mapPostListItemToGridItem);
+
+ return (
+
+
+
+ Back to Explore
+
+
+
+
+
+ {artist.profile_image_url ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
Artist
+
+ {artist.name}
+
+ {artist.group_names && artist.group_names.length > 0 && (
+
+ {artist.group_names.map((group) => (
+
+ {group}
+
+ ))}
+
+ )}
+ {artist.instagram_username && (
+
+ @{artist.instagram_username}
+
+ )}
+
+
+
+
+
+
+
Looks
+
Decoded looks
+
+
+ {isLooksLoading ? (
+
+ ) : gridItems.length > 0 ? (
+
+ {gridItems.map((item, index) => (
+
+
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function DetailSkeleton() {
+ return (
+
+ );
+}
+
+function GridSkeleton() {
+ return (
+
+ {Array.from({ length: 12 }).map((_, index) => (
+
+
+
+ ))}
+
+ );
+}
+
+function EmptyState({ text }: { text: string }) {
+ return (
+
+ {text}
+
+ );
+}
diff --git a/packages/web/lib/components/brands/BrandDetailClient.tsx b/packages/web/lib/components/brands/BrandDetailClient.tsx
new file mode 100644
index 000000000..c83e6cee3
--- /dev/null
+++ b/packages/web/lib/components/brands/BrandDetailClient.tsx
@@ -0,0 +1,295 @@
+"use client";
+
+import { useState } from "react";
+import type { ReactNode } from "react";
+import Image from "next/image";
+import { Link } from "@/i18n/navigation";
+import { ArrowLeft, Instagram, Store } from "lucide-react";
+import { useLocale } from "next-intl";
+import {
+ useGetBrand,
+ useListBrandLooks,
+ useListBrandSolutions,
+} from "@/lib/api/generated/catalog-entities/catalog-entities";
+import type { SolutionListItem } from "@/lib/api/generated/models";
+import { ExploreCardCell, ExploreSkeletonCell } from "@/lib/components/explore";
+import { mapPostListItemToGridItem } from "@/lib/utils/post-grid-item-mapper";
+import { cn } from "@/lib/utils";
+
+const proxyImg = (url: string) =>
+ url.startsWith("http")
+ ? `/api/v1/image-proxy?url=${encodeURIComponent(url)}`
+ : url;
+
+type BrandTab = "looks" | "products";
+
+export function BrandDetailClient({ brandId }: { brandId: string }) {
+ const locale = useLocale();
+ const [tab, setTab] = useState("looks");
+ const {
+ data: brand,
+ isLoading: isBrandLoading,
+ isError: isBrandError,
+ } = useGetBrand(brandId, { locale });
+ const { data: looks, isLoading: isLooksLoading } = useListBrandLooks(
+ brandId,
+ { per_page: 24 },
+ { query: { staleTime: 60_000 } }
+ );
+ const { data: products, isLoading: isProductsLoading } =
+ useListBrandSolutions(
+ brandId,
+ { per_page: 24 },
+ { query: { staleTime: 60_000 } }
+ );
+
+ if (isBrandLoading) {
+ return ;
+ }
+
+ if (isBrandError || !brand) {
+ return (
+
+
Brand not found
+
+ Back to Explore
+
+
+ );
+ }
+
+ const description = brand.description?.trim() || null;
+ const gridItems = (looks?.data ?? []).map(mapPostListItemToGridItem);
+ const productItems = products?.data ?? [];
+
+ return (
+
+
+
+ Back to Explore
+
+
+
+
+
+ {brand.logo_image_url ? (
+
+ ) : (
+
+ )}
+
+
+
Brand
+
+ {brand.name}
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+
+
+
+ setTab("looks")}>
+ Looks
+
+ setTab("products")}
+ >
+ Products
+
+
+
+ {tab === "looks" ? (
+ isLooksLoading ? (
+
+ ) : gridItems.length > 0 ? (
+
+ {gridItems.map((item, index) => (
+
+
+
+ ))}
+
+ ) : (
+
+ )
+ ) : isProductsLoading ? (
+
+ ) : productItems.length > 0 ? (
+
+ {productItems.map((product) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function ProductCard({ product }: { product: SolutionListItem }) {
+ const url = product.affiliate_url ?? product.original_url ?? null;
+ const metadata = product.metadata as
+ | { brand?: string; price?: string; currency?: string }
+ | null
+ | undefined;
+ const price =
+ metadata?.price && metadata?.currency
+ ? `${metadata.currency} ${metadata.price}`
+ : metadata?.price;
+
+ const content = (
+
+
+ {product.thumbnail_url ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
{product.title}
+ {metadata?.brand && (
+
+ {metadata.brand}
+
+ )}
+ {price &&
{price}
}
+
+ {product.is_adopted && }
+ {product.is_verified && }
+
+
+
+ );
+
+ return url ? (
+
+ {content}
+
+ ) : (
+ content
+ );
+}
+
+function TabButton({
+ active,
+ onClick,
+ children,
+}: {
+ active: boolean;
+ onClick: () => void;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function Badge({ label }: { label: string }) {
+ return (
+
+ {label}
+
+ );
+}
+
+function BrandSkeleton() {
+ return (
+
+ );
+}
+
+function GridSkeleton() {
+ return (
+
+ {Array.from({ length: 12 }).map((_, index) => (
+
+
+
+ ))}
+
+ );
+}
+
+function ProductSkeleton() {
+ return (
+
+ {Array.from({ length: 8 }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+function EmptyState({ text }: { text: string }) {
+ return (
+
+ {text}
+
+ );
+}
diff --git a/packages/web/lib/components/explore/ArtistResultCard.tsx b/packages/web/lib/components/explore/ArtistResultCard.tsx
new file mode 100644
index 000000000..303ad0bfb
--- /dev/null
+++ b/packages/web/lib/components/explore/ArtistResultCard.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import Image from "next/image";
+import { Link } from "@/i18n/navigation";
+import { UserRound } from "lucide-react";
+import type { EntitySearchHit } from "@/lib/api/generated/models";
+
+const proxyImg = (url: string) =>
+ url.startsWith("http")
+ ? `/api/v1/image-proxy?url=${encodeURIComponent(url)}`
+ : url;
+
+export function ArtistResultCard({ artist }: { artist: EntitySearchHit }) {
+ const groups = artist.group_names ?? [];
+
+ return (
+
+
+
+ {artist.profile_image_url ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
Artist
+
+ {artist.name}
+
+ {groups.length > 0 && (
+
+ {groups.join(" / ")}
+
+ )}
+
+ {artist.look_count ?? 0} looks
+
+
+
+
+ );
+}
diff --git a/packages/web/lib/components/explore/ArtistResultsPanel.tsx b/packages/web/lib/components/explore/ArtistResultsPanel.tsx
new file mode 100644
index 000000000..0415dcbfb
--- /dev/null
+++ b/packages/web/lib/components/explore/ArtistResultsPanel.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { useLocale } from "next-intl";
+import { LoadingSpinner } from "@/lib/design-system/loading-spinner";
+import { useCatalogEntitySearch } from "@/lib/hooks/useCatalogEntitySearch";
+import { ArtistResultCard } from "./ArtistResultCard";
+
+export function ArtistResultsPanel({ query }: { query: string }) {
+ const locale = useLocale();
+ const trimmed = query.trim();
+ const {
+ data: artists = [],
+ isLoading,
+ isError,
+ } = useCatalogEntitySearch({
+ query: trimmed,
+ kind: "artist",
+ locale,
+ });
+
+ if (!trimmed) {
+ return (
+
+ );
+ }
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (isError) {
+ return (
+
+ );
+ }
+
+ if (artists.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {artists.map((artist) => (
+
+ ))}
+
+ );
+}
+
+export function CatalogLoadingState({ label }: { label: string }) {
+ return (
+
+
+
+ );
+}
+
+export function CatalogEmptyState({
+ title,
+ description,
+}: {
+ title: string;
+ description: string;
+}) {
+ return (
+
+
{title}
+
{description}
+
+ );
+}
diff --git a/packages/web/lib/components/explore/BrandResultCard.tsx b/packages/web/lib/components/explore/BrandResultCard.tsx
new file mode 100644
index 000000000..7d9504b61
--- /dev/null
+++ b/packages/web/lib/components/explore/BrandResultCard.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import Image from "next/image";
+import { Link } from "@/i18n/navigation";
+import { BadgeCheck, Store } from "lucide-react";
+import type { EntitySearchHit } from "@/lib/api/generated/models";
+
+const proxyImg = (url: string) =>
+ url.startsWith("http")
+ ? `/api/v1/image-proxy?url=${encodeURIComponent(url)}`
+ : url;
+
+export function BrandResultCard({ brand }: { brand: EntitySearchHit }) {
+ return (
+
+
+
+ {brand.logo_image_url ? (
+
+ ) : (
+
+ )}
+
+
+
Brand
+
+ {brand.name}
+ {brand.product_count != null && brand.product_count > 0 && (
+
+ )}
+
+
+ {brand.product_count ?? 0} products / {brand.look_count ?? 0} looks
+
+
+
+
+ );
+}
diff --git a/packages/web/lib/components/explore/BrandResultsPanel.tsx b/packages/web/lib/components/explore/BrandResultsPanel.tsx
new file mode 100644
index 000000000..54a4f4864
--- /dev/null
+++ b/packages/web/lib/components/explore/BrandResultsPanel.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { useLocale } from "next-intl";
+import { useCatalogEntitySearch } from "@/lib/hooks/useCatalogEntitySearch";
+import { BrandResultCard } from "./BrandResultCard";
+import { CatalogEmptyState, CatalogLoadingState } from "./ArtistResultsPanel";
+
+export function BrandResultsPanel({ query }: { query: string }) {
+ const locale = useLocale();
+ const trimmed = query.trim();
+ const {
+ data: brands = [],
+ isLoading,
+ isError,
+ } = useCatalogEntitySearch({
+ query: trimmed,
+ kind: "brand",
+ locale,
+ });
+
+ if (!trimmed) {
+ return (
+
+ );
+ }
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (isError) {
+ return (
+
+ );
+ }
+
+ if (brands.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {brands.map((brand) => (
+
+ ))}
+
+ );
+}
diff --git a/packages/web/lib/components/search/__tests__/SearchSuggestions.test.tsx b/packages/web/lib/components/search/__tests__/SearchSuggestions.test.tsx
index 0d909c2f3..a36cceee0 100644
--- a/packages/web/lib/components/search/__tests__/SearchSuggestions.test.tsx
+++ b/packages/web/lib/components/search/__tests__/SearchSuggestions.test.tsx
@@ -197,6 +197,20 @@ describe("SearchSuggestions — entity section", () => {
expect(screen.queryByText("People & Brands")).not.toBeInTheDocument();
expect(screen.getByText("blackpink")).toBeInTheDocument();
});
+
+ it("does not nest remove control inside a button (invalid HTML)", () => {
+ recentState.searches = ["나이키"];
+
+ renderSuggestions({ query: "" });
+
+ const option = screen.getByRole("option", { name: /나이키/i });
+ expect(option.tagName).toBe("DIV");
+ const removeBtn = screen.getByRole("button", {
+ name: "Remove 나이키 from recent searches",
+ });
+ expect(option).toContainElement(removeBtn);
+ expect(removeBtn.closest("button")).toBe(removeBtn);
+ });
});
describe("SearchSuggestions — recent search row (#885 nested-button fix)", () => {
diff --git a/packages/web/lib/components/shell/shellNav.ts b/packages/web/lib/components/shell/shellNav.ts
index afef13e9a..9d9feee4f 100644
--- a/packages/web/lib/components/shell/shellNav.ts
+++ b/packages/web/lib/components/shell/shellNav.ts
@@ -49,6 +49,8 @@ export const SHELL_ROUTES = [
"/style-dna",
"/posts", // 포스트 상세 = (shell) Decoded Look Detail (레거시 다크 헤더 억제)
"/items", // 카탈로그 아이템 detail(#786 후속: 아이템 좋아요 → 개인화)
+ "/artists", // 카탈로그 artist detail
+ "/brands", // 카탈로그 brand detail
];
const LOCALE_PREFIX_RE = new RegExp(`^/(${LIVE_LOCALES.join("|")})(?=/|$)`);
diff --git a/packages/web/lib/hooks/useCatalogEntitySearch.ts b/packages/web/lib/hooks/useCatalogEntitySearch.ts
new file mode 100644
index 000000000..a5551b255
--- /dev/null
+++ b/packages/web/lib/hooks/useCatalogEntitySearch.ts
@@ -0,0 +1,39 @@
+"use client";
+
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import {
+ getSearchEntitiesQueryKey,
+ searchEntities,
+} from "@/lib/api/generated/search/search";
+import type { EntitySearchHit } from "@/lib/api/generated/models";
+
+export type CatalogEntityKind = "artist" | "brand";
+
+export function useCatalogEntitySearch({
+ query,
+ kind,
+ locale,
+ limit = 24,
+}: {
+ query: string;
+ kind: CatalogEntityKind;
+ locale?: string;
+ limit?: number;
+}) {
+ const q = query.trim();
+ const enabled = q.length > 0;
+ const params = { q, type: kind, locale, limit };
+
+ return useQuery({
+ queryKey: getSearchEntitiesQueryKey(params),
+ queryFn: ({ signal }) => searchEntities(params, undefined, signal),
+ enabled,
+ staleTime: 60_000,
+ gcTime: 5 * 60_000,
+ placeholderData: keepPreviousData,
+ select: (data) =>
+ data.data.filter(
+ (hit): hit is EntitySearchHit => hit.entity_type === kind
+ ),
+ });
+}
From d193a864f1df1225a389b88db3dc7e5cf2fe1fe8 Mon Sep 17 00:00:00 2001
From: CIOI
Date: Sat, 11 Jul 2026 21:36:41 +0900
Subject: [PATCH 02/10] chore(web): reword auth comments to pass API boundary
guard
Co-authored-by: Cursor
---
packages/web/app/[locale]/(shell)/page.tsx | 2 +-
packages/web/lib/components/auth/redesign/AuthHero.tsx | 2 +-
packages/web/lib/components/auth/redesign/errors.ts | 2 +-
packages/web/lib/components/auth/redesign/validation.ts | 4 ++--
4 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/packages/web/app/[locale]/(shell)/page.tsx b/packages/web/app/[locale]/(shell)/page.tsx
index d4e71a026..c77378f00 100644
--- a/packages/web/app/[locale]/(shell)/page.tsx
+++ b/packages/web/app/[locale]/(shell)/page.tsx
@@ -72,7 +72,7 @@ interface ArtistProfileEntry {
const proxyImg = (url: string) =>
`/api/v1/image-proxy?url=${encodeURIComponent(url)}`;
-/** REST API fetch — no Supabase fallback */
+/** REST API fetch — no direct DB fallback */
async function fetchPosts(params: string, locale?: Locale): Promise {
try {
const res = await serverApiGet(
diff --git a/packages/web/lib/components/auth/redesign/AuthHero.tsx b/packages/web/lib/components/auth/redesign/AuthHero.tsx
index b03408f50..4e161b055 100644
--- a/packages/web/lib/components/auth/redesign/AuthHero.tsx
+++ b/packages/web/lib/components/auth/redesign/AuthHero.tsx
@@ -22,7 +22,7 @@ const TARGET_CARDS = 36;
* Editorial collage backdrop for the web split-screen left panel — a masonry
* of grayscale fashion images that slowly drifts diagonally (bottom-left →
* top-right), per the design mockup. Sourced from the public backend feed
- * (`listPosts`), the sanctioned path after direct Supabase access (#661).
+ * (`listPosts`), the sanctioned path after direct provider DB access (#661).
*
* The grid height is content-driven (no fixed bottom) so columns balance, and
* images are tiled to TARGET_CARDS so all columns overflow the panel — leaving
diff --git a/packages/web/lib/components/auth/redesign/errors.ts b/packages/web/lib/components/auth/redesign/errors.ts
index b9a7ff809..71107a602 100644
--- a/packages/web/lib/components/auth/redesign/errors.ts
+++ b/packages/web/lib/components/auth/redesign/errors.ts
@@ -1,5 +1,5 @@
/**
- * Maps raw Supabase auth error messages to friendly, user-facing copy.
+ * Maps raw auth provider error messages to friendly, user-facing copy.
* Falls back to the original message when unrecognized.
*/
export function mapAuthError(message?: string | null): string | null {
diff --git a/packages/web/lib/components/auth/redesign/validation.ts b/packages/web/lib/components/auth/redesign/validation.ts
index 663919c99..b7ac6fbe9 100644
--- a/packages/web/lib/components/auth/redesign/validation.ts
+++ b/packages/web/lib/components/auth/redesign/validation.ts
@@ -2,8 +2,8 @@
* Shared client-side validation for the redesigned auth forms.
* Returns an error string, or null when valid.
*
- * Password length matches Supabase `minimum_password_length = 6`
- * (supabase/config.toml); `password_requirements = ""` → no complexity rule.
+ * Password length matches GoTrue `minimum_password_length = 6`
+ * (local auth config); `password_requirements = ""` → no complexity rule.
*/
export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
From f44ac26488885cebabb40c95fd9277f5f7aff75a Mon Sep 17 00:00:00 2001
From: CIOI
Date: Sat, 11 Jul 2026 21:37:54 +0900
Subject: [PATCH 03/10] style(web): fix prettier drift in about and catalog
gaps pages
Co-authored-by: Cursor
---
packages/web/app/about/page.tsx | 30 +++++++++----------
.../gaps/__tests__/collab-brand.test.ts | 15 ++++------
.../app/admin/entities/gaps/collab-brand.ts | 4 ++-
packages/web/app/admin/entities/gaps/page.tsx | 29 +++++++++---------
4 files changed, 38 insertions(+), 40 deletions(-)
diff --git a/packages/web/app/about/page.tsx b/packages/web/app/about/page.tsx
index 8f85699a2..c910ec0f1 100644
--- a/packages/web/app/about/page.tsx
+++ b/packages/web/app/about/page.tsx
@@ -74,10 +74,10 @@ export default function AboutPage() {
textColor="muted"
className="max-w-2xl text-base md:text-lg"
>
- DECODED is a style search engine evolving into a personal stylist.
- We curate celebrity and influencer style data and use AI to decode
- any look — then recommend a set of substitute products that
- recreate its style, fitted to your budget.
+ DECODED is a style search engine evolving into a personal stylist. We
+ curate celebrity and influencer style data and use AI to decode any
+ look — then recommend a set of substitute products that recreate its
+ style, fitted to your budget.
@@ -95,15 +95,15 @@ export default function AboutPage() {
DECODED answers that question — not by finding you one exact
product, but by decoding the whole look. Our Cody Recreation
- Engine reads a look's Style Formula (what defines it, what's
- swappable) and recommends a substitute set that recreates the
- same style within your budget.
+ Engine reads a look's Style Formula (what defines it,
+ what's swappable) and recommends a substitute set that
+ recreates the same style within your budget.
Our platform is built for people who don't just admire a
look—they want to actually wear it. Whether the exact piece is
- findable or not, DECODED helps you go from inspiration to a
- styled outfit you can buy.
+ findable or not, DECODED helps you go from inspiration to a styled
+ outfit you can buy.
In short: a style search engine evolving into a personal
@@ -158,8 +158,8 @@ export default function AboutPage() {
Decode the Look. Recreate the Style.
- We exist to remove the friction between loving someone's
- style and actually recreating it—turning “How do I get that
+ We exist to remove the friction between loving someone's style
+ and actually recreating it—turning “How do I get that
look?” into a styled outfit you can buy today.
@@ -170,10 +170,10 @@ export default function AboutPage() {
DECODED is for globally minded Gen Z and Millennials who follow
- celebrity and influencer style on Instagram and Pinterest, and
- want to turn that inspiration into their own wardrobe. If
- you've ever saved a look just to figure out how to recreate
- it, DECODED is for you.
+ celebrity and influencer style on Instagram and Pinterest, and want
+ to turn that inspiration into their own wardrobe. If you've
+ ever saved a look just to figure out how to recreate it, DECODED is
+ for you.
diff --git a/packages/web/app/admin/entities/gaps/__tests__/collab-brand.test.ts b/packages/web/app/admin/entities/gaps/__tests__/collab-brand.test.ts
index 2d0a40b45..a584d2687 100644
--- a/packages/web/app/admin/entities/gaps/__tests__/collab-brand.test.ts
+++ b/packages/web/app/admin/entities/gaps/__tests__/collab-brand.test.ts
@@ -15,10 +15,7 @@ describe("collab-brand", () => {
});
it("splits collab parts for search prefill", () => {
- expect(splitCollabBrandParts("Nike x Levi's")).toEqual([
- "Nike",
- "Levi's",
- ]);
+ expect(splitCollabBrandParts("Nike x Levi's")).toEqual(["Nike", "Levi's"]);
expect(splitCollabBrandParts("RICK OWENS x Converse")).toEqual([
"RICK OWENS",
"Converse",
@@ -37,12 +34,10 @@ describe("collab-brand", () => {
"Supreme",
"HANES",
]);
- expect(
- collabBrandSearchHints("Supreme Maison Margiela HANES")
- ).toEqual(["Supreme", "HANES"]);
- expect(collabBrandSearchHints("Nike x Levi's")).toEqual([
- "Nike",
- "Levi's",
+ expect(collabBrandSearchHints("Supreme Maison Margiela HANES")).toEqual([
+ "Supreme",
+ "HANES",
]);
+ expect(collabBrandSearchHints("Nike x Levi's")).toEqual(["Nike", "Levi's"]);
});
});
diff --git a/packages/web/app/admin/entities/gaps/collab-brand.ts b/packages/web/app/admin/entities/gaps/collab-brand.ts
index e41b56206..abed19000 100644
--- a/packages/web/app/admin/entities/gaps/collab-brand.ts
+++ b/packages/web/app/admin/entities/gaps/collab-brand.ts
@@ -23,7 +23,9 @@ export function manualCollabSearchHints(sourceName: string): [string, string] {
}
export function collabBrandSearchHints(sourceName: string): [string, string] {
- return splitCollabBrandParts(sourceName) ?? manualCollabSearchHints(sourceName);
+ return (
+ splitCollabBrandParts(sourceName) ?? manualCollabSearchHints(sourceName)
+ );
}
/** Prefill catalog search for partial brand gaps (e.g. "GENTLE MONSTER Jennie" → "GENTLE MONSTER"). */
diff --git a/packages/web/app/admin/entities/gaps/page.tsx b/packages/web/app/admin/entities/gaps/page.tsx
index f3481425e..f4492876e 100644
--- a/packages/web/app/admin/entities/gaps/page.tsx
+++ b/packages/web/app/admin/entities/gaps/page.tsx
@@ -20,10 +20,7 @@ import {
type CatalogGapRow,
type CatalogGapSuggestResult,
} from "@/lib/api/admin/catalog-gaps";
-import {
- BrandSearchPicker,
- type BrandSearchHit,
-} from "./brand-search-picker";
+import { BrandSearchPicker, type BrandSearchHit } from "./brand-search-picker";
import {
isCollabBrandName,
collabBrandSearchHints,
@@ -227,7 +224,8 @@ function SuggestionSummary({
)}
- Confidence {(suggestion.confidence * 100).toFixed(0)}% — {suggestion.reason}
+ Confidence {(suggestion.confidence * 100).toFixed(0)}% —{" "}
+ {suggestion.reason}
{suggestion.evidence_urls.length > 0 && (
@@ -305,8 +303,9 @@ export default function CatalogGapsPage() {
const [primaryBrand, setPrimaryBrand] = useState(null);
const [collaboratorBrand, setCollaboratorBrand] =
useState(null);
- const [linkTargetBrand, setLinkTargetBrand] =
- useState(null);
+ const [linkTargetBrand, setLinkTargetBrand] = useState(
+ null
+ );
const [manualCollabMode, setManualCollabMode] = useState(false);
const [singleBrandLinkMode, setSingleBrandLinkMode] = useState(false);
const [brandCreateExpanded, setBrandCreateExpanded] = useState(false);
@@ -323,8 +322,7 @@ export default function CatalogGapsPage() {
const exactMatchCount = gaps.filter((g) => g.match_tier === "exact").length;
const isLinkable = selected ? isLinkableTier(selected.match_tier) : false;
const isLikely = selected?.match_tier === "likely";
- const showLinkableReview =
- isLinkable && !(isLikely && rejectLikelyMatch);
+ const showLinkableReview = isLinkable && !(isLikely && rejectLikelyMatch);
const showSuggestFlow = selected && !showLinkableReview;
const isBrandCollab =
entityType === "brand" &&
@@ -643,8 +641,9 @@ export default function CatalogGapsPage() {
Link to two existing catalog brands — do not create a compound
- brand row. Primary sets
brand_id
- ; collaborator is stored in{" "}
+ brand row. Primary sets{" "}
+
brand_id; collaborator
+ is stored in{" "}
metadata.brands.
{manualCollabMode && !isBrandCollab && (
@@ -809,7 +808,9 @@ export default function CatalogGapsPage() {
{selected.reference_count} references ·{" "}
- {singleBrandLinkMode ? "link existing brand" : "not in catalog"}
+ {singleBrandLinkMode
+ ? "link existing brand"
+ : "not in catalog"}
@@ -983,8 +984,8 @@ export default function CatalogGapsPage() {
{isLikely && rejectLikelyMatch && (
- Catalog likely match was rejected. Gemini will not link to
- the pre-matched row unless suggest returns that ID explicitly.
+ Catalog likely match was rejected. Gemini will not link to the
+ pre-matched row unless suggest returns that ID explicitly.
)}
From b9af005d13b5095b88881e5e65aa1dd61689a7cf Mon Sep 17 00:00:00 2001
From: CIOI
Date: Sat, 11 Jul 2026 21:38:19 +0900
Subject: [PATCH 04/10] style(web): format tsconfig.json for pre-push check
Co-authored-by: Cursor
---
packages/web/tsconfig.json | 22 +++++-----------------
1 file changed, 5 insertions(+), 17 deletions(-)
diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json
index 07c9b373f..d54320b11 100644
--- a/packages/web/tsconfig.json
+++ b/packages/web/tsconfig.json
@@ -1,10 +1,6 @@
{
"compilerOptions": {
- "lib": [
- "dom",
- "dom.iterable",
- "esnext"
- ],
+ "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -22,15 +18,9 @@
}
],
"paths": {
- "@/*": [
- "./*"
- ],
- "@decoded/shared": [
- "../shared/index.ts"
- ],
- "@decoded/shared/*": [
- "../shared/*"
- ]
+ "@/*": ["./*"],
+ "@decoded/shared": ["../shared/index.ts"],
+ "@decoded/shared/*": ["../shared/*"]
},
"target": "ES2017"
},
@@ -41,7 +31,5 @@
"**/*.tsx",
".next/dev/types/**/*.ts"
],
- "exclude": [
- "node_modules"
- ]
+ "exclude": ["node_modules"]
}
From e61414e8292df354b8e7f9aa46fce81970414b7f Mon Sep 17 00:00:00 2001
From: CIOI
Date: Sat, 11 Jul 2026 21:43:32 +0900
Subject: [PATCH 05/10] fix(api-server): allow entity_to_document arg count in
entity reindex
Co-authored-by: Cursor
---
packages/api-server/src/batch/entity_reindex.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/api-server/src/batch/entity_reindex.rs b/packages/api-server/src/batch/entity_reindex.rs
index 4f2ca2421..d9b51fa3f 100644
--- a/packages/api-server/src/batch/entity_reindex.rs
+++ b/packages/api-server/src/batch/entity_reindex.rs
@@ -21,7 +21,7 @@ use tracing::{error, info, warn};
use uuid::Uuid;
// json! 매크로 전개에 unwrap 포함 — entity_to_document는 정적 필드만 직렬화
-#[allow(clippy::disallowed_methods)]
+#[allow(clippy::disallowed_methods, clippy::too_many_arguments)]
fn entity_to_document(
kind: EntityKind,
id: Uuid,
From 553f520606209c6483a0efe69f370aaed46f4daf Mon Sep 17 00:00:00 2001
From: CIOI
Date: Sat, 11 Jul 2026 23:13:03 +0900
Subject: [PATCH 06/10] feat(catalog): unify discovery search and group brand
products
Expose the live catalog and look indexes through one Explore result surface, and derive brand product groups from catalog, look, or backfilled metadata categories.
Co-authored-by: Cursor
---
.../scripts/backfill_solution_categories.py | 289 +++++++++++++++
packages/api-server/openapi.json | 56 +++
.../src/domains/catalog_entities/service.rs | 10 +-
.../domains/solutions/affiliate_resolve.rs | 44 ++-
.../api-server/src/domains/solutions/dto.rs | 16 +
.../src/domains/solutions/service.rs | 131 ++++++-
.../src/entities/affiliate_catalog_items.rs | 4 +
.../(shell)/explore/ExploreClient.tsx | 336 ++++--------------
.../components/brands/BrandDetailClient.tsx | 139 ++++----
.../components/explore/ComingSoonState.tsx | 37 --
.../lib/components/explore/EntityTabBar.tsx | 65 ----
.../explore/UnifiedSearchResults.tsx | 171 +++++++++
.../web/lib/components/search/SearchInput.tsx | 36 +-
.../components/search/SearchSuggestions.tsx | 44 ++-
.../__tests__/SearchSuggestions.test.tsx | 7 +-
packages/web/lib/hooks/useSearchURLSync.ts | 6 +-
16 files changed, 945 insertions(+), 446 deletions(-)
create mode 100644 packages/ai-server/scripts/backfill_solution_categories.py
delete mode 100644 packages/web/lib/components/explore/ComingSoonState.tsx
delete mode 100644 packages/web/lib/components/explore/EntityTabBar.tsx
create mode 100644 packages/web/lib/components/explore/UnifiedSearchResults.tsx
diff --git a/packages/ai-server/scripts/backfill_solution_categories.py b/packages/ai-server/scripts/backfill_solution_categories.py
new file mode 100644
index 000000000..ff677cbdc
--- /dev/null
+++ b/packages/ai-server/scripts/backfill_solution_categories.py
@@ -0,0 +1,289 @@
+#!/usr/bin/env python3
+# pyright: reportMissingImports=false
+"""Backfill missing solution categories from each linked Look's Cody analysis.
+
+This is a one-off, schema-free metadata backfill. It only updates solutions
+whose metadata.category is absent, merges new keys into metadata JSONB, and
+never overwrites title, URL, price, brand, or an existing category.
+
+A category is applied only when:
+1. the solution title has a high-confidence category keyword, and
+2. the linked post's cody_analysis contains the same category.
+
+Usage:
+ cd packages/ai-server
+ uv run python scripts/backfill_solution_categories.py \
+ --db-env ../../.env.backend.prod --brand-id --dry-run
+
+ uv run python scripts/backfill_solution_categories.py \
+ --db-env ../../.env.backend.prod --brand-id --apply
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import logging
+import os
+import re
+from collections.abc import Iterable
+from pathlib import Path
+from typing import Any
+
+logger = logging.getLogger("backfill-solution-categories")
+
+_CATEGORY_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
+ (
+ "footwear",
+ re.compile(
+ r"\b(sneaker|sneakers|shoe|shoes|boot|boots|loafer|loafer|sandal|sandals|heel|heels|slide|slides)\b",
+ re.IGNORECASE,
+ ),
+ ),
+ (
+ "bag",
+ re.compile(
+ r"\b(bag|tote|pouch|clutch|backpack|shoulder bag|crossbody|satchel)\b",
+ re.IGNORECASE,
+ ),
+ ),
+ (
+ "outerwear",
+ re.compile(
+ r"\b(jacket|coat|blazer|parka|cardigan|windbreaker|bomber|vest)\b",
+ re.IGNORECASE,
+ ),
+ ),
+ (
+ "dress",
+ re.compile(r"\b(dress|gown|jumpsuit|romper)\b", re.IGNORECASE),
+ ),
+ (
+ "bottom",
+ re.compile(
+ r"\b(pant|pants|trouser|trousers|jeans|denim|shorts|skirt|bermuda|leggings)\b",
+ re.IGNORECASE,
+ ),
+ ),
+ (
+ "top",
+ re.compile(
+ r"\b(t-?shirt|tee|shirt|top|sweater|hoodie|sweatshirt|knit|tank|polo|blouse)\b",
+ re.IGNORECASE,
+ ),
+ ),
+ (
+ "accessory",
+ re.compile(
+ r"\b(belt|hat|cap|scarf|glove|glasses|sunglasses|watch|ring|necklace|bracelet|earring)\b",
+ re.IGNORECASE,
+ ),
+ ),
+)
+
+_LOOK_CATEGORY_ALIASES = {
+ "fashion-top": "top",
+ "fashion-bottom": "bottom",
+ "footwear": "footwear",
+ "bag": "bag",
+ "accessory": "accessory",
+ "accessory-jewelry": "accessory",
+ "accessory-watch": "accessory",
+ "outerwear": "outerwear",
+ "dress": "dress",
+}
+
+_CANDIDATES_SQL = """
+SELECT
+ s.id,
+ s.title,
+ s.metadata,
+ p.id AS post_id,
+ p.cody_analysis
+FROM public.solutions s
+JOIN public.spots sp ON sp.id = s.spot_id
+JOIN public.posts p ON p.id = sp.post_id
+WHERE s.status = 'active'
+ AND COALESCE(NULLIF(btrim(s.metadata ->> 'category'), ''), '') = ''
+ AND ($1::uuid IS NULL OR s.brand_id = $1)
+ORDER BY s.created_at DESC
+"""
+
+_UPDATE_SQL = """
+UPDATE public.solutions
+SET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb,
+ updated_at = NOW()
+WHERE id = $1
+ AND COALESCE(NULLIF(btrim(metadata ->> 'category'), ''), '') = ''
+"""
+
+
+def parse_env_file(path: Path) -> dict[str, str]:
+ if not path.exists():
+ raise FileNotFoundError(f"env file not found: {path}")
+ values: dict[str, str] = {}
+ for raw in path.read_text().splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, _, value = line.partition("=")
+ values[key.strip()] = value.strip().strip('"').strip("'")
+ return values
+
+
+def infer_title_category(title: str) -> str | None:
+ for category, pattern in _CATEGORY_PATTERNS:
+ if pattern.search(title):
+ return category
+ return None
+
+
+def look_categories(cody_analysis: Any) -> set[str]:
+ if isinstance(cody_analysis, str):
+ try:
+ cody_analysis = json.loads(cody_analysis)
+ except json.JSONDecodeError:
+ return set()
+ if not isinstance(cody_analysis, dict):
+ return set()
+ items = cody_analysis.get("items")
+ if not isinstance(items, list):
+ return set()
+
+ categories: set[str] = set()
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ category = item.get("category")
+ if isinstance(category, str):
+ normalized = _LOOK_CATEGORY_ALIASES.get(category)
+ if normalized:
+ categories.add(normalized)
+
+ path = item.get("category_path")
+ if isinstance(path, dict):
+ item_type = path.get("type")
+ if isinstance(item_type, str) and item_type in _LOOK_CATEGORY_ALIASES:
+ categories.add(_LOOK_CATEGORY_ALIASES[item_type])
+ elif isinstance(item_type, str) and item_type in {
+ "top",
+ "bottom",
+ "footwear",
+ "bag",
+ "accessory",
+ "outerwear",
+ "dress",
+ }:
+ categories.add(item_type)
+ return categories
+
+
+def classify(rows: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
+ changes: list[dict[str, Any]] = []
+ for row in rows:
+ title = str(row["title"])
+ category = infer_title_category(title)
+ categories = look_categories(row.get("cody_analysis"))
+ if not category or category not in categories:
+ continue
+ changes.append(
+ {
+ "id": str(row["id"]),
+ "title": title,
+ "post_id": str(row["post_id"]),
+ "category": category,
+ "look_categories": sorted(categories),
+ "metadata_patch": {
+ "category": category,
+ "category_source": "look_analysis_title_backfill",
+ "category_confidence": 0.95,
+ },
+ }
+ )
+ return changes
+
+
+async def run(args: argparse.Namespace) -> int:
+ import asyncpg
+
+ db_url = args.db_url
+ if not db_url and args.db_env:
+ db_url = parse_env_file(Path(args.db_env).expanduser())[
+ "OPERATION_DATABASE_URL"
+ ]
+ if not db_url:
+ db_url = os.environ.get("OPERATION_DATABASE_URL", "")
+ if not db_url:
+ raise ValueError("Provide --db-url, --db-env, or OPERATION_DATABASE_URL")
+
+ resolved_url = db_url.replace(":5432/", ":6543/", 1)
+ conn = await asyncpg.connect(
+ resolved_url,
+ ssl=None if "localhost" in resolved_url or "127.0.0.1" in resolved_url else "require",
+ statement_cache_size=0 if "pooler.supabase.com" in resolved_url else 100,
+ )
+ try:
+ rows = await conn.fetch(_CANDIDATES_SQL, args.brand_id)
+ changes = classify([dict(row) for row in rows])
+ logger.info(
+ "examined %s uncategorized active solutions; %s high-confidence updates",
+ len(rows),
+ len(changes),
+ )
+ for change in changes[: args.preview_limit]:
+ logger.info(
+ "%s → %s (%s)",
+ change["title"],
+ change["category"],
+ change["post_id"],
+ )
+
+ if args.audit_file:
+ audit_path = Path(args.audit_file).expanduser()
+ audit_path.parent.mkdir(parents=True, exist_ok=True)
+ audit_path.write_text(json.dumps(changes, ensure_ascii=False, indent=2))
+ logger.info("wrote audit preview to %s", audit_path)
+
+ if not args.apply:
+ logger.info("dry-run complete; rerun with --apply to persist changes")
+ return 0
+
+ async with conn.transaction():
+ updated = 0
+ for change in changes:
+ result = await conn.execute(
+ _UPDATE_SQL,
+ change["id"],
+ json.dumps(change["metadata_patch"]),
+ )
+ updated += int(result.rsplit(" ", 1)[-1])
+ logger.info("updated %s solutions", updated)
+ return 0
+ finally:
+ await conn.close()
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--db-url")
+ parser.add_argument("--db-env")
+ parser.add_argument("--brand-id", help="Optional brand UUID to scope the backfill")
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Preview changes only (the default unless --apply is provided)",
+ )
+ parser.add_argument("--apply", action="store_true")
+ parser.add_argument("--preview-limit", type=int, default=25)
+ parser.add_argument(
+ "--audit-file",
+ default="tmp/solution-category-backfill-preview.json",
+ )
+ args = parser.parse_args()
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
+ return asyncio.run(run(args))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/api-server/openapi.json b/packages/api-server/openapi.json
index c0e9ef563..7ee2ad2cf 100644
--- a/packages/api-server/openapi.json
+++ b/packages/api-server/openapi.json
@@ -11266,6 +11266,20 @@
"format": "uuid",
"description": "브랜드 ID (warehouse FK, nullable)"
},
+ "category": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "상품 카테고리 (catalog 우선, legacy metadata fallback)"
+ },
+ "category_path": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "카테고리 전체 경로 (e.g. \"Clothing > Pants\")"
+ },
"created_at": {
"type": "string",
"format": "date-time",
@@ -11308,6 +11322,13 @@
],
"description": "원본 상품 URL (Shop the Look 링크용)"
},
+ "product_type": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "상품 세부 유형 (e.g. \"Jeans\")"
+ },
"served_market": {
"type": [
"string",
@@ -11315,6 +11336,13 @@
],
"description": "서빙된 market (catalog variant 픽 결과; non-catalog → null)"
},
+ "subcategory": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Look spot에서 지정한 상품 세부 카테고리"
+ },
"thumbnail_url": {
"type": [
"string",
@@ -13570,6 +13598,20 @@
"format": "uuid",
"description": "브랜드 ID (warehouse FK, nullable)"
},
+ "category": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "상품 카테고리 (catalog 우선, legacy metadata fallback)"
+ },
+ "category_path": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "카테고리 전체 경로 (e.g. \"Clothing > Pants\")"
+ },
"created_at": {
"type": "string",
"format": "date-time",
@@ -13612,6 +13654,13 @@
],
"description": "원본 상품 URL (Shop the Look 링크용)"
},
+ "product_type": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "상품 세부 유형 (e.g. \"Jeans\")"
+ },
"served_market": {
"type": [
"string",
@@ -13619,6 +13668,13 @@
],
"description": "서빙된 market (catalog variant 픽 결과; non-catalog → null)"
},
+ "subcategory": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Look spot에서 지정한 상품 세부 카테고리"
+ },
"thumbnail_url": {
"type": [
"string",
diff --git a/packages/api-server/src/domains/catalog_entities/service.rs b/packages/api-server/src/domains/catalog_entities/service.rs
index 6f2ca2648..4d850e36a 100644
--- a/packages/api-server/src/domains/catalog_entities/service.rs
+++ b/packages/api-server/src/domains/catalog_entities/service.rs
@@ -185,7 +185,15 @@ pub async fn list_brand_solutions(
) -> AppResult {
ensure_brand_exists(db, brand_id).await?;
let pagination = Pagination::new(query.page, query.per_page);
- solutions_service::list_solutions_by_brand_id(db, assets_db, brand_id, pagination, market).await
+ solutions_service::list_solutions_by_brand_id(
+ db,
+ assets_db,
+ brand_id,
+ pagination,
+ market,
+ query.locale.as_deref(),
+ )
+ .await
}
pub async fn list_brand_looks(
diff --git a/packages/api-server/src/domains/solutions/affiliate_resolve.rs b/packages/api-server/src/domains/solutions/affiliate_resolve.rs
index a48a3f740..de93579f1 100644
--- a/packages/api-server/src/domains/solutions/affiliate_resolve.rs
+++ b/packages/api-server/src/domains/solutions/affiliate_resolve.rs
@@ -8,9 +8,19 @@ use std::collections::HashMap;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use uuid::Uuid;
-use crate::entities::affiliate_catalog_item_variants as variants;
+use crate::entities::{
+ affiliate_catalog_item_variants as variants, affiliate_catalog_items as catalog_items,
+};
use crate::market::{pick_variant_for_market, ResolvedAffiliate, VariantOffer, FALLBACK_MARKET};
+/// Public taxonomy fields from an affiliate catalog item.
+#[derive(Debug, Clone, Default)]
+pub struct CatalogTaxonomy {
+ pub category: Option,
+ pub category_path: Option,
+ pub product_type: Option,
+}
+
/// catalog_item_id 들에 대해 user/INTL variant offer 를 한 번에 로드.
/// 반환: catalog_item_id(Uuid) → 해당 item 의 후보 offers.
async fn load_offers(
@@ -51,6 +61,38 @@ async fn load_offers(
map
}
+/// Catalog taxonomy for the given IDs, loaded in one query per result page.
+pub async fn load_taxonomies(
+ assets_db: &DatabaseConnection,
+ catalog_item_ids: &[Uuid],
+) -> HashMap {
+ if catalog_item_ids.is_empty() {
+ return HashMap::new();
+ }
+
+ let rows = catalog_items::Entity::find()
+ .filter(catalog_items::Column::Id.is_in(catalog_item_ids.iter().copied()))
+ .all(assets_db)
+ .await
+ .unwrap_or_else(|error| {
+ tracing::warn!(%error, "assets catalog taxonomy query failed");
+ Vec::new()
+ });
+
+ rows.into_iter()
+ .map(|item| {
+ (
+ item.id,
+ CatalogTaxonomy {
+ category: item.category,
+ category_path: item.category_path,
+ product_type: item.product_type,
+ },
+ )
+ })
+ .collect()
+}
+
/// 단건 해석.
pub async fn resolve_one(
assets_db: &DatabaseConnection,
diff --git a/packages/api-server/src/domains/solutions/dto.rs b/packages/api-server/src/domains/solutions/dto.rs
index 8beeedf10..16afd819b 100644
--- a/packages/api-server/src/domains/solutions/dto.rs
+++ b/packages/api-server/src/domains/solutions/dto.rs
@@ -232,6 +232,22 @@ pub struct SolutionListItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option,
+ /// 상품 카테고리 (catalog 우선, legacy metadata fallback)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub category: Option,
+
+ /// Look spot에서 지정한 상품 세부 카테고리
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub subcategory: Option,
+
+ /// 카테고리 전체 경로 (e.g. "Clothing > Pants")
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub category_path: Option,
+
+ /// 상품 세부 유형 (e.g. "Jeans")
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub product_type: Option,
+
/// 썸네일 이미지 URL
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail_url: Option,
diff --git a/packages/api-server/src/domains/solutions/service.rs b/packages/api-server/src/domains/solutions/service.rs
index 843eab25d..3c8c91c89 100644
--- a/packages/api-server/src/domains/solutions/service.rs
+++ b/packages/api-server/src/domains/solutions/service.rs
@@ -9,7 +9,7 @@ use sea_orm::{
use serde_json::Value as JsonValue;
use uuid::Uuid;
-use crate::entities::users;
+use crate::entities::{spots, subcategories, users};
use crate::{
domains::users::service::get_user_by_id,
entities::solutions::{ActiveModel, Column, Entity as Solutions},
@@ -20,6 +20,91 @@ use super::dto::{
CreateSolutionDto, SolutionListItem, SolutionResponse, UpdateSolutionDto, VoteStatsDto,
};
+fn metadata_text(metadata: Option<&JsonValue>, key: &str) -> Option {
+ metadata?
+ .get(key)?
+ .as_str()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(ToOwned::to_owned)
+}
+
+fn taxonomy_fields(
+ metadata: Option<&JsonValue>,
+ taxonomy: Option<&super::affiliate_resolve::CatalogTaxonomy>,
+) -> (Option, Option, Option) {
+ (
+ taxonomy
+ .and_then(|value| value.category.clone())
+ .or_else(|| metadata_text(metadata, "category")),
+ taxonomy
+ .and_then(|value| value.category_path.clone())
+ .or_else(|| metadata_text(metadata, "category_path")),
+ taxonomy
+ .and_then(|value| value.product_type.clone())
+ .or_else(|| metadata_text(metadata, "product_type"))
+ .or_else(|| metadata_text(metadata, "sub_category")),
+ )
+}
+
+fn parse_catalog_item_id(value: &str) -> Option {
+ Uuid::parse_str(value).ok()
+}
+
+fn localized_name(name: &JsonValue, locale: Option<&str>) -> Option {
+ let primary_key = if locale == Some("en") { "en" } else { "ko" };
+ name.get(primary_key)
+ .and_then(JsonValue::as_str)
+ .or_else(|| name.get("en").and_then(JsonValue::as_str))
+ .or_else(|| name.get("ko").and_then(JsonValue::as_str))
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(ToOwned::to_owned)
+}
+
+async fn load_spot_subcategories(
+ db: &DatabaseConnection,
+ spot_ids: &[Uuid],
+ locale: Option<&str>,
+) -> AppResult> {
+ if spot_ids.is_empty() {
+ return Ok(std::collections::HashMap::new());
+ }
+
+ let spot_rows = spots::Entity::find()
+ .filter(spots::Column::Id.is_in(spot_ids.iter().copied()))
+ .all(db)
+ .await
+ .map_err(AppError::DatabaseError)?;
+ let subcategory_ids = spot_rows
+ .iter()
+ .filter_map(|spot| spot.subcategory_id)
+ .collect::>();
+ if subcategory_ids.is_empty() {
+ return Ok(std::collections::HashMap::new());
+ }
+
+ let names = subcategories::Entity::find()
+ .filter(subcategories::Column::Id.is_in(subcategory_ids))
+ .all(db)
+ .await
+ .map_err(AppError::DatabaseError)?
+ .into_iter()
+ .filter_map(|subcategory| {
+ localized_name(&subcategory.name, locale).map(|name| (subcategory.id, name))
+ })
+ .collect::>();
+
+ Ok(spot_rows
+ .into_iter()
+ .filter_map(|spot| {
+ spot.subcategory_id
+ .and_then(|id| names.get(&id).cloned())
+ .map(|name| (spot.id, name))
+ })
+ .collect())
+}
+
/// Solution 생성 (verify 흐름 전용, 트랜잭션 호환).
///
/// raw_posts.parse_result.items[] 의 brand/product/price/thumbnail_url 을 spot
@@ -131,6 +216,11 @@ pub async fn list_solutions_by_spot_id(
})
.collect();
let resolved = super::affiliate_resolve::resolve_many(assets_db, &rows, user_market).await;
+ let taxonomy_ids = rows
+ .iter()
+ .filter_map(|(_, value, _)| value.as_deref().and_then(parse_catalog_item_id))
+ .collect::>();
+ let taxonomies = super::affiliate_resolve::load_taxonomies(assets_db, &taxonomy_ids).await;
let mut items = Vec::new();
for (solution, user) in solutions {
@@ -141,6 +231,13 @@ pub async fn list_solutions_by_spot_id(
};
let aff = resolved.get(&solution.id);
+ let taxonomy = solution
+ .catalog_item_id
+ .as_deref()
+ .and_then(parse_catalog_item_id)
+ .and_then(|id| taxonomies.get(&id));
+ let (category, category_path, product_type) =
+ taxonomy_fields(solution.metadata.as_ref(), taxonomy);
items.push(SolutionListItem {
id: solution.id,
brand_id: solution.brand_id,
@@ -149,6 +246,10 @@ pub async fn list_solutions_by_spot_id(
link_type: solution.link_type,
title: solution.title,
metadata: solution.metadata,
+ category,
+ subcategory: None,
+ category_path,
+ product_type,
thumbnail_url: solution.thumbnail_url,
original_url: solution.original_url,
affiliate_url: aff.and_then(|r| r.affiliate_url.clone()),
@@ -173,6 +274,7 @@ pub async fn list_solutions_by_brand_id(
brand_id: Uuid,
pagination: crate::utils::pagination::Pagination,
user_market: &str,
+ locale: Option<&str>,
) -> AppResult> {
let select = Solutions::find()
.filter(Column::BrandId.eq(brand_id))
@@ -206,6 +308,16 @@ pub async fn list_solutions_by_brand_id(
})
.collect();
let resolved = super::affiliate_resolve::resolve_many(assets_db, &rows, user_market).await;
+ let taxonomy_ids = rows
+ .iter()
+ .filter_map(|(_, value, _)| value.as_deref().and_then(parse_catalog_item_id))
+ .collect::>();
+ let taxonomies = super::affiliate_resolve::load_taxonomies(assets_db, &taxonomy_ids).await;
+ let spot_ids = solutions
+ .iter()
+ .map(|(solution, _)| solution.spot_id)
+ .collect::>();
+ let spot_subcategories = load_spot_subcategories(db, &spot_ids, locale).await?;
let mut items = Vec::new();
for (solution, user) in solutions {
@@ -214,6 +326,13 @@ pub async fn list_solutions_by_brand_id(
continue;
};
let aff = resolved.get(&solution.id);
+ let taxonomy = solution
+ .catalog_item_id
+ .as_deref()
+ .and_then(parse_catalog_item_id)
+ .and_then(|id| taxonomies.get(&id));
+ let (category, category_path, product_type) =
+ taxonomy_fields(solution.metadata.as_ref(), taxonomy);
items.push(SolutionListItem {
id: solution.id,
brand_id: solution.brand_id,
@@ -222,6 +341,10 @@ pub async fn list_solutions_by_brand_id(
link_type: solution.link_type,
title: solution.title,
metadata: solution.metadata,
+ category,
+ subcategory: spot_subcategories.get(&solution.spot_id).cloned(),
+ category_path,
+ product_type,
thumbnail_url: solution.thumbnail_url,
original_url: solution.original_url,
affiliate_url: aff.and_then(|r| r.affiliate_url.clone()),
@@ -444,6 +567,8 @@ pub async fn admin_list_solutions(
tracing::warn!("Solution {} has no associated user", solution.id);
continue;
};
+ let (category, category_path, product_type) =
+ taxonomy_fields(solution.metadata.as_ref(), None);
items.push(SolutionListItem {
id: solution.id,
brand_id: solution.brand_id,
@@ -452,6 +577,10 @@ pub async fn admin_list_solutions(
link_type: solution.link_type,
title: solution.title,
metadata: solution.metadata,
+ category,
+ subcategory: None,
+ category_path,
+ product_type,
thumbnail_url: solution.thumbnail_url,
original_url: solution.original_url,
affiliate_url: solution.affiliate_url,
diff --git a/packages/api-server/src/entities/affiliate_catalog_items.rs b/packages/api-server/src/entities/affiliate_catalog_items.rs
index f35c4a593..e475295f8 100644
--- a/packages/api-server/src/entities/affiliate_catalog_items.rs
+++ b/packages/api-server/src/entities/affiliate_catalog_items.rs
@@ -49,6 +49,10 @@ pub struct Model {
#[sea_orm(nullable)]
pub currency: Option,
+ /// 공급자 원본 카테고리 (e.g. "Clothing").
+ #[sea_orm(nullable)]
+ pub category: Option,
+
/// "Clothing > Pants" 형태 text (jsonb 아님 — catalog taxonomy).
#[sea_orm(nullable)]
pub category_path: Option,
diff --git a/packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx b/packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx
index 8e3842d11..79a411bcc 100644
--- a/packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx
+++ b/packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx
@@ -1,48 +1,22 @@
"use client";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
-import { useTranslations } from "next-intl";
-import { Search, X, ChevronDown } from "lucide-react";
+import { Search, X } from "lucide-react";
+import { useRouter } from "@/i18n/navigation";
import { useDebounce, useRecentSearchesStore } from "@decoded/shared";
import { useExploreData } from "@/lib/hooks/useExploreData";
import { useSearchStore } from "@/lib/stores/searchStore";
-import { FilterChip } from "@/lib/components/explore/FilterChip";
import { ExploreCardCell, ExploreSkeletonCell } from "@/lib/components/explore";
-import {
- EntityTabBar,
- type ExploreEntity,
-} from "@/lib/components/explore/EntityTabBar";
import {
FilterChipRow,
type StyleTag,
} from "@/lib/components/explore/FilterChipRow";
import { SortDropdown } from "@/lib/components/explore/SortDropdown";
-import { CategoryFilter } from "@/lib/components/explore/CategoryFilter";
-import {
- sortOptionsForMode,
- coerceSortForMode,
- activeStyleForQuery,
- activeCategoryForQuery,
- queryForCategory,
- type ExploreCategoryId,
-} from "@/lib/components/explore/exploreFilters";
-import { ComingSoonState } from "@/lib/components/explore/ComingSoonState";
-import { ArtistResultsPanel } from "@/lib/components/explore/ArtistResultsPanel";
-import { BrandResultsPanel } from "@/lib/components/explore/BrandResultsPanel";
+import { UnifiedSearchResults } from "@/lib/components/explore/UnifiedSearchResults";
import { LoadingSpinner } from "@/lib/design-system/loading-spinner";
import { SearchSuggestions } from "@/lib/components/search/SearchSuggestions";
import type { GridItem } from "@/lib/components/ThiingsGrid";
import type { PostGridItem } from "@/lib/hooks/useImages";
-import { cn } from "@/lib/utils";
-
-const POPULAR_SEARCH_TAGS = [
- "BLACKPINK",
- "NewJeans",
- "Lisa",
- "Jennie",
- "Minji",
- "Hanni",
-] as const;
type Props = {
initialPosts?: PostGridItem[];
@@ -67,22 +41,17 @@ export function ExploreClient({
initialPage,
initialTotalPages,
}: Props) {
- const t = useTranslations("Explore");
const debouncedQuery = useSearchStore((state) => state.debouncedQuery);
const query = useSearchStore((state) => state.query);
const setQuery = useSearchStore((state) => state.setQuery);
const setDebouncedQuery = useSearchStore((state) => state.setDebouncedQuery);
+ const router = useRouter();
const inputRef = useRef(null);
const containerRef = useRef(null);
const sentinelRef = useRef(null);
- const [entity, setEntity] = useState("looks");
- // Editorial style + category highlights are DERIVED from the query (single
- // source of truth) — a chip can never stay lit while the applied query moves
- // on. Both are canned-query palettes; only one query is active at a time.
- const activeStyle: StyleTag = activeStyleForQuery(query);
- const activeCategory: ExploreCategoryId = activeCategoryForQuery(query);
+ const [activeStyle, setActiveStyle] = useState("All");
const debouncedValue = useDebounce(query, 300);
useEffect(() => {
@@ -125,14 +94,27 @@ export function ExploreClient({
}, [showSuggestions]);
const handleSuggestionSelect = useCallback(
- (selectedQuery: string) => {
+ (
+ selectedQuery: string,
+ options?: { entityType?: string; entityId?: string }
+ ) => {
+ if (
+ options?.entityId &&
+ (options.entityType === "artist" || options.entityType === "brand")
+ ) {
+ router.push(`/${options.entityType}s/${options.entityId}`);
+ setShowSuggestions(false);
+ inputRef.current?.blur();
+ return;
+ }
+
setQuery(selectedQuery);
setDebouncedQuery(selectedQuery);
setShowSuggestions(false);
addRecentSearch(selectedQuery);
inputRef.current?.blur();
},
- [setQuery, setDebouncedQuery, addRecentSearch]
+ [router, setQuery, setDebouncedQuery, addRecentSearch]
);
// 비동기 엔티티 히트 도착으로 서제스천 구성이 바뀌면 하이라이트 리셋
@@ -160,33 +142,27 @@ export function ExploreClient({
setQuery("");
setDebouncedQuery("");
setShowSuggestions(false);
+ setActiveStyle("All");
inputRef.current?.focus();
}, [setQuery, setDebouncedQuery]);
- // Canned-query palette: a chip injects a real search query (empty "" clears).
- // The chip's highlight is derived from the query, so no separate state to desync.
- const applyCannedQuery = useCallback(
- (value: string) => {
- setQuery(value);
- setDebouncedQuery(value);
- if (value) addRecentSearch(value);
+ // Style chips inject a real search query (no fake facet filtering).
+ const handleStyleChange = useCallback(
+ (tag: StyleTag) => {
+ setActiveStyle(tag);
+ if (tag === "All") {
+ setQuery("");
+ setDebouncedQuery("");
+ } else {
+ setQuery(tag);
+ setDebouncedQuery(tag);
+ addRecentSearch(tag);
+ }
setShowSuggestions(false);
},
[setQuery, setDebouncedQuery, addRecentSearch]
);
- // Editorial style chips inject the tag label as a canned search query.
- const handleStyleChange = useCallback(
- (tag: StyleTag) => applyCannedQuery(tag === "All" ? "" : tag),
- [applyCannedQuery]
- );
-
- // Category chips inject their canned query ("All" → "" clears).
- const handleCategoryChange = useCallback(
- (id: ExploreCategoryId) => applyCannedQuery(queryForCategory(id)),
- [applyCannedQuery]
- );
-
const {
items,
isLoading,
@@ -197,13 +173,6 @@ export function ExploreClient({
hasNextPage,
isFetchingNextPage,
mode,
- artistFacets,
- contextFacets,
- selectedArtists,
- toggleArtist,
- clearArtistFilters,
- activeContext,
- setContext,
activeSort,
setSort,
} = useExploreData({
@@ -216,7 +185,7 @@ export function ExploreClient({
// Infinite scroll via sentinel (replaces ThiingsGrid onReachEnd)
useEffect(() => {
const sentinel = sentinelRef.current;
- if (!sentinel || entity !== "looks") return;
+ if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) {
@@ -227,7 +196,7 @@ export function ExploreClient({
);
observer.observe(sentinel);
return () => observer.disconnect();
- }, [entity, hasNextPage, isFetchingNextPage, fetchNextPage]);
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const gridItems: GridItem[] = useMemo(() => {
return items
@@ -248,36 +217,13 @@ export function ExploreClient({
}));
}, [items]);
- const contextOptions = useMemo(() => {
- return Object.entries(contextFacets)
- .sort(([, a], [, b]) => b - a)
- .slice(0, 10);
- }, [contextFacets]);
-
- const artistBadges = useMemo(() => {
- return Object.entries(artistFacets)
- .sort(([, a], [, b]) => b - a)
- .slice(0, 8);
- }, [artistFacets]);
-
- // Crit 1: only surface sorts the active mode can honor. `solution_count` is a
- // search-only backend sort, so browse hides it; `displaySort` coerces the value
- // so the can never show a sort browse silently ignores.
- const sortOptions = sortOptionsForMode(mode);
- const displaySort = coerceSortForMode(activeSort, mode);
-
- const hasActiveFilters =
- selectedArtists.length > 0 ||
- activeContext !== null ||
- displaySort !== "relevant";
-
return (
{/* Page header */}
@@ -293,12 +239,7 @@ export function ExploreClient({
onFocus={() => query.length > 0 && setShowSuggestions(true)}
onKeyDown={handleKeyDown}
data-testid="explore-search-input"
- placeholder={t("search.placeholder")}
- role="combobox"
- aria-label="Search"
- aria-autocomplete="list"
- aria-expanded={showSuggestions}
- aria-controls="explore-search-listbox"
+ placeholder="Search looks, artists, and brands..."
className="w-full rounded-full border border-border bg-card py-2 pl-10 pr-10 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
{query.length > 0 && (
@@ -306,116 +247,51 @@ export function ExploreClient({
onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
type="button"
- aria-label={t("search.clearAria")}
+ aria-label="Clear search"
>
)}
- {/* Single chrome: SearchSuggestions owns the positioned dropdown card.
- It was double-wrapped in a second bordered/shadowed card → the
- double-chrome bug (#843). Render it directly. */}
{showSuggestions && (
- setShowSuggestions(false)}
- onItemsChange={handleSuggestionItemsChange}
- />
+
+ setShowSuggestions(false)}
+ onItemsChange={handleSuggestionItemsChange}
+ />
+
)}
- {/* Entity tabs */}
-
-
- {entity === "artists" ? (
-
- ) : entity === "brands" ? (
-
- ) : entity !== "looks" ? (
-
+ {mode === "search" ? (
+
) : (
<>
- {/* Category filter (canned-query palette) */}
-
-
- {/* Editorial style chips + mode-aware sort row */}
+ {/* Browse controls only — search results are always unified. */}
-
+
- {/* Search-mode facets (context + artist badges) */}
- {mode === "search" &&
- (contextOptions.length > 0 || artistBadges.length > 0) && (
-
- {contextOptions.length > 0 && (
-
-
- setContext(
- e.target.value === "" ? null : e.target.value
- )
- }
- className={cn(
- "cursor-pointer appearance-none rounded-full border bg-transparent py-1 pl-3 pr-7 text-xs font-medium transition-colors",
- activeContext
- ? "border-primary/30 bg-primary/10 text-foreground"
- : "border-border text-muted-foreground hover:bg-accent"
- )}
- >
- {t("search.contextLabel")}
- {contextOptions.map(([value, count]) => (
-
- {value} ({count})
-
- ))}
-
-
-
- )}
- {artistBadges.map(([name, count]) => (
-
toggleArtist(name)}
- onRemove={() => toggleArtist(name)}
- />
- ))}
- {hasActiveFilters && (
- {
- clearArtistFilters();
- setContext(null);
- setSort("relevant");
- }}
- className="ml-1 flex-shrink-0 text-[10px] text-muted-foreground transition-colors hover:text-foreground"
- type="button"
- >
- {t("search.clearAll")}
-
- )}
-
- )}
-
- {/* Loading skeleton */}
{isLoading && items.length === 0 && (
)}
- {/* Error state — browse mode */}
- {isError && mode !== "search" && (
+ {isError && (
⚠️
- {t("loadError.title")}
+ Failed to load posts
- {error instanceof Error ? error.message : t("loadError.body")}
+ {error instanceof Error
+ ? error.message
+ : "Something went wrong while loading posts."}
refetch()}
className="rounded-full border border-border bg-card px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
type="button"
>
- {t("loadError.retry")}
+ Retry
)}
- {/* Error state — search mode (backend/Meilisearch outage) */}
- {isError && mode === "search" && (
-
-
🛠️
-
- {t("searchError.title")}
-
-
- {(() => {
- const msg =
- error instanceof Error
- ? error.message
- : t("searchError.body");
- return msg.length > 140 ? `${msg.slice(0, 140)}…` : msg;
- })()}
-
-
- refetch()}
- className="rounded-full border border-border bg-card px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
- type="button"
- >
- {t("searchError.retry")}
-
-
- {t("searchError.clear")}
-
-
-
- )}
-
- {/* Empty state with search suggestions */}
{!isError && !isLoading && items.length === 0 && (
-
- {debouncedQuery.trim().length > 0 ? "🔍" : "📷"}
-
+
📷
- {debouncedQuery.trim().length > 0
- ? t("empty.noResults", { query: debouncedQuery })
- : t("empty.noPosts")}
+ No posts found yet.
- {debouncedQuery.trim().length > 0
- ? t("empty.noResultsHint")
- : t("empty.noPostsHint")}
+ Check back later.
- {debouncedQuery.trim().length > 0 && (
- <>
-
- {POPULAR_SEARCH_TAGS.map((tag) => (
- handleSuggestionSelect(tag)}
- className="rounded-full border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:border-primary/30 hover:bg-accent"
- >
- {tag}
-
- ))}
-
-
- {t("empty.clear")}
-
- >
- )}
)}
@@ -547,7 +359,7 @@ export function ExploreClient({
{isFetchingNextPage && (
-
+
)}
>
diff --git a/packages/web/lib/components/brands/BrandDetailClient.tsx b/packages/web/lib/components/brands/BrandDetailClient.tsx
index c83e6cee3..56fd0d3b2 100644
--- a/packages/web/lib/components/brands/BrandDetailClient.tsx
+++ b/packages/web/lib/components/brands/BrandDetailClient.tsx
@@ -1,7 +1,5 @@
"use client";
-import { useState } from "react";
-import type { ReactNode } from "react";
import Image from "next/image";
import { Link } from "@/i18n/navigation";
import { ArrowLeft, Instagram, Store } from "lucide-react";
@@ -14,18 +12,14 @@ import {
import type { SolutionListItem } from "@/lib/api/generated/models";
import { ExploreCardCell, ExploreSkeletonCell } from "@/lib/components/explore";
import { mapPostListItemToGridItem } from "@/lib/utils/post-grid-item-mapper";
-import { cn } from "@/lib/utils";
const proxyImg = (url: string) =>
url.startsWith("http")
? `/api/v1/image-proxy?url=${encodeURIComponent(url)}`
: url;
-type BrandTab = "looks" | "products";
-
export function BrandDetailClient({ brandId }: { brandId: string }) {
const locale = useLocale();
- const [tab, setTab] = useState
("looks");
const {
data: brand,
isLoading: isBrandLoading,
@@ -61,6 +55,7 @@ export function BrandDetailClient({ brandId }: { brandId: string }) {
const description = brand.description?.trim() || null;
const gridItems = (looks?.data ?? []).map(mapPostListItemToGridItem);
const productItems = products?.data ?? [];
+ const productGroups = groupProductsByCategory(productItems);
return (
@@ -120,43 +115,44 @@ export function BrandDetailClient({ brandId }: { brandId: string }) {
-
- setTab("looks")}>
- Looks
-
- setTab("products")}
- >
- Products
-
-
+ Looks
+ {isLooksLoading ? (
+
+ ) : gridItems.length > 0 ? (
+
+ {gridItems.map((item, index) => (
+
+
+
+ ))}
+
+ ) : (
+
+ )}
+
- {tab === "looks" ? (
- isLooksLoading ? (
-
- ) : gridItems.length > 0 ? (
-
- {gridItems.map((item, index) => (
-
-
-
- ))}
-
- ) : (
-
- )
- ) : isProductsLoading ? (
+
+ Products
+ {isProductsLoading ? (
) : productItems.length > 0 ? (
-
- {productItems.map((product) => (
-
+
+ {productGroups.map(([category, groupedProducts]) => (
+
+
+ {categoryLabel(category)}
+
+
+ {groupedProducts.map((product) => (
+
+ ))}
+
+
))}
) : (
@@ -167,6 +163,44 @@ export function BrandDetailClient({ brandId }: { brandId: string }) {
);
}
+function groupProductsByCategory(products: SolutionListItem[]) {
+ const groups = new Map
();
+
+ for (const product of products) {
+ const category =
+ product.subcategory?.trim() ||
+ product.category?.trim() ||
+ product.category_path?.split(">").at(0)?.trim() ||
+ product.product_type?.trim() ||
+ "Other";
+ const group = groups.get(category);
+ if (group) {
+ group.push(product);
+ } else {
+ groups.set(category, [product]);
+ }
+ }
+
+ return [...groups.entries()].sort(([left], [right]) => {
+ if (left === "Other") return 1;
+ if (right === "Other") return -1;
+ return left.localeCompare(right);
+ });
+}
+
+function categoryLabel(category: string) {
+ const labels: Record = {
+ top: "Top",
+ bottom: "Bottom",
+ footwear: "Shoes",
+ bag: "Bags",
+ outerwear: "Outerwear",
+ dress: "Dresses",
+ accessory: "Accessories",
+ };
+ return labels[category] ?? category;
+}
+
function ProductCard({ product }: { product: SolutionListItem }) {
const url = product.affiliate_url ?? product.original_url ?? null;
const metadata = product.metadata as
@@ -220,31 +254,6 @@ function ProductCard({ product }: { product: SolutionListItem }) {
);
}
-function TabButton({
- active,
- onClick,
- children,
-}: {
- active: boolean;
- onClick: () => void;
- children: ReactNode;
-}) {
- return (
-
- {children}
-
- );
-}
-
function Badge({ label }: { label: string }) {
return (
diff --git a/packages/web/lib/components/explore/ComingSoonState.tsx b/packages/web/lib/components/explore/ComingSoonState.tsx
deleted file mode 100644
index 3c7f922e8..000000000
--- a/packages/web/lib/components/explore/ComingSoonState.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { Sparkles, Shirt, Tag, Users, Palette } from "lucide-react";
-import { useTranslations } from "next-intl";
-import type { ExploreEntity } from "./EntityTabBar";
-
-const ICON: Record, React.ReactNode> = {
- moods: ,
- items: ,
- brands: ,
- people: ,
-};
-
-/**
- * Honest zero-state for entity tabs whose backend hasn't landed.
- */
-export function ComingSoonState({
- entity,
-}: {
- entity: Exclude;
-}) {
- const t = useTranslations("Explore");
- const icon = ICON[entity];
- const title = t(`comingSoon.${entity}.title`);
- const description = t(`comingSoon.${entity}.description`);
- return (
-
-
- {icon}
-
-
{title}
-
{description}
-
-
- {t("comingSoon.badge")}
-
-
- );
-}
diff --git a/packages/web/lib/components/explore/EntityTabBar.tsx b/packages/web/lib/components/explore/EntityTabBar.tsx
deleted file mode 100644
index 93e9b8d59..000000000
--- a/packages/web/lib/components/explore/EntityTabBar.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-"use client";
-
-import { useTranslations } from "next-intl";
-import { cn } from "@/lib/utils";
-
-export type ExploreEntity = "looks" | "moods" | "items" | "brands" | "people";
-
-// Order only — display labels come from the `Explore.entities` message namespace.
-export const EXPLORE_ENTITIES: ExploreEntity[] = [
- "looks",
- "moods",
- "items",
- "brands",
- "people",
-];
-
-export interface EntityTabBarProps {
- active: ExploreEntity;
- onChange: (entity: ExploreEntity) => void;
- className?: string;
-}
-
-/**
- * Explore entity tabs (Looks / Moods / Items / Brands / People).
- * Active = lime underline. Tabs without a backend stay clickable and show
- * an honest ComingSoonState as their content.
- */
-export function EntityTabBar({
- active,
- onChange,
- className,
-}: EntityTabBarProps) {
- const t = useTranslations("Explore");
- return (
-
- {EXPLORE_ENTITIES.map((id) => {
- const isActive = id === active;
- return (
- onChange(id)}
- className={cn(
- "-mb-px shrink-0 border-b-2 px-4 py-2.5 text-sm font-medium transition-colors",
- isActive
- ? "border-primary text-foreground"
- : "border-transparent text-muted-foreground hover:text-foreground"
- )}
- >
- {t(`entities.${id}`)}
-
- );
- })}
-
- );
-}
diff --git a/packages/web/lib/components/explore/UnifiedSearchResults.tsx b/packages/web/lib/components/explore/UnifiedSearchResults.tsx
new file mode 100644
index 000000000..2c607d39b
--- /dev/null
+++ b/packages/web/lib/components/explore/UnifiedSearchResults.tsx
@@ -0,0 +1,171 @@
+"use client";
+
+import type { ReactNode, RefObject } from "react";
+import { useLocale } from "next-intl";
+import { Search } from "lucide-react";
+import { useCatalogEntitySearch } from "@/lib/hooks/useCatalogEntitySearch";
+import { LoadingSpinner } from "@/lib/design-system/loading-spinner";
+import { ExploreCardCell, ExploreSkeletonCell } from "@/lib/components/explore";
+import type { GridItem } from "@/lib/components/ThiingsGrid";
+import { ArtistResultCard } from "./ArtistResultCard";
+import { BrandResultCard } from "./BrandResultCard";
+
+const ENTITY_RESULT_LIMIT = 3;
+
+type UnifiedSearchResultsProps = {
+ query: string;
+ lookItems: GridItem[];
+ looksLoading: boolean;
+ looksError: boolean;
+ looksErrorMessage?: string;
+ onRetryLooks: () => void;
+ onClearSearch: () => void;
+ sentinelRef: RefObject;
+ isFetchingMoreLooks: boolean;
+};
+
+function ResultSection({
+ title,
+ children,
+}: {
+ title: string;
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
+
+/**
+ * Search results from every currently supported public search index.
+ * This intentionally has no entity tabs: a query searches looks, artists,
+ * and brands together.
+ */
+export function UnifiedSearchResults({
+ query,
+ lookItems,
+ looksLoading,
+ looksError,
+ looksErrorMessage,
+ onRetryLooks,
+ onClearSearch,
+ sentinelRef,
+ isFetchingMoreLooks,
+}: UnifiedSearchResultsProps) {
+ const locale = useLocale();
+ const trimmedQuery = query.trim();
+ const artistSearch = useCatalogEntitySearch({
+ query: trimmedQuery,
+ kind: "artist",
+ locale,
+ limit: ENTITY_RESULT_LIMIT,
+ });
+ const brandSearch = useCatalogEntitySearch({
+ query: trimmedQuery,
+ kind: "brand",
+ locale,
+ limit: ENTITY_RESULT_LIMIT,
+ });
+
+ const artists = artistSearch.data ?? [];
+ const brands = brandSearch.data ?? [];
+ const entitiesLoading = artistSearch.isLoading || brandSearch.isLoading;
+ const hasResults = artists.length > 0 || brands.length > 0 || lookItems.length > 0;
+ const allSearchesFinished = !looksLoading && !entitiesLoading;
+
+ return (
+
+ {artists.length > 0 && (
+
+
+ {artists.map((artist) => (
+
+ ))}
+
+
+ )}
+
+ {brands.length > 0 && (
+
+
+ {brands.map((brand) => (
+
+ ))}
+
+
+ )}
+
+
+ {looksLoading && lookItems.length === 0 ? (
+
+ {Array.from({ length: 12 }).map((_, index) => (
+
+
+
+ ))}
+
+ ) : looksError ? (
+
+
+ {looksErrorMessage ?? "Looks are temporarily unavailable."}
+
+
+ Retry
+
+
+ ) : lookItems.length > 0 ? (
+ <>
+
+ {lookItems.map((item, index) => (
+
+
+
+ ))}
+
+
+ {isFetchingMoreLooks && (
+
+
+
+ )}
+ >
+ ) : allSearchesFinished && !hasResults ? (
+
+
+
+ No results for “{trimmedQuery}”
+
+
+ Try a different name, brand, or style keyword.
+
+
+ Clear search
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/packages/web/lib/components/search/SearchInput.tsx b/packages/web/lib/components/search/SearchInput.tsx
index 9b0b657b1..75bfed2e0 100644
--- a/packages/web/lib/components/search/SearchInput.tsx
+++ b/packages/web/lib/components/search/SearchInput.tsx
@@ -17,6 +17,7 @@ import {
import { useSearchNavigation } from "../../hooks/useSearchURLSync";
import { SearchSuggestions } from "./SearchSuggestions";
import { useTrackEvent } from "@/lib/hooks/useTrackEvent";
+import { useRouter } from "@/i18n/navigation";
interface SearchInputProps {
placeholder?: string;
@@ -54,6 +55,7 @@ export const SearchInput = memo(function SearchInput({
const setDebouncedQuery = useSearchStore((s) => s.setDebouncedQuery);
const addRecentSearch = useRecentSearchesStore((s) => s.addSearch);
const track = useTrackEvent();
+ const router = useRouter();
const debouncedQuery = useDebounce(query, 250);
const { navigateToSearch } = useSearchNavigation();
@@ -63,6 +65,38 @@ export const SearchInput = memo(function SearchInput({
setDebouncedQuery(debouncedQuery);
}, [debouncedQuery, setDebouncedQuery]);
+ const handleSuggestionSelect = useCallback(
+ (
+ searchQuery: string,
+ options?: { entityType?: string; entityId?: string }
+ ) => {
+ const trimmed = searchQuery.trim();
+ if (!trimmed) return;
+
+ if (
+ options?.entityId &&
+ (options.entityType === "artist" || options.entityType === "brand")
+ ) {
+ router.push(`/${options.entityType}s/${options.entityId}`);
+ setIsFocused(false);
+ inputRef.current?.blur();
+ return;
+ }
+
+ addRecentSearch(trimmed);
+ track({
+ event_type: "search_query",
+ metadata: { query: trimmed, entity_type: options?.entityType },
+ });
+
+ navigateToSearch(trimmed);
+
+ setIsFocused(false);
+ inputRef.current?.blur();
+ },
+ [router, navigateToSearch, addRecentSearch, track]
+ );
+
// Handle search execution
const handleSearch = useCallback(
(searchQuery: string) => {
@@ -196,7 +230,7 @@ export const SearchInput = memo(function SearchInput({
setIsFocused(false)}
onItemsChange={() => setSelectedIndex(-1)}
/>
diff --git a/packages/web/lib/components/search/SearchSuggestions.tsx b/packages/web/lib/components/search/SearchSuggestions.tsx
index 536a4de1b..6e6fd1cc3 100644
--- a/packages/web/lib/components/search/SearchSuggestions.tsx
+++ b/packages/web/lib/components/search/SearchSuggestions.tsx
@@ -14,10 +14,15 @@ const ENTITY_KIND_META: Record = {
group: { icon: Users, label: "Group" },
};
+export type SearchSuggestionSelectOptions = {
+ entityType?: string;
+ entityId?: string;
+};
+
interface SearchSuggestionsProps {
query: string;
selectedIndex: number;
- onSelect: (query: string) => void;
+ onSelect: (query: string, options?: SearchSuggestionSelectOptions) => void;
onClose: () => void;
/** 항목 구성 변경 시(비동기 엔티티 도착 등) 부모가 하이라이트를 리셋하도록 통지 */
onItemsChange?: () => void;
@@ -63,14 +68,27 @@ export const SearchSuggestions = memo(function SearchSuggestions({
)
: recentSearches;
+ const selectEntity = useCallback(
+ (name: string, entityType: string, entityId: string) => {
+ onSelect(name, { entityType, entityId });
+ },
+ [onSelect]
+ );
+
// Build items array for keyboard navigation
- const items: Array<{ type: "entity" | "recent" | "popular"; value: string }> =
- [
+ const items: Array<{
+ type: "entity" | "recent" | "popular";
+ value: string;
+ entityType?: string;
+ entityId?: string;
+ }> = [
...entityHits.map((hit) => ({
type: "entity" as const,
// alias→canonical bridge: search the resolved entity name, not the
// raw alias the user typed (keeps click + keyboard-Enter consistent)
value: entityHitToCanonicalQuery(hit),
+ entityType: hit.entity_type,
+ entityId: hit.id,
})),
...filteredRecent.slice(0, 5).map((value) => ({
type: "recent" as const,
@@ -100,7 +118,15 @@ export const SearchSuggestions = memo(function SearchSuggestions({
const handleEnter = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
- onSelect(items[selectedIndex].value);
+ const item = items[selectedIndex];
+ if (item.type === "entity" && item.entityType) {
+ onSelect(item.value, {
+ entityType: item.entityType,
+ entityId: item.entityId,
+ });
+ } else {
+ onSelect(item.value);
+ }
}
};
@@ -178,9 +204,13 @@ export const SearchSuggestions = memo(function SearchSuggestions({
data-hit-type="entity"
role="option"
aria-selected={isSelected}
- // Bridge alias→canonical: search the resolved entity name
- // (not the raw alias), so an entity hit is never a dead-end.
- onClick={() => onSelect(entityHitToCanonicalQuery(hit))}
+ onClick={() =>
+ selectEntity(
+ entityHitToCanonicalQuery(hit),
+ hit.entity_type,
+ hit.id
+ )
+ }
className={cn(
"w-full flex items-center gap-3 px-3 py-2 text-sm text-left transition-colors",
isSelected
diff --git a/packages/web/lib/components/search/__tests__/SearchSuggestions.test.tsx b/packages/web/lib/components/search/__tests__/SearchSuggestions.test.tsx
index a36cceee0..a57ffdf3b 100644
--- a/packages/web/lib/components/search/__tests__/SearchSuggestions.test.tsx
+++ b/packages/web/lib/components/search/__tests__/SearchSuggestions.test.tsx
@@ -115,14 +115,17 @@ describe("SearchSuggestions — entity section", () => {
expect(container).toBeEmptyDOMElement();
});
- it("calls onSelect with the entity display name on click", () => {
+ it("calls onSelect with the entity display name and type on click", () => {
entityState.hits = [NIKE_HIT];
const onSelect = vi.fn();
renderSuggestions({ onSelect });
fireEvent.click(screen.getByText("Nike"));
- expect(onSelect).toHaveBeenCalledWith("Nike");
+ expect(onSelect).toHaveBeenCalledWith("Nike", {
+ entityType: "brand",
+ entityId: NIKE_HIT.id,
+ });
});
it("bridges click to the resolved entity name — locale-blind, not the alias", () => {
diff --git a/packages/web/lib/hooks/useSearchURLSync.ts b/packages/web/lib/hooks/useSearchURLSync.ts
index a73ec9178..0537b8beb 100644
--- a/packages/web/lib/hooks/useSearchURLSync.ts
+++ b/packages/web/lib/hooks/useSearchURLSync.ts
@@ -122,13 +122,11 @@ export function useSearchNavigation() {
setQuery(query);
setDebouncedQuery(query);
- // Navigate to search page with query
const params = new URLSearchParams();
if (query) params.set("q", query);
-
const url = params.toString()
- ? `/search?${params.toString()}`
- : "/search";
+ ? `/explore?${params.toString()}`
+ : "/explore";
router.push(url);
},
[router, setQuery, setDebouncedQuery]
From ac42b5be2fcf15c11fe6374a13a3e57b7d30b0b5 Mon Sep 17 00:00:00 2001
From: CIOI
Date: Sat, 11 Jul 2026 23:13:35 +0900
Subject: [PATCH 07/10] style(web): format catalog discovery surfaces
Keep the unified Explore and typeahead components compliant with the web formatting gate.
Co-authored-by: Cursor
---
packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx | 4 +---
packages/web/lib/components/explore/UnifiedSearchResults.tsx | 3 ++-
2 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx b/packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx
index 79a411bcc..8f9952eb2 100644
--- a/packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx
+++ b/packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx
@@ -272,9 +272,7 @@ export function ExploreClient({
lookItems={gridItems}
looksLoading={isLoading}
looksError={isError}
- looksErrorMessage={
- error instanceof Error ? error.message : undefined
- }
+ looksErrorMessage={error instanceof Error ? error.message : undefined}
onRetryLooks={refetch}
onClearSearch={handleClear}
sentinelRef={sentinelRef}
diff --git a/packages/web/lib/components/explore/UnifiedSearchResults.tsx b/packages/web/lib/components/explore/UnifiedSearchResults.tsx
index 2c607d39b..03522ecc5 100644
--- a/packages/web/lib/components/explore/UnifiedSearchResults.tsx
+++ b/packages/web/lib/components/explore/UnifiedSearchResults.tsx
@@ -73,7 +73,8 @@ export function UnifiedSearchResults({
const artists = artistSearch.data ?? [];
const brands = brandSearch.data ?? [];
const entitiesLoading = artistSearch.isLoading || brandSearch.isLoading;
- const hasResults = artists.length > 0 || brands.length > 0 || lookItems.length > 0;
+ const hasResults =
+ artists.length > 0 || brands.length > 0 || lookItems.length > 0;
const allSearchesFinished = !looksLoading && !entitiesLoading;
return (
From 35b871fb067aa3523b9e58a06419aad062034c38 Mon Sep 17 00:00:00 2001
From: CIOI
Date: Sun, 12 Jul 2026 20:11:07 +0900
Subject: [PATCH 08/10] fix(web): align tests with unified search
Remove the obsolete entity-tab test and assert the entity metadata required for
direct catalog detail navigation.
Co-authored-by: Cursor
---
.../__tests__/EntityTabBar.locale.test.tsx | 34 -------------------
.../__tests__/SearchSuggestions.test.tsx | 5 ++-
2 files changed, 4 insertions(+), 35 deletions(-)
delete mode 100644 packages/web/lib/components/explore/__tests__/EntityTabBar.locale.test.tsx
diff --git a/packages/web/lib/components/explore/__tests__/EntityTabBar.locale.test.tsx b/packages/web/lib/components/explore/__tests__/EntityTabBar.locale.test.tsx
deleted file mode 100644
index 852111864..000000000
--- a/packages/web/lib/components/explore/__tests__/EntityTabBar.locale.test.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * @vitest-environment jsdom
- */
-import React from "react";
-import { describe, it, expect, vi } from "vitest";
-import { render, screen } from "@testing-library/react";
-import "@testing-library/jest-dom";
-import { NextIntlClientProvider } from "next-intl";
-import ko from "@/messages/ko.json";
-import en from "@/messages/en.json";
-
-import { EntityTabBar } from "../EntityTabBar";
-
-function renderWithLocale(locale: "ko" | "en", messages: typeof en) {
- return render(
-
-
-
- );
-}
-
-describe("EntityTabBar locale rendering (#845)", () => {
- it("renders English entity tab labels under en", () => {
- renderWithLocale("en", en);
- expect(screen.getByRole("tab", { name: "Looks" })).toBeInTheDocument();
- expect(screen.getByRole("tab", { name: "People" })).toBeInTheDocument();
- });
-
- it("renders Korean entity tab labels under ko", () => {
- renderWithLocale("ko", ko);
- expect(screen.getByRole("tab", { name: "룩" })).toBeInTheDocument();
- expect(screen.getByRole("tab", { name: "인물" })).toBeInTheDocument();
- });
-});
diff --git a/packages/web/lib/components/search/__tests__/SearchSuggestions.test.tsx b/packages/web/lib/components/search/__tests__/SearchSuggestions.test.tsx
index a57ffdf3b..b4634788f 100644
--- a/packages/web/lib/components/search/__tests__/SearchSuggestions.test.tsx
+++ b/packages/web/lib/components/search/__tests__/SearchSuggestions.test.tsx
@@ -138,7 +138,10 @@ describe("SearchSuggestions — entity section", () => {
renderSuggestions({ query: "스우시", onSelect });
fireEvent.click(screen.getByText("나이키"));
- expect(onSelect).toHaveBeenCalledWith("나이키");
+ expect(onSelect).toHaveBeenCalledWith("나이키", {
+ entityType: "brand",
+ entityId: NIKE_KO_HIT.id,
+ });
});
it("marks entity hits with a discrimination hook distinct from text hits", () => {
From c585171655b8c0fdffe242eb5461d0f7ab05cc7a Mon Sep 17 00:00:00 2001
From: CIOI
Date: Wed, 15 Jul 2026 01:45:23 +0900
Subject: [PATCH 09/10] fix(api-server): add balance_gender to brand looks
PostListQuery
Rebase onto dev left list_brand_looks missing the new PostListQuery field,
which blocked local API compilation.
Co-authored-by: Cursor
---
packages/api-server/src/domains/catalog_entities/service.rs | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/api-server/src/domains/catalog_entities/service.rs b/packages/api-server/src/domains/catalog_entities/service.rs
index 4d850e36a..265ceea04 100644
--- a/packages/api-server/src/domains/catalog_entities/service.rs
+++ b/packages/api-server/src/domains/catalog_entities/service.rs
@@ -225,6 +225,7 @@ pub async fn list_brand_looks(
complete_outfit: None,
include_magazine_items: None,
viewer_id: None,
+ balance_gender: None,
},
)
.await
From 908ad6e0575f3763103f056ac64fb5b5461f64ee Mon Sep 17 00:00:00 2001
From: CIOI
Date: Wed, 15 Jul 2026 02:36:17 +0900
Subject: [PATCH 10/10] ci(invariants): allowlist catalog_entities assets_db
usage (#886)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Brand /solutions delegates to solutions_service for affiliate URL and
catalog taxonomy resolution — same cross-pool pattern as domains/solutions.
Co-authored-by: Cursor
---
.github/workflows/api-server-invariants.yml | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/.github/workflows/api-server-invariants.yml b/.github/workflows/api-server-invariants.yml
index a79725f2e..99fdf6efb 100644
--- a/.github/workflows/api-server-invariants.yml
+++ b/.github/workflows/api-server-invariants.yml
@@ -144,6 +144,12 @@ jobs:
# 인스타 트렌드 불릿, assets 프로젝트 테이블)를 참고 신호로 read-only 조회.
# 엔티티 파일은 정의 + "assets_db 로만 조회" 주석. operations 아닌 assets 에
# 두는 결정(외부 파이프라인 산출물 = raw_posts 와 동일 성격).
+ #
+ # domains/catalog_entities/
+ # — GET /brands/{id}/solutions (#886). list_brand_solutions 가
+ # solutions_service::list_solutions_by_brand_id 에 위임 — prod solutions 조회 +
+ # assets affiliate_catalog_items/variants 로 market-aware affiliate URL·taxonomy
+ # resolve (domains/solutions/ 와 동일 cross-pool 패턴). looks/artist 는 prod-only.
- name: Guard dual-DB pool selection — assets_db allowlist (#717)
run: |
set -euo pipefail
@@ -178,6 +184,7 @@ jobs:
| grep -v "^${SRC}/domains/admin/solutions\.rs:" \
| grep -v "^${SRC}/domains/catalog/service\.rs:" \
| grep -v "^${SRC}/domains/catalog/handlers\.rs:" \
+ | grep -v "^${SRC}/domains/catalog_entities/" \
| grep -v "^${SRC}/domains/posts/service\.rs:" \
| grep -v "^${SRC}/domains/posts/handlers\.rs:" \
| grep -v "^${SRC}/domains/posts/looksets\.rs:" \