Skip to content

Instantly share code, notes, and snippets.

@bluntbrain
Last active May 9, 2026 23:25
Show Gist options
  • Select an option

  • Save bluntbrain/55486cd6ea659bd19eb7c1b9917f4a4d to your computer and use it in GitHub Desktop.

Select an option

Save bluntbrain/55486cd6ea659bd19eb7c1b9917f4a4d to your computer and use it in GitHub Desktop.
Talkamore: Monthly + Yearly Subscription Implementation Prompts

Dodo Payments Dashboard Setup: Monthly + Yearly Subscriptions

Step 1: Create the Monthly Subscription Product

  1. Go to https://app.dodopayments.com → log in
  2. Navigate to Products (left sidebar)
  3. Click + Add Product (or "Create Product")
  4. Fill in:
    • Product Name: Talkamore Monthly
    • Type: Select Subscription (not one-time)
    • Billing Interval: Monthly
    • Price: 12.99 USD
    • Description (optional): "Talkamore Pro — monthly subscription. Cancel anytime."
  5. Click Save / Create
  6. Copy the product ID — it looks like pdt_xxxxxxxxxxxxx
  7. Save this as your DODO_MONTHLY_PRODUCT_ID

Step 2: Create the Yearly Subscription Product

  1. Still in Products, click + Add Product again
  2. Fill in:
    • Product Name: Talkamore Yearly
    • Type: Select Subscription
    • Billing Interval: Yearly (or Annual)
    • Price: 109.00 USD
    • Description (optional): "Talkamore Pro — yearly subscription. Save 30% vs monthly."
  3. Click Save / Create
  4. Copy the product IDpdt_xxxxxxxxxxxxx
  5. Save this as your DODO_YEARLY_PRODUCT_ID

Step 3: Verify Your Webhook Endpoint

  1. Go to Webhooks (left sidebar, or under Settings → Webhooks)

  2. You should already have a webhook configured pointing at your backend:

    https://your-backend-url.railway.app/api/webhooks/dodo
    
  3. Verify it's still active and the signing secret matches your DODO_WEBHOOK_SECRET env var

  4. If you need to check the secret: the webhook detail page shows the signing secret (or lets you regenerate it). It starts with whsec_

  5. Check which events are enabled. You need ALL of these:

    • payment.succeeded ← already enabled
    • payment.failed ← already enabled
    • subscription.active (or subscription.created) ← NEW, enable this
    • subscription.renewedNEW, enable this
    • subscription.cancelledNEW, enable this
    • subscription.expiredNEW, enable this

    If Dodo doesn't let you pick individual events, it likely sends all events to the endpoint — that's fine, your backend handler will route by type field.

Step 4: (Optional) Recreate Coupon for Subscriptions

If you had a FAM10 coupon for lifetime and want it for subscriptions:

  1. Go to Coupons / Discounts in the dashboard
  2. Create a new coupon:
    • Code: FAM10
    • Type: Percentage discount
    • Amount: 10%
    • Applicable products: Select both Talkamore Monthly and Talkamore Yearly
    • Usage limits: Set as desired (e.g., first 100 uses, or unlimited)
  3. Save

If you're not keeping the coupon for subscriptions, skip this step.

Step 5: Add Environment Variables to Railway

Go to your Railway project → backend service → Variables tab.

Add these two new variables:

DODO_MONTHLY_PRODUCT_ID=pdt_xxxxxxxxxxxxx   (from Step 1)
DODO_YEARLY_PRODUCT_ID=pdt_xxxxxxxxxxxxx    (from Step 2)

Keep these existing variables as-is:

DODO_API_KEY=...              (unchanged)
DODO_WEBHOOK_SECRET=whsec_... (unchanged)
DODO_API_BASE_URL=...         (unchanged)
DODO_PRODUCT_ID=pdt_...       (keep for ~7 days for in-flight lifetime purchases, then remove)

Step 6: Test Mode First!

