Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
978 changes: 978 additions & 0 deletions docs/superpowers/plans/2026-05-14-tryon-ux-web-funnel.md

Large diffs are not rendered by default.

159 changes: 159 additions & 0 deletions docs/superpowers/specs/2026-05-14-tryon-ux-web-funnel-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# Try-on UX Web Funnel Design

## Context

Issue #507 groups the Virtual Try-on UX work across button wiring, modal UI, item search, profile closet, sharing, mobile photo input, and photo consent. The first implementation slice should complete the web funnel that users can exercise today:

1. Open try-on from a post or item surface.
2. Select or confirm try-on items in the global VTON modal.
3. Provide a person photo with clear consent.
4. Generate a try-on result.
5. Save the result to profile Tries.
6. Reopen related saved or liked posts from Profile Closet.
7. Share or download the result image.

This slice intentionally avoids new database migrations and platform-native sharing integrations. Existing VTON routes, profile routes, generated API clients, and frontend stores are the primary integration points.

## Goals

- Use one canonical global `VtonModal` surface for every try-on entry point.
- Wire existing try-on buttons and profile closet entries to `useVtonStore.openWithItems`.
- Improve the modal flow enough for a complete web try-on funnel.
- Keep search scoped to VTON-ready items returned by `/api/v1/vton/items`.
- Add a post-based Profile Closet using existing saved and liked post data.
- Add a local, versioned photo-consent gate before camera, gallery, or upload access.
- Add mobile camera/gallery selection and reuse existing image compression.
- Preserve save-to-profile behavior and provenance snapshots already used by Profile Tries.
- Provide Web Share API, download, and clipboard fallback for result images.

## Non-Goals

- No item-level closet database model.
- No server-side consent persistence or settings-screen withdrawal flow.
- No new migrations or SQL changes.
- No direct Meilisearch integration inside the VTON modal.
- No crop or rotate editor in the first slice.
- No platform-specific Instagram, YouTube, or X upload automation.
- No decoded watermark compositing in this first slice.

## Decisions

### Canonical Surface

All try-on entry points use the global modal mounted through `LazyVtonModal` in `packages/web/app/layout.tsx`. Entry points pass `postId` and VTON item snapshots through `useVtonStore.openWithItems(postId, items)`. The legacy `/lab/vton` route can remain as a development surface, but it should not become the canonical user funnel.

### Modal UX

The existing modal remains the main shell:

- `VtonModal` owns orchestration state.
- `VtonPhotoArea` owns photo input, preview, result, save, share, download, and reset controls.
- `VtonItemPanel` owns item/post source selection, search, category filters, item selection, and try-on action.
- `useVtonTryOn` owns generation, save-to-profile, share, and tracking side effects.

The first slice should improve the current components rather than introduce a second modal or route-specific variants.

### Search

Search stays inside the VTON item picker and uses `/api/v1/vton/items?q=&category=` via `useVtonItemFetch`. The API may expand its SQL query beyond title-only matching if needed, but it should still return the current `ItemData` shape unless a task explicitly updates tests and consumers.

Meilisearch-backed `/api/v1/search` remains the app-wide search system and is out of scope for the first VTON funnel slice.

### Closet

Profile Closet is post-based for this slice. It reuses existing profile data sources:

- `/api/v1/users/me/saved`
- `/api/v1/users/me/liked`
- existing generated users API clients
- existing `SavedGrid` and `LikesGrid` query patterns

The Closet tab should show saved and liked posts as try-on sources. Each closet item should either open the post detail or expose a Try-on action that fetches or derives VTON-ready items for that post, then calls the global modal. Item-level closet storage and multi-outfit closet management are deferred.

### Photo Consent

Photo consent is a local gate in this slice. Before a user opens camera, gallery, or upload input from VTON, the modal shows a consent sheet explaining:

- face/body image is used for virtual try-on generation,
- the original may be saved only when the user saves the try-on result,
- generated results can be saved to Profile Tries,
- consent can be reset by clearing browser data until a server settings flow exists.

