Reusable AI filters (admin library) and the self-service seller "Store" builder that turns an uploaded photo + filters into a branded merch storefront, with lazy first-visit asset generation
A filter is a reusable, named AI recipe — a prompt plus an optional reference image and tags — created and curated by admins. Sellers (talent/brands) then pick filters in their self-service Store builder to turn a single uploaded photo into a set of branded merch designs, each printed on the products they choose.
There are two sides to the feature:
| Side | Who | Where | Purpose |
|---|---|---|---|
| Filter library | Admins | Merch Admin → Filters tab | Create / edit / delete the global filters (prompt + reference image + tags) |
| Store builder | Sellers | Account dashboard → Store page | Upload a photo, pick filters, choose products + pricing, activate a storefront |
The seller storefront uses lazy asset generation: the expensive AI image + product renders are deferred from store-creation time to the first time a shopper opens the store.
Filters live in a single standalone table — they are global (not scoped to a campaign or account).
merch_filter| Column | Type | Notes |
|---|---|---|
id | text (PK) | nanoid() |
name | text, not null | Display name |
description | text, nullable | Optional blurb |
reference_image_url | text, nullable | Public S3 URL shown in the editor and used as a generation reference |
tags | jsonb string[], default [] | Free-form tags for grouping/search (normalized: trimmed, de-duped, non-empty) |
prompt | text, nullable | The instruction applied during generation. Required before a filter can generate art |
created_by | text, nullable | Email of the admin who created it |
created_at / updated_at | timestamptz | created_at is indexed (merch_filter_created_idx) |
Migration: packages/db/drizzle/0141_merch_filter.sql. Access layer: packages/db/src/access/merch/merch-filter.ts (listMerchFilters, getMerchFilterById, createMerchFilter, updateMerchFilter, deleteMerchFilter).
A seller store does not add filter columns to the merch schema. Each branded design is a regular merch_design whose default config (merch_design_config with product_type = NULL) carries a configJson that references the originating filter and tracks the lazy asset lifecycle (see Lazy asset generation).
Merch Admin sidebar → Filters (/filters, admin-only). Page component: packages/merch-admin/client/src/app/pages/filters-page.tsx.
Business logic (validation, tag normalization) lives in packages/merch-admin/srv/src/filters.ts — router code stays thin and only handles auth, parsing, and errors.
All under /api/merch/admin/filters, admin-only (requireAdmin), CORS-enabled. Responses use the admin shape { status: "success", data: { ... } }.
| Endpoint | Method | Body / Params | Purpose |
|---|---|---|---|
/filters | GET | — | List all filters (newest first) |
/filters | POST | { name, description?, referenceImageUrl?, tags?, prompt? } | Create a filter (name required; created_by = admin email) |
/filters/:id | GET | — | Fetch one filter |
/filters/:id | PATCH | partial FilterInput | Update (only provided fields; empty name rejected) |
/filters/:id | DELETE | — | Delete |
Validation failures throw FilterValidationError → HTTP 400; auth failures throw AdminAuthError.
The seller "Store" page (packages/offers/client/src/app/components/YourMerchPage.tsx, route /dashboard/store) is a self-service builder. There is one store per seller account.
The builder loads any existing store on mount and hydrates its state from the backend (no localStorage); an activated store's data is persisted and synced, not kept as a local draft.
Whenever a photo is uploaded it is always saved as a design (filterId = null, name "Original"). A store can be published with only the original image — no filters required. In the builder the "Original" tile is shown as a permanently-selected, non-interactive thumbnail; filters are the only toggle.
The seller catalog is intentionally limited to DEFAULT-HOODIE1 and DEFAULT-TSHIRT1. Catalog image keys may be bare S3 keys or absolute URLs; the endpoint only prefixes bare keys (resolveImageUrl) to avoid double-prefixing.
New seller stores are created commercial + isEvergreen and default to the Design First shop flow (shopFlow: "design_first") — the storefront leads with the design picker, not the product grid.
Authenticated (non-admin) endpoints under /api/merch/seller, built on the @zooly/util-srv route() helper (auth: "user").
| Endpoint | Method | Purpose |
|---|---|---|
/seller/filters | GET | List the global filters for the builder |
/seller/catalog | GET | List the curated catalog products (Hoodie, T-Shirt) with resolved image URLs |
/seller/store | GET | Load the seller's single store (or null) to hydrate the builder |
/seller/store | POST | Create or update (upsert) the store: campaign + products + designs (maxDuration = 300) |
/seller/store/generate | POST | { uploadedImageUrl, filterId } → one-off branded preview (legacy/eager path; lazy stores no longer call this) |
Client wrappers live in packages/offers/client/src/lib/appApi.ts (fetchMerchFilters, fetchMerchCatalog, fetchMerchStore, saveMerchStore, generateBrandedMerchItem).
POST /seller/store design entries accept { filterId: string | null, name, imageUrl? }. filterId = null is the original; imageUrl is optional because lazy stores persist designs without a pre-generated image.
A filter design's art is produced by generateBrandedItem (packages/merch/srv/src/seller-store.ts):
referenceImageUrl, both passed as plain references.prompt (a missing prompt is a hard error).gpt-image-2 (gateway openai/gpt-image-2) followed by BRIA RMBG 2.0 background removal, quality low.This uses a dedicated generateBrandedArt path (packages/merch/img-gen/src/ai-image-service.ts) — not the selfie pipeline — so there's no background flattening / selfie filtering. The result is uploaded to merch/seller-store/… on S3.
The original design needs no AI: it is the uploaded image itself, simply rendered onto the product mockups (same compositing pipeline as the admin "Render" button, renderProductPreviewForArt).
To keep store activation fast and avoid paying for AI on stores nobody visits, the heavy work — the AI image (for filter designs) and the per-product mockup renders — is deferred to the first storefront visit.
assetStatus)Stored in each design's default configJson (no migration needed):
| Status | Meaning |
|---|---|
pending | Inputs saved, nothing generated yet (lazy filter designs) |
generating | A materializer has atomically claimed it (generatingStartedAt set) |
ready | displayImageUrl + renderedProductPreviews are populated |
error | Last materialize attempt failed (retryable) |
Legacy/eager designs omit assetStatus and are treated as ready.
pending; materialized on first visit.materializeDesignAssets(designId)The single, idempotent unit of work:
generateBrandedItem(...).renderedProductPreviews.displayImageUrl + renderedProductPreviews and flip assetStatus = ready (or error on failure).POST /api/merch/store/[slug]/ensure-assets triggers materialization; GET returns a read-only status snapshot. Public (anonymous shoppers), maxDuration = 300.claimNextPendingDesignForGeneration — an atomic UPDATE … FOR UPDATE SKIP LOCKED that flips pending/error/stale-generating → generating. This makes concurrent first-visitors safe (no double-generation) and lets stale claims (older than 5 min) be reclaimed.The campaign layout mounts useEnsureStoreAssets(), which POSTs ensure-assets, polls until pending === 0, then silently refetches the campaign so freshly generated images appear. On the Choose Your Design page, designs that aren't ready show a loading spinner and cannot be selected (single-design auto-skip also waits for ready). Because selection is blocked until ready, the chosen design is always ready by the time the shopper reaches the product picker. assetStatus is surfaced to the client through campaign-transforms.ts.
replaceCartItemsForSession (cart-service.ts) calls the centralized ensureDesignReadyForCheckout(designId) for every cart item's design: an unready design is materialized inline, and if it still can't be made ready the cart write is blocked. This guarantees no unfulfillable item is ever committed. No-op for eager/legacy designs.
For stores that are never visited (or whose materialization stalled), GET /api/merch/cron/store-asset-backfill (Bearer CRON_SECRET, every 10 min in vercel.json) finds campaigns with pending designs (listCampaignIdsWithPendingDesigns) and drains their claims via backfillPendingSellerStoreAssets(). Concurrency-safe alongside live first-visit traffic.
Database
packages/db/src/schema/merchFilterTable.ts merch_filter table
packages/db/src/access/merch/merch-filter.ts filter CRUD
packages/db/src/access/merch/merch-design.ts claimNextPendingDesignForGeneration,
listCampaignIdsWithPendingDesigns
packages/db/drizzle/0141_merch_filter.sql migration
Admin filter library
packages/merch-admin/srv/src/filters.ts validation + business logic
apps/zooly-app/app/api/merch/admin/filters/… admin CRUD endpoints
packages/merch-admin/client/src/app/pages/filters-page.tsx admin UI
Seller store + lazy generation
packages/merch/srv/src/seller-store.ts generateBrandedItem, saveSellerStore,
getSellerStore, materializeDesignAssets,
ensureStoreAssets, getStoreAssetStatus,
ensureDesignReadyForCheckout,
backfillPendingSellerStoreAssets
packages/merch/img-gen/src/ai-image-service.ts generateBrandedArt (branded, no selfie steps)
packages/merch/srv/src/campaign-transforms.ts surfaces assetStatus to the storefront
packages/merch/srv/src/cart-service.ts checkout invariant gate
apps/zooly-app/app/api/merch/seller/… filters / catalog / store / generate
apps/zooly-app/app/api/merch/store/[slug]/ensure-assets/route.ts first-visit materializer
apps/zooly-app/app/api/merch/cron/store-asset-backfill/route.ts straggler cron
Storefront (shopper)
packages/merch/client/src/hooks/use-ensure-store-assets.ts trigger + poll + refresh
packages/merch/client/src/app/campaign-layout.tsx mounts the hook
packages/merch/client/src/app/pages/design-selection-page.tsx skeletons + ready gating
Seller builder (talent dashboard)
packages/offers/client/src/app/components/YourMerchPage.tsx
packages/offers/client/src/lib/appApi.ts
saveSellerStore upserts; it never creates a second campaign for the same account.saveSellerStore is lazy by default (lazy: true) — it persists filter designs as pending and skips AI/render; only the original is materialized at save.ensure-assets) so each storefront poll makes progress; calls are awaited sequentially to avoid overlap, and claims are atomic.On This Page
What is the Filter feature?Data model[object Object]Admin: the Filter libraryWhere to accessWhat you can doAdmin APISeller: the Store builderBuilder flowThe original image is always includedCurated catalogDefaultsSeller APIBranded art generationLazy asset generationAsset lifecycle (,[object Object],)What's eager vs. lazy at save time[object Object]First-visit materializer + concurrencyStorefront skeletonsCheckout invariantStraggler backfill (cron)ArchitectureNotes & gotchas