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;