Before going live, test everything in Dodo's test mode:

  1. In the Dodo dashboard, switch to Test Mode (usually a toggle in the top bar or settings)

  2. Create the same two products in test mode (test product IDs will be different)

  3. Set your staging/dev env vars to use the test product IDs

  4. Set DODO_API_BASE_URL=https://test.dodopayments.com on your staging environment

  5. Use Dodo's test card numbers to simulate:

    • Successful payment: Complete a monthly and yearly checkout
    • Failed payment: Use a test card that declines
    • Cancellation: Cancel a test subscription from the dashboard and verify the webhook fires
    • Renewal: If Dodo allows simulating renewals in test mode, trigger one
  6. Check your webhook logs in the Dodo dashboard → Webhooks → Recent deliveries:

    • Verify events arrive with the correct type field
    • Verify metadata.userId is present in subscription events
    • Verify the response from your backend is 200

Step 7: Go Live

Once testing is complete:

  1. Switch Railway env vars to live product IDs
  2. Ensure DODO_API_BASE_URL is https://live.dodopayments.com (or remove it — that's the default)
  3. Deploy backend first (so webhook handlers are ready)
  4. Deploy frontend (so the new pricing UI goes live)
  5. Do one real checkout yourself to verify end-to-end

Step 8: Decommission Lifetime Product (After ~7 Days)

