Admin, Landing & Demo
Admin / operator dashboard (/admin)
PIN-gated (requireAdminSession). Tabs in admin-dashboard-client.tsx, defined in admin-tabs.ts.
Since #731 there are 12 tabs in 4 groups, one row with a hairline between groups:
Business Overview · Merchants · Activation · Costs & P&L │ AI Generations · AI settings │
Growth Leads · Campaigns · Affiliates · Blog │ Support Inquiries · Logs.
Merged tabs carry views (?tab=<id>&view=<view>, the first view is the default and stays out of the
URL): Generations = Log · Quality · AI settings (ai-settings) = Tuning · Routing ·
Leads = All · Landing popup · Live demo. The retired ids still work (LEGACY_TABS):
?tab=quality → Generations·Quality, tuning → AI settings·Tuning, routing → AI settings·Routing,
demo-leads → Leads·Live demo — Telegram alerts, docs and bookmarks keep landing on the same screen.
- Overview — aggregate metrics across all stores, incl. an install-source breakdown
(installs + paid conversions per channel — see Install Attribution) and
expiredTrialCount(getAdminMetricstotals) — the Trial expired segment, judged by date (trial_ends_atin the past), see Customer segments. The “Customers” row = Installed + one KPI card per segment (+ “Test (excluded)”); the “Exclude test stores from totals” toggle refetches withincludeTest. - Merchants — per-store metrics (incl. a “Source” column = acquisition channel), a row of
segment chips (see Customer segments), a segment badge on every
row/card + operator
actions: set qualified trial, reset usage,
grant Shopify credit, create custom plan, change AI model, refresh Web Pixel. Toggle
Cards ⇄ Table (cards default:
StoreAvatarmonogram/favicon + headline metrics); a card or the row name links to the per-store detail page (below). - Generations — Log: the generation log; Quality: shopper 👍/👎 per model + the recent results feed (the former Quality tab); Speed (#730): how long a generation takes — see below.
- AI settings — Tuning: category model rules + per-product prompt tuning (the pending-approvals count rides the tab label); Routing: smart provider routing.
- Leads — ONE table over both lead sources (
lib/admin/lead-rows.ts): landing popup (qualified-trial signups,marketing_leads) and live demo (captured_emailswheresource=landing_demo), with a Source column, a source filter with counts, and a CSV export of what is filtered. The same email in both sources is two rows. - Logs — structured
app_logswith level control. - Inquiries — contact-form submissions (
source=contact_inquiry).
One definition per number (#379)
- PostgREST returns at most 1 000 rows per request, whatever
.limit()says (measured on dev: alimit=5000read answered 1 000 rows,content-range 0-999/2114610). Every admin read that can pass that — attributed orders/lines, captured emails, quality ratings, provider jobs, payment history — goes throughreadAllPages(lib/server/read-all-pages.ts): an exact count, then 1 000-row.range()pages concurrently, with atruncatedflag past the cap (the Overview says “these figures are a floor” when it is set). A page query must be ordered byid. - The Overview has ONE activity row, the Period (the date-range card). The fixed-30-day “Platform
usage” row was removed: it repeated the same names from a different source, window and test filter
(with all-time emails inside a 30-day row). The Period (
getActivitySummary(range, { includeTest })) counts installed stores only, test stores only when included (the same toggle as the totals): try-on sessions started (tryon_sessions), sessions with a result (statuscompleted), purchases + revenue byattributedTotals(line-level when the store has lines, else order-level — the list’s rule), emails and consents. Revenue is summed in each store’s own currency (labelled so). - List and detail agree on emails: the merchants list’s emails/consents are the last 30 days
(
readCapturedEmailsByStore), the store detail page’s window — no longer the RPC’s all-time count. - Quality stats (
getTryOnFeedbackStatsAllStores) leave test stores’ ratings out.
Load path and caching (#380)
The dashboard used to take ~30 s to open: the mount fired /api/admin/metrics twice at once (totals
- Period) and both ran the whole aggregation. Now:
- One metrics request on mount. Every
/api/admin/metricscall carries the selected?from&to, and that one answer feeds both the totals and the Period panel. - Server: 60 s cache + single-flight (
loadAdminMetricsSharedinlib/server/admin-metrics-cache.ts). Concurrent requests join the run in flight; only a success is cached, so a failure is retried by the next request.?fresh=1(the Refresh button) skips the cache. The Period numbers share in-flight work per range but are never cached. /api/admin/costsonly reads. Pricing newly completed jobs (refreshGenerationCosts) moved to the hourly cron/api/cron/generation-costs(35 * * * *, locked). See Cost & P&L.- Cap monitor: the live Shopify scan (one call per active subscribed store) is cached for 5 minutes
and shared by concurrent opens (
createCachedLoader,lib/server/cached-loader.ts). The Nudge still reads live. - Merchants list order (#377). Every sort is a COPY with a stable tiebreak on the shop domain
(
sortMerchants,lib/client/merchant-list.ts) — the Overview chart used to sort the sharedmetrics.merchantsarray in place, so the list’s order depended on the tab rendered last. The toolbar has a “Sort by” select with every key plus a direction button, in Cards and Table view alike; the chips still show and remove the multi-sort. The page index is clamped (clampPage), the chosen sort and view are remembered in this browser (localStorage), a cold?tab=merchantslink says “Loading merchants…”, and a degraded payload (the per-store aggregation timed out → zeros,metricsDegraded) is served with a banner but never cached — the next request recomputes. - Overview cards (feature usage, generation health, result quality, cap monitor) keep their last
answer across tab switches (
useAdminWidgetData,lib/client/admin-widget-data.ts): a return within 60 s fetches nothing; an older answer renders at once and refreshes in the background. - Store detail: billing history and recent generations start with the store-metrics request (they
need only the store id); logs and order fees follow it (they need the shop domain).
getEmailCaptureInsightsruns its five reads in parallel. - Deadlines: both heavy routes (
/api/admin/metrics,/api/admin/store-metrics) setmaxDuration = 120; the store-detail RPC carries the same request deadline as the merchant path (TRYVIO_METRICS_DEADLINE_MS), so an abandoned request releases its connection.
Customer segments (#724)
src/lib/admin/customer-segment.ts (pure) is THE customer segment: one derivation of “who is this
merchant to us right now” from status, subscription_status, plan_id, trial_ends_at,
is_test vs NOW. Every admin count/filter/badge/MRR, the activation ladder’s Paying rung, the
Costs P&L isPaid and the campaign audiences read it — the admin never reads the raw
subscription_status (see Billing Pipeline). The
segments are a partition (every store in exactly one):
| Segment | Label | Rule |
|---|---|---|
paying | Paying | installed, active, trial over (trial_ends_at in the past, or null). The ONLY segment in MRR |
trial_plan | Trial — plan chosen | installed, active, trial_ends_at > now (Shopify reports an in-trial subscription as ACTIVE; nothing is charged yet). Own count + Expected MRR, never in MRR |
trial_no_plan | Trial — no plan | installed, trial/trial_expired judged by DATE: trial_ends_at null or in the future |
awaiting_approval | Awaiting approval | installed, pending |
trial_expired | Trial expired | installed, trial/trial_expired with trial_ends_at in the past (covers a stale trial label) |
stopped | Stopped (installed) | installed, cancelled / expired / frozen |
uninstalled_paid | Uninstalled — former payer | status='uninstalled' wins over every billing column |
uninstalled_never_paid | Uninstalled — never paid | same |
test | Test | is_test, unless test stores are included (then the store gets its billing segment) |
Functions: billingSegment(store, now, everPaid), customerSegment(store, now, { everPaid, includeTest }), monthlyPrice(store) (custom_price for custom plans, USD list price otherwise,
null on no plan), segmentTotals() (segmentCounts + mrr + expectedMrr + planBreakdown of paying
stores), hasEverPaid(trialEndsAt, events), inMarketingAudience(audience, store, now),
MARKETING_AUDIENCES.
Former payer vs never paid. hasEverPaid runs over billing_events + install_events
uninstall rows (getPaymentHistory(storeIds) in supabase-admin.ts, only for uninstalled stores).
Money moved if: a successful overage_charged with amount > 0; OR a period_rolled_over after the
trial end; OR a subscription stint (started by activated/plan_changed to a non-trial plan, ended
by cancelled/expired/uninstall) whose end is later than max(start, trial end). declined /
frozen do not end a stint (a declined upgrade leaves the current plan charging). A stint with no
recorded end counts as not paid (only pre-11.08 uninstalls, before install_events). An approval
alone is never a charge.
Metrics. getAdminMetrics({ includeTest }) (/api/admin/metrics?includeTest=1): each merchant
row carries segment, billingSegment, monthlyPrice; totals carry segmentCounts (partition
over all stores), expectedMrr, includeTest. paidSubscribers = Paying count; estimatedMrr and
planBreakdown = Paying stores only; trialCount = Trial — no plan; expiredTrialCount = Trial
expired; source-breakdown “converted” = Paying. conversionRate30d is plan APPROVALS (activated
events) ÷ installs, both 30d (an in-trial approval counts; UI label “Plan approvals ÷ installs
(30d)”). The server 60s cache has one slot per variant (includeTest on/off); the Refresh button
sends fresh=1. getAdminStoreMetrics uses the same derivation for the detail page.
UI. Overview: “Status — installed stores” tiles (one per store-health class present, #737)
exclude uninstalled + test stores. SaaS revenue: “MRR · paying”, “Expected MRR · trial with plan”, per-plan
”· paying” cards, churn. Merchants tab: segment chips (multi-select toggle + count each; presets
Installed / All; default = every installed segment); the status select only narrows by health class;
“Hide test” moves test stores between the Test chip and their billing segment. Rows/cards show a
badge with the price (Paying) or plan + “charges <date>” (Trial — plan chosen); raw plan_id /
subscription_status stay in the expanded row and on the detail page (“Plan / subscription”).
Churn. isChurned (lib/client/admin-format) is status === 'uninstalled' only (activity label
“Uninstalled”). An installed store that declined/lapsed is “Stopped (installed)”, never churned.
Campaign audiences. getMarketingAudience uses inMarketingAudience: uninstalled and test
stores are never mailed (the old “all” included both). New values segment:<name> for the
installed segments; legacy values keep their meaning: active = Paying + Trial — plan chosen,
trial = Trial — no plan, plan:X = paying or trial-with-plan on plan X.
Store health — one class with a cause (#737)
Owner decision 2026-10-01. Until #737 two rules answered “how is this store doing”: the admin badge
(healthStatus: any session in 7 / 30 days → Healthy / At risk / Inactive) and the portfolio
watchdog (classifyPortfolioStore). The same store could read “Healthy” in /admin and “declining” in
Telegram, and “Inactive” mixed an expired trial, a theme embed never enabled and a store with no
shoppers. Now ONE pure classifier, lib/admin/store-health.ts, decides for the admin list, the store
page, the Overview tiles, the status filter and the watchdog.
The classes, tried in order (the first that applies wins — the cause beats the symptom):
| Class | Rule | Lever |
|---|---|---|
| Uninstalled · Test | status / is_test (a test store is judged as a real one when test stores are included) | — |
| Onboarding | installed under 72 h | finish setup |
| Paused | resolveStoreGate has the widget off: trial ended, trial allowance spent, cancelled/frozen | billing |
| No traffic data | a paying store with zero product views in 7 days — likelier our pixel than no shoppers | check the web pixel |
| No traffic | zero product views in 7 days (not paying) — neutral, never an alert | — |
| Widget not showing | product views but zero widget renders in 7 days, or an open #525 episode | enable the app embed |
| Never started | ≥ 3 days, zero try-ons ever, the widget shows | setup / placement |
| Went quiet | had try-ons, none for ≥ 5 days, shoppers keep visiting | check the storefront, call |
| Declining | this week under 50 % of last week, last week ≥ 10 | look for a theme / traffic change |
| Healthy | — | — |
Facts. getStoreHealthFacts({ storeIds?, now? }) (supabase-admin) is the one gatherer: billable
try-ons from tryon_billing_ledger (refunds excluded) over 14 days, paged with readAllPages —
the watchdog’s old single request stopped at PostgREST’s 1 000 rows, undercounting the biggest store —
plus one indexed “newest try-on” read for each store with nothing in the window (went quiet vs never
started); product views + widget renders from widget_health_stats over 7 days; the #525 episode
from app_config.widget_health_state. A failed traffic read degrades to 0 views; a failed gatherer
shows the badge as “Unknown”, never the list.
Surfaces. getAdminMetrics and getAdminStoreMetrics add health (klass, reason, lever)
and attentionScore per store (healthOf: +1000 when paying, plus the class’s severity). The list’s
default sort is Needs attention (paying stores first, then severity); the status column header
sorts by it. StoreHealthBadge (components/admin-segment-badge.tsx) carries the reason and the
lever in its tooltip; the store page spells them out under the header. The portfolio watchdog
(getPortfolioHealthRows → classifyPortfolioStore) now delegates to classifyStoreHealth (its
historical names: Paused = gated, Onboarding = too_new, Uninstalled = churned) and alerts on
gated, went_quiet and tracking_silent; widget_not_showing stays #525’s to chase.
On prod (2026-10-01, read-only, 58 installed real stores): 37 Paused (35 expired trials — the old badge called 7 of them “At risk”), 9 No traffic (1 of them paying → No traffic data), 5 Healthy, 4 Onboarding, 2 Widget not showing, 1 Went quiet, 0 Never started, 0 Declining.
Generation speed (#730)
GET /api/admin/generation-time?window=24h|7d|30d (default 7d) → lib/admin/generation-time.ts
(buildGenerationTime). Two clocks, never mixed:
- provider time = a completed job’s
started_at → completed_at(the provider’s own work); - shopper wait = the session’s
created_at→ thecompleted_atof the job that DELIVERED the image (the last completed job of that session — a retry after a failure counts its full wait).
Reported as median / mean / p95 overall, per provider, and per model on its provider
(nano-banana-2 · kie.ai vs nano-banana-2 · fal.ai — the same model runs at very different speeds),
plus a daily median trend (UTC days). Excluded AND counted: failed jobs, jobs still pending/running
15 min after creation (stuck), test-store jobs, mock jobs (external_job_id starting mock- — every
dev generation under TRYON_MOCK_MODE), and jobs without timestamps. Fewer than 5 measurements read
“too few (n)”, never as a number. The read (getGenerationTimeRows) embeds job → session → store in one
select, pages it (PostgREST returns 1000 rows per request; pages run concurrently after a count) up to
30 000 rows and flags truncated beyond; a single-flight guard per window shares one read between
concurrent requests. Staging (prod copy, 60d) for scale: kie nano-banana-2-lite median 29.5 s / p95 124 s,
fal nano-banana-lite median 9.5 s.
Custom plan creation
Admin → Merchants → ”★ Custom plan” calls POST /api/admin/billing
{ action: 'create_custom_plan', … } → createShopifySubscription with custom
price/limits → returns a confirmationUrl to send to the merchant. On approval it activates
like any plan; the store’s custom_plan fields drive limits/overage. With trialDays > 0 the
pending write also records trial_ends_at = now + trialDays, so an in-trial custom plan classifies
as Trial — plan chosen (#724); zero trial days leaves the install-time window untouched (shortening
it would take the merchant’s free days if they decline).
Per-store detail page (/admin/store/[storeId])
A dedicated, admin-auth-gated page for one store (mirrors the admin/page.tsx session guard).
Reuses existing endpoints — no new DB or API-shape changes:
- Data:
/api/admin/metrics(pick the store bystoreId),/api/admin/billing-history?storeId=,/api/admin/generations?storeId=,/api/admin/logs?search=<shopDomain>&level=error. - Sections: header (avatar, name, domain, health/plan/sub badges + the customer segment badge (#724), install/last-active/last-sync,
quick actions Sync/Pixel/Shopify/Dashboard), KPI row, conversion funnel (clicks → sessions →
generated → purchases via
computeStoreFunnel), config & billing (change model, mark/unmark test, reset usage, qualified trial, grant credit, custom plan — same endpoints as the Merchants row), billing history, recent generations, recent errors. - Shared code:
lib/client/store-detail.ts(purestoreAvatar/computeStoreFunnel, unit-tested),lib/client/admin-types.ts(canonicalMerchant/AdminMetrics/…),lib/client/admin-format.ts(formatters + status helpers + the metric-definition table below),components/store-avatar.tsx(monogram + best-effort favicon). - Out (v2): per-day time-series trend charts; real Shopify Brand-API logo; view/override of merchant widget settings.
Metric definitions — unit + window on every number (#68)
ADMIN_METRICS in lib/client/admin-format.ts is the ONE table both admin surfaces render labels
and tooltips from (metricLabel() = "<name> · <window>", metricHelp() = definition + unit +
window + source query). <Kpi> on the detail page takes a metric key, not a label, so a number
cannot ship without its unit and window. Four units (raw events, try-on sessions, billable
looks, attributed orders) and four windows (last 30d, last 7d, all-time, current billing
period) coexist on that page — mixing them silently was the #68 report.
- Try-ons used → “Billable try-ons · billing period” =
stores.try_ons_used, LOOKS. It is not comparable with “Generated · last 30d” =merchant_metrics_event_totals_v3.successfulGenerationSessions, distinct SESSIONS. Same rule as the merchant glossary: try-ons = looks only in billing. - Failed generations on the detail page =
v3.failedGenerationSessions(30d sessions) — the same fieldgetMerchantMetricsuses, so% successdivides one window by itself. It used to be an all-time rawtryon_generation_failedcount, which skewed the rate down forever. - The
/adminlist readsadmin_store_event_totals. Since #380 itsfailed_generation_events(“Errors · last 30d”) is counted in the row’s own 30-day window — it used to be all-time, and that, with an unfilteredmax(occurred_at), made the RPC group every analytics row ever written on each cache miss (27 s on dev’s 2.1M rows; ~0.3 s after). The RPC now reads one index range per store and takes “last active” from a per-store backward index probe. The list no longer prints a cross-window error percentage. - Full per-number audit + open owner decisions:
05_tasks/proof/68/DEFINITIONS.md.
View as merchant (#149)
/admin/store/[storeId]/as-merchant shows the store’s dashboard Overview exactly as the merchant sees
it — Revenue & ROI (attributed revenue, Tryvio cost, return, ROI, estimated lift), orders, reach,
conversion impact and activity — using the merchant’s own components (MerchantOverviewPanels,
DateRangePicker, ProofDrawer in app/merchant/merchant-dashboard-parts.tsx, extracted verbatim from
the merchant dashboard). Entry: the store detail page’s ”👁 View as merchant · ROI” button (it
replaced the old ↗ Dashboard link to /merchant?shop=, which opened the merchant’s app itself and
fired their notifications + billing reconcile).
- Data:
GET /api/admin/store-merchant-metrics?storeId=&from=&to=[&compareFrom&compareTo]→lib/server/merchant-metrics-bundle.tscomputeMerchantMetrics()— the SAME function/api/shopify/metricscalls, so thedataobject is byte-identical for the same store + range (the 60 s cache key and the in-flight coalescing are shared too). Admin session → 401 first; the store comes fromstoreIdonly (the merchant endpoint stays merchant-scoped). - Read-only: the admin path never runs the merchant route’s follow-ups (web-pixel install,
reconcileStoreBilling,checkMerchantNotifications); inside the view, links into the merchant’s app (widget settings, products) render as text (OperatorViewProvider). - Banner + switcher: a sticky banner names the store (“Viewing as the merchant · read-only”);
an EntityPicker (
GET /api/admin/store-search?q=) switches store. - States: a never-computed range shows the merchant’s own “preparing” state; stale numbers say so; an unknown store reads “This store does not exist.”
The App Store review ask (#587)
ONE ask: an honest review on the Shopify App Store. Nothing is offered for it, ever. Since
2026-07-07 Shopify removes an app’s reviews (and can unpublish the listing) when anything is traded
for one; its own guidance names “Get one month free by leaving us a review!” as the counter-example.
The earlier testimonial-for-credit email (the old “Email A”) was deleted on 2026-09-16 at the
owner’s direction — templates.testimonialAsk, resolveTestimonialGrant and the
email.testimonial_ask.* keys are gone. The general admin credit action grant_try_on_credit still
exists and is not tied to any review.
- The review itself is Shopify’s. The client calls App Bridge
shopify.reviews.request()(App Bridge v4 CDN global, loaded inapp/layout.tsx). Shopify enforces the limits: ≥ 24h after install, a 60-day cooldown, at most 3 per 365 days, never on mobile, never if already reviewed. Its nine decline codes aremobile-app,already-reviewed,annual-limit-reached,cooldown-period,merchant-ineligible,recently-installed,already-open,open-in-progress,cancelled. A decline is an ANSWER, never an error and never retried; onlyalready-reviewedends our ask for good (isPermanentReviewDecline). The merchant sees one of three sentences (reviewDeclineMessageKey): already reviewed / desktop admin only / not right now. - Who: only a merchant on a paid plan (
stores.subscription_status = 'active'; a trial that ended unpaid is never asked — owner, 2026-10-02) with at least one order that came after a try-on. A merchant with zero such orders is never asked. An unreadable order count isfacts_unavailable— never read as zero and never as enough. - When — the timeline (#749, owner 2026-10-02). Day 0 is the store’s first subscription
activation (its earliest
billing_events.activatedrow,getFirstSubscriptionActivation) — for most merchants the install day, because the plan is approved inside the 14-day trial. A paying store with no activation row isno_activation_date, never guessed from the install date.- step 1, the bell: once the free trial is over (
stores.trial_ends_atpassed; no trial on record = it ended at activation). - step 2, the prompt: from day 30 of the subscription (
REVIEW_ASK_PROMPT_SUBSCRIPTION_DAY) and at least 7 days after the bell, until the email is due. Only the dashboard can show it — a merchant who does not come back in that window skips it. - step 3, the email: from day 43 (
REVIEW_ASK_EMAIL_SUBSCRIPTION_DAY), 7 days after the prompt when it was shown and 14 days after the bell in any case. Sent whether or not the merchant read the bell. It is the last step: nothing follows it, not even an unshown prompt. - A merchant who reaches the ladder already past day 43 climbs it one step a week from the bell
(
REVIEW_ASK_SURFACE_GAP_DAYS = 7). Anything not yet due readsnot_due_yet.
- step 1, the bell: once the free trial is over (
- Three surfaces, one ask. Each rung is used once.
- the daily cron
/api/cron/review-ask(15 9 * * *,runReviewAskCron) rings the bell and sends the email for every store they are due for — the merchant never has to open the app. Before #749 the bell was rung off a dashboard load and the email was only an admin button, so a merchant who did not open the app was never asked (prod, 2026-10-02: 7 qualifying stores, 1 ask). Brake:app_config.review_ask_enabledother thantrue, orOUTBOUND_DISABLED, is a dry run that writes its list toapp_config.review_ask_dry_run;?dryRun=1writes nothing. A cron lock plus the unique(store_id, surface)index make two racing runs ask once. A live run that asked or failed posts one line per store (shop + step, never an address) to the ops Telegram chat. Non-paying stores are decided from the store row alone, with no per-store read. - rung 1, the bell notification: written in the merchant’s own
dashboard_locale, with their orders + attributed revenue in the body. Notificationtypemerchant_ask_review, dedupe keymerchant-ask-review. A notification that cannot be written deletes the ask row, so the next run retries. - rung 2, the in-app prompt:
components/review-ask-prompt.tsx, mounted on the merchant dashboard. A card in the page’s own flow, never an overlay — an ask must not block a screen the merchant is working on. Buttons: “Write a review” / “Not now” / “Don’t ask me again”. Server decides viaGET /api/shopify/review-ask; that GET is what OPENS the rung, so it is never cached. An unanswered prompt keeps rendering forREVIEW_ASK_PROMPT_OPEN_DAYS = 7so a page reload does not swallow the ask. - rung 3, the email: sent by the cron. The admin route stays for inspection and a manual
send:
GET /api/admin/review-ask(dry run: every store with a named reason, never an address),&view=preview&storeId=(the exact letter),POST { dryRun: false, confirmCount }to send. Anything other thandryRun:falseis a dry run; a count that no longer matches is refused with 409.
- the daily cron
- Saying no: “Not now” pauses the whole ladder for
REVIEW_ASK_SNOOZE_DAYS = 30. “Don’t ask me again” is honoured forever, and so is the email’s one-click decline (/unsubscribe?…&s=review_ask, scopereview_ask) — it silences the in-app surfaces too. A merchant who acted (requested) is never asked again; Shopify’s limits take over. - The record: table
merchant_review_asks(migration20260916_merchant_review_asks.sql, applied to dev 2026-09-16):store_id, surface, asked_at, outcome, outcome_code, outcome_at, with a UNIQUE index on(store_id, surface)— that index is what makes an ask happen once however many dashboard loads race.outcomeis one oflater | never | requested | declined;outcome_codestores Shopify’s verbatim decline code. A failed email send deletes its row (deleteReviewAsk) so “asked once” keeps meaning an ask that actually reached the merchant. - The numbers: every figure the ask shows (try-ons, add-to-carts, orders after a try-on,
attributed revenue, ROI) comes from
readMerchantResults(lib/server/merchant-results.ts), which readsgetMerchantMetrics— the same reader/api/shopify/metricsserves the dashboard from, and the one place the #521 attribution rule lives — plusbuildMerchantRoiSummaryfromlib/analytics/roi-metrics.tsfor ROI. There is no second calculation, asserted by a source-level test. #521 is blocked, so today’s figures are today’s; when it unblocks the ask follows with no change. Money and ROI are formatted exactly as the dashboard’s ROI cards format them (formatReviewAskMoney/formatReviewAskRoi). The sell currency is resolved fromgetShopInfobecausegetMerchantMetricsreturns a hardcoded"USD". ROI is omitted rather than invented when its cost inputs are unreadable. - Copy guard:
findForbiddenReviewAskTerms(lib/billing/review-ask.ts, EN + BG patterns) runs over the composed letter AND is asserted over every in-app string in every locale. It refuses anything that reads as an offer (free,credit,discount,bonus,gift,reward,in exchange+ Bulgarian stems) and any ask for a positive review. Copy is EN + BG; the other 22 locales render the approved English until the owner approves a translation, and a new translation must add its terms to that list. - Where the code is:
lib/billing/review-ask.ts(pure decision + guard + formatters),lib/server/review-ask-run.ts(the surfaces),lib/server/merchant-results.ts(the numbers),app/api/cron/review-ask/route.ts(the daily bell + email),app/api/shopify/review-ask/route.ts(merchant),app/api/admin/review-ask/route.ts(inspect / manual send),components/review-ask-prompt.tsx(the prompt).
The EN/BG wording is still awaiting the owner’s approval.
Marketing landing (/)
Single client component components/landing/landing-page.tsx (Syne + DM Sans, violet→fuchsia
brand). Sections: Nav (auto-hide on scroll, mobile drawer), Hero (before/after slider +
floating metrics), TrustedBy (Icedout), Problem, HowItWorks, Integration, Features, LiveDemo,
Comparison, VideoDemo, Results, ROICalculator, Pricing, FinalCTA, FAQ, Footer. Smooth-scroll
anchors with a sticky-header offset; overflow-x: clip + global box-sizing: border-box.
Nav/Footer are exported and reused by /contact, /privacy, /terms.
Site analytics & cookie consent (#727)
The public site measures its visitors with GA4 and the Meta pixel — and only after the
visitor says yes. Nothing loads before that: no request to Google or Meta, no _ga/_fbp cookie
(“basic” Consent Mode; the v2 default denied is pushed first, then the update — which grants Google analytics storage only: its ad signals stay denied even after “Accept all”, because “Marketing” is the Meta pixel and the site runs no Google Ads).
- Where it lives. The shared marketing
Footermountscomponents/marketing/cookie-consent.tsx: the fixed banner (Reject all / Accept all side by side, equal size; Settings with the two purposes) and a Cookie settings link in the footer that reopens it. The merchant app never renders that footer, the root layout mounts nothing, and a unit test fails if any non-marketing file imports the tracking code. - Where it runs.
lib/marketing-analytics/tracking-config.tsallows onlytryvio.aiandwww.tryvio.ai, and only when the build carries an ID.app.tryvio.ai, dev, staging, previews and localhost show no banner and load nothing, even with the IDs present. - IDs.
NEXT_PUBLIC_GA4_MEASUREMENT_ID(required),NEXT_PUBLIC_META_PIXEL_ID(waiveduntil the pixel exists) — public; on dev/staging too, where the host rule keeps them inert; baked in at build time (a change needs a redeploy). Unset = no banner, no tags (see Env Vars)./api/health/deepreports the GA4 ID assite_analytics; the pixel is not reported while its key iswaived— a feature that is dark by decision would keep the post-deploy check red (it is added assite_meta_pixelwhen the pixel exists). - The choice. Cookie
ttn_consent=1.a<0|1>.m<0|1>.<unix s>, host-only, 180 days; a newCONSENT_VERSIONasks everyone again. Withdrawing a purpose deletes its cookies and reloads the page (a tag that ran cannot be unloaded). A returning visitor’s tags load afterload+ idle, so LCP is untouched. - Events. GA4:
page_view(withsite_locale),install_click(cta_position),generate_lead(form,contact_type),language_switch,blog_read(60% of the body or 30 s visible). Meta:PageView,ViewContent(landing,pricing),Lead, customInstallClick. Registersite_locale,cta_position,form,contact_type,from_locale,to_locale,post_slug,read_triggeras GA4 custom dimensions to report on them. - Install links carry UTMs. Every install button uses
siteInstallUrl(<position>)→utm_source=landing&utm_medium=referral&utm_campaign=site&utm_content=<position>, so the App Store listing’s own GA4 (#728) sees the site as the source.landingis the value Install Attribution already maps to the landing channel. - Internal traffic. A browser under automation (
navigator.webdriver) or one marked with?ttn_internal=1(cookie;=0clears) sends GA4 hits withtraffic_type=internal— activate GA4’s Internal traffic data filter — and never loads the Meta pixel. - Proof switches.
?ttn_debug=1(per tab) sends to GA4 DebugView and lets the pixel load under automation.?ttn_tracking=testworks on non-production hosts only: the real loaders with placeholder IDs, so a dev build can prove the whole path (playwright-debug/proof-727-site-tracking.js). - Privacy policy.
/privacy§5 (EN/BG/RO; English under a notice elsewhere) lists every cookie, Google Ireland (processor) and Meta Platforms Ireland (joint controller for collection), retention and the opt-outs. Change the tags → change that section.
The banner’s own count (#732)
GA4 sees only the visitors who allow analytics, so on its own it cannot say how big the site’s
traffic really is. The banner therefore counts itself — cookieless, with no identifier — into
marketing_consent_counts: one number per UTC day × locale × outcome, nothing per visitor.
- What is counted. Every public page view, by the consent state it loaded with:
view_ask(the banner asked),view_analytics,view_no_analytics. Every answer to a banner that asked on that page:accept,reject,partial_analytics,partial_marketing— classified by the choice, so Save with both on is an accept. A later change in Cookie settings is not an answer and is not counted. No choice is derived: showings minus answers (each showing ends in exactly one of the two). - The unit is the page view, because without an identifier nothing else can be counted: a visitor who ignores the banner on three pages is three showings.
- The path.
cookie-consent.tsx→countPageView/countBannerAnswer(lib/marketing-analytics/browser.ts) →POST /api/marketing/consent-countwith a text body of two fields (o,l), sent withfetch(keepalive, credentials: "omit")— no cookie travels with it (sendBeaconwould send_ga). Internal traffic (automation,?ttn_internal=1) sends nothing, the same rule GA4’s filter applies. - The endpoint validates the outcome and the locale against the lists in
lib/marketing-analytics/consent-counts.ts, drops crawlers by user agent (isLikelyBot), limits one IP to 60 counts per 10 minutes in memory only (the IP is never written), and increments atomically throughincrement_marketing_consent_count(service role only — anon and authenticated cannot execute it). - Where it shows. Admin → Leads, at the top: banner shown, accepted / rejected / partial /
no choice as shares of the showings, and GA4 sees ≈ N% of page views = (
view_analytics+accept+partial_analytics) ÷ all counted page views. Divide a GA4 page-view count by that share to estimate the real one. 7 / 30 / 90 days, plus a per-day, per-locale table (GET /api/admin/site-consent?window=). - On dev it counts only in test mode (
?ttn_tracking=test), the one place the banner runs there. Proof:playwright-debug/proof-732-banner-counts.js(with--base https://tryvio.aiit proves a browser under automation sends no count at all).
Standalone live demo
The landing live demo is fully decoupled from the demo store. It calls the AI provider directly — no store, billing, session, or storefront rate limits.
- Products: curated Icedout set in
lib/demo-products.ts(sunglasses, necklace, bracelet, ring, watch), self-hosted images inpublic/landing/demo/. POST /api/demo/try-on{ personImageDataUrl, productId }→ resolves the garment from the allowlist,createTryOnTaskdirectly, per-IP in-memory throttle. ReturnstaskId.GET /api/demo/try-on-status?taskId=→getTaskSnapshotdirectly.POST /api/demo/capture-email→captured_emails(source=landing_demo).- Client gate: 2 free generations (localStorage), then email.
Contact form
/contact → POST /api/contact → stores in captured_emails (source=contact_inquiry,
metadata: name/storeUrl/message/type). No email service configured — read submissions in the
admin Inquiries tab.