From 96505afedf3c1d2c29c6ca38df44d5a557a7e6aa Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Thu, 28 May 2026 23:20:43 +0900 Subject: [PATCH] feat(vton): infinite scroll posts + drop whitelist + reset stale items - API: remove tops/bottoms/shoes subcategory whitelist that filtered posts to ~1 result. Whitelist already proved fragile once (#602/#609); rely on items.length>0 filter + post status instead. - API: cursor-based pagination via ?cursor=. Default listing branch returns nextCursor when the page filled; search and post_id branches always return nextCursor=null. Over-fetches limit*3 because items.length>0 is a JS filter, not SQL. - useVtonPostFetch: loadMore/hasMore/isLoadingMore, AbortController for in-flight cancel, dedupe across cursor boundary. - VtonItemPanel: IntersectionObserver sentinel (rootMargin 240px) inside the scroll container drives loadMore. - useVtonItemFetch: reset items state before fetching so a previously preloaded post's items don't linger in the grid while the next fetch is pending (visible when switching post -> items mode). - tests: 11 pass; new cases for cursor predicate, nextCursor emission, and cursor being ignored on search/post_id branches. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/v1/vton/posts/__tests__/route.test.ts | 174 ++++++++++++------ packages/web/app/api/v1/vton/posts/route.ts | 140 +++++++------- .../web/lib/components/vton/VtonItemPanel.tsx | 50 ++++- .../web/lib/components/vton/VtonModal.tsx | 9 +- packages/web/lib/hooks/useVtonItemFetch.ts | 6 + packages/web/lib/hooks/useVtonPostFetch.ts | 77 +++++++- 6 files changed, 320 insertions(+), 136 deletions(-) diff --git a/packages/web/app/api/v1/vton/posts/__tests__/route.test.ts b/packages/web/app/api/v1/vton/posts/__tests__/route.test.ts index 1e637bea..e13aa520 100644 --- a/packages/web/app/api/v1/vton/posts/__tests__/route.test.ts +++ b/packages/web/app/api/v1/vton/posts/__tests__/route.test.ts @@ -25,6 +25,7 @@ function createQueryMock(response: QueryResponse) { in: vi.fn(() => query), eq: vi.fn(() => query), or: vi.fn(() => query), + lt: vi.fn(() => query), order: vi.fn(() => query), limit: vi.fn(() => query), not: vi.fn(() => query), @@ -34,28 +35,15 @@ function createQueryMock(response: QueryResponse) { return query; } -function setupSupabase( - subcategoryResponse: QueryResponse, - postsResponse: QueryResponse -) { - const subcategoryQuery = createQueryMock(subcategoryResponse); +function setupSupabase(postsResponse: QueryResponse) { const postsQuery = createQueryMock(postsResponse); - const fromMock = vi.fn((table: string) => { - if (table === "subcategories") return subcategoryQuery; - return postsQuery; - }); + const fromMock = vi.fn(() => postsQuery); createSupabaseServerClientMock.mockResolvedValue({ from: fromMock }); - return { fromMock, subcategoryQuery, postsQuery }; + return { fromMock, postsQuery }; } -const defaultSubcategoryRows = [ - { id: "sc-tops" }, - { id: "sc-bottoms" }, - { id: "sc-shoes" }, -]; - const blackpinkPost = { id: "post-bp", title: "Stage Look", @@ -113,11 +101,8 @@ beforeEach(() => { }); describe("GET /api/v1/vton/posts", () => { - it("queries subcategories with tops/bottoms/shoes whitelist", async () => { - const { subcategoryQuery } = setupSupabase( - { data: [], error: null }, - { data: [], error: null } - ); + it("does not query subcategories — no whitelist filter", async () => { + const { fromMock } = setupSupabase({ data: [], error: null }); const { GET } = await import("../route"); const res = await GET( @@ -125,14 +110,10 @@ describe("GET /api/v1/vton/posts", () => { ); expect(res.status).toBe(200); - expect(subcategoryQuery.in).toHaveBeenCalledWith("code", [ - "tops", - "bottoms", - "shoes", - ]); + expect(fromMock).not.toHaveBeenCalledWith("subcategories"); }); - it("includes posts whose spots are in any of tops/bottoms/shoes subcategories", async () => { + it("includes posts regardless of spots subcategory (no whitelist)", async () => { const makeSolution = (id: string, title: string) => ({ id, title, @@ -160,38 +141,37 @@ describe("GET /api/v1/vton/posts", () => { }, { id: "post-2", - title: "Shoes post", + title: "Accessories post", context: null, artist_name: null, group_name: null, image_url: "https://cdn.example.com/post2.jpg", spots: [ { - subcategory_id: "sc-shoes", - solutions: [makeSolution("sol-2", "Sneakers")], + // Previously filtered out by the tops/bottoms/shoes whitelist — + // now that the whitelist is gone, this post should pass through. + subcategory_id: "sc-accessories", + solutions: [makeSolution("sol-2", "Sunglasses")], }, ], }, { id: "post-3", - title: "Bottoms post", + title: "Untagged post", context: null, artist_name: null, group_name: null, image_url: "https://cdn.example.com/post3.jpg", spots: [ { - subcategory_id: "sc-bottoms", + subcategory_id: null, solutions: [makeSolution("sol-3", "Jeans")], }, ], }, ]; - setupSupabase( - { data: defaultSubcategoryRows, error: null }, - { data: postsRows, error: null } - ); + setupSupabase({ data: postsRows, error: null }); const { GET } = await import("../route"); const res = await GET( @@ -210,10 +190,10 @@ describe("GET /api/v1/vton/posts", () => { }); it("does not apply any text filter when q is omitted", async () => { - const { postsQuery } = setupSupabase( - { data: defaultSubcategoryRows, error: null }, - { data: [blackpinkPost, newjeansPost], error: null } - ); + const { postsQuery } = setupSupabase({ + data: [blackpinkPost, newjeansPost], + error: null, + }); const { GET } = await import("../route"); const res = await GET(makeRequest("http://localhost/api/v1/vton/posts")); @@ -236,10 +216,10 @@ describe("GET /api/v1/vton/posts", () => { }) ); vi.stubGlobal("fetch", fetchMock); - const { postsQuery } = setupSupabase( - { data: defaultSubcategoryRows, error: null }, - { data: [blackpinkPost], error: null } - ); + const { postsQuery } = setupSupabase({ + data: [blackpinkPost], + error: null, + }); const { GET } = await import("../route"); const res = await GET( @@ -263,10 +243,10 @@ describe("GET /api/v1/vton/posts", () => { "fetch", vi.fn(async () => new Response(null, { status: 502 })) ); - const { postsQuery } = setupSupabase( - { data: defaultSubcategoryRows, error: null }, - { data: [blackpinkPost], error: null } - ); + const { postsQuery } = setupSupabase({ + data: [blackpinkPost], + error: null, + }); const { GET } = await import("../route"); const res = await GET( @@ -286,10 +266,7 @@ describe("GET /api/v1/vton/posts", () => { Response.json({ data: [{ id: "person-only", type: "person" }] }) ); vi.stubGlobal("fetch", fetchMock); - const { postsQuery } = setupSupabase( - { data: defaultSubcategoryRows, error: null }, - { data: [], error: null } - ); + const { postsQuery } = setupSupabase({ data: [], error: null }); const { GET } = await import("../route"); const res = await GET( @@ -304,11 +281,96 @@ describe("GET /api/v1/vton/posts", () => { expect(json.posts).toEqual([]); }); - it("still supports post_id lookups regardless of q presence", async () => { - const { postsQuery } = setupSupabase( - { data: defaultSubcategoryRows, error: null }, - { data: [blackpinkPost], error: null } + it("returns nextCursor when the default listing fills the page", async () => { + const makeSolution = (id: string) => ({ + id, + title: `Item ${id}`, + thumbnail_url: `https://cdn.example.com/${id}.jpg`, + description: null, + keywords: null, + status: "active", + accurate_count: 1, + }); + const postsRows = Array.from({ length: 2 }, (_, i) => ({ + id: `post-${i}`, + title: `Post ${i}`, + context: null, + artist_name: null, + group_name: null, + image_url: `https://cdn.example.com/p${i}.jpg`, + created_at: `2026-05-${10 + i}T00:00:00Z`, + spots: [{ subcategory_id: "sc", solutions: [makeSolution(`sol-${i}`)] }], + })).reverse(); // newest first + + setupSupabase({ data: postsRows, error: null }); + + const { GET } = await import("../route"); + const res = await GET( + makeRequest("http://localhost/api/v1/vton/posts?limit=2") ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.posts).toHaveLength(2); + expect(json.nextCursor).toBe("2026-05-10T00:00:00Z"); + }); + + it("returns nextCursor=null when fewer posts than the page limit pass the filter", async () => { + setupSupabase({ data: [blackpinkPost], error: null }); + + const { GET } = await import("../route"); + const res = await GET( + makeRequest("http://localhost/api/v1/vton/posts?limit=24") + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.posts).toHaveLength(1); + expect(json.nextCursor).toBeNull(); + }); + + it("passes cursor as a `created_at < cursor` predicate on the default listing", async () => { + const { postsQuery } = setupSupabase({ data: [], error: null }); + const cursor = "2026-04-01T00:00:00Z"; + + const { GET } = await import("../route"); + await GET( + makeRequest( + `http://localhost/api/v1/vton/posts?cursor=${encodeURIComponent(cursor)}` + ) + ); + + expect(postsQuery.lt).toHaveBeenCalledWith("created_at", cursor); + }); + + it("ignores cursor for search and post_id branches (nextCursor always null)", async () => { + const fetchMock = vi.fn(async () => + Response.json({ data: [{ id: "post-bp", type: "post" }] }) + ); + vi.stubGlobal("fetch", fetchMock); + const { postsQuery } = setupSupabase({ + data: [blackpinkPost], + error: null, + }); + + const { GET } = await import("../route"); + const res = await GET( + makeRequest( + "http://localhost/api/v1/vton/posts?q=blackpink&cursor=2026-04-01T00:00:00Z" + ) + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(postsQuery.lt).not.toHaveBeenCalled(); + expect(json.nextCursor).toBeNull(); + }); + + it("still supports post_id lookups regardless of q presence", async () => { + const { postsQuery } = setupSupabase({ + data: [blackpinkPost], + error: null, + }); const { GET } = await import("../route"); const res = await GET( diff --git a/packages/web/app/api/v1/vton/posts/route.ts b/packages/web/app/api/v1/vton/posts/route.ts index 68c2b26f..47d75055 100644 --- a/packages/web/app/api/v1/vton/posts/route.ts +++ b/packages/web/app/api/v1/vton/posts/route.ts @@ -3,9 +3,6 @@ import { API_BASE_URL } from "@/lib/server-env"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import type { Json } from "@/lib/supabase/types"; -const VTON_CATEGORY_CODES = ["tops", "bottoms", "shoes"] as const; -type SupabaseClient = Awaited>; - type SolutionRow = { id: string; title: string | null; @@ -28,6 +25,7 @@ type PostRow = { artist_name: string | null; group_name: string | null; image_url: string | null; + created_at: string | null; spots?: SpotRow[] | null; }; @@ -46,25 +44,6 @@ function toKeywords(value: Json | null): string[] | null { return keywords.length > 0 ? keywords : null; } -async function getVtonSubcategoryIds(supabase: SupabaseClient) { - const { data, error } = await supabase - .from("subcategories") - .select("id") - .in("code", VTON_CATEGORY_CODES); - - if (error) { - if (process.env.NODE_ENV === "development") { - console.error( - "Error fetching vton subcategories:", - JSON.stringify(error) - ); - } - return new Set(); - } - - return new Set((data ?? []).map((row) => row.id)); -} - async function fetchSearchPostIds( query: string, limit: number @@ -110,14 +89,16 @@ export async function GET(request: NextRequest) { const limit = Math.min(Number(searchParams.get("limit")) || 24, 50); const postId = searchParams.get("post_id")?.trim() || ""; const query = searchParams.get("q")?.trim() || ""; + // Cursor is an ISO `created_at` string from a previous page's `nextCursor`. + // Only the default listing branch (no q, no post_id) uses it. + const cursor = searchParams.get("cursor")?.trim() || ""; try { const supabase = await createSupabaseServerClient(); - const subcategoryIds = await getVtonSubcategoryIds(supabase); let postsQuery = supabase .from("posts") .select( - "id, title, context, artist_name, group_name, image_url, spots(subcategory_id, solutions(id, title, thumbnail_url, description, keywords, status, accurate_count))" + "id, title, context, artist_name, group_name, image_url, created_at, spots(subcategory_id, solutions(id, title, thumbnail_url, description, keywords, status, accurate_count))" ) .eq("status", "active") .not("image_url", "is", null); @@ -132,7 +113,7 @@ export async function GET(request: NextRequest) { } else if (searchPostIds && searchPostIds.length === 0) { // Meilisearch responded successfully with zero post hits — trust it // and skip a broad DB ilike fanout. - return NextResponse.json({ posts: [] }); + return NextResponse.json({ posts: [], nextCursor: null }); } else { const pattern = `%${sanitizeIlikeValue(query)}%`; postsQuery = postsQuery @@ -143,9 +124,10 @@ export async function GET(request: NextRequest) { .limit(limit * 3); } } else { - postsQuery = postsQuery - .order("created_at", { ascending: false }) - .limit(limit * 3); + postsQuery = postsQuery.order("created_at", { ascending: false }); + if (cursor) postsQuery = postsQuery.lt("created_at", cursor); + // Over-fetch — items.length>0 is filtered in JS, not in SQL. + postsQuery = postsQuery.limit(limit * 3); } const { data, error } = await postsQuery; @@ -155,61 +137,77 @@ export async function GET(request: NextRequest) { console.error("Error fetching vton posts:", JSON.stringify(error)); } return NextResponse.json( - { posts: [], error: error.message }, + { posts: [], nextCursor: null, error: error.message }, { status: 500 } ); } - const posts = ((data ?? []) as PostRow[]) - .map((post) => { - const eligibleSpots = (post.spots ?? []).filter( - (spot) => - subcategoryIds.size > 0 && - spot.subcategory_id && - subcategoryIds.has(spot.subcategory_id) - ); - const items = eligibleSpots - .flatMap((spot) => spot.solutions ?? []) - .filter( - (solution) => - solution.title && - (solution.thumbnail_url || post.image_url) && - (!solution.status || solution.status === "active") - ) - .sort((a, b) => (b.accurate_count ?? 0) - (a.accurate_count ?? 0)) - .map((solution) => ({ - id: solution.id, - title: solution.title || "Untitled item", - thumbnail_url: solution.thumbnail_url || post.image_url || "", - description: solution.description, - keywords: toKeywords(solution.keywords), - })); - - return { - id: post.id, - title: - post.title || - post.context || - post.artist_name || - post.group_name || - "Untitled post", - image_url: post.image_url || "", - artist_name: post.artist_name, - group_name: post.group_name, - context: post.context, - items, - }; - }) + const mapped = ((data ?? []) as PostRow[]).map((post) => { + const items = (post.spots ?? []) + .flatMap((spot) => spot.solutions ?? []) + .filter( + (solution) => + solution.title && + (solution.thumbnail_url || post.image_url) && + (!solution.status || solution.status === "active") + ) + .sort((a, b) => (b.accurate_count ?? 0) - (a.accurate_count ?? 0)) + .map((solution) => ({ + id: solution.id, + title: solution.title || "Untitled item", + thumbnail_url: solution.thumbnail_url || post.image_url || "", + description: solution.description, + keywords: toKeywords(solution.keywords), + })); + + return { + id: post.id, + title: + post.title || + post.context || + post.artist_name || + post.group_name || + "Untitled post", + image_url: post.image_url || "", + artist_name: post.artist_name, + group_name: post.group_name, + context: post.context, + created_at: post.created_at, + items, + }; + }); + + const visible = mapped .filter((post) => post.image_url && post.items.length > 0) .slice(0, postId ? 1 : limit); - return NextResponse.json({ posts }); + // Only the default listing (no q, no post_id) is cursor-paginated; for + // those we expose nextCursor when the page filled up. + const canPaginate = !postId && !query; + const lastVisible = visible[visible.length - 1]; + const nextCursor = + canPaginate && visible.length === limit && lastVisible?.created_at + ? lastVisible.created_at + : null; + + const posts = visible.map( + ({ + created_at: _created_at, + ...rest + }: (typeof visible)[number] & { created_at: string | null }) => rest + ); + + return NextResponse.json({ posts, nextCursor }); } catch (err) { if (process.env.NODE_ENV === "development") { console.error("VTON posts error:", err); } return NextResponse.json( - { posts: [], error: err instanceof Error ? err.message : "Proxy error" }, + { + posts: [], + nextCursor: null, + error: err instanceof Error ? err.message : "Proxy error", + }, { status: 502 } ); } diff --git a/packages/web/lib/components/vton/VtonItemPanel.tsx b/packages/web/lib/components/vton/VtonItemPanel.tsx index 24b8e90b..3fab6fa6 100644 --- a/packages/web/lib/components/vton/VtonItemPanel.tsx +++ b/packages/web/lib/components/vton/VtonItemPanel.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useRef } from "react"; import { Search, X } from "lucide-react"; import { CATEGORIES, @@ -26,6 +27,8 @@ interface VtonItemPanelProps { isProcessing: boolean; isLoadingItems: boolean; isLoadingPosts: boolean; + isLoadingMore: boolean; + hasMore: boolean; searchQuery: string; error: string | null; bgError: { error: string | null } | null; @@ -40,6 +43,7 @@ interface VtonItemPanelProps { postSnapshot: VtonSourcePostSnapshot ) => void; onDeselect: (item: ItemData) => void; + onLoadMore: () => void; onTryOn: () => void; } @@ -81,6 +85,8 @@ export function VtonItemPanel({ isProcessing, isLoadingItems, isLoadingPosts, + isLoadingMore, + hasMore, searchQuery, error, bgError, @@ -91,10 +97,37 @@ export function VtonItemPanel({ onSelectItem, onSelectPost, onDeselect, + onLoadMore, onTryOn, }: VtonItemPanelProps) { const currentSelected = selectedItems[activeCategory]; const showPostGrid = sourceMode === "posts" && !isPostMode; + const sentinelRef = useRef(null); + const scrollContainerRef = useRef(null); + const onLoadMoreRef = useRef(onLoadMore); + onLoadMoreRef.current = onLoadMore; + + useEffect(() => { + if (!showPostGrid || !hasMore) return; + const sentinel = sentinelRef.current; + const root = scrollContainerRef.current; + if (!sentinel || !root) return; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + onLoadMoreRef.current(); + break; + } + } + }, + { root, rootMargin: "240px", threshold: 0 } + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [showPostGrid, hasMore, posts.length]); return (
+
{showPostGrid ? ( <>

@@ -263,6 +299,18 @@ export function VtonItemPanel({ : "No try-on ready posts yet."}

)} + {hasMore && ( +