a tool for shared writing and social publishing

wip first attempt at wiring up pro tier

+972 -23
+30
.github/workflows/stripe-sync.yml
··· 1 + name: Stripe Product Sync 2 + 3 + on: 4 + push: 5 + branches: [main] 6 + paths: ["stripe/**"] 7 + workflow_dispatch: 8 + inputs: 9 + mode: 10 + description: "Stripe mode" 11 + required: true 12 + default: "test" 13 + type: choice 14 + options: 15 + - test 16 + - live 17 + 18 + jobs: 19 + sync: 20 + runs-on: ubuntu-latest 21 + environment: ${{ github.event.inputs.mode == 'live' && 'production' || 'staging' }} 22 + steps: 23 + - uses: actions/checkout@v4 24 + - uses: actions/setup-node@v4 25 + with: 26 + node-version: 20 27 + - run: npm ci 28 + - run: npx tsx stripe/sync.ts 29 + env: 30 + STRIPE_SECRET_KEY: ${{ github.event.inputs.mode == 'live' && secrets.STRIPE_SECRET_KEY_LIVE || secrets.STRIPE_SECRET_KEY_TEST }}
+40
actions/cancelSubscription.ts
··· 1 + "use server"; 2 + 3 + import { getIdentityData } from "./getIdentityData"; 4 + import { stripe } from "stripe/client"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import { Ok, Err, type Result } from "src/result"; 7 + 8 + export async function cancelSubscription(): Promise< 9 + Result<{ cancelAt: string }, string> 10 + > { 11 + const identity = await getIdentityData(); 12 + if (!identity) { 13 + return Err("Not authenticated"); 14 + } 15 + 16 + const { data: sub } = await supabaseServerClient 17 + .from("user_subscriptions") 18 + .select("stripe_subscription_id, current_period_end") 19 + .eq("identity_id", identity.id) 20 + .single(); 21 + 22 + if (!sub?.stripe_subscription_id) { 23 + return Err("No active subscription found"); 24 + } 25 + 26 + await stripe.subscriptions.update(sub.stripe_subscription_id, { 27 + cancel_at_period_end: true, 28 + }); 29 + 30 + // Optimistic update 31 + await supabaseServerClient 32 + .from("user_subscriptions") 33 + .update({ 34 + status: "canceling", 35 + updated_at: new Date().toISOString(), 36 + }) 37 + .eq("identity_id", identity.id); 38 + 39 + return Ok({ cancelAt: sub.current_period_end || "" }); 40 + }
+62
actions/createCheckoutSession.ts
··· 1 + "use server"; 2 + 3 + import { getIdentityData } from "./getIdentityData"; 4 + import { stripe } from "stripe/client"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import { PRICE_IDS } from "stripe/products"; 7 + import { Ok, Err, type Result } from "src/result"; 8 + 9 + export async function createCheckoutSession( 10 + cadence: "month" | "year", 11 + returnUrl?: string, 12 + ): Promise<Result<{ url: string }, string>> { 13 + const identity = await getIdentityData(); 14 + if (!identity) { 15 + return Err("Not authenticated"); 16 + } 17 + 18 + const priceId = PRICE_IDS[cadence]; 19 + if (!priceId) { 20 + return Err("Price not configured. Set STRIPE_PRICE_MONTHLY_ID and STRIPE_PRICE_YEARLY_ID env vars."); 21 + } 22 + 23 + // Check for existing Stripe customer 24 + let customerId: string | undefined; 25 + const { data: existingSub } = await supabaseServerClient 26 + .from("user_subscriptions") 27 + .select("stripe_customer_id") 28 + .eq("identity_id", identity.id) 29 + .single(); 30 + 31 + if (existingSub?.stripe_customer_id) { 32 + customerId = existingSub.stripe_customer_id; 33 + } 34 + 35 + const successUrl = new URL( 36 + "/api/checkout/success", 37 + process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000", 38 + ); 39 + successUrl.searchParams.set("session_id", "{CHECKOUT_SESSION_ID}"); 40 + if (returnUrl) { 41 + successUrl.searchParams.set("return", returnUrl); 42 + } 43 + 44 + const cancelUrl = returnUrl || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; 45 + 46 + const session = await stripe.checkout.sessions.create({ 47 + mode: "subscription", 48 + line_items: [{ price: priceId, quantity: 1 }], 49 + client_reference_id: identity.id, 50 + ...(customerId 51 + ? { customer: customerId } 52 + : { customer_email: identity.email || undefined }), 53 + success_url: successUrl.toString(), 54 + cancel_url: cancelUrl, 55 + }); 56 + 57 + if (!session.url) { 58 + return Err("Failed to create checkout session"); 59 + } 60 + 61 + return Ok({ url: session.url }); 62 + }
+35 -2
actions/getIdentityData.ts
··· 34 34 leaflets_to_documents(*, documents(*)), 35 35 leaflets_in_publications(*, publications(*), documents(*)) 36 36 ) 37 - ) 37 + ), 38 + user_subscriptions(plan, status, current_period_end), 39 + user_entitlements(entitlement_key, granted_at, expires_at, source, metadata) 38 40 )`, 39 41 ) 40 42 .eq("identities.notifications.read", false) ··· 43 45 .single() 44 46 : null; 45 47 if (!auth_res?.data?.identities) return null; 48 + 49 + // Transform embedded entitlements into a keyed record, filtering expired 50 + const now = new Date().toISOString(); 51 + const entitlements: Record< 52 + string, 53 + { 54 + granted_at: string; 55 + expires_at: string | null; 56 + source: string | null; 57 + metadata: unknown; 58 + } 59 + > = {}; 60 + for (const row of auth_res.data.identities.user_entitlements || []) { 61 + if (row.expires_at && row.expires_at < now) continue; 62 + entitlements[row.entitlement_key] = { 63 + granted_at: row.granted_at, 64 + expires_at: row.expires_at, 65 + source: row.source, 66 + metadata: row.metadata, 67 + }; 68 + } 69 + 70 + const subscription = auth_res.data.identities.user_subscriptions ?? null; 71 + 46 72 if (auth_res.data.identities.atp_did) { 47 73 //I should create a relationship table so I can do this in the above query 48 74 let { data: rawPublications } = await supabaseServerClient ··· 54 80 return { 55 81 ...auth_res.data.identities, 56 82 publications, 83 + entitlements, 84 + subscription, 57 85 }; 58 86 } 59 87 60 - return { ...auth_res.data.identities, publications: [] }; 88 + return { 89 + ...auth_res.data.identities, 90 + publications: [], 91 + entitlements, 92 + subscription, 93 + }; 61 94 }
+2 -1
app/(home-pages)/home/Actions/AccountSettings.tsx
··· 14 14 import { ManageProSubscription } from "app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription"; 15 15 import { Modal } from "components/Modal"; 16 16 import { UpgradeContent } from "app/lish/[did]/[publication]/UpgradeModal"; 17 + import { useIsPro } from "src/hooks/useEntitlement"; 17 18 18 19 export const AccountSettings = (props: { entityID: string }) => { 19 20 let [state, setState] = useState< ··· 53 54 let menuItemClassName = 54 55 "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; 55 56 56 - let isPro = true; 57 + let isPro = useIsPro(); 57 58 58 59 return ( 59 60 <div className="flex flex-col gap-0.5">
+70
app/api/checkout/success/route.ts
··· 1 + import { NextRequest, NextResponse } from "next/server"; 2 + import { stripe } from "stripe/client"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { parseEntitlements } from "stripe/products"; 5 + 6 + export async function GET(req: NextRequest) { 7 + const sessionId = req.nextUrl.searchParams.get("session_id"); 8 + const returnUrl = req.nextUrl.searchParams.get("return") || "/"; 9 + 10 + if (!sessionId) { 11 + return NextResponse.redirect(new URL(returnUrl, req.url)); 12 + } 13 + 14 + try { 15 + const session = await stripe.checkout.sessions.retrieve(sessionId, { 16 + expand: ["subscription", "subscription.items.data.price.product"], 17 + }); 18 + 19 + const identityId = session.client_reference_id; 20 + const customerId = session.customer as string; 21 + const sub = 22 + typeof session.subscription === "object" ? session.subscription : null; 23 + 24 + if (identityId && sub) { 25 + const priceItem = sub.items.data[0]; 26 + const product = 27 + priceItem?.price.product && 28 + typeof priceItem.price.product === "object" && 29 + !("deleted" in priceItem.price.product) 30 + ? priceItem.price.product 31 + : null; 32 + const periodEnd = priceItem?.current_period_end ?? 0; 33 + 34 + // Optimistic upsert — idempotent with webhook handler 35 + await supabaseServerClient.from("user_subscriptions").upsert( 36 + { 37 + identity_id: identityId, 38 + stripe_customer_id: customerId, 39 + stripe_subscription_id: sub.id, 40 + plan: product?.name || "Leaflet Pro", 41 + status: sub.status, 42 + current_period_end: new Date(periodEnd * 1000).toISOString(), 43 + updated_at: new Date().toISOString(), 44 + }, 45 + { onConflict: "identity_id" }, 46 + ); 47 + 48 + const entitlements = product 49 + ? parseEntitlements(product.metadata) 50 + : { publication_analytics: true }; 51 + 52 + for (const key of Object.keys(entitlements)) { 53 + await supabaseServerClient.from("user_entitlements").upsert( 54 + { 55 + identity_id: identityId, 56 + entitlement_key: key, 57 + granted_at: new Date().toISOString(), 58 + expires_at: new Date(periodEnd * 1000).toISOString(), 59 + source: `stripe:${sub.id}`, 60 + }, 61 + { onConflict: "identity_id,entitlement_key" }, 62 + ); 63 + } 64 + } 65 + } catch (err) { 66 + console.error("Error processing checkout success:", err); 67 + } 68 + 69 + return NextResponse.redirect(new URL(returnUrl, req.url)); 70 + }
+22
app/api/inngest/client.ts
··· 61 61 }>; 62 62 }; 63 63 }; 64 + "stripe/checkout.session.completed": { 65 + data: { 66 + sessionId: string; 67 + }; 68 + }; 69 + "stripe/customer.subscription.updated": { 70 + data: { 71 + subscriptionId: string; 72 + }; 73 + }; 74 + "stripe/customer.subscription.deleted": { 75 + data: { 76 + subscriptionId: string; 77 + }; 78 + }; 79 + "stripe/invoice.payment.failed": { 80 + data: { 81 + invoiceId: string; 82 + subscriptionId: string; 83 + customerId: string; 84 + }; 85 + }; 64 86 }; 65 87 66 88 // Create a client to send and receive events
+82
app/api/inngest/functions/stripe_handle_checkout_completed.ts
··· 1 + import { inngest } from "../client"; 2 + import { stripe } from "stripe/client"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { parseEntitlements } from "stripe/products"; 5 + 6 + export const stripe_handle_checkout_completed = inngest.createFunction( 7 + { id: "stripe-handle-checkout-completed" }, 8 + { event: "stripe/checkout.session.completed" }, 9 + async ({ event, step }) => { 10 + const session = await step.run("fetch-checkout-session", async () => { 11 + const s = await stripe.checkout.sessions.retrieve(event.data.sessionId, { 12 + expand: ["subscription", "subscription.items.data.price.product"], 13 + }); 14 + const sub = 15 + typeof s.subscription === "object" ? s.subscription : null; 16 + const priceItem = sub?.items.data[0]; 17 + const product = 18 + priceItem?.price.product && 19 + typeof priceItem.price.product === "object" && 20 + !("deleted" in priceItem.price.product) 21 + ? priceItem.price.product 22 + : null; 23 + const periodEnd = priceItem?.current_period_end ?? 0; 24 + 25 + return { 26 + identityId: s.client_reference_id, 27 + customerId: s.customer as string, 28 + subId: sub?.id ?? null, 29 + subStatus: sub?.status ?? null, 30 + periodEnd, 31 + productName: product?.name || "Leaflet Pro", 32 + productMetadata: product?.metadata ?? null, 33 + }; 34 + }); 35 + 36 + if (!session.identityId || !session.subId) { 37 + throw new Error("Missing client_reference_id or subscription"); 38 + } 39 + 40 + await step.run("upsert-subscription-and-entitlements", async () => { 41 + // Upsert user_subscriptions 42 + await supabaseServerClient.from("user_subscriptions").upsert( 43 + { 44 + identity_id: session.identityId!, 45 + stripe_customer_id: session.customerId, 46 + stripe_subscription_id: session.subId!, 47 + plan: session.productName, 48 + status: session.subStatus, 49 + current_period_end: new Date( 50 + session.periodEnd * 1000, 51 + ).toISOString(), 52 + updated_at: new Date().toISOString(), 53 + }, 54 + { onConflict: "identity_id" }, 55 + ); 56 + 57 + // Parse entitlements from product metadata and upsert 58 + const entitlements = session.productMetadata 59 + ? parseEntitlements( 60 + session.productMetadata as Record<string, string>, 61 + ) 62 + : { publication_analytics: true }; 63 + 64 + for (const key of Object.keys(entitlements)) { 65 + await supabaseServerClient.from("user_entitlements").upsert( 66 + { 67 + identity_id: session.identityId!, 68 + entitlement_key: key, 69 + granted_at: new Date().toISOString(), 70 + expires_at: new Date( 71 + session.periodEnd * 1000, 72 + ).toISOString(), 73 + source: `stripe:${session.subId}`, 74 + }, 75 + { onConflict: "identity_id,entitlement_key" }, 76 + ); 77 + } 78 + }); 79 + 80 + return { success: true }; 81 + }, 82 + );
+23
app/api/inngest/functions/stripe_handle_invoice_payment_failed.ts
··· 1 + import { inngest } from "../client"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + 4 + export const stripe_handle_invoice_payment_failed = inngest.createFunction( 5 + { id: "stripe-handle-invoice-payment-failed" }, 6 + { event: "stripe/invoice.payment.failed" }, 7 + async ({ event, step }) => { 8 + await step.run("mark-subscription-past-due", async () => { 9 + if (event.data.subscriptionId) { 10 + await supabaseServerClient 11 + .from("user_subscriptions") 12 + .update({ 13 + status: "past_due", 14 + updated_at: new Date().toISOString(), 15 + }) 16 + .eq("stripe_subscription_id", event.data.subscriptionId); 17 + } 18 + }); 19 + 20 + // Entitlements remain valid until expires_at 21 + return { success: true }; 22 + }, 23 + );
+21
app/api/inngest/functions/stripe_handle_subscription_deleted.ts
··· 1 + import { inngest } from "../client"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + 4 + export const stripe_handle_subscription_deleted = inngest.createFunction( 5 + { id: "stripe-handle-subscription-deleted" }, 6 + { event: "stripe/customer.subscription.deleted" }, 7 + async ({ event, step }) => { 8 + await step.run("mark-subscription-canceled", async () => { 9 + await supabaseServerClient 10 + .from("user_subscriptions") 11 + .update({ 12 + status: "canceled", 13 + updated_at: new Date().toISOString(), 14 + }) 15 + .eq("stripe_subscription_id", event.data.subscriptionId); 16 + }); 17 + 18 + // Entitlements expire naturally via expires_at — no need to delete them 19 + return { success: true }; 20 + }, 21 + );
+84
app/api/inngest/functions/stripe_handle_subscription_updated.ts
··· 1 + import { inngest } from "../client"; 2 + import { stripe } from "stripe/client"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { parseEntitlements } from "stripe/products"; 5 + 6 + export const stripe_handle_subscription_updated = inngest.createFunction( 7 + { id: "stripe-handle-subscription-updated" }, 8 + { event: "stripe/customer.subscription.updated" }, 9 + async ({ event, step }) => { 10 + const subData = await step.run("fetch-subscription", async () => { 11 + const sub = await stripe.subscriptions.retrieve( 12 + event.data.subscriptionId, 13 + { expand: ["items.data.price.product"] }, 14 + ); 15 + const priceItem = sub.items.data[0]; 16 + const product = 17 + priceItem?.price.product && 18 + typeof priceItem.price.product === "object" && 19 + !("deleted" in priceItem.price.product) 20 + ? priceItem.price.product 21 + : null; 22 + const periodEnd = priceItem?.current_period_end ?? 0; 23 + 24 + return { 25 + id: sub.id, 26 + customerId: sub.customer as string, 27 + status: sub.status, 28 + periodEnd, 29 + productName: product?.name || "Leaflet Pro", 30 + productMetadata: product?.metadata ?? null, 31 + }; 32 + }); 33 + 34 + await step.run("update-subscription-and-entitlements", async () => { 35 + // Find the identity by stripe_customer_id 36 + const { data: existingSub } = await supabaseServerClient 37 + .from("user_subscriptions") 38 + .select("identity_id") 39 + .eq("stripe_customer_id", subData.customerId) 40 + .single(); 41 + 42 + if (!existingSub) { 43 + console.warn( 44 + `No subscription record for customer ${subData.customerId}`, 45 + ); 46 + return; 47 + } 48 + 49 + // Update subscription record 50 + await supabaseServerClient 51 + .from("user_subscriptions") 52 + .update({ 53 + status: subData.status, 54 + plan: subData.productName, 55 + current_period_end: new Date( 56 + subData.periodEnd * 1000, 57 + ).toISOString(), 58 + updated_at: new Date().toISOString(), 59 + }) 60 + .eq("identity_id", existingSub.identity_id); 61 + 62 + // Update entitlement expiry dates for all entitlements from this subscription 63 + const entitlements = subData.productMetadata 64 + ? parseEntitlements( 65 + subData.productMetadata as Record<string, string>, 66 + ) 67 + : {}; 68 + for (const key of Object.keys(entitlements)) { 69 + await supabaseServerClient 70 + .from("user_entitlements") 71 + .update({ 72 + expires_at: new Date( 73 + subData.periodEnd * 1000, 74 + ).toISOString(), 75 + }) 76 + .eq("identity_id", existingSub.identity_id) 77 + .eq("entitlement_key", key) 78 + .eq("source", `stripe:${subData.id}`); 79 + } 80 + }); 81 + 82 + return { success: true }; 83 + }, 84 + );
+8
app/api/inngest/route.tsx
··· 13 13 check_oauth_session, 14 14 } from "./functions/cleanup_expired_oauth_sessions"; 15 15 import { write_records_to_pds } from "./functions/write_records_to_pds"; 16 + import { stripe_handle_checkout_completed } from "./functions/stripe_handle_checkout_completed"; 17 + import { stripe_handle_subscription_updated } from "./functions/stripe_handle_subscription_updated"; 18 + import { stripe_handle_subscription_deleted } from "./functions/stripe_handle_subscription_deleted"; 19 + import { stripe_handle_invoice_payment_failed } from "./functions/stripe_handle_invoice_payment_failed"; 16 20 17 21 export const { GET, POST, PUT } = serve({ 18 22 client: inngest, ··· 28 32 cleanup_expired_oauth_sessions, 29 33 check_oauth_session, 30 34 write_records_to_pds, 35 + stripe_handle_checkout_completed, 36 + stripe_handle_subscription_updated, 37 + stripe_handle_subscription_deleted, 38 + stripe_handle_invoice_payment_failed, 31 39 ], 32 40 });
+67
app/api/webhooks/stripe/route.ts
··· 1 + import { NextRequest, NextResponse } from "next/server"; 2 + import { stripe } from "stripe/client"; 3 + import { inngest } from "app/api/inngest/client"; 4 + 5 + export async function POST(req: NextRequest) { 6 + const body = await req.text(); 7 + const signature = req.headers.get("stripe-signature"); 8 + if (!signature) { 9 + return NextResponse.json({ error: "Missing signature" }, { status: 400 }); 10 + } 11 + 12 + let event; 13 + try { 14 + event = stripe.webhooks.constructEvent( 15 + body, 16 + signature, 17 + process.env.STRIPE_WEBHOOK_SECRET as string, 18 + ); 19 + } catch (err) { 20 + console.error("Stripe webhook signature verification failed:", err); 21 + return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); 22 + } 23 + 24 + switch (event.type) { 25 + case "checkout.session.completed": 26 + await inngest.send({ 27 + name: "stripe/checkout.session.completed", 28 + data: { sessionId: event.data.object.id }, 29 + }); 30 + break; 31 + 32 + case "customer.subscription.created": 33 + case "customer.subscription.updated": 34 + await inngest.send({ 35 + name: "stripe/customer.subscription.updated", 36 + data: { subscriptionId: event.data.object.id }, 37 + }); 38 + break; 39 + 40 + case "customer.subscription.deleted": 41 + await inngest.send({ 42 + name: "stripe/customer.subscription.deleted", 43 + data: { subscriptionId: event.data.object.id }, 44 + }); 45 + break; 46 + 47 + case "invoice.payment_failed": { 48 + const invoice = event.data.object; 49 + const subDetails = invoice.parent?.subscription_details; 50 + const subId = 51 + typeof subDetails?.subscription === "string" 52 + ? subDetails.subscription 53 + : subDetails?.subscription?.id || ""; 54 + await inngest.send({ 55 + name: "stripe/invoice.payment.failed", 56 + data: { 57 + invoiceId: invoice.id, 58 + subscriptionId: subId, 59 + customerId: invoice.customer as string, 60 + }, 61 + }); 62 + break; 63 + } 64 + } 65 + 66 + return NextResponse.json({ received: true }); 67 + }
+27 -2
app/lish/[did]/[publication]/UpgradeModal.tsx
··· 1 1 import { ButtonPrimary } from "components/Buttons"; 2 2 import { Modal } from "components/Modal"; 3 3 import { useState } from "react"; 4 + import { createCheckoutSession } from "actions/createCheckoutSession"; 5 + import { DotLoader } from "components/utils/DotLoader"; 4 6 5 7 export const UpgradeContent = () => { 6 8 let [cadence, setCadence] = useState<"year" | "month">("year"); 9 + let [loading, setLoading] = useState(false); 10 + let [error, setError] = useState<string | null>(null); 11 + 12 + async function handleCheckout() { 13 + setLoading(true); 14 + setError(null); 15 + let result = await createCheckoutSession(cadence, window.location.href); 16 + if (result.ok) { 17 + window.location.href = result.value.url; 18 + } else { 19 + setError(result.error); 20 + setLoading(false); 21 + } 22 + } 23 + 7 24 return ( 8 25 <div className="flex flex-col justify-center text-center sm:gap-4 gap-2 w-full"> 9 26 <h2>Get Leaflet Pro!</h2> ··· 42 59 {cadence === "year" ? "/year" : "/month"} 43 60 </div> 44 61 </div> 45 - <ButtonPrimary fullWidth className="mx-auto"> 46 - Get it! 62 + <ButtonPrimary 63 + fullWidth 64 + className="mx-auto" 65 + onClick={handleCheckout} 66 + disabled={loading} 67 + > 68 + {loading ? <DotLoader /> : "Get it!"} 47 69 </ButtonPrimary> 70 + {error && ( 71 + <div className="text-sm text-red-500 mt-2">{error}</div> 72 + )} 48 73 </div> 49 74 </div> 50 75 </div>
+3 -1
app/lish/[did]/[publication]/dashboard/Actions.tsx
··· 13 13 import { ButtonSecondary, ButtonTertiary } from "components/Buttons"; 14 14 import { UpgradeModal } from "../UpgradeModal"; 15 15 import { LeafletPro } from "components/Icons/LeafletPro"; 16 + import { useIsPro } from "src/hooks/useEntitlement"; 16 17 17 18 export const Actions = (props: { publication: string }) => { 19 + let isPro = useIsPro(); 18 20 return ( 19 21 <> 20 22 <NewDraftActionButton publication={props.publication} /> 21 - <MobileUpgrade /> 23 + {!isPro && <MobileUpgrade />} 22 24 23 25 <PublicationShareButton /> 24 26 <PublicationSettingsButton publication={props.publication} />
+2 -1
app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx
··· 11 11 ComboboxResult, 12 12 useComboboxState, 13 13 } from "components/Combobox"; 14 + import { useIsPro } from "src/hooks/useEntitlement"; 14 15 15 16 type referrorType = { iconSrc: string; name: string; viewCount: string }; 16 17 let refferors = [ ··· 21 22 ]; 22 23 23 24 export const PublicationAnalytics = () => { 24 - let isPro = true; 25 + let isPro = useIsPro(); 25 26 26 27 let { data: publication } = usePublicationData(); 27 28 let [dateRange, setDateRange] = useState<DateRange>({ from: undefined });
+52 -13
app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription.tsx
··· 1 1 import { useState } from "react"; 2 2 import { ButtonPrimary } from "components/Buttons"; 3 3 import { PubSettingsHeader } from "./PublicationSettings"; 4 + import { cancelSubscription } from "actions/cancelSubscription"; 5 + import { useIdentityData } from "components/IdentityProvider"; 6 + import { DotLoader } from "components/utils/DotLoader"; 7 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 4 8 5 9 export const ManageProSubscription = (props: { backToMenu: () => void }) => { 6 10 const [state, setState] = useState<"manage" | "confirm" | "success">( 7 11 "manage", 8 12 ); 13 + const [loading, setLoading] = useState(false); 14 + const [error, setError] = useState<string | null>(null); 15 + const { identity, mutate } = useIdentityData(); 16 + 17 + const subscription = identity?.subscription; 18 + const renewalDate = useLocalizedDate( 19 + subscription?.current_period_end || new Date().toISOString(), 20 + { month: "long", day: "numeric" }, 21 + ); 22 + 23 + async function handleCancel() { 24 + setLoading(true); 25 + setError(null); 26 + let result = await cancelSubscription(); 27 + if (result.ok) { 28 + setState("success"); 29 + mutate(); 30 + } else { 31 + setError(result.error); 32 + } 33 + setLoading(false); 34 + } 9 35 10 36 return ( 11 37 <div> ··· 17 43 <> 18 44 <div> 19 45 You have a <br /> 20 - Pro monthly subscription 21 - <div className="text-lg font-bold text-primary">$12/mo </div> 22 - Renews on the 12th 46 + {subscription?.plan || "Pro"} subscription 47 + <div className="text-lg font-bold text-primary"> 48 + {subscription?.plan || "Leaflet Pro"} 49 + </div> 50 + {subscription?.status === "canceling" 51 + ? `Access until ${renewalDate}` 52 + : `Renews on ${renewalDate}`} 23 53 </div> 24 - <ButtonPrimary 25 - className="mx-auto" 26 - compact 27 - onClick={() => setState("confirm")} 28 - > 29 - Cancel Subscription 30 - </ButtonPrimary> 54 + {subscription?.status !== "canceling" && ( 55 + <ButtonPrimary 56 + className="mx-auto" 57 + compact 58 + onClick={() => setState("confirm")} 59 + > 60 + Cancel Subscription 61 + </ButtonPrimary> 62 + )} 31 63 </> 32 64 )} 33 65 {state === "confirm" && ( ··· 36 68 <ButtonPrimary 37 69 className="mx-auto" 38 70 compact 39 - onClick={() => setState("success")} 71 + onClick={handleCancel} 72 + disabled={loading} 40 73 > 41 - Yes, Cancel it 74 + {loading ? <DotLoader /> : "Yes, Cancel it"} 42 75 </ButtonPrimary> 76 + {error && ( 77 + <div className="text-sm text-red-500 mt-2">{error}</div> 78 + )} 43 79 </> 44 80 )} 45 81 {state === "success" && ( 46 - <div>Your subscription has been successfully cancelled!</div> 82 + <div> 83 + Your subscription has been cancelled. You'll have access until{" "} 84 + {renewalDate}. 85 + </div> 47 86 )} 48 87 </div> 49 88 </div>
+2 -1
app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
··· 16 16 import { UpgradeContent } from "../../UpgradeModal"; 17 17 import { Modal } from "components/Modal"; 18 18 import { ManageProSubscription } from "./ManageProSubscription"; 19 + import { useIsPro } from "src/hooks/useEntitlement"; 19 20 20 21 type menuState = 21 22 | "menu" ··· 85 86 }) => { 86 87 let menuItemClassName = 87 88 "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; 88 - let isPro = true; 89 + let isPro = useIsPro(); 89 90 90 91 return ( 91 92 <div className="flex flex-col gap-0.5">
-1
app/lish/createPub/UpdatePubForm.tsx
··· 99 99 loading={props.loading} 100 100 setLoadingAction={props.setLoadingAction} 101 101 backToMenuAction={props.backToMenuAction} 102 - state={"theme"} 103 102 > 104 103 General Settings 105 104 </PubSettingsHeader>
-1
components/ThemeManager/PubThemeSetter.tsx
··· 119 119 loading={props.loading} 120 120 setLoadingAction={props.setLoading} 121 121 backToMenuAction={props.backToMenu} 122 - state={"theme"} 123 122 > 124 123 Theme and Layout 125 124 </PubSettingsHeader>
+33
drizzle/schema.ts
··· 412 412 publication_idx: index("leaflets_in_publications_publication_idx").on(table.publication), 413 413 leaflets_in_publications_pkey: primaryKey({ columns: [table.publication, table.leaflet], name: "leaflets_in_publications_pkey"}), 414 414 } 415 + }); 416 + 417 + export const user_subscriptions = pgTable("user_subscriptions", { 418 + identity_id: uuid("identity_id").primaryKey().notNull().references(() => identities.id, { onDelete: "cascade" }), 419 + stripe_customer_id: text("stripe_customer_id").notNull(), 420 + stripe_subscription_id: text("stripe_subscription_id"), 421 + plan: text("plan"), 422 + status: text("status"), 423 + current_period_end: timestamp("current_period_end", { withTimezone: true, mode: 'string' }), 424 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 425 + updated_at: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 426 + }, 427 + (table) => { 428 + return { 429 + user_subscriptions_stripe_customer_id_key: unique("user_subscriptions_stripe_customer_id_key").on(table.stripe_customer_id), 430 + user_subscriptions_stripe_subscription_id_key: unique("user_subscriptions_stripe_subscription_id_key").on(table.stripe_subscription_id), 431 + } 432 + }); 433 + 434 + export const user_entitlements = pgTable("user_entitlements", { 435 + identity_id: uuid("identity_id").notNull().references(() => identities.id, { onDelete: "cascade" }), 436 + entitlement_key: text("entitlement_key").notNull(), 437 + granted_at: timestamp("granted_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 438 + expires_at: timestamp("expires_at", { withTimezone: true, mode: 'string' }), 439 + source: text("source"), 440 + metadata: jsonb("metadata"), 441 + }, 442 + (table) => { 443 + return { 444 + identity_id_idx: index("user_entitlements_identity_id_idx").on(table.identity_id), 445 + expires_at_idx: index("user_entitlements_expires_at_idx").on(table.expires_at), 446 + user_entitlements_pkey: primaryKey({ columns: [table.identity_id, table.entitlement_key], name: "user_entitlements_pkey"}), 447 + } 415 448 });
+18
package-lock.json
··· 77 77 "replicache": "^15.3.0", 78 78 "sharp": "^0.34.4", 79 79 "shiki": "^3.8.1", 80 + "stripe": "^20.4.0", 80 81 "swr": "^2.3.3", 81 82 "thumbhash": "^0.1.1", 82 83 "twilio": "^5.3.7", ··· 18232 18233 }, 18233 18234 "funding": { 18234 18235 "url": "https://github.com/sponsors/sindresorhus" 18236 + } 18237 + }, 18238 + "node_modules/stripe": { 18239 + "version": "20.4.0", 18240 + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.0.tgz", 18241 + "integrity": "sha512-F/aN1IQ9vHmlyLNi3DkiIbyzQb6gyBG0uYFd/VrEVQSc9BLtlgknPUx0EvzZdBMRLFuRaPFIFd7Mxwtg7Pbwzw==", 18242 + "license": "MIT", 18243 + "engines": { 18244 + "node": ">=16" 18245 + }, 18246 + "peerDependencies": { 18247 + "@types/node": ">=16" 18248 + }, 18249 + "peerDependenciesMeta": { 18250 + "@types/node": { 18251 + "optional": true 18252 + } 18235 18253 } 18236 18254 }, 18237 18255 "node_modules/style-to-object": {
+1
package.json
··· 88 88 "replicache": "^15.3.0", 89 89 "sharp": "^0.34.4", 90 90 "shiki": "^3.8.1", 91 + "stripe": "^20.4.0", 91 92 "swr": "^2.3.3", 92 93 "thumbhash": "^0.1.1", 93 94 "twilio": "^5.3.7",
+11
src/hooks/useEntitlement.ts
··· 1 + import { useIdentityData } from "components/IdentityProvider"; 2 + 3 + export function useHasEntitlement(key: string): boolean { 4 + const { identity } = useIdentityData(); 5 + if (!identity?.entitlements) return false; 6 + return key in identity.entitlements; 7 + } 8 + 9 + export function useIsPro(): boolean { 10 + return useHasEntitlement("publication_analytics"); 11 + }
+5
stripe/client.ts
··· 1 + import Stripe from "stripe"; 2 + 3 + export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, { 4 + apiVersion: "2026-02-25.clover", 5 + });
+41
stripe/products.ts
··· 1 + export const PRODUCT_DEF_ID = "leaflet_pro_v1"; 2 + 3 + export const PRODUCT_DEFINITION = { 4 + name: "Leaflet Pro", 5 + metadata: { 6 + product_def_id: PRODUCT_DEF_ID, 7 + entitlements: JSON.stringify({ publication_analytics: true }), 8 + }, 9 + }; 10 + 11 + export const PRICE_DEFINITIONS = { 12 + month: { 13 + lookup_key: "leaflet_pro_monthly_v1_usd", 14 + unit_amount: 1200, 15 + currency: "usd", 16 + recurring: { interval: "month" as const }, 17 + }, 18 + year: { 19 + lookup_key: "leaflet_pro_yearly_v1_usd", 20 + unit_amount: 12000, 21 + currency: "usd", 22 + recurring: { interval: "year" as const }, 23 + }, 24 + }; 25 + 26 + // Populated at runtime by sync script or looked up dynamically 27 + export const PRICE_IDS: Record<"month" | "year", string> = { 28 + month: process.env.STRIPE_PRICE_MONTHLY_ID || "", 29 + year: process.env.STRIPE_PRICE_YEARLY_ID || "", 30 + }; 31 + 32 + export function parseEntitlements( 33 + metadata: Record<string, string> | null, 34 + ): Record<string, boolean> { 35 + if (!metadata?.entitlements) return {}; 36 + try { 37 + return JSON.parse(metadata.entitlements); 38 + } catch { 39 + return {}; 40 + } 41 + }
+62
stripe/sync.ts
··· 1 + import Stripe from "stripe"; 2 + import { PRODUCT_DEF_ID, PRODUCT_DEFINITION, PRICE_DEFINITIONS } from "./products"; 3 + 4 + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, { 5 + apiVersion: "2026-02-25.clover", 6 + }); 7 + 8 + async function sync() { 9 + console.log("Syncing Stripe products and prices..."); 10 + 11 + // Find or create product 12 + let product: Stripe.Product | undefined; 13 + const existing = await stripe.products.search({ 14 + query: `metadata["product_def_id"]:"${PRODUCT_DEF_ID}"`, 15 + }); 16 + 17 + if (existing.data.length > 0) { 18 + product = existing.data[0]; 19 + console.log(`Found existing product: ${product.id}`); 20 + // Update if name or metadata changed 21 + product = await stripe.products.update(product.id, { 22 + name: PRODUCT_DEFINITION.name, 23 + metadata: PRODUCT_DEFINITION.metadata, 24 + }); 25 + console.log(`Updated product: ${product.id}`); 26 + } else { 27 + product = await stripe.products.create({ 28 + name: PRODUCT_DEFINITION.name, 29 + metadata: PRODUCT_DEFINITION.metadata, 30 + }); 31 + console.log(`Created product: ${product.id}`); 32 + } 33 + 34 + // Sync prices by lookup_key 35 + for (const [cadence, def] of Object.entries(PRICE_DEFINITIONS)) { 36 + const existingPrices = await stripe.prices.list({ 37 + lookup_keys: [def.lookup_key], 38 + }); 39 + 40 + if (existingPrices.data.length > 0) { 41 + console.log( 42 + `Price "${def.lookup_key}" already exists: ${existingPrices.data[0].id}`, 43 + ); 44 + } else { 45 + const price = await stripe.prices.create({ 46 + product: product.id, 47 + unit_amount: def.unit_amount, 48 + currency: def.currency, 49 + recurring: def.recurring, 50 + lookup_key: def.lookup_key, 51 + }); 52 + console.log(`Created price "${def.lookup_key}": ${price.id}`); 53 + } 54 + } 55 + 56 + console.log("Sync complete."); 57 + } 58 + 59 + sync().catch((err) => { 60 + console.error("Sync failed:", err); 61 + process.exit(1); 62 + });
+76
supabase/database.types.ts
··· 1295 1295 }, 1296 1296 ] 1297 1297 } 1298 + user_entitlements: { 1299 + Row: { 1300 + entitlement_key: string 1301 + expires_at: string | null 1302 + granted_at: string 1303 + identity_id: string 1304 + metadata: Json | null 1305 + source: string | null 1306 + } 1307 + Insert: { 1308 + entitlement_key: string 1309 + expires_at?: string | null 1310 + granted_at?: string 1311 + identity_id: string 1312 + metadata?: Json | null 1313 + source?: string | null 1314 + } 1315 + Update: { 1316 + entitlement_key?: string 1317 + expires_at?: string | null 1318 + granted_at?: string 1319 + identity_id?: string 1320 + metadata?: Json | null 1321 + source?: string | null 1322 + } 1323 + Relationships: [ 1324 + { 1325 + foreignKeyName: "user_entitlements_identity_id_fkey" 1326 + columns: ["identity_id"] 1327 + isOneToOne: false 1328 + referencedRelation: "identities" 1329 + referencedColumns: ["id"] 1330 + }, 1331 + ] 1332 + } 1333 + user_subscriptions: { 1334 + Row: { 1335 + created_at: string 1336 + current_period_end: string | null 1337 + identity_id: string 1338 + plan: string | null 1339 + status: string | null 1340 + stripe_customer_id: string 1341 + stripe_subscription_id: string | null 1342 + updated_at: string 1343 + } 1344 + Insert: { 1345 + created_at?: string 1346 + current_period_end?: string | null 1347 + identity_id: string 1348 + plan?: string | null 1349 + status?: string | null 1350 + stripe_customer_id: string 1351 + stripe_subscription_id?: string | null 1352 + updated_at?: string 1353 + } 1354 + Update: { 1355 + created_at?: string 1356 + current_period_end?: string | null 1357 + identity_id?: string 1358 + plan?: string | null 1359 + status?: string | null 1360 + stripe_customer_id?: string 1361 + stripe_subscription_id?: string | null 1362 + updated_at?: string 1363 + } 1364 + Relationships: [ 1365 + { 1366 + foreignKeyName: "user_subscriptions_identity_id_fkey" 1367 + columns: ["identity_id"] 1368 + isOneToOne: true 1369 + referencedRelation: "identities" 1370 + referencedColumns: ["id"] 1371 + }, 1372 + ] 1373 + } 1298 1374 } 1299 1375 Views: { 1300 1376 [_ in never]: never
+93
supabase/migrations/20260225000000_add_subscription_tables.sql
··· 1 + -- user_subscriptions: tracks Stripe subscription state per identity 2 + create table "public"."user_subscriptions" ( 3 + "identity_id" uuid not null, 4 + "stripe_customer_id" text not null, 5 + "stripe_subscription_id" text, 6 + "plan" text, 7 + "status" text, 8 + "current_period_end" timestamp with time zone, 9 + "created_at" timestamp with time zone not null default now(), 10 + "updated_at" timestamp with time zone not null default now() 11 + ); 12 + 13 + alter table "public"."user_subscriptions" enable row level security; 14 + 15 + CREATE UNIQUE INDEX user_subscriptions_pkey ON public.user_subscriptions USING btree (identity_id); 16 + 17 + alter table "public"."user_subscriptions" add constraint "user_subscriptions_pkey" PRIMARY KEY using index "user_subscriptions_pkey"; 18 + 19 + CREATE UNIQUE INDEX user_subscriptions_stripe_customer_id_key ON public.user_subscriptions USING btree (stripe_customer_id); 20 + 21 + CREATE UNIQUE INDEX user_subscriptions_stripe_subscription_id_key ON public.user_subscriptions USING btree (stripe_subscription_id); 22 + 23 + alter table "public"."user_subscriptions" add constraint "user_subscriptions_identity_id_fkey" FOREIGN KEY (identity_id) REFERENCES identities(id) ON DELETE CASCADE; 24 + 25 + grant delete on table "public"."user_subscriptions" to "anon"; 26 + grant insert on table "public"."user_subscriptions" to "anon"; 27 + grant references on table "public"."user_subscriptions" to "anon"; 28 + grant select on table "public"."user_subscriptions" to "anon"; 29 + grant trigger on table "public"."user_subscriptions" to "anon"; 30 + grant truncate on table "public"."user_subscriptions" to "anon"; 31 + grant update on table "public"."user_subscriptions" to "anon"; 32 + 33 + grant delete on table "public"."user_subscriptions" to "authenticated"; 34 + grant insert on table "public"."user_subscriptions" to "authenticated"; 35 + grant references on table "public"."user_subscriptions" to "authenticated"; 36 + grant select on table "public"."user_subscriptions" to "authenticated"; 37 + grant trigger on table "public"."user_subscriptions" to "authenticated"; 38 + grant truncate on table "public"."user_subscriptions" to "authenticated"; 39 + grant update on table "public"."user_subscriptions" to "authenticated"; 40 + 41 + grant delete on table "public"."user_subscriptions" to "service_role"; 42 + grant insert on table "public"."user_subscriptions" to "service_role"; 43 + grant references on table "public"."user_subscriptions" to "service_role"; 44 + grant select on table "public"."user_subscriptions" to "service_role"; 45 + grant trigger on table "public"."user_subscriptions" to "service_role"; 46 + grant truncate on table "public"."user_subscriptions" to "service_role"; 47 + grant update on table "public"."user_subscriptions" to "service_role"; 48 + 49 + -- user_entitlements: feature access decoupled from billing 50 + create table "public"."user_entitlements" ( 51 + "identity_id" uuid not null, 52 + "entitlement_key" text not null, 53 + "granted_at" timestamp with time zone not null default now(), 54 + "expires_at" timestamp with time zone, 55 + "source" text, 56 + "metadata" jsonb 57 + ); 58 + 59 + alter table "public"."user_entitlements" enable row level security; 60 + 61 + CREATE UNIQUE INDEX user_entitlements_pkey ON public.user_entitlements USING btree (identity_id, entitlement_key); 62 + 63 + alter table "public"."user_entitlements" add constraint "user_entitlements_pkey" PRIMARY KEY using index "user_entitlements_pkey"; 64 + 65 + CREATE INDEX user_entitlements_identity_id_idx ON public.user_entitlements USING btree (identity_id); 66 + 67 + CREATE INDEX user_entitlements_expires_at_idx ON public.user_entitlements USING btree (expires_at); 68 + 69 + alter table "public"."user_entitlements" add constraint "user_entitlements_identity_id_fkey" FOREIGN KEY (identity_id) REFERENCES identities(id) ON DELETE CASCADE; 70 + 71 + grant delete on table "public"."user_entitlements" to "anon"; 72 + grant insert on table "public"."user_entitlements" to "anon"; 73 + grant references on table "public"."user_entitlements" to "anon"; 74 + grant select on table "public"."user_entitlements" to "anon"; 75 + grant trigger on table "public"."user_entitlements" to "anon"; 76 + grant truncate on table "public"."user_entitlements" to "anon"; 77 + grant update on table "public"."user_entitlements" to "anon"; 78 + 79 + grant delete on table "public"."user_entitlements" to "authenticated"; 80 + grant insert on table "public"."user_entitlements" to "authenticated"; 81 + grant references on table "public"."user_entitlements" to "authenticated"; 82 + grant select on table "public"."user_entitlements" to "authenticated"; 83 + grant trigger on table "public"."user_entitlements" to "authenticated"; 84 + grant truncate on table "public"."user_entitlements" to "authenticated"; 85 + grant update on table "public"."user_entitlements" to "authenticated"; 86 + 87 + grant delete on table "public"."user_entitlements" to "service_role"; 88 + grant insert on table "public"."user_entitlements" to "service_role"; 89 + grant references on table "public"."user_entitlements" to "service_role"; 90 + grant select on table "public"."user_entitlements" to "service_role"; 91 + grant trigger on table "public"."user_entitlements" to "service_role"; 92 + grant truncate on table "public"."user_entitlements" to "service_role"; 93 + grant update on table "public"."user_entitlements" to "service_role";