From 57ffd3dc6a140e399f527b21f219e193d7f9e48b Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 10 Mar 2026 13:45:30 -0400 Subject: [PATCH] Implement new pro plan, analytics page, and entitlements flow Squashed commit of the following: commit ab13965a5b79610ca9e4ccfef7ef39350c96927d Author: Jared Pereira Date: Tue Mar 10 13:42:44 2026 -0400 analytics tab behind feature gate commit 79cbb5a86c73d61cfab7ecae823f9c20aae52057 Author: Jared Pereira Date: Tue Mar 10 13:35:22 2026 -0400 add modal and feature gate commit 914b770c9b3dc9f2d1567e065d108e60ece5a798 Author: Jared Pereira Date: Tue Mar 10 00:24:02 2026 -0400 use stripe billing portal commit 8119e5a4433464e718db65248f60b1aa3443287e Merge: 56bcf7f0 c48ba9ba Author: Jared Pereira Date: Mon Mar 9 23:24:24 2026 -0400 Merge branch 'main' into feature/analytics commit 56bcf7f05221066770cb74436c1659e3201c70d0 Author: Jared Pereira Date: Thu Mar 5 20:50:57 2026 -0500 make dashaboard responsive commit 5125657c948dcf93d750f0f09f7ab082daef660b Author: Jared Pereira Date: Wed Mar 4 16:11:41 2026 -0500 Add unique visitors metric, improve analytics dashboard UX Add unique visitors (via deviceId) to Tinybird traffic query with a pageviews/visitors toggle. Reorder dashboard to show traffic first, default to last week date range with preset labels, and polish chart styling and loading states. Co-Authored-By: Claude Opus 4.6 commit a1c8a4c0f6bd65f0abe52c21f02f3be6b43be54e Author: Jared Pereira Date: Tue Mar 3 15:55:45 2026 -0500 Wire up Tinybird analytics to publication dashboard with Recharts Add real data fetching via SWR for traffic, referrers, and subscriber timeseries. Replace placeholder chart divs with Recharts AreaCharts, wire up live referrer data, and fix PostSelector to pass URL paths instead of titles to the analytics API. Co-Authored-By: Claude Opus 4.6 commit 64f88c76955198fdb4f80f341a97739a4514be70 Author: Jared Pereira Date: Thu Feb 26 17:14:01 2026 -0500 fix some edge cases commit cbdf48a0599f16246e84851f5be924803decdd32 Author: Jared Pereira Date: Wed Feb 25 18:54:07 2026 -0500 add drizzle schema commit 5a6b1d4f2652bf3e2e4d9fb0d187bfdb8b86ee04 Author: Jared Pereira Date: Wed Feb 25 18:53:52 2026 -0500 get stripe async commit eaf8239f4ab2534b39f824dfafe69669377c0f39 Author: Jared Pereira Date: Wed Feb 25 18:52:31 2026 -0500 Add reactivate subscription action and UI Allow users to undo a cancelled Pro subscription by setting cancel_at_period_end back to false on Stripe. Shows a "Reactivate Subscription" button when status is "canceling". Co-Authored-By: Claude Opus 4.6 commit 0db529d5e1c6589e3d719985a7428c07cb4be36f Merge: 03dbdba1 da31fd1d Author: Jared Pereira Date: Wed Feb 25 17:28:37 2026 -0500 Merge main into feature/analytics Resolve merge conflicts: - AccountSettings.tsx: keep expanded settings menu with Pro subscription - inngest/route.tsx: include both stripe functions and sync_document_metadata - ProfileHeader.tsx: trivial whitespace conflict - Install missing @yornaath/batshit dependency from main Co-Authored-By: Claude Opus 4.6 commit 03dbdba151e1a1c1f18d348c0fbb60c97e29343b Author: Jared Pereira Date: Wed Feb 25 17:17:31 2026 -0500 wip first attempt at wiring up pro tier commit c047cc8212679652f337020bdd3679c0ad198a0f Author: celine Date: Mon Feb 23 02:45:15 2026 -0500 styling the setting button a little commit 458b466251729193984dcb29555ce4249a856d66 Author: celine Date: Mon Feb 23 02:44:19 2026 -0500 added a manage pro sunscription to account and publication settings commit 1adcb2ab1a2c1e6860186b75685f4e4736a8f788 Author: celine Date: Sat Feb 21 10:46:55 2026 -0500 some tweaks to post selector and combobox commit 1f5b557c4910c29a988b08f2881da3fad18eb48c Author: celine Date: Wed Feb 18 23:04:05 2026 -0500 oops forgot to push a file commit f9bced1e1197d334e90efcba913c722d36d6450b Author: celine Date: Wed Feb 18 23:03:48 2026 -0500 added combobox and populated analytics page commit 68ba2c372e47be42fb69cc824c757a3a6828d7b8 Author: celine Date: Thu Feb 12 07:45:53 2026 -0500 analytics date selector commit a664db07003ee2e91cfbba5bd14e35b0d449eda5 Author: celine Date: Mon Feb 9 13:27:19 2026 -0500 update modal to have close button commit 1d83772789e0a748313748ca47b5a5f2a0bfadf6 Author: celine Date: Mon Feb 9 13:12:29 2026 -0500 Put in pro tier upgrade content commit f94e24f161d9f160a66b8792be84a24936654655 Author: Jared Pereira Date: Mon Feb 9 17:12:57 2026 +0400 wip pro plan spec commit 37bf304d353d04f9ec564fc9a8802e2c36dcdf55 Author: Jared Pereira Date: Mon Feb 9 17:12:51 2026 +0400 refactor skills --- .claude/skills/lexicons/SKILL.md | 304 ++++++ .../SKILL.md} | 6 + .claude/skills/spec/SKILL.md | 85 ++ .github/workflows/stripe-sync.yml | 30 + CLAUDE.md | 64 ++ actions/createBillingPortalSession.ts | 32 + actions/createCheckoutSession.ts | 63 ++ actions/getIdentityData.ts | 37 +- .../home/Actions/AccountSettings.tsx | 4 +- app/(home-pages)/home/Actions/Actions.tsx | 4 +- .../p/[didOrHandle]/ProfileHeader.tsx | 2 + app/api/checkout/success/route.ts | 63 ++ app/api/inngest/client.ts | 22 + .../stripe_handle_checkout_completed.ts | 70 ++ .../stripe_handle_invoice_payment_failed.ts | 23 + .../stripe_handle_subscription_deleted.ts | 21 + .../stripe_handle_subscription_updated.ts | 81 ++ app/api/inngest/route.tsx | 8 + .../[command]/get_publication_analytics.ts | 76 ++ .../get_publication_subscribers_timeseries.ts | 95 ++ app/api/rpc/[command]/route.ts | 4 + app/api/webhooks/stripe/route.ts | 69 ++ app/layout.tsx | 5 + app/lish/[did]/[publication]/UpgradeModal.tsx | 93 ++ .../[did]/[publication]/dashboard/Actions.tsx | 24 + .../dashboard/PublicationAnalytics.tsx | 752 +++++++++++++ .../dashboard/PublicationDashboard.tsx | 17 +- .../dashboard/PublicationSubscribers.tsx | 1 - .../settings/ManageProSubscription.tsx | 65 ++ .../dashboard/settings/PostOptions.tsx | 1 - .../settings/PublicationSettings.tsx | 34 +- app/lish/createPub/UpdatePubForm.tsx | 1 - appview/index.ts | 1 - components/ActionBar/ProfileButton.tsx | 93 +- components/Blocks/BlockCommandBar.tsx | 218 +--- components/Blocks/DateTimeBlock.tsx | 1 + components/Combobox.tsx | 175 +++ components/DatePicker.tsx | 77 +- components/Icons/LeafletPro.tsx | 19 + components/Modal.tsx | 16 +- components/Pages/Backdater.tsx | 2 +- components/SubscriptionSuccessModal.tsx | 61 ++ drizzle/relations.ts | 49 +- drizzle/schema.ts | 102 +- lib/tinybird.ts | 237 +++++ package-lock.json | 998 +++++++++++++++++- package.json | 3 + specs/2026-02-03-pro-tier.md | 246 +++++ src/hooks/useEntitlement.ts | 15 + stripe/client.ts | 12 + stripe/products.ts | 44 + stripe/sync.ts | 62 ++ supabase/database.types.ts | 76 ++ tinybird.config.mjs | 9 + 54 files changed, 4346 insertions(+), 326 deletions(-) create mode 100644 .claude/skills/lexicons/SKILL.md rename .claude/skills/{notifications.md => notifications/SKILL.md} (96%) create mode 100644 .claude/skills/spec/SKILL.md create mode 100644 .github/workflows/stripe-sync.yml create mode 100644 CLAUDE.md create mode 100644 actions/createBillingPortalSession.ts create mode 100644 actions/createCheckoutSession.ts create mode 100644 app/api/checkout/success/route.ts create mode 100644 app/api/inngest/functions/stripe_handle_checkout_completed.ts create mode 100644 app/api/inngest/functions/stripe_handle_invoice_payment_failed.ts create mode 100644 app/api/inngest/functions/stripe_handle_subscription_deleted.ts create mode 100644 app/api/inngest/functions/stripe_handle_subscription_updated.ts create mode 100644 app/api/rpc/[command]/get_publication_analytics.ts create mode 100644 app/api/rpc/[command]/get_publication_subscribers_timeseries.ts create mode 100644 app/api/webhooks/stripe/route.ts create mode 100644 app/lish/[did]/[publication]/UpgradeModal.tsx create mode 100644 app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx create mode 100644 app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription.tsx create mode 100644 components/Combobox.tsx create mode 100644 components/Icons/LeafletPro.tsx create mode 100644 components/SubscriptionSuccessModal.tsx create mode 100644 lib/tinybird.ts create mode 100644 specs/2026-02-03-pro-tier.md create mode 100644 src/hooks/useEntitlement.ts create mode 100644 stripe/client.ts create mode 100644 stripe/products.ts create mode 100644 stripe/sync.ts create mode 100644 tinybird.config.mjs diff --git a/.claude/skills/lexicons/SKILL.md b/.claude/skills/lexicons/SKILL.md new file mode 100644 index 00000000..3d04e88b --- /dev/null +++ b/.claude/skills/lexicons/SKILL.md @@ -0,0 +1,304 @@ +--- +name: lexicons +description: Reference for the AT Protocol lexicon system. Use when working with lexicons, adding new record types, or modifying AT Protocol schemas. +user-invocable: false +--- + +# Lexicon System + +## Overview + +Lexicons define the schema for AT Protocol records. This project has two namespaces: +- **`pub.leaflet.*`** - Leaflet-specific lexicons (documents, publications, blocks, etc.) +- **`site.standard.*`** - Standard site lexicons for interoperability + +The lexicons are defined as TypeScript in `lexicons/src/`, built to JSON in `lexicons/pub/leaflet/` and `lexicons/site/standard/`, and TypeScript types are generated in `lexicons/api/`. + +## Key Files + +- **`lexicons/src/*.ts`** - Source definitions for `pub.leaflet.*` lexicons +- **`lexicons/site/standard/**/*.json`** - JSON definitions for `site.standard.*` lexicons (manually maintained) +- **`lexicons/build.ts`** - Builds TypeScript sources to JSON +- **`lexicons/api/`** - Generated TypeScript types and client +- **`package.json`** - Contains `lexgen` script + +## Running Lexicon Generation + +```bash +npm run lexgen +``` + +This runs: +1. `tsx ./lexicons/build.ts` - Builds `pub.leaflet.*` JSON from TypeScript +2. `lex gen-api` - Generates TypeScript types from all JSON lexicons +3. `tsx ./lexicons/fix-extensions.ts` - Fixes import extensions + +## Adding a New pub.leaflet Lexicon + +### 1. Create the Source Definition + +Create a file in `lexicons/src/` (e.g., `lexicons/src/myLexicon.ts`): + +```typescript +import { LexiconDoc } from "@atproto/lexicon"; + +export const PubLeafletMyLexicon: LexiconDoc = { + lexicon: 1, + id: "pub.leaflet.myLexicon", + defs: { + main: { + type: "record", // or "object" for non-record types + key: "tid", + record: { + type: "object", + required: ["field1"], + properties: { + field1: { type: "string", maxLength: 1000 }, + field2: { type: "integer", minimum: 0 }, + optionalRef: { type: "ref", ref: "other.lexicon#def" }, + }, + }, + }, + // Additional defs for sub-objects + subType: { + type: "object", + properties: { + nested: { type: "string" }, + }, + }, + }, +}; +``` + +### 2. Add to Build + +Update `lexicons/build.ts`: + +```typescript +import { PubLeafletMyLexicon } from "./src/myLexicon"; + +const lexicons = [ + // ... existing lexicons + PubLeafletMyLexicon, +]; +``` + +### 3. Update lexgen Command (if needed) + +If your lexicon is at the top level of `pub/leaflet/` (not in a subdirectory), add it to the `lexgen` script in `package.json`: + +```json +"lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/myLexicon.json ./lexicons/pub/leaflet/*/* ..." +``` + +Note: Files in subdirectories (`pub/leaflet/*/*`) are automatically included. + +### 4. Add to authFullPermissions (for record types) + +If your lexicon is a record type that users should be able to create/update/delete, add it to the `authFullPermissions` permission set in `lexicons/src/authFullPermissions.ts`: + +```typescript +import { PubLeafletMyLexicon } from "./myLexicon"; + +// In the permissions collection array: +collection: [ + // ... existing lexicons + PubLeafletMyLexicon.id, +], +``` + +### 5. Regenerate Types + +```bash +npm run lexgen +``` + +### 6. Use the Generated Types + +```typescript +import { PubLeafletMyLexicon } from "lexicons/api"; + +// Type for the record +type MyRecord = PubLeafletMyLexicon.Record; + +// Validation +const result = PubLeafletMyLexicon.validateRecord(data); +if (result.success) { + // result.value is typed +} + +// Type guard +if (PubLeafletMyLexicon.isRecord(data)) { + // data is typed as Record +} +``` + +## Adding a New site.standard Lexicon + +### 1. Create the JSON Definition + +Create a file in `lexicons/site/standard/` (e.g., `lexicons/site/standard/myType.json`): + +```json +{ + "lexicon": 1, + "id": "site.standard.myType", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["field1"], + "properties": { + "field1": { + "type": "string", + "maxLength": 1000 + } + } + } + } + } +} +``` + +### 2. Regenerate Types + +```bash +npm run lexgen +``` + +The `site/*/* site/*/*/*` globs in the lexgen command automatically pick up new files. + +## Common Lexicon Patterns + +### Referencing Other Lexicons + +```typescript +// Reference another lexicon's main def +{ type: "ref", ref: "pub.leaflet.publication" } + +// Reference a specific def within a lexicon +{ type: "ref", ref: "pub.leaflet.publication#theme" } + +// Reference within the same lexicon +{ type: "ref", ref: "#myDef" } +``` + +### Union Types + +```typescript +{ + type: "union", + refs: [ + "pub.leaflet.pages.linearDocument", + "pub.leaflet.pages.canvas", + ], +} + +// Open union (allows unknown types) +{ + type: "union", + closed: false, // default is true + refs: ["pub.leaflet.content"], +} +``` + +### Blob Types (for images/files) + +```typescript +{ + type: "blob", + accept: ["image/*"], // or specific types like ["image/png", "image/jpeg"] + maxSize: 1000000, // bytes +} +``` + +### Color Types + +The project has color types defined: +- `pub.leaflet.theme.color#rgb` / `#rgba` +- `site.standard.theme.color#rgb` / `#rgba` + +```typescript +// In lexicons/src/theme.ts +export const ColorUnion = { + type: "union", + refs: [ + "pub.leaflet.theme.color#rgba", + "pub.leaflet.theme.color#rgb", + ], +}; +``` + +## Normalization Between Formats + +Use `lexicons/src/normalize.ts` to convert between `pub.leaflet` and `site.standard` formats: + +```typescript +import { + normalizeDocument, + normalizePublication, + isLeafletDocument, + isStandardDocument, + getDocumentPages, +} from "lexicons/src/normalize"; + +// Normalize a document from either format +const normalized = normalizeDocument(record); +if (normalized) { + // normalized is always in site.standard.document format + console.log(normalized.title, normalized.site); + + // Get pages if content is pub.leaflet.content + const pages = getDocumentPages(normalized); +} + +// Normalize a publication +const pub = normalizePublication(record); +if (pub) { + console.log(pub.name, pub.url); +} +``` + +## Handling in Appview (Firehose Consumer) + +When processing records from the firehose in `appview/index.ts`: + +```typescript +import { ids } from "lexicons/api/lexicons"; +import { PubLeafletMyLexicon } from "lexicons/api"; + +// In filterCollections: +filterCollections: [ + ids.PubLeafletMyLexicon, + // ... +], + +// In handleEvent: +if (evt.collection === ids.PubLeafletMyLexicon) { + if (evt.event === "create" || evt.event === "update") { + let record = PubLeafletMyLexicon.validateRecord(evt.record); + if (!record.success) return; + + // Store in database + await supabase.from("my_table").upsert({ + uri: evt.uri.toString(), + data: record.value as Json, + }); + } + if (evt.event === "delete") { + await supabase.from("my_table").delete().eq("uri", evt.uri.toString()); + } +} +``` + +## Publishing Lexicons + +To publish lexicons to an AT Protocol PDS: + +```bash +npm run publish-lexicons +``` + +This runs `lexicons/publish.ts` which publishes lexicons to the configured PDS. diff --git a/.claude/skills/notifications.md b/.claude/skills/notifications/SKILL.md similarity index 96% rename from .claude/skills/notifications.md rename to .claude/skills/notifications/SKILL.md index c2619b53..908953f5 100644 --- a/.claude/skills/notifications.md +++ b/.claude/skills/notifications/SKILL.md @@ -1,3 +1,9 @@ +--- +name: notifications +description: Reference for the notification system. Use when adding new notification types or modifying notification handling. +user-invocable: false +--- + # Notification System ## Overview diff --git a/.claude/skills/spec/SKILL.md b/.claude/skills/spec/SKILL.md new file mode 100644 index 00000000..dbc201e9 --- /dev/null +++ b/.claude/skills/spec/SKILL.md @@ -0,0 +1,85 @@ +--- +name: spec +description: Draft design documents and specs with research-informed questioning +disable-model-invocation: true +--- + +# Spec Writing + +## When to Use + +Use this skill when the user explicitly requests a spec, design document, or similar planning artifact. Do not auto-trigger for routine tasks. + +## Process + +### 1. Initial Scoping + +When starting a spec: +- Ask the user for a brief description of what they want to build/change +- Reason about which question categories matter for this particular work—derive these from the problem, not a checklist + +### 2. Interrogation Loop + +Conduct open-ended questioning until the designer signals completion: + +**Research-informed questions**: Before asking about an area, do just-in-time codebase research. Find relevant files, understand current patterns, identify constraints. Reference specific files and functions in questions when it adds clarity. + +**Adaptive scope**: When the designer marks something as out of scope, update your mental model and don't revisit. However, flag when you believe there are gaps that could cause problems: +- "This approach assumes X—is that intentional?" +- "The current implementation of Y in `src/foo.ts:42` handles Z differently. Should we align or diverge?" + +**Question style**: +- Ask one focused question at a time, or a small related cluster +- Ground questions in what you've learned from the codebase +- Avoid hypotheticals that don't apply to this codebase +- When presenting options, describe tradeoffs without recommending unless asked + +### 3. Spec Writing + +When the designer indicates readiness, produce the spec document. + +## Output + +Save to: `/specs/YYYY-MM-DD-short-name.md` + +### Required Elements + +**Title and status** (draft | approved | implemented) + +**Goal**: What this achieves and why. 1-3 sentences. + +**Design**: The substance of what will be built/changed. For each significant component or concern: +- Describe the approach +- State key decisions with their rationale inline +- Reference specific files, functions, and types where relevant +- Structure subsections to fit the problem—no fixed format + +**Implementation**: Ordered steps that an agent or developer can execute. Each step should: +- Be concrete and actionable +- Reference specific files/functions to modify or create +- Be sequenced correctly (dependencies before dependents) + +### Optional + +- Background context (only if necessary for understanding) +- Open questions (only if unresolved items remain) + +Add other sections if the problem demands it. + +## Writing Style + +- No filler, hedging, or preamble +- No "This document describes..." or "In this spec we will..." +- Start sections with substance, not meta-commentary +- Use precise technical language +- Keep decisions and rationale tight—one sentence each when possible +- Code references use `file/path.ts:lineNumber` or `functionName` in backticks +- Prefer concrete over abstract; specific over general + +## Status Lifecycle + +- **draft**: Work in progress. Should not be merged to main. +- **approved**: Designer has signed off. Ready for implementation. +- **implemented**: Work is complete. Spec is now historical record. + +Update status in the document as it progresses. Specs are point-in-time snapshots—do not update content after implementation begins except to change status. diff --git a/.github/workflows/stripe-sync.yml b/.github/workflows/stripe-sync.yml new file mode 100644 index 00000000..c85affb6 --- /dev/null +++ b/.github/workflows/stripe-sync.yml @@ -0,0 +1,30 @@ +name: Stripe Product Sync + +on: + push: + branches: [main] + paths: ["stripe/**"] + workflow_dispatch: + inputs: + mode: + description: "Stripe mode" + required: true + default: "test" + type: choice + options: + - test + - live + +jobs: + sync: + runs-on: ubuntu-latest + environment: ${{ github.event.inputs.mode == 'live' && 'production' || 'staging' }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npx tsx stripe/sync.ts + env: + STRIPE_SECRET_KEY: ${{ github.event.inputs.mode == 'live' && secrets.STRIPE_SECRET_KEY_LIVE || secrets.STRIPE_SECRET_KEY_TEST }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..2317a3fd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Leaflet is a full-stack TypeScript web app for shared writing and social publishing, built on Bluesky (AT Protocol). Users create collaborative documents ("Leaflets") and publish them as blog posts/newsletters ("Publications") that others can follow. + +## Tech Stack + +- **Frontend**: React 19 + Next.js 16 (App Router, Turbopack) +- **Database**: PostgreSQL via Supabase + Drizzle ORM +- **Real-time sync**: Replicache for optimistic updates and offline support +- **Editor**: ProseMirror with Yjs CRDT +- **Social**: AT Protocol (@atproto packages) for Bluesky integration +- **Jobs**: Inngest for async serverless functions + +## Commands + +```bash +npm run dev # Start Next.js dev server (port 3000) +npm run start-appview-dev # Start AT Protocol appview service +npm run start-feed-service-dev # Start feed subscription service +npm run lint # ESLint +npx tsc # TypeScript type checking (used in CI) +npm run generate-db-types # Regenerate Supabase types after schema changes +npm run lexgen # Regenerate AT Protocol types from lexicons +``` + +## Architecture + +### Data Flow + +1. **Client mutations** go through Replicache (`src/replicache/mutations.ts`) for optimistic local updates +2. **Server actions** in `actions/` persist changes to Supabase +3. **CVR sync** reconciles client and server state via Replicache + +### Document Model + +Documents are composed of **blocks** (text, image, embed, code, poll, etc.). Block components live in `components/Blocks/`. Blocks are rendered in linear lists by `components/Blocks/index.tsx` and in an xy canvas by `components/Canvas.tsx`. + +### AT Protocol / Bluesky + +- **Lexicons** (`lexicons/`) define schemas for reading and writing records to the AT Protocol network and users' PDSs +- **Appview** (`appview/`) consumes the firehose to index published content +- **Feeds** (`feeds/`) provides subscription feeds for publications + +### Key Directories + +- `app/` - Next.js App Router pages and API routes +- `actions/` - Server actions (mutations, auth, subscriptions) +- `components/` - React components +- `src/` - Shared utilities, hooks, Replicache setup +- `appview/` - AT Protocol firehose consumer for indexing +- `feeds/` - Feed service for publication subscriptions +- `lexicons/` - AT Protocol schema definitions +- `supabase/migrations/` - Database schema + +### Patterns + +- **Server actions**: Export async functions with `'use server'` directive, return `Result` from `src/result.ts` +- **Replicache mutations**: Named handlers in `src/replicache/mutations.ts`, keep server mutations idempotent +- **React contexts**: `DocumentProvider`, `LeafletContentProvider` for page-level data +- **Inngest functions**: Async jobs in `app/api/inngest/functions/` diff --git a/actions/createBillingPortalSession.ts b/actions/createBillingPortalSession.ts new file mode 100644 index 00000000..7dea85a1 --- /dev/null +++ b/actions/createBillingPortalSession.ts @@ -0,0 +1,32 @@ +"use server"; + +import { getIdentityData } from "./getIdentityData"; +import { getStripe } from "stripe/client"; +import { supabaseServerClient } from "supabase/serverClient"; +import { Ok, Err, type Result } from "src/result"; + +export async function createBillingPortalSession( + returnUrl: string, +): Promise> { + const identity = await getIdentityData(); + if (!identity) { + return Err("Not authenticated"); + } + + const { data: sub } = await supabaseServerClient + .from("user_subscriptions") + .select("stripe_customer_id") + .eq("identity_id", identity.id) + .single(); + + if (!sub?.stripe_customer_id) { + return Err("No subscription found"); + } + + const session = await getStripe().billingPortal.sessions.create({ + customer: sub.stripe_customer_id, + return_url: returnUrl, + }); + + return Ok({ url: session.url }); +} diff --git a/actions/createCheckoutSession.ts b/actions/createCheckoutSession.ts new file mode 100644 index 00000000..8a355e7a --- /dev/null +++ b/actions/createCheckoutSession.ts @@ -0,0 +1,63 @@ +"use server"; + +import { getIdentityData } from "./getIdentityData"; +import { getStripe } from "stripe/client"; +import { supabaseServerClient } from "supabase/serverClient"; +import { getPriceId } from "stripe/products"; +import { Ok, Err, type Result } from "src/result"; + +export async function createCheckoutSession( + cadence: "month" | "year", + returnUrl?: string, +): Promise> { + const identity = await getIdentityData(); + if (!identity) { + return Err("Not authenticated"); + } + + const priceId = await getPriceId(cadence); + if (!priceId) { + return Err("No Stripe price found. Run the sync script first."); + } + + // Check for existing Stripe customer + let customerId: string | undefined; + const { data: existingSub } = await supabaseServerClient + .from("user_subscriptions") + .select("stripe_customer_id") + .eq("identity_id", identity.id) + .single(); + + if (existingSub?.stripe_customer_id) { + customerId = existingSub.stripe_customer_id; + } + + const successUrl = new URL( + "/api/checkout/success", + process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000", + ); + successUrl.searchParams.set("session_id", "{CHECKOUT_SESSION_ID}"); + if (returnUrl) { + successUrl.searchParams.set("return", returnUrl); + } + + const cancelUrl = returnUrl || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; + + const session = await getStripe().checkout.sessions.create({ + mode: "subscription", + line_items: [{ price: priceId, quantity: 1 }], + client_reference_id: identity.id, + ...(customerId + ? { customer: customerId } + : { customer_email: identity.email || undefined }), + subscription_data: { metadata: { identity_id: identity.id } }, + success_url: successUrl.toString(), + cancel_url: cancelUrl, + }); + + if (!session.url) { + return Err("Failed to create checkout session"); + } + + return Ok({ url: session.url }); +} diff --git a/actions/getIdentityData.ts b/actions/getIdentityData.ts index 6ed8e690..2c7cd1ab 100644 --- a/actions/getIdentityData.ts +++ b/actions/getIdentityData.ts @@ -34,7 +34,9 @@ export async function uncachedGetIdentityData() { leaflets_to_documents(*, documents(*)), leaflets_in_publications(*, publications(*), documents(*)) ) - ) + ), + user_subscriptions(plan, status, current_period_end), + user_entitlements(entitlement_key, granted_at, expires_at, source, metadata) )`, ) .eq("identities.notifications.read", false) @@ -43,6 +45,30 @@ export async function uncachedGetIdentityData() { .single() : null; if (!auth_res?.data?.identities) return null; + + // Transform embedded entitlements into a keyed record, filtering expired + const now = new Date().toISOString(); + const entitlements: Record< + string, + { + granted_at: string; + expires_at: string | null; + source: string | null; + metadata: unknown; + } + > = {}; + for (const row of auth_res.data.identities.user_entitlements || []) { + if (row.expires_at && row.expires_at < now) continue; + entitlements[row.entitlement_key] = { + granted_at: row.granted_at, + expires_at: row.expires_at, + source: row.source, + metadata: row.metadata, + }; + } + + const subscription = auth_res.data.identities.user_subscriptions ?? null; + if (auth_res.data.identities.atp_did) { //I should create a relationship table so I can do this in the above query let { data: rawPublications } = await supabaseServerClient @@ -54,8 +80,15 @@ export async function uncachedGetIdentityData() { return { ...auth_res.data.identities, publications, + entitlements, + subscription, }; } - return { ...auth_res.data.identities, publications: [] }; + return { + ...auth_res.data.identities, + publications: [], + entitlements, + subscription, + }; } diff --git a/app/(home-pages)/home/Actions/AccountSettings.tsx b/app/(home-pages)/home/Actions/AccountSettings.tsx index c3cb0043..44e7be54 100644 --- a/app/(home-pages)/home/Actions/AccountSettings.tsx +++ b/app/(home-pages)/home/Actions/AccountSettings.tsx @@ -6,7 +6,7 @@ import { ThemeSetterContent } from "components/ThemeManager/ThemeSetter"; import { useIsMobile } from "src/hooks/isMobile"; import { PaintSmall } from "components/Icons/PaintSmall"; -export const AccountTheme = (props: { entityID: string }) => { +export const AccountSettings = (props: { entityID: string }) => { let isMobile = useIsMobile(); return ( @@ -14,7 +14,7 @@ export const AccountTheme = (props: { entityID: string }) => { asChild side={isMobile ? "top" : "right"} align={isMobile ? "center" : "start"} - className={`w-xs bg-white!`} + className={`w-xs bg-white!`} arrowFill="bg-white" trigger={ label="Theme" />} > diff --git a/app/(home-pages)/home/Actions/Actions.tsx b/app/(home-pages)/home/Actions/Actions.tsx index 050f7f16..e711d40e 100644 --- a/app/(home-pages)/home/Actions/Actions.tsx +++ b/app/(home-pages)/home/Actions/Actions.tsx @@ -2,7 +2,7 @@ import { ThemePopover } from "components/ThemeManager/ThemeSetter"; import { CreateNewLeafletButton } from "./CreateNewButton"; import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; -import { AccountTheme } from "./AccountSettings"; +import { AccountSettings } from "./AccountSettings"; import { useIdentityData } from "components/IdentityProvider"; import { useReplicache } from "src/replicache"; import { LoginActionButton } from "components/LoginButton"; @@ -13,7 +13,7 @@ export const Actions = () => { return ( <> - {identity && } + {identity && } ); }; diff --git a/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx index f99934c7..13cdbfba 100644 --- a/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx +++ b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx @@ -42,6 +42,8 @@ export const ProfileHeader = (props: { @{props.profile.handle} ); + + return (
; }; }; + "stripe/checkout.session.completed": { + data: { + sessionId: string; + }; + }; + "stripe/customer.subscription.updated": { + data: { + subscriptionId: string; + }; + }; + "stripe/customer.subscription.deleted": { + data: { + subscriptionId: string; + }; + }; + "stripe/invoice.payment.failed": { + data: { + invoiceId: string; + subscriptionId: string; + customerId: string; + }; + }; }; // Create a client to send and receive events diff --git a/app/api/inngest/functions/stripe_handle_checkout_completed.ts b/app/api/inngest/functions/stripe_handle_checkout_completed.ts new file mode 100644 index 00000000..46804dd9 --- /dev/null +++ b/app/api/inngest/functions/stripe_handle_checkout_completed.ts @@ -0,0 +1,70 @@ +import { inngest } from "../client"; +import { getStripe } from "stripe/client"; +import { supabaseServerClient } from "supabase/serverClient"; +import { PRODUCT_DEFINITION, parseEntitlements } from "stripe/products"; + +export const stripe_handle_checkout_completed = inngest.createFunction( + { id: "stripe-handle-checkout-completed" }, + { event: "stripe/checkout.session.completed" }, + async ({ event, step }) => { + const session = await step.run("fetch-checkout-session", async () => { + const s = await getStripe().checkout.sessions.retrieve( + event.data.sessionId, + { expand: ["subscription"] }, + ); + const sub = + typeof s.subscription === "object" ? s.subscription : null; + const periodEnd = sub?.items.data[0]?.current_period_end ?? 0; + const lookupKey = sub?.items.data[0]?.price.lookup_key ?? null; + + return { + identityId: s.client_reference_id, + customerId: s.customer as string, + subId: sub?.id ?? null, + subStatus: sub?.status ?? null, + periodEnd, + lookupKey, + }; + }); + + if (!session.identityId || !session.subId) { + throw new Error("Missing client_reference_id or subscription"); + } + + await step.run("upsert-subscription-and-entitlements", async () => { + const entitlements = parseEntitlements(PRODUCT_DEFINITION.metadata); + + await supabaseServerClient.from("user_subscriptions").upsert( + { + identity_id: session.identityId!, + stripe_customer_id: session.customerId, + stripe_subscription_id: session.subId!, + plan: session.lookupKey, + status: session.subStatus, + current_period_end: new Date( + session.periodEnd * 1000, + ).toISOString(), + updated_at: new Date().toISOString(), + }, + { onConflict: "identity_id" }, + ); + + for (const key of Object.keys(entitlements)) { + await supabaseServerClient.from("user_entitlements").upsert( + { + identity_id: session.identityId!, + entitlement_key: key, + granted_at: new Date().toISOString(), + expires_at: new Date( + session.periodEnd * 1000, + ).toISOString(), + source: `stripe:${session.subId}`, + }, + { onConflict: "identity_id,entitlement_key" }, + ); + } + }); + + return { success: true }; + }, +); diff --git a/app/api/inngest/functions/stripe_handle_invoice_payment_failed.ts b/app/api/inngest/functions/stripe_handle_invoice_payment_failed.ts new file mode 100644 index 00000000..ff9bead7 --- /dev/null +++ b/app/api/inngest/functions/stripe_handle_invoice_payment_failed.ts @@ -0,0 +1,23 @@ +import { inngest } from "../client"; +import { supabaseServerClient } from "supabase/serverClient"; + +export const stripe_handle_invoice_payment_failed = inngest.createFunction( + { id: "stripe-handle-invoice-payment-failed" }, + { event: "stripe/invoice.payment.failed" }, + async ({ event, step }) => { + await step.run("mark-subscription-past-due", async () => { + if (event.data.subscriptionId) { + await supabaseServerClient + .from("user_subscriptions") + .update({ + status: "past_due", + updated_at: new Date().toISOString(), + }) + .eq("stripe_subscription_id", event.data.subscriptionId); + } + }); + + // Entitlements remain valid until expires_at + return { success: true }; + }, +); diff --git a/app/api/inngest/functions/stripe_handle_subscription_deleted.ts b/app/api/inngest/functions/stripe_handle_subscription_deleted.ts new file mode 100644 index 00000000..1ebd839f --- /dev/null +++ b/app/api/inngest/functions/stripe_handle_subscription_deleted.ts @@ -0,0 +1,21 @@ +import { inngest } from "../client"; +import { supabaseServerClient } from "supabase/serverClient"; + +export const stripe_handle_subscription_deleted = inngest.createFunction( + { id: "stripe-handle-subscription-deleted" }, + { event: "stripe/customer.subscription.deleted" }, + async ({ event, step }) => { + await step.run("mark-subscription-canceled", async () => { + await supabaseServerClient + .from("user_subscriptions") + .update({ + status: "canceled", + updated_at: new Date().toISOString(), + }) + .eq("stripe_subscription_id", event.data.subscriptionId); + }); + + // Entitlements expire naturally via expires_at — no need to delete them + return { success: true }; + }, +); diff --git a/app/api/inngest/functions/stripe_handle_subscription_updated.ts b/app/api/inngest/functions/stripe_handle_subscription_updated.ts new file mode 100644 index 00000000..74e9b346 --- /dev/null +++ b/app/api/inngest/functions/stripe_handle_subscription_updated.ts @@ -0,0 +1,81 @@ +import { inngest } from "../client"; +import { getStripe } from "stripe/client"; +import { supabaseServerClient } from "supabase/serverClient"; +import { PRODUCT_DEFINITION, parseEntitlements } from "stripe/products"; + +export const stripe_handle_subscription_updated = inngest.createFunction( + { id: "stripe-handle-subscription-updated" }, + { event: "stripe/customer.subscription.updated" }, + async ({ event, step }) => { + const subData = await step.run("fetch-subscription", async () => { + const sub = await getStripe().subscriptions.retrieve( + event.data.subscriptionId, + ); + const periodEnd = sub.items.data[0]?.current_period_end ?? 0; + const lookupKey = sub.items.data[0]?.price.lookup_key ?? null; + + return { + id: sub.id, + customerId: sub.customer as string, + status: sub.cancel_at_period_end ? "canceling" : sub.status, + periodEnd, + lookupKey, + metadataIdentityId: sub.metadata.identity_id ?? null, + }; + }); + + await step.run("update-subscription-and-entitlements", async () => { + const entitlements = parseEntitlements(PRODUCT_DEFINITION.metadata); + + // Find the identity by stripe_customer_id + const { data: existingSub } = await supabaseServerClient + .from("user_subscriptions") + .select("identity_id") + .eq("stripe_customer_id", subData.customerId) + .single(); + + const identityId = existingSub?.identity_id ?? subData.metadataIdentityId; + + if (!identityId) { + console.warn( + `No subscription record for customer ${subData.customerId} and no identity_id in metadata`, + ); + return; + } + + // Upsert subscription record + await supabaseServerClient + .from("user_subscriptions") + .upsert( + { + identity_id: identityId, + stripe_customer_id: subData.customerId, + stripe_subscription_id: subData.id, + status: subData.status, + plan: subData.lookupKey, + current_period_end: new Date( + subData.periodEnd * 1000, + ).toISOString(), + updated_at: new Date().toISOString(), + }, + { onConflict: "identity_id" }, + ); + + // Upsert entitlements for all entitlements from this subscription + for (const key of Object.keys(entitlements)) { + await supabaseServerClient.from("user_entitlements").upsert( + { + identity_id: identityId, + entitlement_key: key, + granted_at: new Date().toISOString(), + expires_at: new Date(subData.periodEnd * 1000).toISOString(), + source: `stripe:${subData.id}`, + }, + { onConflict: "identity_id,entitlement_key" }, + ); + } + }); + + return { success: true }; + }, +); diff --git a/app/api/inngest/route.tsx b/app/api/inngest/route.tsx index a74f1d98..bb3e0967 100644 --- a/app/api/inngest/route.tsx +++ b/app/api/inngest/route.tsx @@ -13,6 +13,10 @@ import { check_oauth_session, } from "./functions/cleanup_expired_oauth_sessions"; import { write_records_to_pds } from "./functions/write_records_to_pds"; +import { stripe_handle_checkout_completed } from "./functions/stripe_handle_checkout_completed"; +import { stripe_handle_subscription_updated } from "./functions/stripe_handle_subscription_updated"; +import { stripe_handle_subscription_deleted } from "./functions/stripe_handle_subscription_deleted"; +import { stripe_handle_invoice_payment_failed } from "./functions/stripe_handle_invoice_payment_failed"; import { sync_document_metadata } from "./functions/sync_document_metadata"; export const { GET, POST, PUT } = serve({ @@ -29,6 +33,10 @@ export const { GET, POST, PUT } = serve({ cleanup_expired_oauth_sessions, check_oauth_session, write_records_to_pds, + stripe_handle_checkout_completed, + stripe_handle_subscription_updated, + stripe_handle_subscription_deleted, + stripe_handle_invoice_payment_failed, sync_document_metadata, ], }); diff --git a/app/api/rpc/[command]/get_publication_analytics.ts b/app/api/rpc/[command]/get_publication_analytics.ts new file mode 100644 index 00000000..540c4564 --- /dev/null +++ b/app/api/rpc/[command]/get_publication_analytics.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; +import { makeRoute } from "../lib"; +import type { Env } from "./route"; +import { getIdentityData } from "actions/getIdentityData"; +import { tinybird } from "lib/tinybird"; + +export type GetPublicationAnalyticsReturnType = Awaited< + ReturnType<(typeof get_publication_analytics)["handler"]> +>; + +export const get_publication_analytics = makeRoute({ + route: "get_publication_analytics", + input: z.object({ + publication_uri: z.string(), + from: z.string().optional(), + to: z.string().optional(), + path: z.string().optional(), + }), + handler: async ( + { publication_uri, from, to, path }, + { supabase }: Pick, + ) => { + const identity = await getIdentityData(); + if (!identity?.atp_did || !identity.entitlements?.publication_analytics || !identity.entitlements?.pro_plan_visible) { + return { error: "unauthorized" as const }; + } + + // Verify the user owns this publication + const { data: publication } = await supabase + .from("publications") + .select("*, publication_domains(*)") + .eq("uri", publication_uri) + .single(); + + if (!publication || publication.identity_did !== identity.atp_did) { + return { error: "not_found" as const }; + } + + const domain = publication.publication_domains?.[0]?.domain; + if (!domain) { + return { + result: { traffic: [], topReferrers: [], topPages: [] }, + }; + } + + const [trafficResult, referrersResult, pagesResult] = await Promise.all([ + tinybird.publicationTraffic.query({ + domain, + ...(from ? { date_from: from } : {}), + ...(to ? { date_to: to } : {}), + ...(path ? { path } : {}), + }), + tinybird.publicationTopReferrers.query({ + domain, + ...(from ? { date_from: from } : {}), + ...(to ? { date_to: to } : {}), + ...(path ? { path } : {}), + limit: 10, + }), + tinybird.publicationTopPages.query({ + domain, + ...(from ? { date_from: from } : {}), + ...(to ? { date_to: to } : {}), + limit: 20, + }), + ]); + + return { + result: { + traffic: trafficResult.data, + topReferrers: referrersResult.data, + topPages: pagesResult.data, + }, + }; + }, +}); diff --git a/app/api/rpc/[command]/get_publication_subscribers_timeseries.ts b/app/api/rpc/[command]/get_publication_subscribers_timeseries.ts new file mode 100644 index 00000000..ac280b7c --- /dev/null +++ b/app/api/rpc/[command]/get_publication_subscribers_timeseries.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; +import { makeRoute } from "../lib"; +import type { Env } from "./route"; +import { getIdentityData } from "actions/getIdentityData"; + +export type GetPublicationSubscribersTimeseriesReturnType = Awaited< + ReturnType<(typeof get_publication_subscribers_timeseries)["handler"]> +>; + +export const get_publication_subscribers_timeseries = makeRoute({ + route: "get_publication_subscribers_timeseries", + input: z.object({ + publication_uri: z.string(), + from: z.string().optional(), + to: z.string().optional(), + }), + handler: async ( + { publication_uri, from, to }, + { supabase }: Pick, + ) => { + const identity = await getIdentityData(); + if (!identity?.atp_did || !identity.entitlements?.publication_analytics || !identity.entitlements?.pro_plan_visible) { + return { error: "unauthorized" as const }; + } + + // Verify ownership + const { data: publication } = await supabase + .from("publications") + .select("uri, identity_did") + .eq("uri", publication_uri) + .single(); + + if (!publication || publication.identity_did !== identity.atp_did) { + return { error: "not_found" as const }; + } + + let query = supabase + .from("publication_subscriptions") + .select("created_at") + .eq("publication", publication_uri) + .order("created_at", { ascending: true }); + + if (from) { + query = query.gte("created_at", from); + } + if (to) { + query = query.lte("created_at", to); + } + + const { data: subscriptions } = await query; + + // Bucket subscriptions by day and compute cumulative count + const dailyCounts: Record = {}; + for (const sub of subscriptions || []) { + const day = sub.created_at.slice(0, 10); + dailyCounts[day] = (dailyCounts[day] || 0) + 1; + } + + let cumulative = 0; + + // If we have a from filter, get the count of subscriptions before that date + if (from) { + const { count } = await supabase + .from("publication_subscriptions") + .select("*", { count: "exact", head: true }) + .eq("publication", publication_uri) + .lt("created_at", from); + cumulative = count || 0; + } + + // Build timeseries over the full date range, filling gaps with the + // running cumulative so the chart always has data points to display. + const timeseries: { day: string; total_subscribers: number }[] = []; + if (from && to) { + const cursor = new Date(from); + cursor.setUTCHours(0, 0, 0, 0); + const end = new Date(to); + end.setUTCHours(0, 0, 0, 0); + while (cursor <= end) { + const key = cursor.toISOString().slice(0, 10); + cumulative += dailyCounts[key] || 0; + timeseries.push({ day: key, total_subscribers: cumulative }); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + } else { + const days = Object.keys(dailyCounts).sort(); + for (const day of days) { + cumulative += dailyCounts[day]; + timeseries.push({ day, total_subscribers: cumulative }); + } + } + + return { result: { timeseries } }; + }, +}); diff --git a/app/api/rpc/[command]/route.ts b/app/api/rpc/[command]/route.ts index ebb2af14..05912055 100644 --- a/app/api/rpc/[command]/route.ts +++ b/app/api/rpc/[command]/route.ts @@ -17,6 +17,8 @@ import { get_profile_data } from "./get_profile_data"; import { get_user_recommendations } from "./get_user_recommendations"; import { get_hot_feed } from "./get_hot_feed"; import { get_document_interactions } from "./get_document_interactions"; +import { get_publication_analytics } from "./get_publication_analytics"; +import { get_publication_subscribers_timeseries } from "./get_publication_subscribers_timeseries"; let supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, @@ -47,6 +49,8 @@ let Routes = [ get_user_recommendations, get_hot_feed, get_document_interactions, + get_publication_analytics, + get_publication_subscribers_timeseries, ]; export async function POST( req: Request, diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts new file mode 100644 index 00000000..6ffc1ecf --- /dev/null +++ b/app/api/webhooks/stripe/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getStripe } from "stripe/client"; +import { inngest } from "app/api/inngest/client"; + +export async function POST(req: NextRequest) { + const body = await req.text(); + const signature = req.headers.get("stripe-signature"); + if (!signature) { + return NextResponse.json({ error: "Missing signature" }, { status: 400 }); + } + + let event; + try { + event = getStripe().webhooks.constructEvent( + body, + signature, + process.env.STRIPE_WEBHOOK_SECRET as string, + ); + } catch (err) { + console.error("Stripe webhook signature verification failed:", err); + return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); + } + + switch (event.type) { + case "checkout.session.completed": + await inngest.send({ + name: "stripe/checkout.session.completed", + data: { sessionId: event.data.object.id }, + }); + break; + + case "customer.subscription.created": + case "customer.subscription.updated": + await inngest.send({ + name: "stripe/customer.subscription.updated", + data: { subscriptionId: event.data.object.id }, + }); + break; + + case "customer.subscription.deleted": + await inngest.send({ + name: "stripe/customer.subscription.deleted", + data: { subscriptionId: event.data.object.id }, + }); + break; + + case "invoice.payment_failed": { + const invoice = event.data.object; + const sub = invoice.parent?.subscription_details?.subscription; + const subId = + typeof sub === "string" + ? sub + : typeof sub === "object" && sub + ? sub.id + : ""; + await inngest.send({ + name: "stripe/invoice.payment.failed", + data: { + invoiceId: invoice.id, + subscriptionId: subId, + customerId: invoice.customer as string, + }, + }); + break; + } + } + + return NextResponse.json({ received: true }); +} diff --git a/app/layout.tsx b/app/layout.tsx index 16e89e78..0b31dcdd 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -9,6 +9,8 @@ import { IdentityProviderServer } from "components/IdentityProviderServer"; import { headers } from "next/headers"; import { RequestHeadersProvider } from "components/Providers/RequestHeadersProvider"; import { RouteUIStateManager } from "components/RouteUIStateManger"; +import { SubscriptionSuccessModal } from "components/SubscriptionSuccessModal"; +import { Suspense } from "react"; export const metadata = { title: "Leaflet", @@ -87,6 +89,9 @@ export default async function RootLayout({ timezone={ipTimezone} > {children} + + + diff --git a/app/lish/[did]/[publication]/UpgradeModal.tsx b/app/lish/[did]/[publication]/UpgradeModal.tsx new file mode 100644 index 00000000..4ef23710 --- /dev/null +++ b/app/lish/[did]/[publication]/UpgradeModal.tsx @@ -0,0 +1,93 @@ +import { ButtonPrimary } from "components/Buttons"; +import { Modal } from "components/Modal"; +import { useState } from "react"; +import { createCheckoutSession } from "actions/createCheckoutSession"; +import { DotLoader } from "components/utils/DotLoader"; + +export const UpgradeContent = () => { + let [cadence, setCadence] = useState<"year" | "month">("year"); + let [loading, setLoading] = useState(false); + let [error, setError] = useState(null); + + async function handleCheckout() { + setLoading(true); + setError(null); + let result = await createCheckoutSession(cadence, window.location.href); + if (result.ok) { + window.location.href = result.value.url; + } else { + setError(result.error); + setLoading(false); + } + } + + return ( +
+