Acceptance is stored in `localStorage` with a versioned key, for example `decoded:vton-photo-consent:v1`. A future version can invalidate the local flag by changing the key or stored version. Rejection leaves the file input closed.

### Mobile Photo Input

The first slice adds explicit Camera and Gallery actions in `VtonPhotoArea`. Camera uses an input with `accept="image/*"` and `capture="environment"` or equivalent mobile-browser-friendly behavior. Gallery uses an input with `accept="image/*"` and no capture attribute.

Selected files are compressed with the existing `compressImage` utility before conversion to preview/base64. The existing preview and generation flow remains unchanged after the compressed file is read.

Crop and rotate controls are deferred. The later implementation can reuse the request flow's `ImageEditor` and `react-advanced-cropper` if needed.

### Sharing

Result sharing stays image-first:

- Use `navigator.share` with a `File` when supported.
- Fall back to clipboard copy when native file sharing fails or is unavailable.
- Keep the direct download action.
- Track share/download actions through the existing authenticated `useTrackEvent` path when practical.

Platform-specific deep links are not a first-slice success criterion because web upload behavior differs sharply across Instagram, YouTube, and X.

## Data Flow

1. User clicks a Try-on entry point.
2. Entry point calls `openWithItems(postId, items)` or opens the modal in item search mode.
3. `VtonModal` loads post/item context through existing VTON hooks.
4. User chooses Camera, Gallery, or Upload.
5. Consent gate checks local consent version.
6. If accepted, the relevant file input opens.
7. The chosen image is compressed, read as data URL, and stored as preview/base64 state.
8. User selects VTON items or confirms preloaded post items.
9. `useVtonTryOn.handleTryOn` posts to `/api/v1/vton`.
10. Result renders in `VtonPhotoArea`.
11. User can save to `/api/v1/tries`, download, or share.
12. Saved tries remain visible in Profile Tries through the existing `/api/v1/users/me/tries` flow.

## Error Handling

- Consent rejection should leave the photo area unchanged and explain that a photo is required to continue.
- Compression failure should fall back to the original file, matching current `compressImage` behavior.
- Unsupported camera capture should degrade to normal file selection.
- Share cancellation should not show an error toast.
- Share failure should fall back to clipboard copy.
- Save failures keep the existing messages for unauthenticated, oversized, and generic failures.
- VTON generation errors continue to surface in the modal error state.

## Testing Strategy

- Add focused unit tests for consent storage and modal gating behavior.
- Add component tests for Camera/Gallery input routing and compressed file handling.
- Add tests for VTON item search empty/loading/result states if the item panel changes.
- Add Profile Closet tests that verify saved/liked data renders and a Try-on action opens the global modal contract.
- Extend share tests around Web Share fallback only if share logic is split into a testable utility.
- Run targeted package tests for modified VTON/profile files before implementation completion.
- Run `bun run typecheck` or the repo's current scoped typecheck command after generated API changes. No generated API changes are expected in the first slice.

## Implementation Boundaries

The implementation should be split into small tasks:

1. Consent utility and tests.
2. VTON photo input UI and compressed file handling.
3. VTON item picker search and modal UX polish.
4. Try-on entry point wiring audit and fixes.
5. Profile Closet tab using saved/liked post sources.
6. Result share/download tracking polish.
7. End-to-end local QA on a known try-on-ready post.

Each task should keep changes scoped to `packages/web` unless a concrete API gap is proven. New migrations, new Rust DTOs, or generated client changes require a separate plan checkpoint.

## Open Follow-Ups

- Server-persisted consent, settings-page withdrawal, and legal copy review.
- Item-level closet storage and outfit grouping.
- Meilisearch-backed VTON item ranking.
- Crop, rotate, and photo quality guidance.
- Watermarked branded share images.
- Platform-specific social publishing flows.
139 changes: 139 additions & 0 deletions packages/web/app/api/v1/vton/items/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const createSupabaseServerClientMock = vi.fn();

vi.mock("@/lib/server-env", () => ({
API_BASE_URL: "http://api.test",
}));

vi.mock("@/lib/supabase/server", () => ({
createSupabaseServerClient: createSupabaseServerClientMock,
}));

type QueryResponse = {
data: unknown[] | null;
error: { message: string } | null;
};

function makeRequest(
url = "http://localhost/api/v1/vton/items?q=stage&category=tops&limit=12"
) {
return new Request(url) as unknown as import("next/server").NextRequest;
}

function createQueryMock(response: QueryResponse) {
const query = {
select: vi.fn(() => query),
in: vi.fn(() => query),
order: vi.fn(() => query),
limit: vi.fn(() => query),
ilike: vi.fn(() => query),
then: (resolve: (value: QueryResponse) => unknown) =>
Promise.resolve(response).then(resolve),
};
return query;
}

function setupSupabase(solutionResponses: QueryResponse[]) {
const subcategoryQuery = createQueryMock({
data: [{ id: "subcategory-tops" }],
error: null,
});
const solutionQueries: ReturnType<typeof createQueryMock>[] = [];
let solutionIndex = 0;
const fromMock = vi.fn((table: string) => {
if (table === "subcategories") return subcategoryQuery;
const query = createQueryMock(
solutionResponses[solutionIndex++] ?? { data: [], error: null }
);
solutionQueries.push(query);
return query;
});

createSupabaseServerClientMock.mockResolvedValue({ from: fromMock });

return { fromMock, subcategoryQuery, solutionQueries };
}

const stageJacket = {
id: "solution-1",
title: "Stage Jacket",
thumbnail_url: "https://cdn.example.com/jacket.jpg",
description: "Black cropped jacket",
keywords: ["jacket", "stage"],
accurate_count: 7,
spots: {
post_id: "post-search-1",
subcategory_id: "subcategory-tops",
posts: { image_url: "https://cdn.example.com/post.jpg" },
},
};

beforeEach(() => {
vi.resetModules();
createSupabaseServerClientMock.mockReset();
vi.unstubAllGlobals();
});

describe("GET /api/v1/vton/items", () => {
it("uses Meilisearch post results to filter VTON-compatible items", async () => {
const fetchMock = vi.fn(async () =>
Response.json({
data: [
{ id: "post-search-1", type: "post" },
{ id: "person-1", type: "person" },
],
})
);
vi.stubGlobal("fetch", fetchMock);
const { solutionQueries } = setupSupabase([
{ data: [stageJacket], error: null },
]);

const { GET } = await import("../route");
const res = await GET(makeRequest());
const json = await res.json();

expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledWith(
"http://api.test/api/v1/search?q=stage&page=1&limit=60",
{ headers: { Accept: "application/json" } }
);
expect(solutionQueries[0].in).toHaveBeenCalledWith("spots.post_id", [
"post-search-1",
]);
expect(json.items).toEqual([
{
id: "solution-1",
title: "Stage Jacket",
thumbnail_url: "https://cdn.example.com/jacket.jpg",
description: "Black cropped jacket",
keywords: ["jacket", "stage"],
},
]);
});

it("falls back to the existing Supabase title and description search when Meilisearch is unavailable", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 502 }))
);
const { solutionQueries } = setupSupabase([
{ data: [stageJacket], error: null },
{ data: [], error: null },
]);

const { GET } = await import("../route");
const res = await GET(makeRequest());
const json = await res.json();

expect(res.status).toBe(200);
expect(solutionQueries).toHaveLength(2);
expect(solutionQueries[0].ilike).toHaveBeenCalledWith("title", "%stage%");
expect(solutionQueries[1].ilike).toHaveBeenCalledWith(
"description",
"%stage%"
);
expect(json.items).toHaveLength(1);
});
});
Loading
Loading