TikTok Shop Video Generator
只需一次点击,即可将任何病毒式 TikTok 视频转换为您自家产品的广告。适用于 TikTok Shop 卖家和联盟伙伴——无需拍摄、无需剪辑。免费试用。
AI 翻译
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗Paste your repo. Get your whole launch.
LaunchLoop turns a vibe-coded app into a launch kit. Paste a public GitHub repo or a live URL and it reads the README, package.json, recent commits and the live page, writes a short product brief, then generates:
@vercel/og (1200×630, 1600×900, 1270×760 PH gallery) in colours pulled from the site's CSSEvery platform limit is enforced (PH tagline 60, description 260, X 280 weighted, LinkedIn 3,000) and vote requests are stripped out. Visitors get a free watermarked preview (the PH tagline plus one X post). The full kit costs £19 through Stripe Checkout and arrives on a results page, as a ZIP and by email via Resend. Ship Loop (/ship-loop) is a free waitlist only: sign-ups are tagged shiploop_waitlist and emailed if and when it launches. Nothing about Ship Loop is for sale, and /api/checkout answers any non-kit product with a 400 and the waitlist message.
Stack: Next.js 16 (App Router, Cache Components), TypeScript, Tailwind v4, shadcn/ui, AI SDK 7 with Vercel AI Gateway, Stripe, Resend, @vercel/og, Playwright.
Requires Node 24.
npm install
npm run dev # http://localhost:3417
No keys are needed. With nothing configured, the app runs end to end in mock mode:
| Integration | Without a key |
|---|---|
| LLM (AI Gateway) | Deterministic template copy built from the real brief (src/lib/copy.ts) |
| Stripe | A local test checkout page at /checkout/mock |
| Resend | Emails (with the ZIP attached) go to a local outbox: GET /api/dev/outbox |
| Storage | JSON files in ./.data |
Click Try a sample app on the home page for an offline demo, or paste any public repo or site.
To use real services, copy .env.example to .env.local and fill in what you need.
All are optional locally. See .env.example for the full annotated list.
| Variable | Needed for |
|---|---|
AI_GATEWAY_API_KEY | Local copy generation through Vercel AI Gateway. Not needed on Vercel (OIDC) or after vercel env pull |
AI_GATEWAY_MODEL, AI_GATEWAY_FALLBACK_MODELS | Primary model (default anthropic/claude-sonnet-4.5) and fallbacks (default openai/gpt-5-mini; none disables) |
LLM_PROVIDER | Set to mock to force template copy even when gateway credentials exist |
STRIPE_SECRET_KEY | Real Stripe Checkout (use sk_test_… for test mode) |
STRIPE_WEBHOOK_SECRET | Verifying /api/stripe/webhook |
STRIPE_PRICE_KIT | Optional: a dashboard Price instead of the inline £19 |
STRIPE_PROMO_LAUNCH14 | Optional: the LAUNCH14 promotion code id (promo_…). Without it, Checkout looks up the active code named LAUNCH14 (see "Experiments") |
RESEND_API_KEY, RESEND_FROM, RESEND_REPLY_TO | Sending kit and sign-in emails |
RESEND_SEGMENT_ID or RESEND_AUDIENCE_ID | Storing newsletter, waitlist and buyer contacts in Resend |
GITHUB_TOKEN | Optional: GitHub API limit goes from 60 to 5,000 requests/hour |
KV_REST_API_URL, KV_REST_API_TOKEN | Production storage (Upstash Redis / Vercel KV) |
NEXT_PUBLIC_SITE_URL | Canonical URLs, sitemap, email links |
MOCK_MODE | Force everything into mock mode |
PREVIEW_RATE_LIMIT_PER_HOUR, PREVIEW_RATE_LIMIT_PER_DAY | Free previews (and free tool generations) per IP: default 10 an hour and 40 a day, from PREVIEW_LIMITS in src/lib/site.ts. A missing, malformed or non-positive value uses the default. The limiter, the 429 copy, the terms, privacy page, pricing, FAQ and llms.txt all read resolvedPreviewLimits(), so they follow an override. Override only for testing. See "Free preview limits" |
freePreviewGate in src/lib/rate-limit.ts guards every free LLM call (/api/kits and /api/tools/taglines); paid kit generation never goes through it. In order:
FREE_PREVIEWS_DISABLED=1 returns 503 busy.sample) skips both counters; it's served from the 24-hour dedupe cache.Retry-After header, and retryAfterSeconds (time to the end of the window that's full) and limits (the enforced allowance) in the body. The form says "Try again in N min" and states the allowance from limits, since the browser bundle can't see the env overrides. A refused request gives its slot straight back, so retrying while limited doesn't use up the day.cap:previews:<UTC day>, PREVIEW_GLOBAL_DAILY_CAP) returns 503 busy (and gives the per-IP slot back). It's checked after the per-IP limit so one noisy IP can't use it up. It counts every attempt that gets this far and is never refunded.A preview counts once a model call has completed. A run that fails before the brief is written (a private or missing repo, an unreachable or empty site, the AI Gateway down) calls slot.refund() and gets its slot back. Each of those still cost a GitHub or site fetch, so an IP gets at most 10 refunds an hour and 30 a day (PREVIEW_REFUNDS_PER_HOUR, PREVIEW_REFUNDS_PER_DAY, testing only); past that a failure counts like a preview. A visitor who closes the tab doesn't stop the run: the preview is still written and saved (and reachable from the kit link or after login), so it counts. A dedupe cache hit (the same repo at the same commit within 24 hours, kit.reusedFrom) makes no model call, so it's refunded the same way, within the same allowance.
The limit box is never a dead end. The 429 carries kitId, the visitor's newest unpaid preview from the last day (from the ll_kits cookie, or their account when signed in; nothing new is stored), and the form shows "Unlock my kit, " linking to it, or "See the Launch Kit, " to /pricing without one. Next to it, "Try again in N min" counts down to the reset and becomes a retry at 0.
Store errors fail closed. If Upstash errors, the gate returns 503 busy and reports the error to Sentry (stage: rate-limit.store) rather than letting the request through. Kits and briefs live in the same store, so failing open would spend model calls on previews that can't be saved, and would drop abuse protection exactly when nobody's watching.
fulfilKit (src/lib/fulfil.ts) makes up to 3 attempts, waiting 10s and then 30s between them (KIT_RETRY_BACKOFF_MS), inside the after() of whichever route triggered it (webhook, Checkout return, status poll; 120s maxDuration). Sections that succeed are kept, so a retry only writes what's missing. If the waits won't fit in the function (slow 45s timeouts), the kit records nextAttemptAt and the kit page's status poll starts the next attempt when it's due.
When the last attempt fails:
status: "failed" with refund_needed: true and failedAt. No template or partial copy is delivered: no content, no ZIP, and the kit and account pages show a "We hit a problem generating your kit" state with a Try again button;stage: fulfil.final_failure, with kitId and paymentRef) and PostHog gets kit_generation_failed.Refunds are never automatic: find kits with refund_needed: true (Sentry alert or PostHog event), then deliver the kit (the Try again button, or POST /api/kits/<id>/retry) or refund the payment in Stripe. A retry that succeeds clears refund_needed.
stripe listen --forward-to localhost:3417/api/stripe/webhook # prints STRIPE_WEBHOOK_SECRET
Pay with card 4242 4242 4242 4242. The webhook isn't required locally: the Checkout success URL (/api/checkout/return) verifies the session server-side and starts fulfilment. The webhook is the backstop if the buyer closes the tab.
Signed-out visitors can buy the kit without logging in. Checkout has no customer or account behind it and Stripe collects the email; the kit id, UTMs and LAUNCH14 metadata are the same as for signed-in buyers. Guest sessions use customer_creation: "if_required" (no Stripe Customer; the email comes back in customer_details), except when LAUNCH14 is applied: it's first-time-only, which Stripe's API reference says is invalid without a Customer, so those keep "always". Once paid (webhook or success URL), the kit goes to that email (src/lib/accounts.ts):
user:<email>:account, or an existing kit index) gets the kit in its account straight away;pending:<email>:kits), claimed into the account on that address's first magic-link login. A signed-in session already sees its pending purchases in My kits.The buyer lands on /checkout/success, which offers a magic link prefilled with the email from the Checkout session. Signed-in buyers still land on /account/kits/[id]?paid=1.
URL ─▶ ingest (GitHub API + live page, SSRF-guarded) ─▶ facts (stack, stars, commits, README stats)
─▶ brief (LLM, facts override) ─▶ preview (free)
Stripe paid ─▶ recordPayment ─▶ fulfilKit: full copy (LLM + rubric + retry) ─▶ enforce limits ─▶ ZIP + email
| Path | What it does |
|---|---|
src/lib/ingest/ | GitHub reader, live-page scraper, brand-colour extraction (hex/rgb/hsl/oklch), SSRF-safe fetch, screenshot and logo discovery (visuals.ts; PNG and JPEG, plus SVG logos, because Satori draws WebP and GIF as blank boxes) |
src/lib/brief.ts | Deterministic facts and the LLM product brief |
src/lib/llm.ts | The only LLM module. AI SDK generateText + Output.object through the AI Gateway provider, fallback models, rubric-driven retries |
src/lib/copy.ts | Prompts, mock writer, rubric (limits, vote-begging, hype words), final enforcement |
src/lib/limits.ts | Platform limits, X weighted length, vote-request stripping |
src/lib/images.tsx | The three kit images via @vercel/og: laid out by image-layout.ts (one layout per size with the product screenshot, one without), Geist from assets/fonts, screenshot and logo re-fetched through safe-fetch by image-assets.ts, a corner-badge watermark until paid. scripts/render-kit-images.tsx renders them for real repos |
src/lib/fulfil.ts | Idempotent post-payment generation and email |
src/lib/payments.ts | Stripe Checkout (or the mock checkout URL) |
src/lib/accounts.ts | Accounts (emails that have logged in), and guest purchases waiting for their email's first login |
src/lib/email.ts, src/lib/audience.ts | Resend email and contacts, with mock outbox |
src/lib/store.ts | Key-value store: Upstash REST or local JSON files |
All model calls go through Vercel AI Gateway using the AI SDK's gateway provider (@ai-sdk/gateway), in src/lib/llm.ts:
anthropic/claude-sonnet-4.5, with openai/gpt-5-mini as a gateway-level fallback via providerOptions.gateway.models. If every provider for the primary fails, the gateway retries on the fallback. Override with AI_GATEWAY_MODEL and AI_GATEWAY_FALLBACK_MODELS using IDs from https://ai-gateway.vercel.sh/v1/models. The model that actually answered is saved on each kit (kit.llm, e.g. gateway:anthropic/claude-sonnet-4.5).x-vercel-oidc-token header, so no key is needed. Keep "Secure backend access with OIDC federation" enabled in the project settings; it's on by default.vercel link (project launchloop, team luciengeorge16) and vercel env pull .env.local to get a VERCEL_OIDC_TOKEN (valid ~12 hours; re-pull or use vercel dev), or set AI_GATEWAY_API_KEY. An API key always takes precedence over OIDC.AI_GATEWAY_API_KEY, no VERCEL_OIDC_TOKEN and not running on Vercel, or with LLM_PROVIDER=mock / MOCK_MODE=true, copy comes from deterministic templates and nothing is sent to the gateway. Mock mode is for local development and tests only: on a production deployment (VERCEL_ENV=production) it's refused and every call fails as if the gateway were down.generateText with Output.object() and zod schemas that hold types only. Character limits, thread length and banned phrases are checked by a rubric; failures are fed back for up to three attempts, then hard-enforced.providerOptions.gateway.disallowPromptTraining: true, so the gateway only routes to providers that don't train on prompts (fallbacks included). A model with no qualifying provider fails with no_providers_available and the visitor sees ai_busy; check this when changing AI_GATEWAY_*_MODEL. It isn't enforced for BYOK keys.maxOutputTokens (OUTPUT_TOKENS in src/lib/llm.ts): brief 800, preview and taglines 400, kit sections 1500, LinkedIn 2500. At the rubric limits the JSON measures roughly 310, 80, 45, 300 to 440 and 650 tokens. If an answer is still cut off (, e.g. CJK copy or a reasoning-model fallback), the next attempt gets double the budget.To switch models, change the env vars; no code changes are needed.
src/app/layout.tsx; a generated site OG image at /opengraph-image/sitemap.xml and /robots.txt (kits, checkout and API are disallowed; kit pages are noindex)/faq with FAQPage JSON-LD; SoftwareApplication JSON-LD with offers on the home page/llms.txt: a plain-language description for AI assistantsdocs/seo/indexnow.md)h1 per page, landmarks, labelled forms, a skip linkVercel Web Analytics and Speed Insights count page views and performance. PostHog tracks the preview-to-purchase funnel. It's off until NEXT_PUBLIC_POSTHOG_KEY is set.
| Event | Fired from | Properties |
|---|---|---|
preview_started | Browser, kit form submit | source (github / site / sample), plan (free / kit intent), isLoggedIn |
preview_completed | Browser, preview stream done | the above plus kitId, durationMs |
preview_failed | Browser, preview error | the above plus code |
repo_cta_shown | Browser, a site_no_content error with its "Use my GitHub repo instead" button | code (site_no_content), plan, isLoggedIn |
repo_cta_clicked | Browser, that button: the form switches to a GitHub repo input | the above plus prefill (github: https://github.com/; github_pages: the repo named by a *.github.io address) |
preview_resubmit_blocked | Browser, submitting the link that just failed with site_no_content again (nothing is sent; focus moves to the repo button) | code (site_no_content), plan, isLoggedIn |
kit_created | Server, /api/kits once the preview kit is saved (streamed or not) | kitId, source (github / site / sample), isLoggedIn, stream, reused (same repo state within 24 h), durationMs, client_disconnected when the browser had gone, plus the kit's first-touch utm_*, referring_domain and is_test |
newsletter_form_viewed | Browser, a newsletter form at least half on screen, once per placement per page (useFormViewed, after the page load like the SDK; the footer reports again after a client navigation). Hidden forms (the footer's, next to an inline signup) never report | placement (as below), path (scrubbed: /kit/[id]), and the landing's utm_source, utm_medium and utm_campaign when present. Nothing typed |
checkout_completed is the purchase event: it only fires once Stripe confirms the payment, from the webhook, so it doesn't depend on the buyer's browser coming back. Filter mock = false and env = production for real revenue. No email or other buyer details are sent.
kit_created is the server's count of finished previews, a separate event from preview_completed so existing dashboards don't double count. preview_completed only fires when the browser is still there at the end; a closed tab, a dropped stream (preview_failed with recovered: true) or a visitor who leaves mid-generation still gets a kit, and only kit_created sees it. It's captured on the page's id (sent as analyticsId with the preview request, see pageAnalyticsId in src/lib/analytics.ts), else a server_ id, and its uuid comes from the kit id, so one kit counts once. Use it rather than preview_completed as the funnel's preview step when you want every kit made.
Every event also carries env (production / preview / development).
posthog-js runs with persistence: "memory": no cookies, no local storage, a fresh anonymous id per full page load, and no person profiles. Autocapture, session replay, surveys and heatmaps are off. URL properties are scrubbed like Vercel Analytics (private kit ids become [id], query strings keep only utm_)./api/kits (for kit_created) and /api/checkout; the checkout one goes into the Checkout session metadata (ph_distinct_id, plus price_id and logged_in) so the webhook's checkout_completed lands on the same id. Its uuid is derived from the session id, so webhook retries don't double count. Preview deployments never get the webhook, so they don't send checkout_completed for real Stripe sessions.?aid= (in the login intent, or ph_distinct_id in Checkout metadata) and the next page bootstraps PostHog with it. Funnel events (and flags hashed on the id) stay on one id with nothing stored in the browser. A manual reload or a fresh visit still gets a new id, which is why the kit paywall's price experiment is hashed on the kit instead (see "Experiments")./lp-relay, rewritten in next.config.ts to NEXT_PUBLIC_POSTHOG_HOST, so ad blockers don't drop events. The server posts to PostHog directly.posthog-js reads utm_source, utm_medium, utm_campaign (and utm_content, utm_term) from the landing URL, so a link like https://launchloop.dev/?utm_source=x&utm_medium=social&utm_campaign=week2 puts them on that $pageview and on every later browser event in the visit (preview_started, preview_completed, ...). Nothing strips them first: PostHog boots in instrumentation-client.ts before React, the URL scrubber keeps utm_ params, the in-page start links keep them in the URL, and every internal "start" link navigates client-side so the visit (and its anonymous id) carries on. Server events (checkout_started, checkout_completed) don't carry UTMs themselves; they join through the shared id (see above).
To see which source converts, use a funnel and let the first step supply the source:
$pageview → preview_completed → checkout_started → checkout_completed. Add a filter env = production (and mock = false on the last two steps).utm_source (or utm_campaign for a single campaign), with breakdown attribution First touchpoint (the default). "All steps" would drop everyone, because the server-side steps don't carry UTMs.Person properties like $initial_utm_source aren't useful here: anonymous visitors get no person profile.
PostHog multivariate flags: exp_launch14_promo (50/50, variants control and treatment) and exp_hero_headline (variants control and test). Until the variant is known the paywall price shows a placeholder. Each view records exactly what it served, once, on the page's anonymous id (the id its preview and checkout events use):
Assigned: PostHog's $feature_flag_called exposure with $feature_flag_response = the variant shown. This is what PostHog experiments count.
Fallback: when flags take over 1.5 s (reason: "timeout"), the flags request fails ("error"), or the flag is off for this id ("unassigned"), the page serves control and records a flag_fallback event (flag, variant: "control", reason, fallback: true) instead of an exposure. Fallbacks are deliberately kept out of the experiment: counting them as control exposures would pile slow or blocked visitors into the control arm. A fallback never flips to the variant once flags do arrive.
Checkout events carry $feature/<flag> only for assignments and fallback/<flag> for fallbacks, always with the variant the page served. posthog-js's own $feature/<flag> tags (from its evaluation for the page's id, which needn't be what was shown) are stripped from browser events in before_send.
exp_launch14_promo is bucketed per kit. The kit page asks PostHog from the server (kitVariant in src/lib/analytics-server.ts, posthog-node) with the distinct id kit:<kitId>, and streams the answer to the paywall (useAssignedExperiment), which sends the exposure. Hashing on the browser's id would re-roll the price on every reload, since that id lives in page memory only. Same kit, same price, on any reload or device, with no cookie or storage. The unit is the kit, so a visitor who reloads is counted as several exposed (anonymous) users in the same arm, as before.
exp_hero_headline is off unless the deploy sets HERO_EXPERIMENT=1. Unset (the default everywhere), the home page is fully static with the original h1 and never asks PostHog, so it stays a CDN hit. The variable is read when the page is prerendered, so turning it on or off takes a redeploy.
With HERO_EXPERIMENT=1, it's bucketed per home page view, on the server. The h1 is the home page's LCP text, so it can't wait for posthog-js (loaded after the page) or swap once flags arrive. The h1 becomes the page's only per-request part: a Suspense boundary (AssignedHeroHeadline in src/app/page.tsx) makes one POST to PostHog's /flags (lookupHeroHeadline in src/lib/hero-headline.ts, not posthog-node) for a fresh distinct id , the same unit as the browser's per-page-load id, and streams the h1 into the otherwise static page. The request is aborted at 500 ms (; the env override of that name is capped at 2.5 s) with no retries, so nothing keeps running after the page has its answer; a timeout serves control as a fallback. A failed lookup goes to Sentry at most once a minute per server instance, so a PostHog outage isn't one event per page view.
| Flag | Where | control | treatment / test |
|---|---|---|---|
exp_launch14_promo | Kit paywall (UnlockPanel), and server-side on the header CTA, home, /pricing, /faq, /ship-loop and the kit form (KitPrice) | £19, and kit Checkout has no promotion code field | treatment: £19 struck through, £14 with LAUNCH14; kit Checkout gets discounts: [{ promotion_code }] |
exp_hero_headline | Home page h1 only | "Paste your repo. Get your whole launch." | test: HERO_HEADLINE_TEST in src/lib/hero-headline.ts, the same h1 with everything after the first sentence highlighted |
STRIPE_PROMO_LAUNCH14, else looked up by code). If Stripe turns the code down, or no active code exists, the buyer gets a £19 session and the paywall says so before redirecting. Turn the flag off (or roll it to 100% control) when the code expires.isLaunchPromoOn() (src/lib/launch-promo-server.ts) evaluates exp_launch14_promo for one fixed id, so it's only meaningful at a 0% or 100% rollout. It's cached with a 60 s revalidate (the pages are static and pick up a flip within a minute or two, no deploy), and any error or a 1.5 s timeout shows the plain £19. Kit checkouts don't use it: they resolve the kit's own assignment (next point), which matches the marketing pages at a 0% or 100% rollout, so a £14 seen on home or /pricing is then the Stripe total. PostHog serves no flags to bot user agents (including HeadlessChrome), so browser-side checks with a headless browser see control unless they set a normal user agent.LAUNCH_PROMO_ENDS_AT (src/lib/launch-promo.ts, 22 Oct 2026 22:00 Paris) both isLaunchPromoOn() and resolveKitPromo() return off/control without reading exp_launch14_promo, so Checkout never applies £14 after it, whatever the flag says. A marketing page cached just before can show £14 for a few more minutes until it revalidates; Checkout still charges £19./api/checkout ignores any promo variant in the request and resolves exp_launch14_promo itself with resolveKitPromo (src/lib/kit-promo.ts), the same function the paywall uses, so page and checkout agree: PostHog on kit:<kitId>; if PostHog doesn't answer (error or 1.5 s timeout), the variant PostHog last assigned that kit, which the server keeps next to the kit (kitpromo:<kitId>, 30 days; nothing in the browser); otherwise control. Checkout logs a server-side flag_fallback (source: "checkout", remembered) and a server warning when PostHog didn't answer. A flag switched off is control for every kit at once, never the remembered variant. If the paywall showed £14 but the session isn't discounted (the flag changed since, or Stripe turned the code down), the buyer is told before paying."Weekly launch teardowns" has three signups: the footer, a card under the free preview paywall (src/components/kit/preview-newsletter.tsx, hidden when the visitor arrives with ?unlock=1 on the way to checkout), and a block at the end of every blog post, between the free-preview CTA and the related links (src/components/blog-newsletter.tsx, rendered by BlogPostLayout, outline button). Behind the newsletter-inline-signup flag (below), posts get one signup after the intro instead, and the home page a band under the hero. All post to /api/newsletter, which stores the contact with its placement (footer, preview, blog, blog_inline or home_inline; a repeat signup updates it) and adds it to the Resend segment. The lead magnet is the /product-hunt-launch-checklist guide: the success state links to it, and a new address gets one welcome email with the link (src/emails/newsletter-welcome.tsx).
store().incr("newsletter:welcome:<email>") before the response, so repeat or concurrent signups don't resend it. The response says what happened (welcome: sent, existing or skipped), and the success message only mentions an email when one was sent.NEWSLETTER_RATE_LIMIT_PER_HOUR), and welcome emails have a global daily cap (NEWSLETTER_WELCOME_DAILY_CAP). Past the cap the contact is still added; the skip is logged.?subscribed=1, or ?subscribed=0&error=<code> and a #hash that reveals the message without JavaScript. next is only honoured as a same-origin path, checked after URL parsing in newsletterReturnPath, which builds every redirect.newsletter_subscribed is captured by the API, not the browser: posthog-js drops every event from automated browsers (navigator.webdriver, headless Chrome), which is how QA signs up, and a plain form post has no SDK at all. The event fires on every successful signup, so it is not a count of new subscribers on its own:
welcome = existing is a repeat: the address had already claimed its welcome email (the claim lasts a year from the first signup, so an address that signs up again after that counts as sent once more).welcome = skipped is usually a new address past the daily welcome email cap, but it is also what a failed claim (the store erroring) returns, and then the address may be a repeat. Those are rare and logged in Sentry (api.newsletter.welcomeClaim), so skipped can slightly overcount.welcome != existing, is_test != true and env = production; add is_webdriver != true to drop other automated signups. For QA, open the page with ?qa=1 (or use a @resend.dev address): JS and no-JS signups are both tagged is_test.newsletter_form_viewed (placement X, env = production, is_test != true) to (same placement, plus ). Both are on the page's id (the form posts ), so they join per visitor without a client submit event. is a browser event, so automated browsers and no-JS visits never send it, while their signups still count.Ship Loop is a free waitlist; nothing about it is sold. /ship-loop and the kit upsell post to /api/waitlist, which writes to the shared store (Upstash Redis in production and preview):
contact:waitlist:<email>: { email, source: "waitlist", tag: "shiploop_waitlist", signup_source, plan, createdAt } (plan only on older signups: no form sends one now). A repeat signup keeps the first createdAt and answers duplicate: true.contacts:waitlist: a set of every waitlist email. SMEMBERS contacts:waitlist is the launch mailing list.Preview keys carry the preview: prefix. The contact is also added to the Resend audience/segment, which is shared with newsletter and buyer contacts, so the store is the source of truth for who joined the waitlist.
shiploop_waitlist_ask (PostHog flag 331856, a boolean run at 100%). ShipLoopUpsell renders on the kit success page (/account/kits/[id]?paid=1, kit owner only) when resolveShipLoopWaitlistAsk() (src/lib/waitlist-ask-server.ts) reads the flag as true for kit:<id> on the server. It fails closed: false, a missing flag, an error, no PostHog key or no answer within 800 ms (the paywall kill switch's timeout, resolveKillSwitch in src/lib/kill-switch.ts) hides it. It's only read on that page, for the owner. It's the waitlist form only: no price, no checkout. (exp_shiploop_success_upsell is no longer read.)WaitlistForm via src/lib/waitlist-client.ts), on both surfaces. Each carries signup_source (kit_upsell or ship_loop_page), kitId on the kit upsell, and is_test: true only on QA traffic (see "QA traffic"). None carries the email.
waitlist_cta_viewed: the form is at least half on screen, once per mount.waitlist_signup (duplicate): only after /api/waitlist answers 2xx.waitlist_error (status, reason): any other answer (invalid_email for 400, rate_limited for 429, server_error for 5xx, http_error otherwise), or status: 0, reason: "network" when the request got no response. The form shows the error with a retry (or "Edit your email" for a 400) and a link to email [email protected].src/lib/observability.ts exposes captureException / captureMessage and registerErrorHook. Server errors flow in through onRequestError in src/instrumentation.ts. Client errors (error boundaries plus window error and unhandled-rejection listeners in src/instrumentation-client.ts) are beaconed to /api/log. To plug in Sentry:
npm i @sentry/nextjs
Initialise Sentry in register() in src/instrumentation.ts, then call registerErrorHook((e, ctx) => Sentry.captureException(e, { extra: ctx })).
npm run test:e2e:install # once: downloads Chromium
npm run test:e2e
Playwright builds the app into .next-test and runs it on port 3418 in forced mock mode, alongside a fixture site on port 3419. The smoke tests cover:
tests/e2e/newsletter-inline.spec.ts), against a third build with NEWSLETTER_INLINE=on on port 3421 (.next-test-inline, projects inline-desktop and inline-mobile); every other spec runs with the flag off, as in production until the flag exists$pageview and later events, and one anonymous id from a landing page through to the server-side checkout_completed (the fixture server records what the fake PostHog receives at /ph/_events)Unit tests in tests/unit run without a browser or server. npm test runs both sets: the Playwright *.spec.ts files (playwright.unit.config.ts) and the Vitest *.test.ts files (vitest.config.mts, against an in-memory store): the free-preview gate, the /api/kits rate limit (11th request in an hour and 41st in a day get a 429 with Retry-After; Upstash errors fail closed), the no-template ai_busy path, paid kit retries and refund_needed, the LLM output budgets and disallowPromptTraining.
Also: npm run lint, npm run typecheck, npm run build. .github/workflows/ci.yml runs all of them plus the e2e suite.
KV_REST_API_URL and KV_REST_API_TOKEN. Without it, kits are written to /tmp and disappear between invocations.AI_GATEWAY_MODEL / AI_GATEWAY_FALLBACK_MODELS, and make sure the team has AI Gateway credits or BYOK configured. Add the other environment variables: STRIPE_SECRET_KEY, RESEND_API_KEY, RESEND_FROM (on a verified domain), RESEND_SEGMENT_ID or RESEND_AUDIENCE_ID, GITHUB_TOKEN, and NEXT_PUBLIC_SITE_URL (e.g. https://launchloop.dev).https://<your-domain>/api/stripe/webhook for checkout.session.completed and checkout.session.async_payment_succeeded, and set STRIPE_WEBHOOK_SECRET.after() with maxDuration up to 120s; the free preview (/api/kits) and tagline generator (/api/tools/taglines) allow 300s, the Hobby limit with Fluid compute. Each model attempt times out after 45s. On plans with lower limits, reduce them.Checkout has no promotion code field. Kit discounts only come from the LAUNCH14 experiment (discounts on the session).
OG_PREVIEW_RATE_LIMIT_PER_HOUR | OG preview tool checks per IP per hour (default 60) |
NEWSLETTER_RATE_LIMIT_PER_HOUR | Newsletter signups per IP per hour, shared across instances (default 10) |
NEWSLETTER_WELCOME_DAILY_CAP | Newsletter welcome emails per day across everyone (default 200). Past it, signups still subscribe without the email |
CHECKOUT_RATE_LIMIT_PER_HOUR | Optional: signed-out kit checkouts per IP per hour (default 20) |
PREVIEW_GLOBAL_DAILY_CAP | Optional spend cap: free previews and tagline generations across all visitors per UTC day (default 1500). Past it, /api/kits and /api/tools/taglines return 503 code: "busy" ("LaunchLoop is very busy right now, try again in a bit"). Paid kit generation is never capped by it |
FREE_PREVIEWS_DISABLED | Optional kill switch: 1 turns off free previews and tagline generations with the same busy response. Paid kit generation keeps running. Unset = on |
KIT_RETRY_BACKOFF_MS | Optional: waits between paid kit generation attempts, comma-separated milliseconds (default 10000,30000). See "Paid kit failures" |
SENTRY_DSN | Reserved for Sentry (see "Error logging") |
NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST | Conversion analytics (see "Analytics"). Host defaults to https://eu.i.posthog.com |
finishReason: "length"captureExceptionThrottled (src/lib/observability.ts): at most one event per error type (llm.gateway:<error name>:<status>) per minute per instance, with a count of the skipped ones on the next event. Skipped ones are still logged. Store errors in the rate limiter (rate-limit.store) use the same throttle.AI_GATEWAY_FALLBACK_MODELS, or a 45s timeout), nothing stands in for the AI. The free preview and tagline generator return 503 code: "ai_busy" ("Our AI is busy, try again in a minute"), save nothing, and the form offers Retry with the same input; the browser records preview_failed with code: "ai_busy". A brief, preview or kit section the model keeps returning incomplete is the same error, never patched with canned text. Paid kits: see "Paid kit failures".newsletter_subscribed | Server, /api/newsletter on every successful signup, from the form's fetch or a plain form post, including repeats | placement (footer / preview / blog / blog_inline / home_inline; anything else is recorded as footer), welcome (sent: new address, welcome email sent; existing: a repeat of an address already claimed; skipped: no email sent, see "Counting signups" below), submitted (js / form), is_webdriver (js only), is_test for QA traffic (a QA-tagged tab, a page with ?qa=1 or utm_source=qa in its Referer, or a @resend.dev address, on both paths). On the page's id for js (so it joins the page's $pageview and UTMs), a server_ id for form. No email |
waitlist_cta_viewed | Browser, the Ship Loop waitlist form at least half on screen (kit success page or /ship-loop), once per mount | signup_source (kit_upsell / ship_loop_page), kitId (kit_upsell), is_test for QA traffic. No email |
waitlist_signup | Browser, only after /api/waitlist answers 2xx | the above plus duplicate |
waitlist_error | Browser, /api/waitlist answered non-2xx (including a 400 for an invalid email) or not at all | the above plus status (0: no response) and reason (invalid_email / rate_limited / server_error / http_error / network). Never the email or the server's message |
checkout_started | Server, /api/checkout once a session URL exists | plan (kit), product_type (kit / shiploop), kitId, price_id, amount in pence (after any discount), currency, isLoggedIn, mock, promo_code / promo when LAUNCH14 was due, $feature/<flag> for the variants the page showed (fallback/<flag> when it served a fallback) |
checkout_completed | Server, Stripe webhook (checkout.session.completed / async_payment_succeeded with payment_status: paid), or /api/checkout/mock in test mode | the above plus stripeSessionId. amount is Stripe's amount_total; promo_code is LAUNCH14 (kit only; Checkout has no promotion code field) |
hero:<uuid>HERO_FLAG_TIMEOUT_MStimeoutdiv with the heading role (the page's HTML also holds the streamed h1, hidden until React reveals it, so the placeholder isn't an h1 and the HTML has one). React reveals a streamed boundary no sooner than 300 ms after first paint, so the streamed h1 carries an inline script that shows it in that box as soon as it's parsed. If it hasn't arrived 700 ms after the placeholder is first styled (HERO_FAILSAFE_MS), CSS alone shows control, so a stalled render can't leave the headline blank; a variant arriving after that is discarded and the view is recorded as a timeout fallback. Without JavaScript the placeholder shows control and nothing is recorded.HeroHeadline (useRenderedExperiment) records the exposure on the page's own id once the SDK loads after the page, as before, so preview_started and checkout_started on that id count toward the experiment. A client-side return to / within the page load keeps the headline already shown. While the flag doesn't exist or is switched off (PostHog leaves it out of the flags response), the page shows control and records nothing.controltreatmentexp_launch14_promoexp_hero_headline. Feature flags → New: key exp_hero_headline, served value "Multiple variants" with control 50% and test 50%, one release condition for 100% of users with no property filters (the ids are anonymous and fresh per view, so person properties never match), and it can evaluate on the server only. Then Experiments → New, using that flag, with primary metric preview_started and secondary metric checkout_started, set HERO_EXPERIMENT=1 on the Production environment in Vercel and redeploy. Until both are done everyone sees control. To stop, remove the variable and redeploy (or switch the flag off).newsletter_subscribedwelcome != existinganalyticsIdnewsletter_form_viewedisEmail in src/lib/email-address.ts, re-exported by src/lib/audience.ts): the field's pattern blocks a@b before hydration, and once hydrated the form is novalidate and shows "Enter a valid email address." itself, with aria-invalid and aria-describedby on the field, without sending a request. The API still checks every post.sm the field and button stack; both are 44 px tall and the field's text is 16 px, so iOS doesn't zoom in on focus.--primary-foreground is the foreground colour (about 5.4:1 on the brand orange, which is unchanged; white was 3.6:1), and the hover is the solid, lighter --primary-hover (about 6.4:1), so it passes on the dark bands too. Orange text (text-primary) on light backgrounds is still under 4.5:1 (a follow-up).newsletter-inline-signup (kill switch). A boolean PostHog flag read on the server by isNewsletterInlineOn() (src/lib/newsletter-flag-server.ts) for the marketing pages' fixed id, with deploy_env as a person property so it can be released to Preview first. Only true turns it on; a missing flag, false, a variant, an error or a 1.5 s timeout are off, and the code reads off until the flag is created. On: each post gets InlineNewsletter (blog_inline) just before its first top-level h2 instead of the end-of-post block, / gets a band directly under the hero (home_inline, outside HomeHero, so the hero's tracking and experiments don't see it), and on those pages the footer shows "Weekly launch teardowns. Sign up above" linking to #newsletter instead of its form (CSS picks one: from the HTML with :has([data-inline-newsletter]) until hydration, then from a count of mounted inline signups, since the root layout can't know the route and Next keeps pages visited client-side in the DOM, hidden). It's cached like the promo flag (60 s revalidate), so the pages stay prerendered with the right blocks (no flash, no layout shift) and a flip shows up within a few minutes, with no deploy. NEWSLETTER_INLINE=off forces it off anywhere without asking PostHog (takes a redeploy); NEWSLETTER_INLINE=on forces it on outside production, as the e2e build does.role="status" region that is mounted (empty) from the start, so screen readers announce it, and focus moves to the checklist link when the form is replaced. The blog block's form is named by its heading, so a blog page doesn't have two forms called "Newsletter signup".