Get Leaflet Pro!

+
+
+
Analytics
+
Views per Post
+
Subscriber Counts
+
Top Referrers
+
+
Coming ASAP
+
Emails
+
Paid Membership
+
+
+
+
+ + +
+
+
+ {cadence === "year" ? "$120" : "$12"} +
+
+ {cadence === "year" ? "/year" : "/month"} +
+
+ + {loading ? : "Get it!"} + + {error && ( +
{error}
+ )} +
+
+
+
+ ); +}; + +export const UpgradeModal = (props: { + trigger: React.ReactNode; + asChild?: boolean; +}) => { + return ( + {props.trigger}
} + > + + + ); +}; diff --git a/app/lish/[did]/[publication]/dashboard/Actions.tsx b/app/lish/[did]/[publication]/dashboard/Actions.tsx index 92a7b9de..bac897c7 100644 --- a/app/lish/[did]/[publication]/dashboard/Actions.tsx +++ b/app/lish/[did]/[publication]/dashboard/Actions.tsx @@ -10,11 +10,19 @@ import { usePublicationData } from "./PublicationSWRProvider"; import { useSmoker } from "components/Toast"; import { useIsMobile } from "src/hooks/isMobile"; import { SpeedyLink } from "components/SpeedyLink"; +import { ButtonSecondary, ButtonTertiary } from "components/Buttons"; +import { UpgradeModal } from "../UpgradeModal"; +import { LeafletPro } from "components/Icons/LeafletPro"; +import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; export const Actions = (props: { publication: string }) => { + let isPro = useIsPro(); + let canSeePro = useCanSeePro(); return ( <> + {canSeePro && !isPro && } + @@ -77,3 +85,19 @@ function PublicationShareButton() { ); } + +const MobileUpgrade = () => { + return ( + } + className={`sm:hidden block bg-[var(--accent-light)]!`} + style={{ backgroundColor: "var(--accent-light) important!" }} + /> + } + /> + ); +}; diff --git a/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx b/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx new file mode 100644 index 00000000..0ea36d2e --- /dev/null +++ b/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx @@ -0,0 +1,752 @@ +import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; +import { UpgradeContent } from "../UpgradeModal"; +import { Popover } from "components/Popover"; +import { DatePicker } from "components/DatePicker"; +import { Fragment, useMemo, useState } from "react"; +import { useLocalizedDate } from "src/hooks/useLocalizedDate"; +import type { DateRange } from "react-day-picker"; +import { usePublicationData } from "./PublicationSWRProvider"; +import { Combobox, ComboboxResult } from "components/Combobox"; +import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; +import { callRPC } from "app/api/rpc/client"; +import useSWR from "swr"; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; + +type ReferrerType = { referrer_host: string; pageviews: number }; +type TrafficMetric = "pageviews" | "visitors"; + +const dayTickFormatter = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", +}); + +function formatDayTick(value: string) { + let d = new Date(value + "T00:00:00"); + if (isNaN(d.getTime())) return value; + return dayTickFormatter.format(d); +} + +function formatYTick(value: number) { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return value.toLocaleString(); +} + +function endOfDay(date: Date): Date { + let d = new Date(date); + d.setHours(23, 59, 59, 999); + return d; +} + +function fillDailyGaps( + data: T[], + fill: (day: string) => T, + from?: Date, + to?: Date, +): T[] { + let start = from || (data.length > 0 ? new Date(data[0].day) : null); + let end = + to || (data.length > 0 ? new Date(data[data.length - 1].day) : null); + if (!start || !end) return data; + + let lookup = new Map(data.map((d) => [d.day, d])); + let result: T[] = []; + let cursor = new Date(start); + cursor.setHours(0, 0, 0, 0); + let endDate = new Date(end); + endDate.setHours(0, 0, 0, 0); + + while (cursor <= endDate) { + let key = cursor.toISOString().slice(0, 10); + result.push(lookup.get(key) ?? fill(key)); + cursor.setDate(cursor.getDate() + 1); + } + return result; +} + +export const PublicationAnalytics = (props: { + showPageBackground: boolean; +}) => { + let isPro = useIsPro(); + let canSeePro = useCanSeePro(); + + let { data: publication } = usePublicationData(); + let [dateRange, setDateRange] = useState(() => { + let from = new Date(); + from.setDate(from.getDate() - 7); + return { from, to: new Date() }; + }); + let [datePreset, setDatePreset] = useState("Last Week"); + let [selectedPost, setSelectedPost] = useState< + { title: string; path: string } | undefined + >(undefined); + let [selectedReferror, setSelectedReferror] = useState< + ReferrerType | undefined + >(undefined); + let [trafficMetric, setTrafficMetric] = + useState("visitors"); + + let publicationUri = publication?.publication?.uri; + + let { data: analyticsData, isLoading: analyticsLoading } = useSWR( + publicationUri + ? [ + "publication-analytics", + publicationUri, + dateRange.from?.toISOString(), + dateRange.to?.toISOString(), + selectedPost?.path, + ] + : null, + async () => { + let res = await callRPC("get_publication_analytics", { + publication_uri: publicationUri!, + ...(dateRange.from ? { from: dateRange.from.toISOString() } : {}), + ...(dateRange.to ? { to: endOfDay(dateRange.to).toISOString() } : {}), + ...(selectedPost ? { path: `/${selectedPost.path}` } : {}), + }); + return res?.result; + }, + ); + + let { data: subscribersData, isLoading: subscribersLoading } = useSWR( + publicationUri + ? [ + "publication-subscribers-timeseries", + publicationUri, + dateRange.from?.toISOString(), + dateRange.to?.toISOString(), + ] + : null, + async () => { + let res = await callRPC("get_publication_subscribers_timeseries", { + publication_uri: publicationUri!, + ...(dateRange.from ? { from: dateRange.from.toISOString() } : {}), + ...(dateRange.to ? { to: endOfDay(dateRange.to).toISOString() } : {}), + }); + return res?.result; + }, + ); + + let filledTraffic = useMemo( + () => + fillDailyGaps( + analyticsData?.traffic || [], + (day) => ({ day, pageviews: 0, visitors: 0 }), + dateRange.from, + dateRange.to ?? new Date(), + ), + [analyticsData?.traffic, dateRange.from, dateRange.to], + ); + + if (!canSeePro) return null; + + if (!isPro) + return ( +
+ +
+ ); + + return ( +
+
+ + +
+
+
+

Traffic

+ + + {selectedReferror && ( + <> + +
+ {" "} + {selectedReferror.referrer_host} +
+ + )} +
+ +
+ + +
+
+
+

Subscribers

+ +
+
+ ); +}; + +const ChartSkeleton = () => ( +
+ + + + + + + +
+); + +const ListSkeleton = ({ rows = 4 }: { rows?: number }) => ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+
+
+
+ ))} +
+); + +const SubscribersChart = (props: { + data: { day: string; total_subscribers: number }[]; + isLoading: boolean; +}) => { + if (props.isLoading) { + return ; + } + if (props.data.length === 0) { + return ( +
+ No subscriber data +
+ ); + } + return ( +
+ + + + + + + + + +
+ ); +}; + +const TrafficChart = (props: { + data: { day: string; pageviews: number; visitors: number }[]; + isLoading: boolean; + metric: TrafficMetric; +}) => { + let today = new Date().toISOString().slice(0, 10); + let metricLabel = props.metric === "pageviews" ? "pageviews" : "visitors"; + + let chartData = useMemo(() => { + if (props.data.length === 0) return []; + return props.data.map((d, i) => { + let value = d[props.metric]; + let isToday = d.day === today; + let isBeforeToday = + i === props.data.length - 2 && + props.data[props.data.length - 1]?.day === today; + return { + day: d.day, + // Solid line: all points except today + complete: isToday ? undefined : value, + // Dashed line: bridge from yesterday to today + partial: isToday || isBeforeToday ? value : undefined, + }; + }); + }, [props.data, props.metric, today]); + + if (props.isLoading) { + return ; + } + if (props.data.length === 0) { + return ( +
+ No traffic data +
+ ); + } + return ( +
+ + + + + + { + if (!active || !payload?.length) return null; + let pageviews = + payload.find((p) => p.dataKey === "complete")?.value ?? + payload.find((p) => p.dataKey === "partial")?.value; + return ( +
+
+ {formatDayTick(String(label))} +
+
{Number(pageviews).toLocaleString()} {metricLabel}
+
+ ); + }} + /> + + +
+
+
+ ); +}; + +const MetricToggle = (props: { + metric: TrafficMetric; + setMetric: (m: TrafficMetric) => void; +}) => { + let options: { value: TrafficMetric; label: string }[] = [ + { value: "visitors", label: "Visitors" }, + { value: "pageviews", label: "Pageviews" }, + ]; + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +}; + +const PostSelector = (props: { + selectedPost: { title: string; path: string } | undefined; + setSelectedPost: (s: { title: string; path: string } | undefined) => void; +}) => { + let { data } = usePublicationData(); + let { documents } = data || {}; + + let posts = useMemo( + () => + documents?.map((doc) => ({ + title: doc.record.title, + path: doc.record.path || "", + })), + [documents], + ); + + let [highlighted, setHighlighted] = useState(undefined); + let [searchValue, setSearchValue] = useState(""); + + let postTitles = posts?.map((p) => p.title); + let filteredTitles = useMemo( + () => + postTitles && + postTitles.filter((title) => + title.toLowerCase().includes(searchValue.toLowerCase()), + ), + [searchValue, postTitles], + ); + + let filteredTitlesWithClear = ["All Posts", ...(filteredTitles || [])]; + + return ( + + {props.selectedPost?.title ?? "All Posts"} + + } + results={filteredTitlesWithClear || []} + highlighted={highlighted} + setHighlighted={setHighlighted} + onSelect={() => { + if (highlighted === "All Posts" || !highlighted) { + props.setSelectedPost(undefined); + } else { + let post = posts?.find((p) => p.title === highlighted); + props.setSelectedPost(post); + } + }} + sideOffset={2} + searchValue={searchValue} + setSearchValue={setSearchValue} + showSearch + > + {filteredTitlesWithClear.map((title) => { + if (title === "All Posts") + return ( + <> + { + props.setSelectedPost(undefined); + }} + highlighted={highlighted} + setHighlighted={setHighlighted} + > + All Posts + + {filteredTitles && filteredTitles.length !== 0 && ( +
+ )} + + ); + return ( + { + let post = posts?.find((p) => p.title === title); + props.setSelectedPost(post); + }} + highlighted={highlighted} + setHighlighted={setHighlighted} + > + {title} + + ); + })} +
+ ); +}; + +const DateRangeSelector = (props: { + pubStartDate: string | undefined; + dateRange: DateRange; + setDateRange: (dateRange: DateRange) => void; + datePreset: string | null; + setDatePreset: (preset: string | null) => void; + showBackground?: boolean; +}) => { + let buttonClass = + "rounded-md px-1 text-sm border border-accent-contrast text-accent-contrast"; + + let currentDate = new Date(); + + let startDate = useLocalizedDate( + props.dateRange.from?.toISOString() || + props.pubStartDate || + "2025-01-01T12:00:00.000Z", + { + month: "short", + day: "numeric", + }, + ); + + let endDate = useLocalizedDate( + (props.dateRange.to ?? currentDate).toISOString(), + { + month: "short", + day: "numeric", + }, + ); + + function handleDateChange(range: DateRange | undefined) { + if (range) { + props.setDateRange(range); + props.setDatePreset(null); + } + } + + return ( + + {props.datePreset + ? props.datePreset + : props.dateRange.from === undefined + ? "All Time" + : `${startDate} - ${endDate}`} +
+ } + > +
+ + + +
+
+ date > new Date()} + /> + + ); +}; + +const TopPages = (props: { + pages: { path: string; pageviews: number }[]; + selectedPost: { title: string; path: string } | undefined; + setSelectedPost: (s: { title: string; path: string } | undefined) => void; + isLoading: boolean; +}) => { + let { data } = usePublicationData(); + let docsByPath = useMemo(() => { + let map = new Map(); + for (let doc of data?.documents || []) { + let path = doc.record.path || ""; + let normalized = path.startsWith("/") ? path : `/${path}`; + map.set(normalized, { title: doc.record.title, path }); + } + return map; + }, [data?.documents]); + + return ( +
+

Top Pages

+
+ {props.isLoading && } + {!props.isLoading && props.pages.length === 0 && ( +
No page data
+ )} + {props.pages.map((page) => { + let doc = docsByPath.get(page.path); + let isSelected = selectedPostPath(props.selectedPost) === page.path; + return ( + + +
+
+ ); + })} +
+
+ ); +}; + +function selectedPostPath( + post: { title: string; path: string } | undefined, +): string | undefined { + if (!post) return undefined; + return post.path.startsWith("/") ? post.path : `/${post.path}`; +} + +const TopReferrors = (props: { + refferors: ReferrerType[]; + setSelectedReferror: (ref: ReferrerType) => void; + selectedReferror: ReferrerType | undefined; + isLoading: boolean; +}) => { + return ( +
+

Top Referrers

+
+ {props.isLoading && } + {!props.isLoading && props.refferors.length === 0 && ( +
No referrer data
+ )} + {props.refferors.map((ref) => { + let selected = ref === props.selectedReferror; + return ( + + +
+
+ ); + })} +
+
+ ); +}; diff --git a/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx b/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx index 0821c232..3da89926 100644 --- a/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx @@ -12,6 +12,8 @@ import { } from "components/PageLayouts/DashboardLayout"; import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; import { type NormalizedPublication } from "src/utils/normalizeRecords"; +import { PublicationAnalytics } from "./PublicationAnalytics"; +import { useCanSeePro } from "src/hooks/useEntitlement"; export default function PublicationDashboard({ publication, @@ -23,6 +25,7 @@ export default function PublicationDashboard({ null >; }) { + let canSeePro = useCanSeePro(); let [searchValue, setSearchValue] = useState(""); let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); @@ -55,7 +58,7 @@ export default function PublicationDashboard({ /> ), }, - Published: { + Posts: { content: ( + ), + controls: null, + }, + } + : {}), }} actions={} currentPage="pub" diff --git a/app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx b/app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx index ac1b27b2..aede5c80 100644 --- a/app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx @@ -175,7 +175,6 @@ const SubscriberListItem = (props: { // ); // props.setCheckedSubscribers(newCheckedSubscribers); // } - // console.log(props.checkedSubscribers); // }} // > <> diff --git a/app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription.tsx b/app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription.tsx new file mode 100644 index 00000000..f878dcd9 --- /dev/null +++ b/app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription.tsx @@ -0,0 +1,65 @@ +import { useState } from "react"; +import { ButtonPrimary } from "components/Buttons"; +import { createBillingPortalSession } from "actions/createBillingPortalSession"; +import { useIdentityData } from "components/IdentityProvider"; +import { DotLoader } from "components/utils/DotLoader"; +import { useLocalizedDate } from "src/hooks/useLocalizedDate"; +import { GoBackSmall } from "components/Icons/GoBackSmall"; + +export const ManageProSubscription = (props: { backToMenu: () => void }) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { identity } = useIdentityData(); + + const subscription = identity?.subscription; + const renewalDate = useLocalizedDate( + subscription?.current_period_end || new Date().toISOString(), + { month: "long", day: "numeric", year: "numeric" }, + ); + + async function handleManageBilling() { + setLoading(true); + setError(null); + const result = await createBillingPortalSession(window.location.href); + if (result.ok) { + window.location.href = result.value.url; + } else { + setError(result.error); + setLoading(false); + } + } + + return ( +
+
+ Manage Subscription + +
+
+
+ You have a
+ {subscription?.plan || "Pro"} subscription +
+ {subscription?.plan || "Leaflet Pro"} +
+ {subscription?.status === "canceled" + ? "Your subscription has ended" + : subscription?.status === "canceling" + ? `Access until ${renewalDate}` + : `Renews on ${renewalDate}`} +
+ + {loading ? : "Manage Billing"} + + {error &&
{error}
} +
+
+ ); +}; diff --git a/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx b/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx index 07c02452..0a8b49d5 100644 --- a/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx +++ b/app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx @@ -71,7 +71,6 @@ export const PostOptions = (props: { loading={props.loading} setLoadingAction={props.setLoading} backToMenuAction={props.backToMenu} - state={"post-options"} > Post Options diff --git a/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx b/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx index 73cd71a6..cd5545eb 100644 --- a/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx +++ b/app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx @@ -14,7 +14,7 @@ import { DotLoader } from "components/utils/DotLoader"; import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; import { PostOptions } from "./PostOptions"; -type menuState = "menu" | "general" | "theme" | "post-options"; +type menuState = "menu" | "pub-settings" | "theme" | "post-settings"; export function PublicationSettingsButton(props: { publication: string }) { let isMobile = useIsMobile(); @@ -38,7 +38,7 @@ export function PublicationSettingsButton(props: { publication: string }) { /> } > - {state === "general" ? ( + {state === "pub-settings" ? ( setState("menu")} loading={loading} @@ -50,7 +50,7 @@ export function PublicationSettingsButton(props: { publication: string }) { loading={loading} setLoading={setLoading} /> - ) : state === "post-options" ? ( + ) : state === "post-settings" ? ( setState("menu")} loading={loading} @@ -80,18 +80,12 @@ const PubSettingsMenu = (props: { return (
- - Settings - + Settings - - - {props.loading ? : "Update"} - + {props.setLoadingAction && ( + + {props.loading ? : "Update"} + + )}
)}
diff --git a/app/lish/createPub/UpdatePubForm.tsx b/app/lish/createPub/UpdatePubForm.tsx index c7301c5c..b03953dd 100644 --- a/app/lish/createPub/UpdatePubForm.tsx +++ b/app/lish/createPub/UpdatePubForm.tsx @@ -101,7 +101,6 @@ export const EditPubForm = (props: { loading={props.loading} setLoadingAction={props.setLoadingAction} backToMenuAction={props.backToMenuAction} - state={"theme"} > General Settings diff --git a/appview/index.ts b/appview/index.ts index 31ead5fb..7be9e892 100644 --- a/appview/index.ts +++ b/appview/index.ts @@ -388,7 +388,6 @@ async function handleEvent(evt: Event) { embedRecord.embed?.media?.external?.uri?.includes(QUOTE_PARAM); if (!hasQuoteParam) return; - console.log("FOUND EMBED!!!"); // Now validate the record since we know it contains our quote param let record = AppBskyFeedPost.validateRecord(evt.record); diff --git a/components/ActionBar/ProfileButton.tsx b/components/ActionBar/ProfileButton.tsx index 218101c2..58983d4e 100644 --- a/components/ActionBar/ProfileButton.tsx +++ b/components/ActionBar/ProfileButton.tsx @@ -3,22 +3,33 @@ import { ActionButton } from "./ActionButton"; import { useIdentityData } from "components/IdentityProvider"; import { AccountSmall } from "components/Icons/AccountSmall"; import { useRecordFromDid } from "src/utils/useRecordFromDid"; -import { Menu, MenuItem } from "components/Menu"; import { useIsMobile } from "src/hooks/isMobile"; import { LogoutSmall } from "components/Icons/LogoutSmall"; import { mutate } from "swr"; import { SpeedyLink } from "components/SpeedyLink"; +import { Popover } from "components/Popover"; +import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; +import { Modal } from "components/Modal"; +import { UpgradeContent } from "app/lish/[did]/[publication]/UpgradeModal"; +import { ManageProSubscription } from "app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription"; +import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; +import { useState } from "react"; export const ProfileButton = () => { let { identity } = useIdentityData(); let { data: record } = useRecordFromDid(identity?.atp_did); let isMobile = useIsMobile(); + let [state, setState] = useState<"menu" | "manage-subscription">("menu"); + let isPro = useIsPro(); + let canSeePro = useCanSeePro(); return ( - setState("menu")} + className="w-xs" trigger={ { /> } > - {record && ( - <> - - {}}>View Profile - + {state === "manage-subscription" ? ( + setState("menu")} /> + ) : ( +
+ {record && ( + <> + + + -
- +
+ + )} + {canSeePro && ( + <> + {!isPro ? ( + + Get Leaflet Pro + +
+ } + > + + + ) : ( + + )} + +
+ + )} + + + )} - { - await fetch("/api/auth/logout"); - mutate("identity", null); - }} - > - - Log Out - -
+ ); }; diff --git a/components/Blocks/BlockCommandBar.tsx b/components/Blocks/BlockCommandBar.tsx index 3381de97..b9c4b0b6 100644 --- a/components/Blocks/BlockCommandBar.tsx +++ b/components/Blocks/BlockCommandBar.tsx @@ -1,12 +1,10 @@ -import { useEffect, useRef, useState } from "react"; -import * as Popover from "@radix-ui/react-popover"; +import { useState } from "react"; import { blockCommands } from "./BlockCommands"; import { useReplicache } from "src/replicache"; import { useEntitySetContext } from "components/EntitySetProvider"; -import { NestedCardThemeProvider } from "components/ThemeManager/ThemeProvider"; -import { UndoManager } from "src/undoManager"; import { useLeafletPublicationData } from "components/PageSWRDataProvider"; import { setEditorState, useEditorStates } from "src/state/useEditorState"; +import { Combobox, ComboboxResult } from "components/Combobox"; type Props = { parent: string; @@ -25,8 +23,6 @@ export const BlockCommandBar = ({ props: Props; searchValue: string; }) => { - let ref = useRef(null); - let [highlighted, setHighlighted] = useState(undefined); let { rep, undoManager } = useReplicache(); @@ -51,173 +47,67 @@ export const BlockCommandBar = ({ const matchesName = command.name .toLocaleLowerCase() .includes(lowerSearchValue); - const matchesAlternate = command.alternateNames?.some((altName) => - altName.toLocaleLowerCase().includes(lowerSearchValue) - ) ?? false; + const matchesAlternate = + command.alternateNames?.some((altName) => + altName.toLocaleLowerCase().includes(lowerSearchValue), + ) ?? false; const matchesSearch = matchesName || matchesAlternate; const isVisible = !pub || !command.hiddenInPublication; return matchesSearch && isVisible; }); - useEffect(() => { - if ( - !highlighted || - !commandResults.find((result) => result.name === highlighted) - ) - setHighlighted(commandResults[0]?.name); - if (commandResults.length === 1) { - setHighlighted(commandResults[0].name); - } - }, [commandResults, setHighlighted, highlighted]); - - useEffect(() => { - let listener = async (e: KeyboardEvent) => { - let reverseDir = ref.current?.dataset.side === "top"; - let currentHighlightIndex = commandResults.findIndex( - (command: { name: string }) => - highlighted && command.name === highlighted, - ); - - if (reverseDir ? e.key === "ArrowUp" : e.key === "ArrowDown") { - setHighlighted( - commandResults[ - currentHighlightIndex === commandResults.length - 1 || - currentHighlightIndex === undefined - ? 0 - : currentHighlightIndex + 1 - ].name, - ); - return; - } - if (reverseDir ? e.key === "ArrowDown" : e.key === "ArrowUp") { - setHighlighted( - commandResults[ - currentHighlightIndex === 0 || - currentHighlightIndex === undefined || - currentHighlightIndex === -1 - ? commandResults.length - 1 - : currentHighlightIndex - 1 - ].name, - ); - return; - } - - // on enter, select the highlighted item - if (e.key === "Enter") { + return ( + r.name)} + highlighted={highlighted} + setHighlighted={setHighlighted} + onSelect={async () => { + let command = commandResults.find((c) => c.name === highlighted); + if (!command || !rep) return; undoManager.startGroup(); - e.preventDefault(); - rep && - (await commandResults[currentHighlightIndex]?.onSelect( - rep, - { - ...props, - entity_set: entity_set.set, - }, - undoManager, - )); + await command.onSelect( + rep, + { ...props, entity_set: entity_set.set }, + undoManager, + ); undoManager.endGroup(); - return; - } - }; - - window.addEventListener("keydown", listener); - - return () => window.removeEventListener("keydown", listener); - }, [highlighted, setHighlighted, commandResults, rep, entity_set.set, props]); - - return ( - { - if (!open) { - clearCommandSearchText(); - } }} + onOpenChange={() => clearCommandSearchText()} > - - - e.preventDefault()} - className={` - commandMenuContent group/cmd-menu - z-20 w-[264px] - flex data-[side=top]:items-end items-start - `} - > - -
- {commandResults.length === 0 ? ( -
- No blocks found -
- ) : ( - commandResults.map((result, index) => ( -
- { - rep && - result.onSelect( - rep, - { - ...props, - entity_set: entity_set.set, - }, - undoManager, - ); - }} - highlighted={highlighted} - setHighlighted={(highlighted) => - setHighlighted(highlighted) - } - /> - {commandResults[index + 1] && - result.type !== commandResults[index + 1].type && ( -
- )} -
- )) + {commandResults.length === 0 ? ( +
+ No blocks found +
+ ) : ( + commandResults.map((result, index) => ( +
+ { + rep && + result.onSelect( + rep, + { ...props, entity_set: entity_set.set }, + undoManager, + ); + }} + highlighted={highlighted} + setHighlighted={setHighlighted} + > +
+ {result.icon} +
+ {result.name} +
+ {commandResults[index + 1] && + result.type !== commandResults[index + 1].type && ( +
)} -
- - - - - ); -}; - -const CommandResult = (props: { - name: string; - icon: React.ReactNode; - onSelect: () => void; - highlighted: string | undefined; - setHighlighted: (state: string | undefined) => void; -}) => { - let isHighlighted = props.highlighted === props.name; - - return ( - +
+ )) + )} +
); }; -function usePublicationContext() { - throw new Error("Function not implemented."); -} diff --git a/components/Blocks/DateTimeBlock.tsx b/components/Blocks/DateTimeBlock.tsx index d5b40cc1..f424736e 100644 --- a/components/Blocks/DateTimeBlock.tsx +++ b/components/Blocks/DateTimeBlock.tsx @@ -166,6 +166,7 @@ export function BaseDateTimeBlock( >
diff --git a/components/Combobox.tsx b/components/Combobox.tsx new file mode 100644 index 00000000..7d0a5ba9 --- /dev/null +++ b/components/Combobox.tsx @@ -0,0 +1,175 @@ +import { createContext, useContext, useEffect, useRef, useState } from "react"; +import * as Popover from "@radix-ui/react-popover"; +import { NestedCardThemeProvider } from "components/ThemeManager/ThemeProvider"; +import { create } from "zustand"; +import { Input } from "./Input"; + +export const useComboboxState = create(() => ({ + open: false, +})); + +export const Combobox = ({ + results, + onSelect, + children, + onOpenChange, + highlighted, + setHighlighted, + searchValue, + setSearchValue, + showSearch, + trigger, + triggerClassName, + sideOffset, +}: { + children: React.ReactNode; + trigger?: React.ReactNode; + triggerClassName?: string; + results: string[]; + onSelect?: () => void; + onOpenChange?: (open: boolean) => void; + highlighted: string | undefined; + setHighlighted: (h: string | undefined) => void; + searchValue?: string; + setSearchValue?: (s: string) => void; + showSearch?: boolean; + sideOffset?: number; +}) => { + let ref = useRef(null); + + let open = useComboboxState((s) => s.open); + + useEffect(() => { + if (!highlighted || !results.find((result) => result === highlighted)) + setHighlighted(results[0]); + if (results.length === 1) { + setHighlighted(results[0]); + } + }, [results, setHighlighted, highlighted]); + + useEffect(() => { + let listener = async (e: KeyboardEvent) => { + let reverseDir = ref.current?.dataset.side === "top"; + let currentHighlightIndex = results.findIndex( + (result) => highlighted && result === highlighted, + ); + + if (reverseDir ? e.key === "ArrowUp" : e.key === "ArrowDown") { + setHighlighted( + results[ + currentHighlightIndex === results.length - 1 || + currentHighlightIndex === undefined + ? 0 + : currentHighlightIndex + 1 + ], + ); + return; + } + if (reverseDir ? e.key === "ArrowDown" : e.key === "ArrowUp") { + setHighlighted( + results[ + currentHighlightIndex === 0 || + currentHighlightIndex === undefined || + currentHighlightIndex === -1 + ? results.length - 1 + : currentHighlightIndex - 1 + ], + ); + return; + } + + // on enter, select the highlighted item + if (e.key === "Enter") { + onSelect?.(); + useComboboxState.setState({ + open: false, + }); + } + }; + + window.addEventListener("keydown", listener); + + return () => window.removeEventListener("keydown", listener); + }, [highlighted, setHighlighted, results]); + + return ( + { + useComboboxState.setState({ + open: newOpen, + }); + onOpenChange?.(newOpen); + }} + > + +
{trigger}
+
+ + e.preventDefault()} + className={` + commandMenuContent group/cmd-menu + z-20 w-[264px] + flex data-[side=top]:items-end items-start + `} + > + +
+ {showSearch && setSearchValue ? ( + setSearchValue(e.target.value)} + onClick={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + /> + ) : null} +
+ {children} +
+
+ + + + + ); +}; + +export const ComboboxResult = (props: { + result: string; + children: React.ReactNode; + onSelect: () => void; + highlighted: string | undefined; + setHighlighted: (state: string | undefined) => void; + className?: string; +}) => { + let isHighlighted = props.highlighted === props.result; + + return ( + + ); +}; diff --git a/components/DatePicker.tsx b/components/DatePicker.tsx index fa401622..e2f65b8a 100644 --- a/components/DatePicker.tsx +++ b/components/DatePicker.tsx @@ -1,4 +1,8 @@ -import { ChevronProps, DayPicker as ReactDayPicker } from "react-day-picker"; +import { + ChevronProps, + DayPicker as ReactDayPicker, + DayPickerProps, +} from "react-day-picker"; import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; const CustomChevron = (props: ChevronProps) => { @@ -9,46 +13,39 @@ const CustomChevron = (props: ChevronProps) => { ); }; -interface DayPickerProps { - selected: Date | undefined; - onSelect: (date: Date | undefined) => void; - disabled?: (date: Date) => boolean; -} +export const DatePicker = (props: DayPickerProps) => { + const dayPickerProps = { + ...props, + required: props.required ?? false, + mode: props.mode ?? "single", + components: { + Chevron: (chevronProps: ChevronProps) => ( + + ), + ...props.components, + }, + classNames: { + months: "relative", + month_caption: + "font-bold text-center w-full bg-border-light mb-2 py-1 rounded-md", + button_next: + "absolute right-0 top-1 p-1 text-secondary hover:text-accent-contrast flex align-center", + button_previous: + "absolute left-0 top-1 p-1 text-secondary hover:text-accent-contrast rotate-180 flex align-center", + chevron: "text-inherit", + month_grid: "border-separate [border-spacing:2px]", + weekdays: "text-secondary text-sm", + selected: "bg-accent-1 text-accent-2 rounded-md font-bold", + range_middle: "bg-[var(--accent-light)]!", + day: "h-8! w-8! text-center rounded-md sm:hover:bg-border-light", + outside: "text-tertiary", + today: "font-bold", + disabled: "text-border cursor-not-allowed hover:bg-transparent!", + ...props.classNames, + }, + } as DayPickerProps; -export const DatePicker = ({ - selected, - onSelect, - disabled, -}: DayPickerProps) => { - return ( - , - }} - classNames={{ - months: "relative", - month_caption: - "font-bold text-center w-full bg-border-light mb-2 py-1 rounded-md", - button_next: - "absolute right-0 top-1 p-1 text-secondary hover:text-accent-contrast flex align-center", - button_previous: - "absolute left-0 top-1 p-1 text-secondary hover:text-accent-contrast rotate-180 flex align-center", - chevron: "text-inherit", - month_grid: "w-full table-fixed", - weekdays: "text-secondary text-sm", - selected: "bg-accent-1! text-accent-2 rounded-md font-bold", - day: "h-[34px] text-center rounded-md sm:hover:bg-border-light", - outside: "text-tertiary", - today: "font-bold", - disabled: "text-border cursor-not-allowed hover:bg-transparent!", - }} - mode="single" - selected={selected} - defaultMonth={selected} - onSelect={onSelect} - disabled={disabled} - /> - ); + return ; }; export const TimePicker = (props: { diff --git a/components/Icons/LeafletPro.tsx b/components/Icons/LeafletPro.tsx new file mode 100644 index 00000000..8715ae35 --- /dev/null +++ b/components/Icons/LeafletPro.tsx @@ -0,0 +1,19 @@ +import { Props } from "./Props"; + +export const LeafletPro = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/Modal.tsx b/components/Modal.tsx index 2a534a7f..fa9def2e 100644 --- a/components/Modal.tsx +++ b/components/Modal.tsx @@ -1,5 +1,6 @@ import * as Dialog from "@radix-ui/react-dialog"; import React from "react"; +import { CloseTiny } from "./Icons/CloseTiny"; export const Modal = ({ className, @@ -22,27 +23,32 @@ export const Modal = ({ {trigger} - + + + +
- {title && ( + {title ? (

{title}

+ ) : ( + )} {children}
-
diff --git a/components/Pages/Backdater.tsx b/components/Pages/Backdater.tsx index 261f153d..3ca37bbe 100644 --- a/components/Pages/Backdater.tsx +++ b/components/Pages/Backdater.tsx @@ -61,7 +61,7 @@ export const Backdater = (props: { publishedAt: string; docURI: string }) => { return ( {timeAgo(localPublishedAt.toISOString())} diff --git a/components/SubscriptionSuccessModal.tsx b/components/SubscriptionSuccessModal.tsx new file mode 100644 index 00000000..a2146f7e --- /dev/null +++ b/components/SubscriptionSuccessModal.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useSearchParams, useRouter, usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useIdentityData } from "./IdentityProvider"; +import { Modal } from "./Modal"; + +export function SubscriptionSuccessModal() { + let searchParams = useSearchParams(); + let router = useRouter(); + let pathname = usePathname(); + let { identity, mutate } = useIdentityData(); + let [open, setOpen] = useState(false); + let [loading, setLoading] = useState(true); + + let isUpgradeSuccess = searchParams.get("upgrade") === "success"; + + useEffect(() => { + if (!isUpgradeSuccess) return; + setOpen(true); + setLoading(true); + mutate().then(() => setLoading(false)); + }, [isUpgradeSuccess]); + + function handleOpenChange(next: boolean) { + setOpen(next); + if (!next) { + let params = new URLSearchParams(searchParams.toString()); + params.delete("upgrade"); + let qs = params.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + } + } + + if (!isUpgradeSuccess && !open) return null; + + return ( + } + title="Welcome to Pro" + className="w-80" + > + {loading ? ( +
+
+

+ Activating your subscription... +

+
+ ) : ( +
+

+ Your Pro subscription is active. Thanks for supporting Leaflet! +

+
+ )} + + ); +} diff --git a/drizzle/relations.ts b/drizzle/relations.ts index c6db789a..87c4981e 100644 --- a/drizzle/relations.ts +++ b/drizzle/relations.ts @@ -1,5 +1,5 @@ import { relations } from "drizzle-orm/relations"; -import { identities, notifications, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, site_standard_publications, custom_domains, custom_domain_routes, site_standard_documents, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, site_standard_documents_in_publications, documents_in_publications, document_mentions_in_bsky, bsky_posts, permission_token_on_homepage, publication_domains, publication_subscriptions, site_standard_subscriptions, leaflets_to_documents, permission_token_rights, leaflets_in_publications } from "./schema"; +import { identities, notifications, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, recommends_on_documents, poll_votes_on_entity, permission_tokens, user_subscriptions, phone_rsvps_to_entity, site_standard_publications, custom_domains, custom_domain_routes, site_standard_documents, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, site_standard_documents_in_publications, documents_in_publications, document_mentions_in_bsky, bsky_posts, permission_token_on_homepage, publication_domains, publication_subscriptions, site_standard_subscriptions, user_entitlements, permission_token_rights, leaflets_to_documents, leaflets_in_publications } from "./schema"; export const notificationsRelations = relations(notifications, ({one}) => ({ identity: one(identities, { @@ -12,7 +12,9 @@ export const identitiesRelations = relations(identities, ({one, many}) => ({ notifications: many(notifications), publications: many(publications), email_auth_tokens: many(email_auth_tokens), + recommends_on_documents: many(recommends_on_documents), bsky_profiles: many(bsky_profiles), + user_subscriptions: many(user_subscriptions), permission_token: one(permission_tokens, { fields: [identities.home_page], references: [permission_tokens.id] @@ -36,6 +38,7 @@ export const identitiesRelations = relations(identities, ({one, many}) => ({ publication_domains: many(publication_domains), publication_subscriptions: many(publication_subscriptions), site_standard_subscriptions: many(site_standard_subscriptions), + user_entitlements: many(user_entitlements), })); export const publicationsRelations = relations(publications, ({one, many}) => ({ @@ -63,6 +66,7 @@ export const comments_on_documentsRelations = relations(comments_on_documents, ( export const documentsRelations = relations(documents, ({many}) => ({ comments_on_documents: many(comments_on_documents), + recommends_on_documents: many(recommends_on_documents), documents_in_publications: many(documents_in_publications), document_mentions_in_bskies: many(document_mentions_in_bsky), leaflets_to_documents: many(leaflets_to_documents), @@ -113,6 +117,17 @@ export const email_auth_tokensRelations = relations(email_auth_tokens, ({one}) = }), })); +export const recommends_on_documentsRelations = relations(recommends_on_documents, ({one}) => ({ + document: one(documents, { + fields: [recommends_on_documents.document], + references: [documents.uri] + }), + identity: one(identities, { + fields: [recommends_on_documents.recommender_did], + references: [identities.atp_did] + }), +})); + export const poll_votes_on_entityRelations = relations(poll_votes_on_entity, ({one}) => ({ entity_option_entity: one(entities, { fields: [poll_votes_on_entity.option_entity], @@ -140,11 +155,18 @@ export const permission_tokensRelations = relations(permission_tokens, ({one, ma }), email_subscriptions_to_entities: many(email_subscriptions_to_entity), permission_token_on_homepages: many(permission_token_on_homepage), - leaflets_to_documents: many(leaflets_to_documents), permission_token_rights: many(permission_token_rights), + leaflets_to_documents: many(leaflets_to_documents), leaflets_in_publications: many(leaflets_in_publications), })); +export const user_subscriptionsRelations = relations(user_subscriptions, ({one}) => ({ + identity: one(identities, { + fields: [user_subscriptions.identity_id], + references: [identities.id] + }), +})); + export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({ entity: one(entities, { fields: [phone_rsvps_to_entity.entity], @@ -332,14 +354,10 @@ export const site_standard_subscriptionsRelations = relations(site_standard_subs }), })); -export const leaflets_to_documentsRelations = relations(leaflets_to_documents, ({one}) => ({ - document: one(documents, { - fields: [leaflets_to_documents.document], - references: [documents.uri] - }), - permission_token: one(permission_tokens, { - fields: [leaflets_to_documents.leaflet], - references: [permission_tokens.id] +export const user_entitlementsRelations = relations(user_entitlements, ({one}) => ({ + identity: one(identities, { + fields: [user_entitlements.identity_id], + references: [identities.id] }), })); @@ -354,6 +372,17 @@ export const permission_token_rightsRelations = relations(permission_token_right }), })); +export const leaflets_to_documentsRelations = relations(leaflets_to_documents, ({one}) => ({ + document: one(documents, { + fields: [leaflets_to_documents.document], + references: [documents.uri] + }), + permission_token: one(permission_tokens, { + fields: [leaflets_to_documents.leaflet], + references: [permission_tokens.id] + }), +})); + export const leaflets_in_publicationsRelations = relations(leaflets_in_publications, ({one}) => ({ document: one(documents, { fields: [leaflets_in_publications.doc], diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 05b5b5ad..2f44c62c 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, pgEnum, text, jsonb, foreignKey, timestamp, boolean, uuid, index, bigint, unique, uniqueIndex, smallint, primaryKey, integer } from "drizzle-orm/pg-core" +import { pgTable, pgEnum, text, jsonb, foreignKey, timestamp, boolean, uuid, index, bigint, uniqueIndex, unique, smallint, integer, primaryKey } from "drizzle-orm/pg-core" import { sql } from "drizzle-orm" export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3']) @@ -50,6 +50,11 @@ export const comments_on_documents = pgTable("comments_on_documents", { document: text("document").references(() => documents.uri, { onDelete: "cascade", onUpdate: "cascade" } ), indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), profile: text("profile").references(() => bsky_profiles.did, { onDelete: "set null", onUpdate: "cascade" } ), +}, +(table) => { + return { + document_idx: index("comments_on_documents_document_idx").on(table.document), + } }); export const entities = pgTable("entities", { @@ -107,6 +112,21 @@ export const bsky_posts = pgTable("bsky_posts", { cid: text("cid").notNull(), }); +export const recommends_on_documents = pgTable("recommends_on_documents", { + uri: text("uri").primaryKey().notNull(), + record: jsonb("record").notNull(), + document: text("document").notNull().references(() => documents.uri, { onDelete: "cascade", onUpdate: "cascade" } ), + recommender_did: text("recommender_did").notNull().references(() => identities.atp_did, { onDelete: "cascade", onUpdate: "cascade" } ), + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, +(table) => { + return { + document_idx: index("recommends_on_documents_document_idx").on(table.document), + recommender_did_idx: index("recommends_on_documents_recommender_did_idx").on(table.recommender_did), + recommender_document_idx: uniqueIndex("recommends_on_documents_recommender_document_idx").on(table.document, table.recommender_did), + } +}); + export const bsky_profiles = pgTable("bsky_profiles", { did: text("did").primaryKey().notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), record: jsonb("record").notNull(), @@ -133,6 +153,23 @@ export const permission_tokens = pgTable("permission_tokens", { blocked_by_admin: boolean("blocked_by_admin"), }); +export const user_subscriptions = pgTable("user_subscriptions", { + identity_id: uuid("identity_id").primaryKey().notNull().references(() => identities.id, { onDelete: "cascade" } ), + stripe_customer_id: text("stripe_customer_id").notNull(), + stripe_subscription_id: text("stripe_subscription_id"), + plan: text("plan"), + status: text("status"), + current_period_end: timestamp("current_period_end", { withTimezone: true, mode: 'string' }), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updated_at: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, +(table) => { + return { + stripe_customer_id_key: uniqueIndex("user_subscriptions_stripe_customer_id_key").on(table.stripe_customer_id), + stripe_subscription_id_key: uniqueIndex("user_subscriptions_stripe_subscription_id_key").on(table.stripe_subscription_id), + } +}); + export const identities = pgTable("identities", { id: uuid("id").defaultRandom().primaryKey().notNull(), created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), @@ -221,13 +258,6 @@ export const email_subscriptions_to_entity = pgTable("email_subscriptions_to_ent confirmation_code: text("confirmation_code").notNull(), }); -export const documents = pgTable("documents", { - uri: text("uri").primaryKey().notNull(), - data: jsonb("data").notNull(), - indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - bsky_like_count: integer("bsky_like_count").default(0).notNull(), -}); - export const atp_poll_votes = pgTable("atp_poll_votes", { uri: text("uri").primaryKey().notNull(), record: jsonb("record").notNull(), @@ -243,6 +273,23 @@ export const atp_poll_votes = pgTable("atp_poll_votes", { } }); +export const documents = pgTable("documents", { + uri: text("uri").primaryKey().notNull(), + data: jsonb("data").notNull(), + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + sort_date: timestamp("sort_date", { withTimezone: true, mode: 'string' }), + bsky_like_count: integer("bsky_like_count").default(0).notNull(), + recommend_count: integer("recommend_count").default(0).notNull(), + indexed: boolean("indexed").default(false).notNull(), +}, +(table) => { + return { + sort_date_idx: index("documents_sort_date_idx").on(table.uri, table.sort_date), + indexed_at_idx: index("documents_indexed_at_idx").on(table.indexed_at), + idx_documents_ranking: index("idx_documents_ranking").on(table.uri, table.sort_date, table.bsky_like_count, table.recommend_count), + } +}); + export const atp_poll_records = pgTable("atp_poll_records", { uri: text("uri").primaryKey().notNull(), cid: text("cid").notNull(), @@ -295,6 +342,7 @@ export const documents_in_publications = pgTable("documents_in_publications", { (table) => { return { publication_idx: index("documents_in_publications_publication_idx").on(table.publication), + document_idx: index("documents_in_publications_document_idx").on(table.document), documents_in_publications_pkey: primaryKey({ columns: [table.publication, table.document], name: "documents_in_publications_pkey"}), } }); @@ -307,6 +355,7 @@ export const document_mentions_in_bsky = pgTable("document_mentions_in_bsky", { }, (table) => { return { + document_idx: index("document_mentions_in_bsky_document_idx").on(table.document), document_mentions_in_bsky_pkey: primaryKey({ columns: [table.uri, table.document], name: "document_mentions_in_bsky_pkey"}), } }); @@ -365,18 +414,19 @@ export const site_standard_subscriptions = pgTable("site_standard_subscriptions" } }); -export const leaflets_to_documents = pgTable("leaflets_to_documents", { - leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), - document: text("document").notNull().references(() => documents.uri, { onDelete: "cascade", onUpdate: "cascade" } ), - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - title: text("title").default('').notNull(), - description: text("description").default('').notNull(), - tags: text("tags").default('RRAY[').array(), - cover_image: text("cover_image"), +export const user_entitlements = pgTable("user_entitlements", { + identity_id: uuid("identity_id").notNull().references(() => identities.id, { onDelete: "cascade" } ), + entitlement_key: text("entitlement_key").notNull(), + granted_at: timestamp("granted_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + expires_at: timestamp("expires_at", { withTimezone: true, mode: 'string' }), + source: text("source"), + metadata: jsonb("metadata"), }, (table) => { return { - leaflets_to_documents_pkey: primaryKey({ columns: [table.leaflet, table.document], name: "leaflets_to_documents_pkey"}), + identity_id_idx: index("user_entitlements_identity_id_idx").on(table.identity_id), + expires_at_idx: index("user_entitlements_expires_at_idx").on(table.expires_at), + user_entitlements_pkey: primaryKey({ columns: [table.identity_id, table.entitlement_key], name: "user_entitlements_pkey"}), } }); @@ -397,6 +447,22 @@ export const permission_token_rights = pgTable("permission_token_rights", { } }); +export const leaflets_to_documents = pgTable("leaflets_to_documents", { + leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), + document: text("document").notNull().references(() => documents.uri, { onDelete: "cascade", onUpdate: "cascade" } ), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + title: text("title").default('').notNull(), + description: text("description").default('').notNull(), + tags: text("tags").default('RRAY[').array(), + cover_image: text("cover_image"), + preferences: jsonb("preferences"), +}, +(table) => { + return { + leaflets_to_documents_pkey: primaryKey({ columns: [table.leaflet, table.document], name: "leaflets_to_documents_pkey"}), + } +}); + export const leaflets_in_publications = pgTable("leaflets_in_publications", { publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), doc: text("doc").default('').references(() => documents.uri, { onDelete: "set null" } ), @@ -406,11 +472,13 @@ export const leaflets_in_publications = pgTable("leaflets_in_publications", { archived: boolean("archived"), tags: text("tags").default('RRAY[').array(), cover_image: text("cover_image"), + preferences: jsonb("preferences"), }, (table) => { return { leaflet_idx: index("leaflets_in_publications_leaflet_idx").on(table.leaflet), publication_idx: index("leaflets_in_publications_publication_idx").on(table.publication), + doc_idx: index("leaflets_in_publications_doc_idx").on(table.doc), leaflets_in_publications_pkey: primaryKey({ columns: [table.publication, table.leaflet], name: "leaflets_in_publications_pkey"}), } }); \ No newline at end of file diff --git a/lib/tinybird.ts b/lib/tinybird.ts new file mode 100644 index 00000000..8262b5a7 --- /dev/null +++ b/lib/tinybird.ts @@ -0,0 +1,237 @@ +/** + * Tinybird Definitions + * + * Datasource matching the Vercel Web Analytics drain schema, + * endpoint pipes for publication analytics, and typed client. + * + * Column names use camelCase to match the JSON keys sent by + * Vercel's analytics drain (NDJSON format). + */ + +import { + defineDatasource, + defineEndpoint, + Tinybird, + node, + t, + p, + engine, + type InferRow, + type InferParams, + type InferOutputRow, +} from "@tinybirdco/sdk"; + +// ============================================================================ +// Datasources +// ============================================================================ + +/** + * Vercel Web Analytics drain events. + * Column names match the Vercel drain JSON keys exactly. + * `timestamp` is stored as UInt64 (Unix millis) as sent by Vercel. + */ +export const analyticsEvents = defineDatasource("analytics_events", { + description: "Vercel Web Analytics drain events", + schema: { + timestamp: t.uint64(), + eventType: t.string().lowCardinality(), + eventName: t.string().default(""), + eventData: t.string().default(""), + sessionId: t.uint64(), + deviceId: t.uint64(), + origin: t.string(), + path: t.string(), + referrer: t.string().default(""), + queryParams: t.string().default(""), + route: t.string().default(""), + country: t.string().lowCardinality().default(""), + region: t.string().default(""), + city: t.string().default(""), + osName: t.string().lowCardinality().default(""), + osVersion: t.string().default(""), + clientName: t.string().lowCardinality().default(""), + clientType: t.string().lowCardinality().default(""), + clientVersion: t.string().default(""), + deviceType: t.string().lowCardinality().default(""), + deviceBrand: t.string().default(""), + deviceModel: t.string().default(""), + browserEngine: t.string().default(""), + browserEngineVersion: t.string().default(""), + sdkVersion: t.string().default(""), + sdkName: t.string().default(""), + sdkVersionFull: t.string().default(""), + vercelEnvironment: t.string().lowCardinality().default(""), + vercelUrl: t.string().default(""), + flags: t.string().default(""), + deployment: t.string().default(""), + schema: t.string().default(""), + projectId: t.string().default(""), + ownerId: t.string().default(""), + dataSourceName: t.string().default(""), + }, + engine: engine.mergeTree({ + sortingKey: ["origin", "timestamp"], + partitionKey: "toYYYYMM(fromUnixTimestamp64Milli(timestamp))", + }), +}); + +export type AnalyticsEventsRow = InferRow; + +// ============================================================================ +// Endpoints +// ============================================================================ + +/** + * publication_traffic – daily pageview time series for a publication domain. + */ +export const publicationTraffic = defineEndpoint("publication_traffic", { + description: "Daily pageview time series for a publication domain", + params: { + domain: p.string(), + date_from: p.string().optional(), + date_to: p.string().optional(), + path: p.string().optional(), + }, + nodes: [ + node({ + name: "endpoint", + sql: ` + SELECT + toDate(fromUnixTimestamp64Milli(timestamp)) AS day, + count() AS pageviews, + uniq(deviceId) AS visitors + FROM analytics_events + WHERE eventType = 'pageview' + AND domain(origin) = {{String(domain)}} + {% if defined(date_from) %} + AND fromUnixTimestamp64Milli(timestamp) >= parseDateTimeBestEffort({{String(date_from)}}) + {% end %} + {% if defined(date_to) %} + AND fromUnixTimestamp64Milli(timestamp) <= parseDateTimeBestEffort({{String(date_to)}}) + {% end %} + {% if defined(path) %} + AND path = {{String(path)}} + {% end %} + GROUP BY day + ORDER BY day ASC + `, + }), + ], + output: { + day: t.date(), + pageviews: t.uint64(), + visitors: t.uint64(), + }, +}); + +export type PublicationTrafficParams = InferParams; +export type PublicationTrafficOutput = InferOutputRow; + +/** + * publication_top_referrers – top referring domains for a publication. + */ +export const publicationTopReferrers = defineEndpoint( + "publication_top_referrers", + { + description: "Top referrers for a publication domain", + params: { + domain: p.string(), + date_from: p.string().optional(), + date_to: p.string().optional(), + path: p.string().optional(), + limit: p.int32().optional(10), + }, + nodes: [ + node({ + name: "endpoint", + sql: ` + SELECT + domain(referrer) AS referrer_host, + count() AS pageviews + FROM analytics_events + WHERE eventType = 'pageview' + AND domain(origin) = {{String(domain)}} + AND referrer != '' + AND domain(referrer) != {{String(domain)}} + {% if defined(date_from) %} + AND fromUnixTimestamp64Milli(timestamp) >= parseDateTimeBestEffort({{String(date_from)}}) + {% end %} + {% if defined(date_to) %} + AND fromUnixTimestamp64Milli(timestamp) <= parseDateTimeBestEffort({{String(date_to)}}) + {% end %} + {% if defined(path) %} + AND path = {{String(path)}} + {% end %} + GROUP BY referrer_host + ORDER BY pageviews DESC + LIMIT {{Int32(limit, 10)}} + `, + }), + ], + output: { + referrer_host: t.string(), + pageviews: t.uint64(), + }, + }, +); + +export type PublicationTopReferrersParams = InferParams< + typeof publicationTopReferrers +>; +export type PublicationTopReferrersOutput = InferOutputRow< + typeof publicationTopReferrers +>; + +/** + * publication_top_pages – top pages by pageviews for a publication. + */ +export const publicationTopPages = defineEndpoint("publication_top_pages", { + description: "Top pages for a publication domain", + params: { + domain: p.string(), + date_from: p.string().optional(), + date_to: p.string().optional(), + limit: p.int32().optional(10), + }, + nodes: [ + node({ + name: "endpoint", + sql: ` + SELECT + path, + count() AS pageviews + FROM analytics_events + WHERE eventType = 'pageview' + AND domain(origin) = {{String(domain)}} + {% if defined(date_from) %} + AND fromUnixTimestamp64Milli(timestamp) >= parseDateTimeBestEffort({{String(date_from)}}) + {% end %} + {% if defined(date_to) %} + AND fromUnixTimestamp64Milli(timestamp) <= parseDateTimeBestEffort({{String(date_to)}}) + {% end %} + GROUP BY path + ORDER BY pageviews DESC + LIMIT {{Int32(limit, 10)}} + `, + }), + ], + output: { + path: t.string(), + pageviews: t.uint64(), + }, +}); + +export type PublicationTopPagesParams = InferParams; +export type PublicationTopPagesOutput = InferOutputRow< + typeof publicationTopPages +>; + +// ============================================================================ +// Client +// ============================================================================ + +export const tinybird = new Tinybird({ + datasources: { analyticsEvents }, + pipes: { publicationTraffic, publicationTopReferrers, publicationTopPages }, + devMode: false, +}); diff --git a/package-lock.json b/package-lock.json index 08fc15be..71ad7933 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@rocicorp/undo": "^0.2.1", "@supabase/ssr": "^0.3.0", "@supabase/supabase-js": "^2.43.2", + "@tinybirdco/sdk": "^0.0.55", "@tiptap/core": "^2.11.5", "@types/mdx": "^2.0.13", "@vercel/analytics": "^1.5.0", @@ -66,6 +67,7 @@ "react-day-picker": "^9.3.0", "react-dom": "19.2.1", "react-use-measure": "^2.1.1", + "recharts": "^3.7.0", "redlock": "^5.0.0-beta.2", "rehype-parse": "^9.0.0", "rehype-remark": "^10.0.0", @@ -77,6 +79,7 @@ "replicache": "^15.3.0", "sharp": "^0.34.4", "shiki": "^3.8.1", + "stripe": "^20.4.0", "swr": "^2.3.3", "thumbhash": "^0.1.1", "twilio": "^5.3.7", @@ -945,6 +948,27 @@ "linux" ] }, + "node_modules/@clack/core": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.1.tgz", + "integrity": "sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.1.tgz", + "integrity": "sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.0.1", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.2.tgz", @@ -7119,6 +7143,42 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@remirror/core-constants": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", @@ -7225,6 +7285,18 @@ "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@supabase/auth-js": { "version": "2.64.2", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.64.2.tgz", @@ -7548,72 +7620,570 @@ "tslib": "^2.8.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz", + "integrity": "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.13.tgz", + "integrity": "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide/node_modules/tar": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.13.tgz", + "integrity": "sha512-HLgx6YSFKJT7rJqh9oJs/TkBFhxuMOfUKSBEPYwV+t78POOBsdQ7crhZLzwcH3T0UyUuOzU/GK5pk5eKr3wCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.13", + "@tailwindcss/oxide": "4.1.13", + "postcss": "^8.4.41", + "tailwindcss": "4.1.13" + } + }, + "node_modules/@tinybirdco/sdk": { + "version": "0.0.55", + "resolved": "https://registry.npmjs.org/@tinybirdco/sdk/-/sdk-0.0.55.tgz", + "integrity": "sha512-LzOocxjdGy1nXD+03zfY3C4ZQSOnH1CbarMajUnwOwi4OwDeJIEA3nohyPxWAxgXXRgH7kfhM/tokavhP+DH0A==", + "license": "MIT", + "dependencies": { + "@clack/prompts": "^1.0.0", + "chokidar": "^4.0.0", + "commander": "^12.0.0", + "dotenv": "^16.0.0", + "esbuild": "^0.24.0", + "picocolors": "^1.1.1", + "zod": "^3.25.0" + }, + "bin": { + "tinybird": "bin/tinybird.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz", - "integrity": "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==", + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", "cpu": [ - "arm64" + "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">= 10" + "node": ">=18" } }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.13.tgz", - "integrity": "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==", + "node_modules/@tinybirdco/sdk/node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">= 10" + "node": ">=18" } }, - "node_modules/@tailwindcss/oxide/node_modules/tar": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", - "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", - "dev": true, - "license": "BlueOak-1.0.0", + "node_modules/@tinybirdco/sdk/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", "engines": { "node": ">=18" } }, - "node_modules/@tailwindcss/postcss": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.13.tgz", - "integrity": "sha512-HLgx6YSFKJT7rJqh9oJs/TkBFhxuMOfUKSBEPYwV+t78POOBsdQ7crhZLzwcH3T0UyUuOzU/GK5pk5eKr3wCiQ==", - "dev": true, + "node_modules/@tinybirdco/sdk/node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.13", - "@tailwindcss/oxide": "4.1.13", - "postcss": "^8.4.41", - "tailwindcss": "4.1.13" + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/@tinybirdco/sdk/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/@tiptap/core": { @@ -7730,6 +8300,69 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -7928,6 +8561,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -9428,6 +10067,127 @@ "node": ">=0.12" } }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -9541,6 +10301,12 @@ "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", @@ -9688,6 +10454,18 @@ "node": ">=0.10.0" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dreamopt": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/dreamopt/-/dreamopt-0.8.0.tgz", @@ -10501,6 +11279,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.0.tgz", + "integrity": "sha512-RArCX+Zea16+R1jg4mH223Z8p/ivbJjIkU3oC6ld2bdUfmDxiCkFYSi9zLOR2anucWJUeH4Djnzgd0im0nD3dw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/es5-ext": { "version": "0.10.64", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", @@ -12704,6 +13492,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/intl-messageformat": { "version": "10.7.16", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.16.tgz", @@ -16578,9 +17375,31 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", @@ -16745,6 +17564,42 @@ "node": ">= 12.13.0" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts/node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/recma-build-jsx": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", @@ -16838,6 +17693,21 @@ "node": ">=12" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -17102,6 +17972,12 @@ "node": ">=8.6.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -17673,8 +18549,7 @@ "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" }, "node_modules/sonic-boom": { "version": "3.8.1", @@ -17970,6 +18845,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.0.tgz", + "integrity": "sha512-F/aN1IQ9vHmlyLNi3DkiIbyzQb6gyBG0uYFd/VrEVQSc9BLtlgknPUx0EvzZdBMRLFuRaPFIFd7Mxwtg7Pbwzw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/style-to-object": { "version": "1.0.14", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", @@ -18128,6 +19020,12 @@ "next-tick": "1" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -18838,6 +19736,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", diff --git a/package.json b/package.json index 88cb8ff3..81ae2f4b 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@rocicorp/undo": "^0.2.1", "@supabase/ssr": "^0.3.0", "@supabase/supabase-js": "^2.43.2", + "@tinybirdco/sdk": "^0.0.55", "@tiptap/core": "^2.11.5", "@types/mdx": "^2.0.13", "@vercel/analytics": "^1.5.0", @@ -77,6 +78,7 @@ "react-day-picker": "^9.3.0", "react-dom": "19.2.1", "react-use-measure": "^2.1.1", + "recharts": "^3.7.0", "redlock": "^5.0.0-beta.2", "rehype-parse": "^9.0.0", "rehype-remark": "^10.0.0", @@ -88,6 +90,7 @@ "replicache": "^15.3.0", "sharp": "^0.34.4", "shiki": "^3.8.1", + "stripe": "^20.4.0", "swr": "^2.3.3", "thumbhash": "^0.1.1", "twilio": "^5.3.7", diff --git a/specs/2026-02-03-pro-tier.md b/specs/2026-02-03-pro-tier.md new file mode 100644 index 00000000..b91596de --- /dev/null +++ b/specs/2026-02-03-pro-tier.md @@ -0,0 +1,246 @@ +# Pro Tier Subscription System + +**Status**: draft + +## Goal + +Add a paid Pro tier to Leaflet using Stripe for billing, with an entitlements-based architecture that decouples feature access from subscription state. + +## Design + +### Data Model + +Two new tables separate Stripe subscription state from feature access: + +**`user_subscriptions`** — Stripe sync state +- `identity_id` (UUID, FK to identities, PK) +- `stripe_customer_id` (text, unique) +- `stripe_subscription_id` (text, unique, nullable) +- `plan` (text) — Price ID from `stripe/products.ts`, e.g., `pro_monthly_v1_usd` +- `status` (text) — mirrors Stripe (`trialing`, `active`, `past_due`, `canceled`, `unpaid`) plus custom `canceling` (set when `cancel_at_period_end` is true, indicates subscription is active but will not renew) +- `current_period_end` (timestamp) +- `created_at`, `updated_at` + +**`user_entitlements`** — Feature access grants +- `identity_id` (UUID, FK to identities) +- `entitlement_key` (text) — e.g., `analytics` +- `granted_at` (timestamp) +- `expires_at` (timestamp, nullable) — null means permanent +- `source` (text) — provenance, e.g., `stripe:sub_xxx`, `manual:admin`, `promo:launch2026` +- `metadata` (jsonb, nullable) — for limits or additional config +- Primary key: `(identity_id, entitlement_key)` — one entitlement per key per user + +The unique constraint on `(identity_id, entitlement_key)` means writes are upserts. The most recent write wins; `source` tracks provenance. + +### SKU → Entitlements Mapping + +Entitlements for each product are defined in code (`stripe/products.ts`) alongside the product definitions. The sync script pushes this metadata to Stripe for consistency, but the codebase is the source of truth. + +Example from `stripe/products.ts`: + +```json +{ + "entitlements": "{\"analytics\": true}" +} +``` + +The sync script ensures Stripe metadata stays in sync with code definitions. + +### Stripe Product Sync + +Products and prices are defined in code and synced to Stripe via a GitHub Action. This provides version control, reproducibility, and enforces immutability. + +**Directory structure:** +``` +stripe/ +├── products.ts # Product/price definitions with entitlement metadata +└── sync.ts # Script that ensures Stripe matches definitions +``` + +**Product definition format** (`stripe/products.ts`): +```typescript +export const products = [ + { + id: "pro_monthly_v1", + name: "Leaflet Pro (Monthly)", + prices: [ + { + id: "pro_monthly_v1_usd", + currency: "usd", + unit_amount: 900, // $9.00 + recurring: { interval: "month" }, + }, + ], + metadata: { + entitlements: JSON.stringify({ publication_analytics: true }), + }, + }, +]; +``` + +**Sync script behavior** (`stripe/sync.ts`): +1. Fetch existing products and prices by `id` (`stripe.products.retrieve(id)`, `stripe.prices.retrieve(id)`) +2. For each definition: + - If missing in Stripe → create it with the custom `id` + - If exists and matches → no-op + - If exists but differs → update it to match the definition +3. Never deletes products/prices + +Both products and prices use custom `id` for idempotent matching (set at creation). Same definitions apply to both test and live mode—the script targets whichever mode the API key belongs to. + +**GitHub Action** (`.github/workflows/stripe-sync.yml`): +- Triggers on push to `stripe/` directory +- Runs sync against test mode automatically +- Live mode sync requires manual workflow dispatch with approval + +**Versioning/grandfathering:** +To grandfather existing subscribers on different terms, create a new product with a new `id` (e.g., `pro_monthly_v2`). Existing subscribers stay on v1. New subscribers get v2. For additive changes (new entitlements, metadata updates), just update the existing product definition. + +### Stripe Sync Strategy + +**Webhook-driven** with **optimistic updates**: + +1. **Optimistic**: After successful Checkout Session completion on the client, immediately call a server action to write `user_subscriptions` and `user_entitlements` based on the session data. User gets instant access. + +2. **Durable**: Stripe webhooks confirm and reconcile state. Handles edge cases (payment failures, disputes, subscription updates from Stripe dashboard). + +Webhooks to handle: +- `checkout.session.completed` — initial subscription created +- `customer.subscription.created` — backup for subscription creation +- `customer.subscription.updated` — plan changes, renewals, status changes +- `customer.subscription.deleted` — subscription ended +- `invoice.payment_failed` — payment issues +- `customer.subscription.trial_will_end` — trial ending reminder (optional, for notifications) + +### Entitlement Lifecycle + +**Grant flow** (subscription activates or trial starts): +1. Webhook receives event with subscription and product data +2. Parse `entitlements` from product metadata +3. Upsert `user_entitlements` rows with `expires_at` = `current_period_end`, `source` = `stripe:{subscription_id}` + +**Renewal flow** (subscription renews): +1. `customer.subscription.updated` webhook fires +2. Update `expires_at` on all entitlements with matching `source` + +**Cancellation flow** (user cancels but period remains): +1. `customer.subscription.updated` with `cancel_at_period_end: true` +2. Update `user_subscriptions.status` +3. Entitlements remain valid until `expires_at` (already set to period end) + +**Expiration flow** (subscription ends): +1. `customer.subscription.deleted` webhook fires +2. No action needed on `user_entitlements` — they naturally expire via `expires_at` +3. Update `user_subscriptions.status` to `canceled` + +Soft expiration via `expires_at` preserves audit history and handles the canceled-but-paid-through-period case without additional webhooks. + +### Trials + +Stripe-managed trials: +1. Trial starts → subscription created with `status: trialing` +2. Entitlements granted with `expires_at` = trial end date +3. Trial converts → `expires_at` updated to subscription period end +4. Trial lapses → entitlements expire naturally, no action needed + +### Entitlement Checks + +Extend `getIdentityData()` to join against `user_entitlements` and return active entitlements: + +```typescript +type IdentityData = { + id: string; + email: string; + atp_did: string; + // ... existing fields + entitlements: Record | null; + }>; +}; +``` + +Query filters to `expires_at IS NULL OR expires_at > NOW()`. The `useIdentityData()` hook exposes the same shape client-side. + +Feature gating in server actions: + +```typescript +export async function getAnalyticsData() { + const identity = await getIdentityData(); + if (!identity?.entitlements.analytics) { + return err("Pro subscription required"); + } + // ... fetch analytics +} +``` + +### Initial Pro Features + +Single entitlement at launch: +- `publication_analytics` (boolean) — access to analytics features for + publications + +### Webhook Endpoint + +New API route at `app/api/webhooks/stripe/route.ts`: +- Verify Stripe signature using `STRIPE_WEBHOOK_SECRET` +- Dispatch to Inngest functions for processing (keeps webhook response fast, enables retries) + +### Inngest Functions + +New functions in `app/api/inngest/functions/`: +- `stripe/handle-checkout-completed` — process successful checkout +- `stripe/handle-subscription-updated` — sync subscription changes to entitlements +- `stripe/handle-subscription-deleted` — update subscription status + +Events to add to Inngest client: +```typescript +"stripe/checkout.session.completed": { data: { sessionId: string } } +"stripe/customer.subscription.updated": { data: { subscriptionId: string } } +"stripe/customer.subscription.deleted": { data: { subscriptionId: string } } +``` + +### Environment Variables + +``` +STRIPE_SECRET_KEY=sk_... +STRIPE_WEBHOOK_SECRET=whsec_... +``` + +Price IDs are defined in `stripe/products.ts` and imported directly — no env var needed. + +## Open Questions + +- **UI**: Where upgrade prompts appear, pricing page design, checkout flow UX — to be designed separately +- **Multiple plans**: Current design supports one subscription per user. If we add team plans or multiple concurrent subscriptions, `user_subscriptions` would need to become one-to-many + +## Implementation + +1. **Add Stripe packages**: Install `stripe` npm package + +2. **Database migration**: Create `user_subscriptions` and `user_entitlements` tables with indexes on `identity_id` and `expires_at` + +3. **Drizzle schema**: Add table definitions to `drizzle/schema.ts` + +4. **Environment setup**: Add Stripe env vars to `.env.local` and deployment config + +5. **Stripe webhook endpoint**: Create `app/api/webhooks/stripe/route.ts` with signature verification, dispatch events to Inngest + +6. **Inngest events and functions**: Add event types to `app/api/inngest/client.ts`, create handler functions for checkout completed, subscription updated, subscription deleted + +7. **Extend getIdentityData**: Join against `user_entitlements`, filter expired, return entitlements object + +8. **Extend useIdentityData**: Ensure client-side hook receives entitlements from server + +9. **Optimistic update action**: Create `actions/subscriptions/activateSubscription.ts` for immediate access after checkout + +10. **Stripe sync infrastructure**: + - Create `stripe/products.ts` with Pro product definitions and entitlement metadata + - Create `stripe/sync.ts` script using Stripe Node SDK to reconcile definitions with Stripe + - Add `.github/workflows/stripe-sync.yml` for automated test mode sync on push, manual live mode sync + +11. **Initial product sync**: Run sync script against both test and live mode to create Pro products + +12. **Gate analytics**: Add entitlement check to analytics server actions diff --git a/src/hooks/useEntitlement.ts b/src/hooks/useEntitlement.ts new file mode 100644 index 00000000..8d1c0325 --- /dev/null +++ b/src/hooks/useEntitlement.ts @@ -0,0 +1,15 @@ +import { useIdentityData } from "components/IdentityProvider"; + +export function useHasEntitlement(key: string): boolean { + const { identity } = useIdentityData(); + if (!identity?.entitlements) return false; + return key in identity.entitlements; +} + +export function useIsPro(): boolean { + return useHasEntitlement("publication_analytics"); +} + +export function useCanSeePro(): boolean { + return useHasEntitlement("pro_plan_visible"); +} diff --git a/stripe/client.ts b/stripe/client.ts new file mode 100644 index 00000000..1c5921c0 --- /dev/null +++ b/stripe/client.ts @@ -0,0 +1,12 @@ +import Stripe from "stripe"; + +let _stripe: Stripe | null = null; + +export function getStripe(): Stripe { + if (!_stripe) { + _stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, { + apiVersion: "2026-02-25.clover", + }); + } + return _stripe; +} diff --git a/stripe/products.ts b/stripe/products.ts new file mode 100644 index 00000000..820a4c60 --- /dev/null +++ b/stripe/products.ts @@ -0,0 +1,44 @@ +export const PRODUCT_DEF_ID = "leaflet_pro_v1"; + +export const PRODUCT_DEFINITION = { + name: "Leaflet Pro", + metadata: { + product_def_id: PRODUCT_DEF_ID, + entitlements: JSON.stringify({ publication_analytics: true, pro_plan_visible: true }), + }, +}; + +export const PRICE_DEFINITIONS = { + month: { + lookup_key: "leaflet_pro_monthly_v1_usd", + unit_amount: 1200, + currency: "usd", + recurring: { interval: "month" as const }, + }, + year: { + lookup_key: "leaflet_pro_yearly_v1_usd", + unit_amount: 12000, + currency: "usd", + recurring: { interval: "year" as const }, + }, +}; + +export async function getPriceId( + cadence: "month" | "year", +): Promise { + const { getStripe } = await import("./client"); + const key = PRICE_DEFINITIONS[cadence].lookup_key; + const prices = await getStripe().prices.list({ lookup_keys: [key] }); + return prices.data[0]?.id ?? null; +} + +export function parseEntitlements( + metadata: Record | null, +): Record { + if (!metadata?.entitlements) return {}; + try { + return JSON.parse(metadata.entitlements); + } catch { + return {}; + } +} diff --git a/stripe/sync.ts b/stripe/sync.ts new file mode 100644 index 00000000..2a1f3e81 --- /dev/null +++ b/stripe/sync.ts @@ -0,0 +1,62 @@ +import Stripe from "stripe"; +import { PRODUCT_DEF_ID, PRODUCT_DEFINITION, PRICE_DEFINITIONS } from "./products"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, { + apiVersion: "2026-02-25.clover", +}); + +async function sync() { + console.log("Syncing Stripe products and prices..."); + + // Find or create product + let product: Stripe.Product | undefined; + const existing = await stripe.products.search({ + query: `metadata["product_def_id"]:"${PRODUCT_DEF_ID}"`, + }); + + if (existing.data.length > 0) { + product = existing.data[0]; + console.log(`Found existing product: ${product.id}`); + // Update if name or metadata changed + product = await stripe.products.update(product.id, { + name: PRODUCT_DEFINITION.name, + metadata: PRODUCT_DEFINITION.metadata, + }); + console.log(`Updated product: ${product.id}`); + } else { + product = await stripe.products.create({ + name: PRODUCT_DEFINITION.name, + metadata: PRODUCT_DEFINITION.metadata, + }); + console.log(`Created product: ${product.id}`); + } + + // Sync prices by lookup_key + for (const [cadence, def] of Object.entries(PRICE_DEFINITIONS)) { + const existingPrices = await stripe.prices.list({ + lookup_keys: [def.lookup_key], + }); + + if (existingPrices.data.length > 0) { + console.log( + `Price "${def.lookup_key}" already exists: ${existingPrices.data[0].id}`, + ); + } else { + const price = await stripe.prices.create({ + product: product.id, + unit_amount: def.unit_amount, + currency: def.currency, + recurring: def.recurring, + lookup_key: def.lookup_key, + }); + console.log(`Created price "${def.lookup_key}": ${price.id}`); + } + } + + console.log("Sync complete."); +} + +sync().catch((err) => { + console.error("Sync failed:", err); + process.exit(1); +}); diff --git a/supabase/database.types.ts b/supabase/database.types.ts index 78767dc4..5701f414 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -1312,6 +1312,82 @@ export type Database = { }, ] } + user_entitlements: { + Row: { + entitlement_key: string + expires_at: string | null + granted_at: string + identity_id: string + metadata: Json | null + source: string | null + } + Insert: { + entitlement_key: string + expires_at?: string | null + granted_at?: string + identity_id: string + metadata?: Json | null + source?: string | null + } + Update: { + entitlement_key?: string + expires_at?: string | null + granted_at?: string + identity_id?: string + metadata?: Json | null + source?: string | null + } + Relationships: [ + { + foreignKeyName: "user_entitlements_identity_id_fkey" + columns: ["identity_id"] + isOneToOne: false + referencedRelation: "identities" + referencedColumns: ["id"] + }, + ] + } + user_subscriptions: { + Row: { + created_at: string + current_period_end: string | null + identity_id: string + plan: string | null + status: string | null + stripe_customer_id: string + stripe_subscription_id: string | null + updated_at: string + } + Insert: { + created_at?: string + current_period_end?: string | null + identity_id: string + plan?: string | null + status?: string | null + stripe_customer_id: string + stripe_subscription_id?: string | null + updated_at?: string + } + Update: { + created_at?: string + current_period_end?: string | null + identity_id?: string + plan?: string | null + status?: string | null + stripe_customer_id?: string + stripe_subscription_id?: string | null + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "user_subscriptions_identity_id_fkey" + columns: ["identity_id"] + isOneToOne: true + referencedRelation: "identities" + referencedColumns: ["id"] + }, + ] + } } Views: { [_ in never]: never diff --git a/tinybird.config.mjs b/tinybird.config.mjs new file mode 100644 index 00000000..0b1667b7 --- /dev/null +++ b/tinybird.config.mjs @@ -0,0 +1,9 @@ +/** @type {import("@tinybirdco/sdk").TinybirdConfig} */ +const tinybirdConfig = { + include: ["lib/tinybird.ts"], + token: process.env.TINYBIRD_TOKEN, + baseUrl: process.env.TINYBIRD_URL, + devMode: "branch", // or "local" if you want to run the project locally (Tinybird Local required) +}; + +export default tinybirdConfig; -- 2.51.2