Admin, Landing & Demo

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 (getAdminMetrics totals) — the Trial expired segment, judged by date (trial_ends_at in the past), see Customer segments. The “Customers” row = Installed + one KPI card per segment (+ “Test (excluded)”); the “Exclude test stores from totals” toggle refetches with includeTest.
  • 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: StoreAvatar monogram/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_emails where source=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_logs with 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: a limit=5000 read 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 through readAllPages (lib/server/read-all-pages.ts): an exact count, then 1 000-row .range() pages concurrently, with a truncated flag past the cap (the Overview says “these figures are a floor” when it is set). A page query must be ordered by id.
  • 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 (status completed), purchases + revenue by attributedTotals (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/metrics call carries the selected ?from&to, and that one answer feeds both the totals and the Period panel.
  • Server: 60 s cache + single-flight (loadAdminMetricsShared in lib/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/costs only 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 shared metrics.merchants array 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=merchants link 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). getEmailCaptureInsights runs its five reads in parallel.
  • Deadlines: both heavy routes (/api/admin/metrics, /api/admin/store-metrics) set maxDuration = 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):

SegmentLabelRule
payingPayinginstalled, active, trial over (trial_ends_at in the past, or null). The ONLY segment in MRR
trial_planTrial — plan choseninstalled, 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_planTrial — no planinstalled, trial/trial_expired judged by DATE: trial_ends_at null or in the future
awaiting_approvalAwaiting approvalinstalled, pending
trial_expiredTrial expiredinstalled, trial/trial_expired with trial_ends_at in the past (covers a stale trial label)
stoppedStopped (installed)installed, cancelled / expired / frozen
uninstalled_paidUninstalled — former payerstatus='uninstalled' wins over every billing column
uninstalled_never_paidUninstalled — never paidsame
testTestis_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):

ClassRuleLever
Uninstalled · Teststatus / is_test (a test store is judged as a real one when test stores are included)—
Onboardinginstalled under 72 hfinish setup
PausedresolveStoreGate has the widget off: trial ended, trial allowance spent, cancelled/frozenbilling
No traffic dataa paying store with zero product views in 7 days — likelier our pixel than no shopperscheck the web pixel
No trafficzero product views in 7 days (not paying) — neutral, never an alert—
Widget not showingproduct views but zero widget renders in 7 days, or an open #525 episodeenable the app embed
Never started≥ 3 days, zero try-ons ever, the widget showssetup / placement
Went quiethad try-ons, none for ≥ 5 days, shoppers keep visitingcheck the storefront, call
Decliningthis week under 50 % of last week, last week ≥ 10look 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 → the completed_at of 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 by storeId), /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 (pure storeAvatar/computeStoreFunnel, unit-tested), lib/client/admin-types.ts (canonical Merchant/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 field getMerchantMetrics uses, so % success divides one window by itself. It used to be an all-time raw tryon_generation_failed count, which skewed the rate down forever.
  • The /admin list reads admin_store_event_totals. Since #380 its failed_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 unfiltered max(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.ts computeMerchantMetrics() — the SAME function /api/shopify/metrics calls, so the data object 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 from storeId only (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 in app/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 are mobile-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; only already-reviewed ends 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 is facts_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.activated row, getFirstSubscriptionActivation) — for most merchants the install day, because the plan is approved inside the 14-day trial. A paying store with no activation row is no_activation_date, never guessed from the install date.
    • step 1, the bell: once the free trial is over (stores.trial_ends_at passed; 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 reads not_due_yet.
  • 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_enabled other than true, or OUTBOUND_DISABLED, is a dry run that writes its list to app_config.review_ask_dry_run; ?dryRun=1 writes 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. Notification type merchant_ask_review, dedupe key merchant-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 via GET /api/shopify/review-ask; that GET is what OPENS the rung, so it is never cached. An unanswered prompt keeps rendering for REVIEW_ASK_PROMPT_OPEN_DAYS = 7 so 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 than dryRun:false is a dry run; a count that no longer matches is refused with 409.
  • 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, scope review_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 (migration 20260916_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. outcome is one of later | never | requested | declined; outcome_code stores 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 reads getMerchantMetrics — the same reader /api/shopify/metrics serves the dashboard from, and the one place the #521 attribution rule lives — plus buildMerchantRoiSummary from lib/analytics/roi-metrics.ts for 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 from getShopInfo because getMerchantMetrics returns 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.

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 Footer mounts components/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.ts allows only tryvio.ai and www.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 (waived until 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/deep reports the GA4 ID as site_analytics; the pixel is not reported while its key is waived — a feature that is dark by decision would keep the post-deploy check red (it is added as site_meta_pixel when the pixel exists).
  • The choice. Cookie ttn_consent = 1.a<0|1>.m<0|1>.<unix s>, host-only, 180 days; a new CONSENT_VERSION asks 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 after load + idle, so LCP is untouched.
  • Events. GA4: page_view (with site_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, custom InstallClick. Register site_locale, cta_position, form, contact_type, from_locale, to_locale, post_slug, read_trigger as 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. landing is 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; =0 clears) sends GA4 hits with traffic_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=test works 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-count with a text body of two fields (o, l), sent with fetch(keepalive, credentials: "omit") — no cookie travels with it (sendBeacon would 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 through increment_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.ai it 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 in public/landing/demo/.
  • POST /api/demo/try-on { personImageDataUrl, productId } → resolves the garment from the allowlist, createTryOnTask directly, per-IP in-memory throttle. Returns taskId.
  • GET /api/demo/try-on-status?taskId= → getTaskSnapshot directly.
  • 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.