After a week with no lifetime checkouts in-flight:

  1. In Dodo dashboard: Archive or Deactivate the old lifetime product (don't delete — you may want the history)
  2. Remove DODO_PRODUCT_ID env var from Railway
  3. Remove the legacy payment.succeeded → lifetimeAccess = true branch from the backend webhook handler

Important Notes

  • Don't delete the lifetime product — just archive it. Existing purchase records reference it.
  • Webhook event type names: The exact strings Dodo uses may vary. Check your dashboard's webhook log after a test purchase to see the actual type values in the JSON payload. Common patterns: subscription.active, subscription.created, subscription_created — confirm which one Dodo actually sends.
  • The signing secret doesn't change when you add subscription products — it's per-webhook-endpoint, not per-product.
  • Dodo retries failed webhook deliveries — your backend must be idempotent (it already is for lifetime via the P2002 catch). Apply the same pattern for subscription events.

BACKEND PROMPT: Add Monthly + Yearly Subscriptions via Dodo Payments

Context

The Talkamore backend currently supports a single one-time $99 lifetime purchase via Dodo Payments. We're replacing that with monthly ($12.99/mo) and yearly ($109/yr) subscriptions. Existing lifetime buyers stay grandfathered — their lifetimeAccess: true keeps working forever; we just stop selling new lifetime plans.

Current Architecture (what exists today)

Schema (backend/prisma/schema.prisma)

The User model has these paywall fields:

lifetimeAccess             Boolean   @default(false)
messagesUsed               Int       @default(0)
messagesUsedThisMonth      Int       @default(0)
monthResetAt               DateTime?
purchasedAt                DateTime?

Paywall logic (backend/src/lib/paywall.ts)

  • assertCanSend(user) — gates every chat turn BEFORE the LLM call
  • Free users: hard wall at messagesUsed >= 100
  • Paid users (lifetimeAccess: true): rolling monthly cap of 5,000 messages with lazy reset
  • effectiveMessagesUsedThisMonth(user) — handles the lazy monthly reset logic
  • incrementMessageUsage(userId) — bumps counters after successful turn

Dodo integration (backend/src/lib/dodo.ts)

  • createCheckoutSession() — mints a hosted-checkout URL via Dodo REST API (POST /checkouts)
  • handleWebhookEvent() — processes payment.succeeded → creates Purchase row → flips lifetimeAccess: true
  • verifyWebhookSignature() — Standard Webhooks HMAC-SHA256 verification
  • Webhook event types currently handled: payment.succeeded, payment.failed, plus ignored set (refund.*, subscription.*)
  • Idempotency via unique constraint on Purchase.providerTxnId (P2002 = duplicate)

Billing API (backend/src/api/billing.ts)

  • POST /api/upgrade/checkout — JWT-authed, mints Dodo checkout URL. Currently uses single DODO_PRODUCT_ID env var
  • POST /api/webhooks/dodo — public, signature-verified webhook receiver
  • GET /api/me/quota — returns { lifetimeAccess, messagesUsed, messagesUsedThisMonth, monthResetAt, freeLimit, monthlyCap }
  • GET /api/upgrade/spots — public, counts lifetime purchasers for "X/100 spots" urgency

Session cache (backend/src/lib/pi-sessions.ts)

  • Pi sessions are cached in-memory per (userId, conversationId)
  • evictUserSessions(userId) — must be called when subscription status changes so next turn rebuilds with correct state

Env vars currently used:

DODO_API_KEY          — live/test bearer token
DODO_PRODUCT_ID       — the lifetime product (pdt_xxx)
DODO_WEBHOOK_SECRET   — HMAC signing secret
DODO_API_BASE_URL     — https://live.dodopayments.com (or test.dodopayments.com)

What You Need To Build

1. Prisma Schema Migration

Add these fields to the User model:

// ─── subscription state ──────────────────────────────────────
subscriptionPlan              String?    // "monthly" | "yearly" | null
subscriptionStatus            String?    // "active" | "past_due" | "cancelled" | "expired" | null  
subscriptionCurrentPeriodEnd  DateTime?  // when current billing period ends
subscriptionStartedAt         DateTime?  // when subscription first started (analytics)
dodoSubscriptionId            String?    // Dodo's subscription ID for lookups/cancellation

Keep ALL existing fields (lifetimeAccess, messagesUsed, etc.) — they stay for grandfathered users and analytics.

Run: npx prisma migrate dev --name add-subscription-fields

2. Update Paywall Logic (backend/src/lib/paywall.ts)

Create a userHasActiveAccess helper that replaces all raw lifetimeAccess checks:

const ACCESS_GRACE_MS = 3 * 60 * 60 * 1000; // 3 hours grace for late webhook renewal

export function userHasActiveAccess(user: {
  lifetimeAccess: boolean;
  subscriptionStatus: string | null;
  subscriptionCurrentPeriodEnd: Date | null;
}): boolean {
  // grandfathered lifetime buyers
  if (user.lifetimeAccess) return true;
  
  // active subscribers (with grace period for late renewal webhooks)
  if (
    (user.subscriptionStatus === 'active' || user.subscriptionStatus === 'cancelled') &&
    user.subscriptionCurrentPeriodEnd &&
    user.subscriptionCurrentPeriodEnd.getTime() + ACCESS_GRACE_MS > Date.now()
  ) {
    return true;
  }
  
  return false;
}

IMPORTANT: cancelled status with future subscriptionCurrentPeriodEnd still has access — they paid for that period. Access ends when the period expires.

Update assertCanSend to use userHasActiveAccess instead of raw lifetimeAccess. The PaywallUserSnapshot interface needs the new fields added. Update effectiveMessagesUsedThisMonth similarly.

Update incrementMessageUsage to handle subscribers the same way it handles lifetime users (monthly counter with lazy reset).

3. Update Checkout Endpoint (backend/src/api/billing.ts)

POST /api/upgrade/checkout must now accept a request body:

// Parse body: { plan: "monthly" | "yearly" }
// Validate plan is exactly "monthly" or "yearly" — reject anything else with 400
// Map to product ID:
//   "monthly" → process.env.DODO_MONTHLY_PRODUCT_ID
//   "yearly"  → process.env.DODO_YEARLY_PRODUCT_ID

Update the "already paid" guard from if (user.lifetimeAccess) to if (userHasActiveAccess(user)).

The checkout call to createDodoCheckoutSession stays mostly the same — just pass the correct product ID based on plan.

4. Update Dodo Webhook Handler (backend/src/lib/dodo.ts)

This is the most critical change. Add handling for these event types:

subscription.active (or subscription.created):

// Extract from event data:
//   - subscription_id (dodoSubscriptionId)
//   - metadata.userId
//   - product_id → determine plan ("monthly" | "yearly")
//   - current_period_end
// Update user:
//   subscriptionPlan = plan
//   subscriptionStatus = "active"
//   subscriptionCurrentPeriodEnd = current_period_end
//   subscriptionStartedAt = now (only if null — don't overwrite on reactivation)
//   dodoSubscriptionId = subscription_id
//   messagesUsedThisMonth = 0
//   monthResetAt = now
// Create Purchase row (same idempotency pattern)
// Send welcome/receipt email
// Call evictUserSessions(userId)

subscription.renewed (or payment.succeeded for subscription):

// Bump subscriptionCurrentPeriodEnd to new period end
// Set subscriptionStatus = "active" (in case it was "past_due")
// Reset messagesUsedThisMonth = 0, monthResetAt = now
// Create Purchase row for the renewal
// Call evictUserSessions(userId)

subscription.cancelled:

// Set subscriptionStatus = "cancelled"
// Do NOT clear subscriptionCurrentPeriodEnd — user keeps access until period ends
// Do NOT call evictUserSessions — they still have access
// Optional: send "sorry to see you go" email with period end date

subscription.expired:

// Set subscriptionStatus = "expired"
// Call evictUserSessions(userId) — access revoked, next turn rebuilds

payment.failed (for subscription renewal):

// Set subscriptionStatus = "past_due"
// Send "fix your payment method" email
// Do NOT revoke access yet — let Dodo retry (usually 3 attempts over ~7 days)

IMPORTANT: Check Dodo's actual event type strings. The plan above lists likely names, but Dodo's webhook docs may use slightly different names (e.g., subscription.active vs subscription.created). Check your Dodo dashboard webhook logs or docs at https://docs.dodopayments.com to confirm the exact event type strings.

Keep the existing payment.succeededlifetimeAccess: true branch for ~7 days after launch to catch any in-flight lifetime checkouts. After that, remove it.

5. Update Quota API Response (GET /api/me/quota)

Extend the response to include subscription fields:

sendJson(res, 200, {
  // existing fields (keep all)
  lifetimeAccess: user.lifetimeAccess,
  messagesUsed: user.messagesUsed,
  messagesUsedThisMonth: effectiveMessagesUsedThisMonth(user),
  monthResetAt: user.monthResetAt?.toISOString() ?? null,
  freeLimit: FREE_MESSAGE_LIMIT,
  monthlyCap: MONTHLY_MESSAGE_CAP,
  // new subscription fields
  subscriptionPlan: user.subscriptionPlan,
  subscriptionStatus: user.subscriptionStatus,
  subscriptionCurrentPeriodEnd: user.subscriptionCurrentPeriodEnd?.toISOString() ?? null,
});

6. Add Subscription Portal Endpoint (Optional but Recommended)

// POST /api/account/portal — JWT-authed
// Mints a Dodo customer portal URL where users can manage/cancel their subscription
// Returns { portalUrl: string }

7. Update All lifetimeAccess References

Search the entire codebase for raw lifetimeAccess checks and replace with userHasActiveAccess():

  • backend/src/lib/paywall.ts — assertCanSend, incrementMessageUsage
  • backend/src/api/billing.ts — handleStartCheckout, handleMeQuota
  • backend/src/lib/chat.ts — processChat (if it checks directly)
  • Any other files that reference user.lifetimeAccess

8. New Env Vars

DODO_MONTHLY_PRODUCT_ID=pdt_xxx   # from Dodo dashboard Step 1
DODO_YEARLY_PRODUCT_ID=pdt_xxx    # from Dodo dashboard Step 1

Keep DODO_PRODUCT_ID temporarily for the legacy lifetime branch.

9. Idempotency & Safety

  • Every webhook handler must use the same Purchase.providerTxnId unique constraint pattern for idempotency
  • Log every webhook event (type, subscription_id, user_id) to a permanent audit trail
  • On subscription.active/subscription.renewed, call evictUserSessions(userId) so the Pi session rebuilds with correct access state
  • Add a 3-hour grace window on subscriptionCurrentPeriodEnd checks to handle late renewal webhooks

10. Testing Checklist

  • Free user → monthly checkout → webhook fires → subscriptionStatus = 'active', plan = 'monthly', period end ~30 days out
  • Free user → yearly checkout → same flow, period end ~365 days out
  • Active subscriber hits paywall? NO — userHasActiveAccess returns true
  • Cancel subscription → subscriptionStatus = 'cancelled' BUT access continues until period end
  • Period end passes → access revoked, paywall appears
  • Payment failed → past_due status, access still works (grace period)
  • Grandfathered lifetime user → everything unchanged, tier shows "Lifetime"
  • Duplicate webhook → idempotent no-op (P2002 caught)
  • Session eviction fires on status changes

FRONTEND PROMPT: Add Monthly + Yearly Subscription UI

Context

The Talkamore frontend currently supports a single one-time $99 lifetime purchase. We're replacing that with monthly ($12.99/mo) and yearly ($109/yr) subscriptions via Dodo Payments. Existing lifetime buyers stay grandfathered. The backend is being updated separately to support subscriptions — this prompt covers only the frontend changes.

Current Architecture (what exists today)

API Client (lib/api-client.ts)

export interface Quota {
  lifetimeAccess: boolean;
  messagesUsed: number;
  messagesUsedThisMonth: number;
  monthResetAt: string | null;
  freeLimit: number;       // 100
  monthlyCap: number;      // 5000
}

// Checkout — takes no arguments, creates a one-time lifetime checkout
startCheckout: () =>
  request<{ checkoutUrl: string }>("/api/upgrade/checkout", {
    method: "POST",
  }),

Key files and their current behavior:

  • app/upgrade/page.tsx — single $99 price, "spots claimed" urgency, redirects to Dodo checkout. Line 111: if (quota.data?.lifetimeAccess) router.replace("/chat")
  • app/pricing/page.tsx — public pricing page with hardcoded $99 / $180 anchor price
  • components/paywall-modal.tsx — two variants: "paywall" (free user hit 100 msgs) and "quota" (paid user hit 5K/month). CTA links to /upgrade
  • components/user-menu.tsx — tier label: quota.data?.lifetimeAccess ? "Lifetime" : "Free" (line 83). Upgrade CTA: shows "you're on lifetime" or "upgrade plan" (line 246)

Every place lifetimeAccess is referenced:

  • lib/api-client.ts:54 — Quota interface
  • app/upgrade/page.tsx:111-112 — redirect if already paid
  • components/user-menu.tsx:80,83,246,248 — tier label + CTA gate
  • Possibly: app/(journal)/journal/new/HomeView.tsx and other masthead pills (grep to find all)

What You Need To Build

1. Update API Client (lib/api-client.ts)

Extend the Quota interface with new fields the backend will return:

export interface Quota {
  // existing (keep all)
  lifetimeAccess: boolean;
  messagesUsed: number;
  messagesUsedThisMonth: number;
  monthResetAt: string | null;
  freeLimit: number;
  monthlyCap: number;
  // new subscription fields
  subscriptionPlan: "monthly" | "yearly" | null;
  subscriptionStatus: "active" | "past_due" | "cancelled" | "expired" | null;
  subscriptionCurrentPeriodEnd: string | null; // ISO datetime
}

Add a client-side access helper:

export function hasActiveAccess(q: Quota | null | undefined): boolean {
  if (!q) return false;
  if (q.lifetimeAccess) return true;
  if (
    (q.subscriptionStatus === "active" || q.subscriptionStatus === "cancelled") &&
    q.subscriptionCurrentPeriodEnd &&
    new Date(q.subscriptionCurrentPeriodEnd) > new Date()
  ) {
    return true;
  }
  return false;
}

NOTE: cancelled with a future period end still has access — they paid for that period.

Update startCheckout to accept a plan:

startCheckout: (plan: "monthly" | "yearly") =>
  request<{ checkoutUrl: string }>("/api/upgrade/checkout", {
    method: "POST",
    body: JSON.stringify({ plan }),
  }),

2. Rewrite Upgrade Page (app/upgrade/page.tsx)

Replace the single-price layout with a two-tile plan selector:

const PLANS = {
  monthly: { price: "$12.99", period: "/ month", label: "Monthly" },
  yearly: { price: "$109", period: "/ year", label: "Yearly", badge: "save 30%" },
} as const;

const [selectedPlan, setSelectedPlan] = useState<"monthly" | "yearly">("yearly");
// Default to yearly so the better-value option leads

UI structure:

  • Two plan tiles side by side (desktop) / stacked (mobile)
  • Yearly tile highlighted as recommended with a "save 30%" badge
  • CTA button text: dynamic based on selected plan, e.g. "subscribe — $109/year" or "subscribe — $12.99/month"
  • Legal line changes from "no renewals, ever" → "cancel anytime. secure checkout."

Update the already-paid redirect:

// OLD: if (quota.data?.lifetimeAccess) router.replace("/chat");
// NEW:
if (hasActiveAccess(quota.data)) router.replace("/chat");

Update handleUpgrade:

const res = await api.startCheckout(selectedPlan);
// rest stays the same — redirect to res.checkoutUrl

Remove or repurpose:

  • The "X/100 spots claimed" urgency block (lifetime-specific) — remove it
  • The COUPON_CODE = "FAM10" block — remove unless recreated for subscriptions
  • PRICE_USD = 99 constant — replace with PLANS object

Features list update:

  • Remove: "one payment, no renewals, ever"
  • Add: "cancel anytime" or "flexible monthly or yearly plans"
  • Keep all other features (unlimited messages, memory, personas, etc.)

3. Rewrite Public Pricing Page (app/pricing/page.tsx)

Same two-tile layout as the upgrade page:

  • Monthly $12.99/mo vs Yearly $109/yr with "save 30%" badge
  • Replace PRICE_USD = 99 and ANCHOR_YEARLY_USD = 180
  • CTA still routes to /chat (existing behavior — auth-gated upgrade handles checkout)
  • Yearly tile should be visually highlighted/recommended

4. Update Paywall Modal (components/paywall-modal.tsx)

Change the hardcoded "$99 lifetime" CTA copy:

  • Paywall variant (free user): CTA text → "upgrade to keep talking" or "subscribe — from $12.99/mo"
  • Clicking still routes to /upgrade where the user picks monthly vs yearly
  • Quota variant (paid user hit monthly cap): keep as-is (just a "you've reached your limit this month" message, no CTA since they're already paid)

5. Update User Menu (components/user-menu.tsx)

Tier label (line 83):

// OLD: const tier = quota.data?.lifetimeAccess ? "Lifetime" : "Free";
// NEW:
const tier = quota.data?.lifetimeAccess
  ? "Lifetime"
  : quota.data?.subscriptionStatus === "active" || 
    (quota.data?.subscriptionStatus === "cancelled" && 
     quota.data?.subscriptionCurrentPeriodEnd && 
     new Date(quota.data.subscriptionCurrentPeriodEnd) > new Date())
    ? quota.data.subscriptionPlan === "yearly" ? "Yearly" : "Monthly"
    : "Free";

Or simpler using the helper:

const tier = quota.data?.lifetimeAccess
  ? "Lifetime"
  : hasActiveAccess(quota.data)
    ? quota.data?.subscriptionPlan === "yearly" ? "Yearly" : "Monthly"
    : "Free";

Upgrade CTA (line 246):

// OLD: label={quota.data?.lifetimeAccess ? "you're on lifetime" : "upgrade plan"}
// NEW:
label={hasActiveAccess(quota.data) 
  ? (quota.data?.lifetimeAccess ? "you're on lifetime" : "manage subscription")
  : "upgrade plan"}

For active subscribers, "manage subscription" could link to /account or open the Dodo portal (if the backend provides a portal endpoint).

6. Replace ALL lifetimeAccess References

Grep the entire codebase for lifetimeAccess and replace each occurrence with hasActiveAccess(quota.data):

grep -rn "lifetimeAccess" --include="*.tsx" --include="*.ts" .

Likely locations beyond the files above:

  • app/(journal)/journal/new/HomeView.tsx — masthead pills or feature gates
  • Any other component that checks paid status

Every place that does quota.data?.lifetimeAccess should become hasActiveAccess(quota.data) UNLESS it specifically needs to distinguish lifetime from subscription (like the tier label above).

7. Add Subscription Management (Optional but Recommended)

If the backend provides POST /api/account/portal:

  • Add to api-client: getPortalUrl: () => request<{ portalUrl: string }>("/api/account/portal", { method: "POST" })
  • In user-menu or a new /account page, add a "manage subscription" link that opens the Dodo portal in a new tab
  • This lets users cancel/update payment without emailing support

8. TypeScript & Lint

After all changes:

npm run lint
npx tsc --noEmit

The Quota interface change will cause type errors everywhere the old shape was expected — that's actually helpful, it'll show you every call site that needs updating.

9. Testing Checklist

  • Free user sees monthly/yearly tiles on /upgrade and /pricing
  • Yearly is pre-selected and highlighted with "save 30%"
  • Clicking "subscribe" with monthly selected → Dodo checkout for monthly product
  • Clicking "subscribe" with yearly selected → Dodo checkout for yearly product
  • After successful subscription, user-menu shows "Monthly" or "Yearly" tier
  • Active subscriber visiting /upgrade → redirected to /chat
  • Cancelled subscriber (future period end) → still shows as paid, access works
  • Expired subscriber → shows "Free", paywall appears, upgrade CTA visible
  • Grandfathered lifetime user → tier shows "Lifetime", everything unchanged
  • Paywall modal shows subscription CTA, not "$99 lifetime"
  • Public /pricing page shows correct pricing
  • npm run lint and npx tsc --noEmit pass clean
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment