From 2b7844e0b04560b160d3813172c9cbd6fd1bbf08 Mon Sep 17 00:00:00 2001 From: aaron Date: Sun, 29 Mar 2026 22:59:51 +0800 Subject: [PATCH 01/49] feat(backend): wire up Supabase auth, Creem billing, and credits system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements all backend logic from PLAN.md across 7 phases: Phase 1 – Foundation: populate Supabase browser/server/admin clients, full Creem API wrapper (checkout, subscriptions, portal, webhook verification), Next.js middleware with auth-redirect guards, migration 004 adds creem_customer_id to profiles. Phase 2 – Auth: server actions for login/signup/OAuth/logout with Zod validation, OAuth callback route, useUser hook, auth-aware header. Phase 3 – Billing core: Zod webhook schemas, handleWebhookEvent dispatcher (checkout.completed, subscription lifecycle, refund.created), single /api/webhooks/creem route, createCheckoutSession/openCustomerPortal actions, CheckoutButton component, pricing section wired to real Creem product IDs. Phase 4 – Dashboard: layout moved to server component with auth check and real user passed to sidebar; dashboard page adds SubscriptionCard + CreditsCard above existing demo; settings/billing/credits sub-pages; AppSidebar accepts user prop with real nav links; NavUser wires logout. Phase 5 – Credits: migration 005 adds spend_credits RPC with row-level lock; credits types, actions (balance/purchase/spend/transactions), CreditsBalanceCard, TransactionHistory, useCredits hook. Phase 6 – Advanced billing: cancel (scheduled/immediate), resume, pause, upgrade server actions; ManageSubscription component with AlertDialog confirmation; useSubscription hook. Phase 7 – Polish: Toaster added to root layout; ActionResult shared type; vitest config + test setup; 12 passing tests covering webhook handlers, webhook signature verification, and spend_credits edge cases; CI extended with test and build jobs. --- .env.example | 1 + .github/workflows/ci.yml | 31 + package-lock.json | 1856 ++++++++++++++++- package.json | 14 +- .../(dashboard)/dashboard/billing/page.tsx | 97 + .../(dashboard)/dashboard/credits/page.tsx | 33 + src/app/(dashboard)/dashboard/page.tsx | 50 +- .../(dashboard)/dashboard/settings/page.tsx | 35 + src/app/(dashboard)/layout.tsx | 37 +- src/app/api/webhooks/creem/route.ts | 20 + src/app/auth/callback/route.ts | 20 + src/app/layout.tsx | 6 +- src/components/header.tsx | 94 +- src/features/auth/actions/index.ts | 111 + src/features/auth/actions/profile.ts | 37 + src/features/auth/components/login-form.tsx | 117 +- .../auth/components/settings-profile-card.tsx | 82 + src/features/auth/components/signup-form.tsx | 62 +- src/features/auth/hooks/use-user.ts | 29 + src/features/auth/types.ts | 5 + src/features/billing/actions/index.ts | 219 ++ .../billing/components/checkout-button.tsx | 30 + .../components/manage-subscription.tsx | 197 ++ .../billing/components/pricing-section.tsx | 15 +- .../billing/components/subscription-card.tsx | 136 ++ .../billing/hooks/use-subscription.ts | 23 + src/features/billing/types.ts | 82 + .../billing/webhooks/__tests__/index.test.ts | 121 ++ src/features/billing/webhooks/index.ts | 251 +++ .../actions/__tests__/spend-credits.test.ts | 66 + src/features/credits/actions/index.ts | 97 + .../components/credits-balance-card.tsx | 30 + .../components/transaction-history.tsx | 89 + src/features/credits/hooks/use-credits.ts | 22 + src/features/credits/types.ts | 14 + .../dashboard/components/app-sidebar.tsx | 178 +- .../dashboard/components/nav-main.tsx | 24 +- .../dashboard/components/nav-user.tsx | 38 +- src/lib/action-result.ts | 3 + src/lib/creem/__tests__/client.test.ts | 55 + src/lib/creem/client.ts | 141 ++ src/lib/supabase/admin.ts | 8 + src/lib/supabase/client.ts | 8 + src/lib/supabase/server.ts | 28 + src/middleware.ts | 53 + src/test/setup.ts | 1 + .../004_profiles_creem_customer_id.sql | 2 + supabase/migrations/005_spend_credits_rpc.sql | 32 + vitest.config.ts | 17 + 49 files changed, 4354 insertions(+), 363 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/billing/page.tsx create mode 100644 src/app/(dashboard)/dashboard/credits/page.tsx create mode 100644 src/app/(dashboard)/dashboard/settings/page.tsx create mode 100644 src/app/api/webhooks/creem/route.ts create mode 100644 src/app/auth/callback/route.ts create mode 100644 src/features/auth/actions/index.ts create mode 100644 src/features/auth/actions/profile.ts create mode 100644 src/features/auth/components/settings-profile-card.tsx create mode 100644 src/features/auth/hooks/use-user.ts create mode 100644 src/features/auth/types.ts create mode 100644 src/features/billing/actions/index.ts create mode 100644 src/features/billing/components/checkout-button.tsx create mode 100644 src/features/billing/components/manage-subscription.tsx create mode 100644 src/features/billing/components/subscription-card.tsx create mode 100644 src/features/billing/hooks/use-subscription.ts create mode 100644 src/features/billing/types.ts create mode 100644 src/features/billing/webhooks/__tests__/index.test.ts create mode 100644 src/features/billing/webhooks/index.ts create mode 100644 src/features/credits/actions/__tests__/spend-credits.test.ts create mode 100644 src/features/credits/actions/index.ts create mode 100644 src/features/credits/components/credits-balance-card.tsx create mode 100644 src/features/credits/components/transaction-history.tsx create mode 100644 src/features/credits/hooks/use-credits.ts create mode 100644 src/features/credits/types.ts create mode 100644 src/lib/action-result.ts create mode 100644 src/lib/creem/__tests__/client.test.ts create mode 100644 src/middleware.ts create mode 100644 src/test/setup.ts create mode 100644 supabase/migrations/004_profiles_creem_customer_id.sql create mode 100644 supabase/migrations/005_spend_credits_rpc.sql create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example index 743cb24..29d17d1 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,7 @@ CREEM_API_KEY= CREEM_WEBHOOK_SECRET= NEXT_PUBLIC_CREEM_PRODUCT_ID_PRO= NEXT_PUBLIC_CREEM_PRODUCT_ID_CREDITS= +NEXT_PUBLIC_CREEM_PRODUCT_ID_BUSINESS= # OAuth (configured in Supabase dashboard) # GOOGLE_CLIENT_ID and GITHUB_CLIENT_ID are set in Supabase, not here diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ff3807..6c17716 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,3 +32,34 @@ jobs: cache: npm - run: npm ci - run: npm run lint + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run test + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run build + env: + NEXT_PUBLIC_SUPABASE_URL: http://localhost:54321 + NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder + NEXT_PUBLIC_APP_URL: http://localhost:3000 + NEXT_PUBLIC_CREEM_PRODUCT_ID_PRO: placeholder + NEXT_PUBLIC_CREEM_PRODUCT_ID_BUSINESS: placeholder + NEXT_PUBLIC_CREEM_PRODUCT_ID_CREDITS: placeholder diff --git a/package-lock.json b/package-lock.json index aac896d..0893e63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,8 @@ "@mdx-js/react": "^3.1.1", "@next/mdx": "^16.2.1", "@oyerinde/caliper": "^0.2.3", + "@supabase/ssr": "^0.9.0", + "@supabase/supabase-js": "^2.100.1", "@tabler/icons-react": "^3.41.0", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-table": "^8.21.3", @@ -39,16 +41,29 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.2.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^25.5.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "jsdom": "^29.0.1", "oxfmt": "latest", "oxlint": "latest", "postcss": "^8", "tailwindcss": "^4.2.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.2" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -62,6 +77,67 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.1.tgz", + "integrity": "sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -529,6 +605,159 @@ } } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -826,6 +1055,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -1644,16 +1891,22 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@next/env": { @@ -1916,6 +2169,16 @@ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "license": "MIT" }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@oxfmt/binding-android-arm-eabi": { "version": "0.42.0", "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.42.0.tgz", @@ -3028,75 +3291,448 @@ "url": "https://opencollective.com/immer" } }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", - "license": "MIT" - }, - "node_modules/@sindresorhus/merge-streams": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", - "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || >=22.12.0" } }, - "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/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tabler/icons": { - "version": "3.41.0", - "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.41.0.tgz", - "integrity": "sha512-arlig0nkaC9UGqTZuT1MMZepX29t3Ysx5HSy2UvmR+CZrhlNxZrCM6Z4qYBoaIO+2ICZykttjBCSpf+p/t0H3w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/codecalm" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tabler/icons-react": { - "version": "3.41.0", - "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.41.0.tgz", - "integrity": "sha512-8XKc3wZKf1icxqwIPSOO61M+dtf8yJPwAE/rtFAVzc5Ros+OjCqowfcoI5IpKK09RSo8s0hHrWvydGgnXqYILg==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@tabler/icons": "3.41.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/codecalm" - }, - "peerDependencies": { - "react": ">= 16" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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.100.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.100.1.tgz", + "integrity": "sha512-c5FB4nrG7cs1mLSzFGuIVl2iR2YO5XkSJ96uF4zubYm8YDn71XOi2emE9sBm/avfGCj61jaRBLOvxEAVnpys0Q==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.100.1.tgz", + "integrity": "sha512-mo8QheoV4KR+wSubtyEWhZUxWnCM7YZ23TncccMAlbWAHb8YTDqRGRm9IalWCAswniKyud6buZCk9snRqI86KA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz", + "integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.100.1.tgz", + "integrity": "sha512-OIh4mOSo2LdqF2kox76OAPDtcSs+PwKABJOjc6plUV4/LXhFEsI2uwdEEIs7K7fd141qehWEVl/Y+Ts0fNvYsw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.100.1.tgz", + "integrity": "sha512-FHuRWPX4qZQ4x+0Q+ZrKaBZnOiVGiwsgiAUJM98pYRib1yeaE/fOM1lZ1ozd+4gA8Udw23OyaD8SxKS5mT5NYw==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.0", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.9.0.tgz", + "integrity": "sha512-UFY6otYV3yqCgV+AyHj80vNkTvbf1Gas2LW4dpbQ4ap6p6v3eB2oaDfcI99jsuJzwVBCFU4BJI+oDYyhNk1z0Q==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.97.0" + } + }, + "node_modules/@supabase/ssr/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.100.1.tgz", + "integrity": "sha512-x9xpEIoWM4xKiAlwfWTgHPSN6N4Y0aS4FVU4F6ZPbq7Gayw08SrtC6/YH/gOr8CjXQr0HxXYXDop2xGTSjubYA==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.100.1.tgz", + "integrity": "sha512-CAeFm5sfX8sbTzxoxRafhohreIzl9a7R6qHTck3MrgTqm5M5g/u0IHfEKYzI9w/17r8NINl8UZrw2i08wrO7Iw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.100.1", + "@supabase/functions-js": "2.100.1", + "@supabase/postgrest-js": "2.100.1", + "@supabase/realtime-js": "2.100.1", + "@supabase/storage-js": "2.100.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tabler/icons": { + "version": "3.41.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.41.0.tgz", + "integrity": "sha512-arlig0nkaC9UGqTZuT1MMZepX29t3Ysx5HSy2UvmR+CZrhlNxZrCM6Z4qYBoaIO+2ICZykttjBCSpf+p/t0H3w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.41.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.41.0.tgz", + "integrity": "sha512-8XKc3wZKf1icxqwIPSOO61M+dtf8yJPwAE/rtFAVzc5Ros+OjCqowfcoI5IpKK09RSo8s0hHrWvydGgnXqYILg==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.41.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", "dev": true, "license": "MIT", "dependencies": { @@ -3422,6 +4058,81 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -3508,6 +4219,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -3580,6 +4309,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3629,7 +4365,6 @@ "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -3666,23 +4401,171 @@ "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/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/validate-npm-package-name": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", + "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/@types/validate-npm-package-name": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", - "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", - "license": "MIT" + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "license": "ISC" + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, "node_modules/accepts": { "version": "2.0.0", @@ -3811,6 +4694,26 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -3854,6 +4757,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -4015,6 +4928,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -4346,6 +5269,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4494,6 +5438,20 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4511,6 +5469,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "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", @@ -4649,6 +5614,13 @@ "node": ">=0.3.1" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -4727,6 +5699,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -4763,6 +5748,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5007,6 +5999,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -5247,6 +6249,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5500,6 +6517,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -5542,6 +6572,15 @@ "node": ">=18.18.0" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -5593,6 +6632,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -5808,6 +6857,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -5908,6 +6964,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6291,6 +7398,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6492,6 +7609,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -7183,6 +8307,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -7525,6 +8659,17 @@ "node": ">= 10" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -7774,6 +8919,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -7804,6 +8962,13 @@ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7884,6 +9049,51 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -7944,6 +9154,16 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -8251,6 +9471,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -8393,6 +9627,47 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -8460,6 +9735,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -8773,6 +10061,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -8829,6 +10124,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -8838,6 +10140,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -8946,6 +10255,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -8987,6 +10309,13 @@ } } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -9041,6 +10370,71 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinypool": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", @@ -9051,6 +10445,16 @@ "node": "^20.0.0 || >=22.0.0" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.27", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", @@ -9102,6 +10506,19 @@ "node": ">=16" } }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -9190,11 +10607,20 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -9505,6 +10931,205 @@ "d3-timer": "^3.0.1" } }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -9514,6 +11139,41 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9529,6 +11189,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -9627,6 +11304,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 8ae35c5..3e33bc6 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "start": "next start", "lint": "oxlint --format=github", "format": "oxfmt", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@base-ui/react": "^1.3.0", @@ -21,6 +23,8 @@ "@mdx-js/react": "^3.1.1", "@next/mdx": "^16.2.1", "@oyerinde/caliper": "^0.2.3", + "@supabase/ssr": "^0.9.0", + "@supabase/supabase-js": "^2.100.1", "@tabler/icons-react": "^3.41.0", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-table": "^8.21.3", @@ -43,14 +47,20 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.2.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^25.5.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "jsdom": "^29.0.1", "oxfmt": "latest", "oxlint": "latest", "postcss": "^8", "tailwindcss": "^4.2.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.2" }, "overrides": { "@types/react": "19.2.14", diff --git a/src/app/(dashboard)/dashboard/billing/page.tsx b/src/app/(dashboard)/dashboard/billing/page.tsx new file mode 100644 index 0000000..a3fb490 --- /dev/null +++ b/src/app/(dashboard)/dashboard/billing/page.tsx @@ -0,0 +1,97 @@ +import type { Metadata } from "next"; +import { SiteHeader } from "@/features/dashboard/components/site-header"; +import { getUserSubscription } from "@/features/billing/actions"; +import { SubscriptionCard } from "@/features/billing/components/subscription-card"; +import { ManageSubscription } from "@/features/billing/components/manage-subscription"; +import { getCreditsBalance } from "@/features/credits/actions"; +import { PLANS } from "@/features/billing/types"; +import { CheckoutButton } from "@/features/billing/components/checkout-button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export const metadata: Metadata = { + title: "Billing", + description: "Manage your subscription and billing details.", +}; + +export default async function BillingPage() { + const [subscription, creditsBalance] = await Promise.all([ + getUserSubscription(), + getCreditsBalance(), + ]); + + return ( + <> + +
+
+

Billing

+

+ Manage your subscription and payment details. +

+
+ + + + {subscription && } + + {/* Plan comparison */} + + + Available Plans + Upgrade or change your plan. + + +
+ {PLANS.pro.productId && ( +
+

{PLANS.pro.name}

+

+ ${(PLANS.pro.price / 100).toFixed(0)} + + /mo + +

+

+ {PLANS.pro.credits.toLocaleString()} credits/month +

+ + {subscription ? "Switch to Pro" : "Get Pro"} + +
+ )} + {PLANS.business.productId && ( +
+

{PLANS.business.name}

+

+ ${(PLANS.business.price / 100).toFixed(0)} + + /mo + +

+

+ Unlimited credits +

+ + {subscription ? "Switch to Business" : "Get Business"} + +
+ )} +
+
+
+
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/credits/page.tsx b/src/app/(dashboard)/dashboard/credits/page.tsx new file mode 100644 index 0000000..8a486a1 --- /dev/null +++ b/src/app/(dashboard)/dashboard/credits/page.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next"; +import { SiteHeader } from "@/features/dashboard/components/site-header"; +import { CreditsBalanceCard } from "@/features/credits/components/credits-balance-card"; +import { TransactionHistory } from "@/features/credits/components/transaction-history"; +import { getCreditsBalance, getCreditTransactions } from "@/features/credits/actions"; + +export const metadata: Metadata = { + title: "Credits", + description: "View your credits balance and transaction history.", +}; + +export default async function CreditsPage() { + const [balance, transactions] = await Promise.all([ + getCreditsBalance(), + getCreditTransactions(50), + ]); + + return ( + <> + +
+
+

Credits

+

+ Your credit balance and transaction history. +

+
+ + +
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 1de055b..1c03f97 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -1,10 +1,11 @@ import type { Metadata } from "next"; -import { AppSidebar } from "@/features/dashboard/components/app-sidebar"; import { ChartAreaInteractive } from "@/features/dashboard/components/chart-area-interactive"; import { DataTable } from "@/features/dashboard/components/data-table"; import { SectionCards } from "@/features/dashboard/components/section-cards"; import { SiteHeader } from "@/features/dashboard/components/site-header"; -import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { SubscriptionCard } from "@/features/billing/components/subscription-card"; +import { getUserSubscription } from "@/features/billing/actions"; +import { getCreditsBalance } from "@/features/credits/actions"; import data from "./data.json"; @@ -13,31 +14,32 @@ export const metadata: Metadata = { description: "View your key metrics, usage data, and recent activity.", }; -export default function Page() { +export default async function Page() { + const [subscription, creditsBalance] = await Promise.all([ + getUserSubscription(), + getCreditsBalance(), + ]); + return ( - - - - -
-
-
- -
- -
- + <> + +
+
+
+
+ +
+ +
+
+
- - +
+ ); } diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx new file mode 100644 index 0000000..2117fed --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; +import { createClient } from "@/lib/supabase/server"; +import { SiteHeader } from "@/features/dashboard/components/site-header"; +import { SettingsProfileCard } from "@/features/auth/components/settings-profile-card"; + +export const metadata: Metadata = { + title: "Settings", + description: "Manage your account settings and preferences.", +}; + +export default async function SettingsPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + const fullName = + (user?.user_metadata?.full_name as string) ?? ""; + const email = user?.email ?? ""; + + return ( + <> + +
+
+

Settings

+

+ Manage your account information. +

+
+ +
+ + ); +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 534fdd0..4b8fe1c 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,21 +1,52 @@ import type { Metadata } from "next"; +import { createClient } from "@/lib/supabase/server"; +import { redirect } from "next/navigation"; +import { AppSidebar } from "@/features/dashboard/components/app-sidebar"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; export const metadata: Metadata = { title: { template: "%s | Dashboard | CreemKit", default: "Dashboard | CreemKit", }, - description: "Private dashboard for managing subscriptions, credits, and product data.", + description: + "Private dashboard for managing subscriptions, credits, and product data.", robots: { index: false, follow: false, }, }; -export default function DashboardLayout({ +export default async function DashboardLayout({ children, }: { children: React.ReactNode; }) { - return <>{children}; + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) redirect("/login"); + + return ( + + + {children} + + ); } diff --git a/src/app/api/webhooks/creem/route.ts b/src/app/api/webhooks/creem/route.ts new file mode 100644 index 0000000..ad06a78 --- /dev/null +++ b/src/app/api/webhooks/creem/route.ts @@ -0,0 +1,20 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { verifyWebhookSignature } from "@/lib/creem/client"; +import { handleWebhookEvent } from "@/features/billing/webhooks"; + +export async function POST(request: NextRequest) { + const rawBody = await request.text(); + const signature = request.headers.get("creem-signature"); + + if (!signature || !verifyWebhookSignature(rawBody, signature)) { + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + } + + try { + await handleWebhookEvent(rawBody); + return NextResponse.json({ received: true }, { status: 200 }); + } catch (error) { + console.error("Webhook processing error:", error); + return NextResponse.json({ error: "Processing failed" }, { status: 500 }); + } +} diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts new file mode 100644 index 0000000..68f7b5a --- /dev/null +++ b/src/app/auth/callback/route.ts @@ -0,0 +1,20 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const code = searchParams.get("code"); + const next = searchParams.get("next") ?? "/dashboard"; + + if (code) { + const supabase = await createClient(); + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (!error) { + return NextResponse.redirect(new URL(next, request.url)); + } + } + + return NextResponse.redirect( + new URL("/login?error=auth_callback_failed", request.url), + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ea18b3b..ecff0dd 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import './globals.css' import { ThemeProvider } from '@/components/theme-provider' import { cn } from '@/lib/utils' import Script from 'next/script' +import { Toaster } from 'sonner' const inter = Inter({ subsets: ['latin'], variable: '--font-sans' }) @@ -69,7 +70,10 @@ export default function RootLayout({ strategy="afterInteractive" /> )} - {children} + + {children} + + ) diff --git a/src/components/header.tsx b/src/components/header.tsx index 3d72bc4..e85d79d 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import React from "react"; import { cn } from "@/lib/utils"; import { AnimatePresence, motion } from "motion/react"; +import { useUser } from "@/features/auth/hooks/use-user"; const menuItems = [ { name: "Features", href: "/#features" }, @@ -19,6 +20,7 @@ export const navLinks = menuItems.map((item) => ({ export const Header = () => { const [menuState, setMenuState] = React.useState(false); + const { user, loading } = useUser(); const close = () => setMenuState(false); @@ -73,21 +75,37 @@ export const Header = () => { {/* divider */}
- - + {!loading && ( + <> + {user ? ( + + ) : ( + <> + + + + )} + + )}
{/* Hamburger */} @@ -159,21 +177,37 @@ export const Header = () => { transition={{ duration: 0.2, delay: 0.2, ease: "easeOut" }} className="mt-6 flex flex-col gap-2.5" > - - + {!loading && ( + <> + {user ? ( + + ) : ( + <> + + + + )} + + )}
diff --git a/src/features/auth/actions/index.ts b/src/features/auth/actions/index.ts new file mode 100644 index 0000000..b8e291e --- /dev/null +++ b/src/features/auth/actions/index.ts @@ -0,0 +1,111 @@ +"use server"; + +import * as z from "zod"; +import { createClient } from "@/lib/supabase/server"; +import { redirect } from "next/navigation"; +import { headers } from "next/headers"; +import type { AuthActionState, OAuthProvider } from "../types"; + +// -- Schemas -- +const loginSchema = z.object({ + email: z.string().email("Invalid email address"), + password: z.string().min(1, "Password is required"), +}); + +const signupSchema = z + .object({ + fullName: z.string().min(1, "Name is required"), + email: z.string().email("Invalid email address"), + password: z.string().min(8, "Password must be at least 8 characters"), + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); + +// -- Actions -- + +export async function login( + _prevState: AuthActionState, + formData: FormData, +): Promise { + const raw = { + email: formData.get("email"), + password: formData.get("password"), + }; + + const result = loginSchema.safeParse(raw); + if (!result.success) { + return { fieldErrors: result.error.flatten().fieldErrors }; + } + + const supabase = await createClient(); + const { error } = await supabase.auth.signInWithPassword({ + email: result.data.email, + password: result.data.password, + }); + + if (error) { + return { error: "Invalid email or password" }; + } + + redirect("/dashboard"); +} + +export async function signup( + _prevState: AuthActionState, + formData: FormData, +): Promise { + const raw = { + fullName: formData.get("fullName"), + email: formData.get("email"), + password: formData.get("password"), + confirmPassword: formData.get("confirmPassword"), + }; + + const result = signupSchema.safeParse(raw); + if (!result.success) { + return { fieldErrors: result.error.flatten().fieldErrors }; + } + + const supabase = await createClient(); + const { error } = await supabase.auth.signUp({ + email: result.data.email, + password: result.data.password, + options: { + data: { full_name: result.data.fullName }, + }, + }); + + if (error) { + return { error: error.message }; + } + + redirect("/dashboard"); +} + +export async function loginWithOAuth(provider: OAuthProvider): Promise { + const headersList = await headers(); + const origin = headersList.get("origin") ?? "http://localhost:3000"; + + const supabase = await createClient(); + const { data, error } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: `${origin}/auth/callback`, + }, + }); + + if (error || !data.url) { + redirect("/login?error=oauth_failed"); + } + + redirect(data.url); +} + +export async function logout(): Promise { + const supabase = await createClient(); + await supabase.auth.signOut(); + redirect("/login"); +} diff --git a/src/features/auth/actions/profile.ts b/src/features/auth/actions/profile.ts new file mode 100644 index 0000000..e6ab35c --- /dev/null +++ b/src/features/auth/actions/profile.ts @@ -0,0 +1,37 @@ +"use server"; + +import * as z from "zod"; +import { createClient } from "@/lib/supabase/server"; +import type { AuthActionState } from "../types"; + +const profileSchema = z.object({ + fullName: z.string().min(1, "Name is required"), +}); + +export async function updateProfile( + _prevState: AuthActionState, + formData: FormData, +): Promise { + const raw = { fullName: formData.get("fullName") }; + const result = profileSchema.safeParse(raw); + + if (!result.success) { + return { fieldErrors: result.error.flatten().fieldErrors }; + } + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) return { error: "Not authenticated" }; + + const { error } = await supabase + .from("profiles") + .update({ full_name: result.data.fullName }) + .eq("id", user.id); + + if (error) return { error: error.message }; + + return {}; +} diff --git a/src/features/auth/components/login-form.tsx b/src/features/auth/components/login-form.tsx index b275e9a..a8385d9 100644 --- a/src/features/auth/components/login-form.tsx +++ b/src/features/auth/components/login-form.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { useActionState } from "react"; import { Button } from "@/components/ui/button"; import { Card, @@ -18,11 +21,14 @@ import { cn } from "@/lib/utils"; import Link from "next/link"; import { GitHub, Google } from "./provider-icons"; +import { login, loginWithOAuth } from "@/features/auth/actions"; export function LoginForm({ className, ...props }: React.ComponentProps<"div">) { + const [state, action, pending] = useActionState(login, undefined); + return (
@@ -33,45 +39,76 @@ export function LoginForm({ -
- - - - - - - Or continue with - - - Email - - - - Password - - - - - - Don't have an account?{" "} - - Sign up - - - - -
+ + + + + + + Or continue with + +
+ + + Email + + {state?.fieldErrors?.email && ( +

+ {state.fieldErrors.email[0]} +

+ )} +
+ + Password + + {state?.fieldErrors?.password && ( +

+ {state.fieldErrors.password[0]} +

+ )} +
+ {state?.error && ( +

{state.error}

+ )} + + + + Don't have an account?{" "} + + Sign up + + + +
+
+
@@ -87,4 +124,4 @@ export function LoginForm({
); -} \ No newline at end of file +} diff --git a/src/features/auth/components/settings-profile-card.tsx b/src/features/auth/components/settings-profile-card.tsx new file mode 100644 index 0000000..6167419 --- /dev/null +++ b/src/features/auth/components/settings-profile-card.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useActionState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Field, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { updateProfile } from "@/features/auth/actions/profile"; + +export function SettingsProfileCard({ + fullName, + email, +}: { + fullName: string; + email: string; +}) { + const [state, action, pending] = useActionState(updateProfile, undefined); + + return ( + + + Profile + Update your display name. + + +
+ + + Full Name + + {state?.fieldErrors?.fullName && ( +

+ {state.fieldErrors.fullName[0]} +

+ )} +
+ + Email + +

+ Email cannot be changed here. +

+
+ {state?.error && ( +

{state.error}

+ )} + {state !== undefined && !state?.error && !state?.fieldErrors && ( +

Profile updated!

+ )} + +
+
+
+
+ ); +} diff --git a/src/features/auth/components/signup-form.tsx b/src/features/auth/components/signup-form.tsx index 745d0e0..37e209a 100644 --- a/src/features/auth/components/signup-form.tsx +++ b/src/features/auth/components/signup-form.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { useActionState } from "react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { @@ -15,11 +18,14 @@ import { } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import Link from "next/link"; +import { signup } from "@/features/auth/actions"; export function SignupForm({ className, ...props }: React.ComponentProps<"div">) { + const [state, action, pending] = useActionState(signup, undefined); + return (
@@ -30,40 +36,82 @@ export function SignupForm({ -
+ - Full Name - + Full Name + + {state?.fieldErrors?.fullName && ( +

+ {state.fieldErrors.fullName[0]} +

+ )}
Email + {state?.fieldErrors?.email && ( +

+ {state.fieldErrors.email[0]} +

+ )}
Password - + + {state?.fieldErrors?.password && ( +

+ {state.fieldErrors.password[0]} +

+ )}
- + Confirm Password - + + {state?.fieldErrors?.confirmPassword && ( +

+ {state.fieldErrors.confirmPassword[0]} +

+ )}
Must be at least 8 characters long.
+ {state?.error && ( +

{state.error}

+ )} - + Already have an account?{" "} diff --git a/src/features/auth/hooks/use-user.ts b/src/features/auth/hooks/use-user.ts new file mode 100644 index 0000000..1ad01c2 --- /dev/null +++ b/src/features/auth/hooks/use-user.ts @@ -0,0 +1,29 @@ +"use client"; + +import { createClient } from "@/lib/supabase/client"; +import { useEffect, useState } from "react"; +import type { User } from "@supabase/supabase-js"; + +export function useUser() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const supabase = createClient(); + + supabase.auth.getUser().then(({ data: { user } }) => { + setUser(user); + setLoading(false); + }); + + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event, session) => { + setUser(session?.user ?? null); + }); + + return () => subscription.unsubscribe(); + }, []); + + return { user, loading }; +} diff --git a/src/features/auth/types.ts b/src/features/auth/types.ts new file mode 100644 index 0000000..f97b6e2 --- /dev/null +++ b/src/features/auth/types.ts @@ -0,0 +1,5 @@ +export type AuthActionState = + | { error?: string; fieldErrors?: Record } + | undefined; + +export type OAuthProvider = "github" | "google"; diff --git a/src/features/billing/actions/index.ts b/src/features/billing/actions/index.ts new file mode 100644 index 0000000..960b7bc --- /dev/null +++ b/src/features/billing/actions/index.ts @@ -0,0 +1,219 @@ +"use server"; + +import { createClient } from "@/lib/supabase/server"; +import { + createCheckout, + getCustomerPortalLink, + cancelSubscription, + resumeSubscription, + upgradeSubscription, + pauseSubscription, +} from "@/lib/creem/client"; +import { redirect } from "next/navigation"; +import type { Subscription } from "../types"; + +const APP_URL = + process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + +// ---------- Helpers ---------- +function mapRow(row: Record): Subscription { + return { + id: row.id as string, + userId: row.user_id as string, + creemSubscriptionId: (row.creem_subscription_id as string) ?? null, + creemCustomerId: (row.creem_customer_id as string) ?? null, + planId: (row.plan_id as string) ?? null, + status: row.status as Subscription["status"], + currentPeriodStart: (row.current_period_start as string) ?? null, + currentPeriodEnd: (row.current_period_end as string) ?? null, + cancelAtPeriodEnd: (row.cancel_at_period_end as boolean) ?? false, + }; +} + +// ---------- Checkout ---------- +export async function createCheckoutSession( + productId: string, +): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) redirect("/login"); + + const { checkout_url } = await createCheckout({ + productId, + successUrl: `${APP_URL}/dashboard?checkout=success`, + customerEmail: user.email!, + metadata: { user_id: user.id }, + }); + + redirect(checkout_url); +} + +// ---------- Subscription queries ---------- +export async function getUserSubscription(): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) return null; + + const { data } = await supabase + .from("subscriptions") + .select("*") + .eq("user_id", user.id) + .order("created_at", { ascending: false }) + .limit(1) + .single(); + + if (!data) return null; + return mapRow(data as Record); +} + +// ---------- Customer Portal ---------- +export async function openCustomerPortal(): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) redirect("/login"); + + const { data: profile } = await supabase + .from("profiles") + .select("creem_customer_id") + .eq("id", user.id) + .single(); + + if (!profile?.creem_customer_id) { + redirect("/dashboard/billing?error=no_customer"); + } + + const { customer_portal_link } = await getCustomerPortalLink( + profile.creem_customer_id, + ); + + redirect(customer_portal_link); +} + +// ---------- Cancel ---------- +export async function cancelUserSubscription( + mode: "scheduled" | "immediate", +): Promise<{ error?: string }> { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) return { error: "Not authenticated" }; + + const { data: sub } = await supabase + .from("subscriptions") + .select("creem_subscription_id") + .eq("user_id", user.id) + .single(); + + if (!sub?.creem_subscription_id) return { error: "No active subscription" }; + + try { + await cancelSubscription(sub.creem_subscription_id, mode); + if (mode === "scheduled") { + await supabase + .from("subscriptions") + .update({ cancel_at_period_end: true }) + .eq("user_id", user.id); + } else { + await supabase + .from("subscriptions") + .update({ status: "canceled" }) + .eq("user_id", user.id); + } + return {}; + } catch (e) { + return { error: (e as Error).message }; + } +} + +// ---------- Resume ---------- +export async function resumeUserSubscription(): Promise<{ error?: string }> { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) return { error: "Not authenticated" }; + + const { data: sub } = await supabase + .from("subscriptions") + .select("creem_subscription_id") + .eq("user_id", user.id) + .single(); + + if (!sub?.creem_subscription_id) return { error: "No active subscription" }; + + try { + await resumeSubscription(sub.creem_subscription_id); + await supabase + .from("subscriptions") + .update({ cancel_at_period_end: false, status: "active" }) + .eq("user_id", user.id); + return {}; + } catch (e) { + return { error: (e as Error).message }; + } +} + +// ---------- Upgrade ---------- +export async function upgradeUserSubscription( + newProductId: string, +): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) redirect("/login"); + + const { data: sub } = await supabase + .from("subscriptions") + .select("creem_subscription_id") + .eq("user_id", user.id) + .single(); + + if (!sub?.creem_subscription_id) redirect("/dashboard/billing"); + + await upgradeSubscription(sub.creem_subscription_id, newProductId); + // Actual status change comes back via webhook + redirect("/dashboard/billing?upgraded=1"); +} + +// ---------- Pause ---------- +export async function pauseUserSubscription(): Promise<{ error?: string }> { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) return { error: "Not authenticated" }; + + const { data: sub } = await supabase + .from("subscriptions") + .select("creem_subscription_id") + .eq("user_id", user.id) + .single(); + + if (!sub?.creem_subscription_id) return { error: "No active subscription" }; + + try { + await pauseSubscription(sub.creem_subscription_id); + await supabase + .from("subscriptions") + .update({ status: "paused" }) + .eq("user_id", user.id); + return {}; + } catch (e) { + return { error: (e as Error).message }; + } +} diff --git a/src/features/billing/components/checkout-button.tsx b/src/features/billing/components/checkout-button.tsx new file mode 100644 index 0000000..63c2f95 --- /dev/null +++ b/src/features/billing/components/checkout-button.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { createCheckoutSession } from "@/features/billing/actions"; +import { useTransition } from "react"; + +export function CheckoutButton({ + productId, + children, + variant = "default", +}: { + productId: string; + children: React.ReactNode; + variant?: "default" | "outline"; +}) { + const [pending, startTransition] = useTransition(); + + return ( + + ); +} diff --git a/src/features/billing/components/manage-subscription.tsx b/src/features/billing/components/manage-subscription.tsx new file mode 100644 index 0000000..2933fee --- /dev/null +++ b/src/features/billing/components/manage-subscription.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useTransition, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { + cancelUserSubscription, + resumeUserSubscription, + pauseUserSubscription, + openCustomerPortal, +} from "@/features/billing/actions"; +import type { Subscription } from "@/features/billing/types"; +import { toast } from "sonner"; + +export function ManageSubscription({ + subscription, +}: { + subscription: Subscription; +}) { + const [pending, startTransition] = useTransition(); + const [cancelMode, setCancelMode] = useState<"scheduled" | "immediate">( + "scheduled", + ); + + const isActive = + subscription.status === "active" || subscription.status === "trialing"; + const isPaused = subscription.status === "paused"; + + async function handleCancel() { + startTransition(async () => { + const result = await cancelUserSubscription(cancelMode); + if (result.error) { + toast.error(result.error); + } else { + toast.success( + cancelMode === "scheduled" + ? "Subscription will cancel at end of period." + : "Subscription canceled immediately.", + ); + } + }); + } + + async function handleResume() { + startTransition(async () => { + const result = await resumeUserSubscription(); + if (result.error) { + toast.error(result.error); + } else { + toast.success("Subscription resumed!"); + } + }); + } + + async function handlePause() { + startTransition(async () => { + const result = await pauseUserSubscription(); + if (result.error) { + toast.error(result.error); + } else { + toast.success("Subscription paused."); + } + }); + } + + return ( + + + Manage Subscription + + Cancel, pause, or manage your plan via the customer portal. + + + + {/* Customer Portal */} + + + + + {/* Resume (shown when scheduled to cancel) */} + {subscription.cancelAtPeriodEnd && ( + + )} + + {/* Pause / Resume paused */} + {isActive && !subscription.cancelAtPeriodEnd && ( + + )} + + {isPaused && ( + + )} + + {/* Cancel */} + {isActive && !subscription.cancelAtPeriodEnd && ( + + + } + > + Cancel Subscription + + + + Cancel Subscription + + Choose how you want to cancel: + + +
+ + +
+ + Keep Subscription + + Confirm Cancel + + +
+
+ )} +
+
+ ); +} diff --git a/src/features/billing/components/pricing-section.tsx b/src/features/billing/components/pricing-section.tsx index 6f07bf7..8a3df3b 100644 --- a/src/features/billing/components/pricing-section.tsx +++ b/src/features/billing/components/pricing-section.tsx @@ -9,6 +9,7 @@ import { CardTitle, } from '@/components/ui/card' import { IconCheck } from '@tabler/icons-react' +import { CheckoutButton } from '@/features/billing/components/checkout-button' export function PricingSection() { return ( @@ -50,7 +51,7 @@ export function PricingSection() { +
@@ -123,14 +124,12 @@ export function PricingSection() { - +
diff --git a/src/features/billing/components/subscription-card.tsx b/src/features/billing/components/subscription-card.tsx new file mode 100644 index 0000000..70ce867 --- /dev/null +++ b/src/features/billing/components/subscription-card.tsx @@ -0,0 +1,136 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { openCustomerPortal } from "@/features/billing/actions"; +import type { Subscription } from "@/features/billing/types"; +import { PLANS } from "@/features/billing/types"; +import Link from "next/link"; + +type Props = { + subscription: Subscription | null; + creditsBalance: number; +}; + +function statusVariant( + status: Subscription["status"], +): "default" | "secondary" | "destructive" | "outline" { + switch (status) { + case "active": + return "default"; + case "trialing": + return "secondary"; + case "canceled": + case "past_due": + return "destructive"; + default: + return "outline"; + } +} + +function planNameFromId(planId: string | null): string { + if (!planId) return "Free"; + if (planId === process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_PRO) return "Pro"; + if (planId === process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_BUSINESS) + return "Business"; + return "Pro"; +} + +function formatDate(dateStr: string | null): string { + if (!dateStr) return "—"; + return new Date(dateStr).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +export function SubscriptionCard({ subscription, creditsBalance }: Props) { + const isActive = + subscription && + (subscription.status === "active" || subscription.status === "trialing"); + + const planName = subscription + ? planNameFromId(subscription.planId) + : "Free"; + + return ( +
+ {/* Subscription card */} + + +
+ Subscription + {subscription ? ( + + {subscription.status} + + ) : ( + free + )} +
+ {planName} plan +
+ + {subscription && isActive && ( +
+

+ Current period:{" "} + + {formatDate(subscription.currentPeriodStart)} →{" "} + {formatDate(subscription.currentPeriodEnd)} + +

+ {subscription.cancelAtPeriodEnd && ( +

+ ⚠ Cancels at end of current period +

+ )} +
+ )} +
+ {isActive ? ( +
+ +
+ ) : ( + <> + {PLANS.pro.productId && ( + + )} + + )} +
+
+
+ + {/* Credits card */} + + + Credits + Available to use + + +

+ {subscription?.planId === + process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_BUSINESS + ? "∞" + : creditsBalance.toLocaleString()} +

+ +
+
+
+ ); +} diff --git a/src/features/billing/hooks/use-subscription.ts b/src/features/billing/hooks/use-subscription.ts new file mode 100644 index 0000000..48b54dd --- /dev/null +++ b/src/features/billing/hooks/use-subscription.ts @@ -0,0 +1,23 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { getUserSubscription } from "@/features/billing/actions"; +import type { Subscription } from "@/features/billing/types"; + +export function useSubscription() { + const [subscription, setSubscription] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + setLoading(true); + const sub = await getUserSubscription(); + setSubscription(sub); + setLoading(false); + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + return { subscription, loading, refresh }; +} diff --git a/src/features/billing/types.ts b/src/features/billing/types.ts new file mode 100644 index 0000000..0b5cd84 --- /dev/null +++ b/src/features/billing/types.ts @@ -0,0 +1,82 @@ +import * as z from "zod"; + +// ---------- Creem webhook event types ---------- +export type CreemWebhookEventType = + | "checkout.completed" + | "subscription.active" + | "subscription.paid" + | "subscription.canceled" + | "subscription.expired" + | "subscription.trialing" + | "subscription.paused" + | "subscription.update" + | "refund.created"; + +// ---------- Zod schemas for webhook payloads ---------- +export const creemCustomerSchema = z.object({ + id: z.string(), + email: z.string().email(), + name: z.string().optional(), +}); + +export const creemSubscriptionSchema = z.object({ + id: z.string(), + customer: creemCustomerSchema, + product_id: z.string(), + status: z.string(), + current_period_start: z.string().optional(), + current_period_end: z.string().optional(), + cancel_at_period_end: z.boolean().optional(), + metadata: z.record(z.string(), z.string()).optional(), +}); + +export const creemCheckoutSchema = z.object({ + id: z.string(), + customer: creemCustomerSchema, + subscription: creemSubscriptionSchema.optional(), + product_id: z.string(), + metadata: z.record(z.string(), z.string()).optional(), +}); + +export const creemWebhookEventSchema = z.object({ + event_type: z.string(), + object: z.unknown(), +}); + +// ---------- App-level types ---------- +export type SubscriptionStatus = + | "active" + | "canceled" + | "past_due" + | "trialing" + | "incomplete" + | "paused"; + +export type Subscription = { + id: string; + userId: string; + creemSubscriptionId: string | null; + creemCustomerId: string | null; + planId: string | null; + status: SubscriptionStatus; + currentPeriodStart: string | null; + currentPeriodEnd: string | null; + cancelAtPeriodEnd: boolean; +}; + +// ---------- Plan config ---------- +export const PLANS = { + free: { name: "Free", price: 0, credits: 100 }, + pro: { + name: "Pro", + price: 1900, + credits: 5000, + productId: process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_PRO, + }, + business: { + name: "Business", + price: 2900, + credits: -1 /* unlimited */, + productId: process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_BUSINESS, + }, +} as const; diff --git a/src/features/billing/webhooks/__tests__/index.test.ts b/src/features/billing/webhooks/__tests__/index.test.ts new file mode 100644 index 0000000..24789ce --- /dev/null +++ b/src/features/billing/webhooks/__tests__/index.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock admin client +vi.mock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(), +})); + +import { createAdminClient } from "@/lib/supabase/admin"; +import { handleWebhookEvent } from "../index"; + +/** Creates a Supabase-like chainable mock where every method returns `this`, + * and the chain is also a Promise that resolves to { data, error: null }. + */ +function makeChain(returnData: unknown = null) { + const result = { data: returnData, error: null }; + + // Make the chain itself thenable so `await chain.update().eq()` works + const chain: Record = { + then: ( + resolve: (v: unknown) => unknown, + _reject?: (e: unknown) => unknown, + ) => Promise.resolve(result).then(resolve), + catch: (reject: (e: unknown) => unknown) => + Promise.resolve(result).catch(reject), + }; + + const methods = [ + "from", + "select", + "update", + "insert", + "upsert", + "eq", + "single", + "order", + "limit", + "rpc", + ]; + + for (const method of methods) { + chain[method] = vi.fn().mockReturnValue(chain); + } + + // single() should still resolve to { data, error: null } + (chain.single as ReturnType).mockResolvedValue(result); + + return chain; +} + +describe("handleWebhookEvent", () => { + beforeEach(() => { + process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_PRO = "prod_pro"; + process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_BUSINESS = "prod_business"; + }); + + it("handles checkout.completed with subscription", async () => { + const chain = makeChain({ id: "user-123" }); + vi.mocked(createAdminClient).mockReturnValue(chain as never); + + const payload = JSON.stringify({ + event_type: "checkout.completed", + object: { + id: "checkout_1", + customer: { id: "cus_1", email: "user@example.com" }, + product_id: "prod_pro", + metadata: { user_id: "user-123" }, + subscription: { + id: "sub_1", + customer: { id: "cus_1", email: "user@example.com" }, + product_id: "prod_pro", + status: "active", + }, + }, + }); + + await expect(handleWebhookEvent(payload)).resolves.toBeUndefined(); + expect(chain.upsert).toHaveBeenCalled(); + }); + + it("handles subscription.canceled", async () => { + const chain = makeChain({ id: "user-123" }); + vi.mocked(createAdminClient).mockReturnValue(chain as never); + + const payload = JSON.stringify({ + event_type: "subscription.canceled", + object: { + id: "sub_1", + customer: { id: "cus_1", email: "user@example.com" }, + product_id: "prod_pro", + status: "canceled", + }, + }); + + await expect(handleWebhookEvent(payload)).resolves.toBeUndefined(); + expect(chain.update).toHaveBeenCalled(); + }); + + it("throws on invalid Zod payload", async () => { + // event_type is missing — creemWebhookEventSchema will throw + const payload = JSON.stringify({ bad: "payload" }); + await expect(handleWebhookEvent(payload)).rejects.toThrow(); + }); + + it("logs a warning for unhandled event types", async () => { + const consoleSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + + const payload = JSON.stringify({ + event_type: "unknown.event", + object: {}, + }); + + await handleWebhookEvent(payload); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Unhandled webhook event"), + ); + + consoleSpy.mockRestore(); + }); +}); diff --git a/src/features/billing/webhooks/index.ts b/src/features/billing/webhooks/index.ts new file mode 100644 index 0000000..661526b --- /dev/null +++ b/src/features/billing/webhooks/index.ts @@ -0,0 +1,251 @@ +import { createAdminClient } from "@/lib/supabase/admin"; +import { + creemWebhookEventSchema, + creemCheckoutSchema, + creemSubscriptionSchema, + PLANS, +} from "../types"; +import type { CreemWebhookEventType, SubscriptionStatus } from "../types"; + +function mapCreemStatus(status: string): SubscriptionStatus { + switch (status) { + case "active": + return "active"; + case "trialing": + return "trialing"; + case "canceled": + case "cancelled": + return "canceled"; + case "past_due": + return "past_due"; + case "paused": + return "paused"; + default: + return "incomplete"; + } +} + +function creditsForProductId(productId: string): number { + if (productId === process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_PRO) { + return PLANS.pro.credits; + } + if (productId === process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_BUSINESS) { + return -1; // unlimited — no top-up needed + } + // credits product or unknown — default to 500 + return 500; +} + +export async function handleWebhookEvent(rawBody: string): Promise { + const parsed = creemWebhookEventSchema.parse(JSON.parse(rawBody)); + const eventType = parsed.event_type as CreemWebhookEventType; + + switch (eventType) { + case "checkout.completed": + return handleCheckoutCompleted(parsed.object); + case "subscription.active": + case "subscription.paid": + case "subscription.trialing": + case "subscription.update": + return handleSubscriptionUpsert(parsed.object, eventType); + case "subscription.canceled": + case "subscription.expired": + return handleSubscriptionEnded(parsed.object); + case "subscription.paused": + return handleSubscriptionPaused(parsed.object); + case "refund.created": + return handleRefundCreated(parsed.object); + default: + console.warn(`Unhandled webhook event: ${eventType}`); + } +} + +async function handleCheckoutCompleted(object: unknown): Promise { + const checkout = creemCheckoutSchema.parse(object); + const admin = createAdminClient(); + + const userId = checkout.metadata?.user_id; + if (!userId) { + console.warn("checkout.completed: no user_id in metadata"); + return; + } + + // Store creem_customer_id on profiles + await admin + .from("profiles") + .update({ creem_customer_id: checkout.customer.id }) + .eq("id", userId); + + if (checkout.subscription) { + const sub = checkout.subscription; + await admin.from("subscriptions").upsert( + { + user_id: userId, + creem_subscription_id: sub.id, + creem_customer_id: checkout.customer.id, + plan_id: sub.product_id, + status: mapCreemStatus(sub.status), + current_period_start: sub.current_period_start ?? null, + current_period_end: sub.current_period_end ?? null, + cancel_at_period_end: sub.cancel_at_period_end ?? false, + }, + { onConflict: "creem_subscription_id" }, + ); + } else { + // One-time credit purchase + const credits = creditsForProductId(checkout.product_id); + if (credits > 0) { + const { data: existing } = await admin + .from("credits") + .select("balance") + .eq("user_id", userId) + .single(); + + if (existing) { + await admin + .from("credits") + .update({ balance: existing.balance + credits }) + .eq("user_id", userId); + } else { + await admin + .from("credits") + .insert({ user_id: userId, balance: credits }); + } + + await admin.from("credit_transactions").insert({ + user_id: userId, + amount: credits, + type: "purchase", + description: `Purchased ${credits} credits`, + }); + } + } +} + +async function handleSubscriptionUpsert( + object: unknown, + eventType: CreemWebhookEventType, +): Promise { + const sub = creemSubscriptionSchema.parse(object); + const admin = createAdminClient(); + + // Look up user_id via creem_customer_id on profiles + const { data: profile } = await admin + .from("profiles") + .select("id") + .eq("creem_customer_id", sub.customer.id) + .single(); + + if (!profile) { + console.warn( + `handleSubscriptionUpsert: no profile for customer ${sub.customer.id}`, + ); + return; + } + + await admin.from("subscriptions").upsert( + { + user_id: profile.id, + creem_subscription_id: sub.id, + creem_customer_id: sub.customer.id, + plan_id: sub.product_id, + status: mapCreemStatus(sub.status), + current_period_start: sub.current_period_start ?? null, + current_period_end: sub.current_period_end ?? null, + cancel_at_period_end: sub.cancel_at_period_end ?? false, + }, + { onConflict: "creem_subscription_id" }, + ); + + // On subscription.paid → top up credits based on plan + if (eventType === "subscription.paid") { + const credits = creditsForProductId(sub.product_id); + if (credits > 0) { + const { data: existing } = await admin + .from("credits") + .select("balance") + .eq("user_id", profile.id) + .single(); + + if (existing) { + await admin + .from("credits") + .update({ balance: existing.balance + credits }) + .eq("user_id", profile.id); + } else { + await admin + .from("credits") + .insert({ user_id: profile.id, balance: credits }); + } + + await admin.from("credit_transactions").insert({ + user_id: profile.id, + amount: credits, + type: "topup", + description: `Monthly credit top-up`, + }); + } + } +} + +async function handleSubscriptionEnded(object: unknown): Promise { + const sub = creemSubscriptionSchema.parse(object); + const admin = createAdminClient(); + + await admin + .from("subscriptions") + .update({ status: "canceled" }) + .eq("creem_subscription_id", sub.id); +} + +async function handleSubscriptionPaused(object: unknown): Promise { + const sub = creemSubscriptionSchema.parse(object); + const admin = createAdminClient(); + + await admin + .from("subscriptions") + .update({ status: "paused" }) + .eq("creem_subscription_id", sub.id); +} + +async function handleRefundCreated(object: unknown): Promise { + // Refund payload has minimal shape — just need subscription or customer ref + const refund = object as Record; + const customerId = (refund.customer as { id?: string } | undefined)?.id; + if (!customerId) return; + + const admin = createAdminClient(); + + const { data: profile } = await admin + .from("profiles") + .select("id") + .eq("creem_customer_id", customerId) + .single(); + + if (!profile) return; + + const refundAmount = typeof refund.amount === "number" ? refund.amount : 0; + + if (refundAmount > 0) { + const { data: existing } = await admin + .from("credits") + .select("balance") + .eq("user_id", profile.id) + .single(); + + if (existing) { + const newBalance = Math.max(0, existing.balance - refundAmount); + await admin + .from("credits") + .update({ balance: newBalance }) + .eq("user_id", profile.id); + + await admin.from("credit_transactions").insert({ + user_id: profile.id, + amount: -refundAmount, + type: "refund", + description: "Refund processed", + }); + } + } +} diff --git a/src/features/credits/actions/__tests__/spend-credits.test.ts b/src/features/credits/actions/__tests__/spend-credits.test.ts new file mode 100644 index 0000000..8d292a6 --- /dev/null +++ b/src/features/credits/actions/__tests__/spend-credits.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock both supabase clients +vi.mock("@/lib/supabase/server", () => ({ + createClient: vi.fn(), +})); + +vi.mock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(), +})); + +import { createClient } from "@/lib/supabase/server"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { spendCredits } from "../index"; + +describe("spendCredits", () => { + const mockUser = { id: "user-123", email: "test@example.com" }; + + beforeEach(() => { + vi.mocked(createClient).mockResolvedValue({ + auth: { + getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }), + }, + } as never); + }); + + it("returns success when RPC returns true", async () => { + vi.mocked(createAdminClient).mockReturnValue({ + rpc: vi.fn().mockResolvedValue({ data: true, error: null }), + } as never); + + const result = await spendCredits(10, "test spend"); + expect(result).toEqual({ success: true }); + }); + + it("returns insufficient credits when RPC returns false", async () => { + vi.mocked(createAdminClient).mockReturnValue({ + rpc: vi.fn().mockResolvedValue({ data: false, error: null }), + } as never); + + const result = await spendCredits(9999, "too many"); + expect(result).toEqual({ success: false, error: "Insufficient credits" }); + }); + + it("returns error when RPC fails", async () => { + vi.mocked(createAdminClient).mockReturnValue({ + rpc: vi + .fn() + .mockResolvedValue({ data: null, error: { message: "DB error" } }), + } as never); + + const result = await spendCredits(10, "failing spend"); + expect(result).toEqual({ success: false, error: "DB error" }); + }); + + it("returns not authenticated when no user", async () => { + vi.mocked(createClient).mockResolvedValue({ + auth: { + getUser: vi.fn().mockResolvedValue({ data: { user: null } }), + }, + } as never); + + const result = await spendCredits(10, "no user"); + expect(result).toEqual({ success: false, error: "Not authenticated" }); + }); +}); diff --git a/src/features/credits/actions/index.ts b/src/features/credits/actions/index.ts new file mode 100644 index 0000000..ddeb083 --- /dev/null +++ b/src/features/credits/actions/index.ts @@ -0,0 +1,97 @@ +"use server"; + +import { createClient } from "@/lib/supabase/server"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { createCheckout } from "@/lib/creem/client"; +import { redirect } from "next/navigation"; +import type { CreditTransaction } from "../types"; + +const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + +export async function getCreditsBalance(): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) return 0; + + const { data } = await supabase + .from("credits") + .select("balance") + .eq("user_id", user.id) + .single(); + + return data?.balance ?? 0; +} + +export async function purchaseCredits(): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) redirect("/login"); + + const { checkout_url } = await createCheckout({ + productId: process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_CREDITS!, + successUrl: `${APP_URL}/dashboard/credits?purchased=1`, + customerEmail: user.email!, + metadata: { user_id: user.id }, + }); + + redirect(checkout_url); +} + +export async function spendCredits( + amount: number, + description: string, +): Promise<{ success: boolean; error?: string }> { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) return { success: false, error: "Not authenticated" }; + + const admin = createAdminClient(); + const { data, error } = await admin.rpc("spend_credits", { + p_user_id: user.id, + p_amount: amount, + p_description: description, + }); + + if (error) return { success: false, error: error.message }; + if (!data) return { success: false, error: "Insufficient credits" }; + + return { success: true }; +} + +export async function getCreditTransactions( + limit = 50, +): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) return []; + + const { data } = await supabase + .from("credit_transactions") + .select("*") + .eq("user_id", user.id) + .order("created_at", { ascending: false }) + .limit(limit); + + if (!data) return []; + + return data.map((row) => ({ + id: row.id as string, + userId: row.user_id as string, + amount: row.amount as number, + type: row.type as CreditTransaction["type"], + description: (row.description as string) ?? null, + createdAt: row.created_at as string, + })); +} diff --git a/src/features/credits/components/credits-balance-card.tsx b/src/features/credits/components/credits-balance-card.tsx new file mode 100644 index 0000000..9850d5b --- /dev/null +++ b/src/features/credits/components/credits-balance-card.tsx @@ -0,0 +1,30 @@ +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { purchaseCredits } from "@/features/credits/actions"; + +export function CreditsBalanceCard({ balance }: { balance: number }) { + return ( + + + Credit Balance + Available credits in your account + + +

+ {balance.toLocaleString()} +

+
+ +
+
+
+ ); +} diff --git a/src/features/credits/components/transaction-history.tsx b/src/features/credits/components/transaction-history.tsx new file mode 100644 index 0000000..b455c3d --- /dev/null +++ b/src/features/credits/components/transaction-history.tsx @@ -0,0 +1,89 @@ +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import type { CreditTransaction } from "@/features/credits/types"; + +type BadgeVariant = "default" | "secondary" | "destructive" | "outline"; + +function typeBadgeVariant(type: CreditTransaction["type"]): BadgeVariant { + switch (type) { + case "purchase": + case "topup": + return "default"; + case "spend": + return "secondary"; + case "refund": + case "adjustment": + return "outline"; + default: + return "secondary"; + } +} + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +export function TransactionHistory({ + transactions, +}: { + transactions: CreditTransaction[]; +}) { + return ( + + + Transaction History + Your recent credit activity + + + {transactions.length === 0 ? ( +

+ No transactions yet. +

+ ) : ( +
+ {transactions.map((tx) => ( +
+
+ + {tx.type} + + + {tx.description ?? "—"} + +
+
+ = 0 + ? "text-green-600 font-medium" + : "text-red-500 font-medium" + } + > + {tx.amount >= 0 ? "+" : ""} + {tx.amount.toLocaleString()} + + + {formatDate(tx.createdAt)} + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/features/credits/hooks/use-credits.ts b/src/features/credits/hooks/use-credits.ts new file mode 100644 index 0000000..80b6d75 --- /dev/null +++ b/src/features/credits/hooks/use-credits.ts @@ -0,0 +1,22 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { getCreditsBalance } from "@/features/credits/actions"; + +export function useCredits() { + const [balance, setBalance] = useState(0); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + setLoading(true); + const b = await getCreditsBalance(); + setBalance(b); + setLoading(false); + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + return { balance, loading, refresh }; +} diff --git a/src/features/credits/types.ts b/src/features/credits/types.ts new file mode 100644 index 0000000..73f2a01 --- /dev/null +++ b/src/features/credits/types.ts @@ -0,0 +1,14 @@ +export type CreditBalance = { + userId: string; + balance: number; + updatedAt: string; +}; + +export type CreditTransaction = { + id: string; + userId: string; + amount: number; + type: "purchase" | "topup" | "spend" | "refund" | "adjustment"; + description: string | null; + createdAt: string; +}; diff --git a/src/features/dashboard/components/app-sidebar.tsx b/src/features/dashboard/components/app-sidebar.tsx index 8b41259..9e6f570 100644 --- a/src/features/dashboard/components/app-sidebar.tsx +++ b/src/features/dashboard/components/app-sidebar.tsx @@ -2,7 +2,6 @@ import * as React from "react"; -import { NavDocuments } from "@/features/dashboard/components/nav-documents"; import { NavMain } from "@/features/dashboard/components/nav-main"; import { NavSecondary } from "@/features/dashboard/components/nav-secondary"; import { NavUser } from "@/features/dashboard/components/nav-user"; @@ -17,139 +16,53 @@ import { } from "@/components/ui/sidebar"; import { IconDashboard, - IconListDetails, - IconChartBar, - IconFolder, - IconUsers, - IconCamera, - IconFileDescription, - IconFileAi, IconSettings, IconHelp, - IconSearch, - IconDatabase, - IconReport, - IconFileWord, + IconCreditCard, + IconCoins, IconInnerShadowTop, } from "@tabler/icons-react"; -const data = { - user: { - name: "shadcn", - email: "m@example.com", - avatar: "/avatars/shadcn.jpg", +const navMain = [ + { + title: "Dashboard", + url: "/dashboard", + icon: , + }, + { + title: "Billing", + url: "/dashboard/billing", + icon: , + }, + { + title: "Credits", + url: "/dashboard/credits", + icon: , }, - navMain: [ - { - title: "Dashboard", - url: "#", - icon: , - }, - { - title: "Lifecycle", - url: "#", - icon: , - }, - { - title: "Analytics", - url: "#", - icon: , - }, - { - title: "Projects", - url: "#", - icon: , - }, - { - title: "Team", - url: "#", - icon: , - }, - ], - navClouds: [ - { - title: "Capture", - icon: , - isActive: true, - url: "#", - items: [ - { - title: "Active Proposals", - url: "#", - }, - { - title: "Archived", - url: "#", - }, - ], - }, - { - title: "Proposal", - icon: , - url: "#", - items: [ - { - title: "Active Proposals", - url: "#", - }, - { - title: "Archived", - url: "#", - }, - ], - }, - { - title: "Prompts", - icon: , - url: "#", - items: [ - { - title: "Active Proposals", - url: "#", - }, - { - title: "Archived", - url: "#", - }, - ], - }, - ], - navSecondary: [ - { - title: "Settings", - url: "#", - icon: , - }, - { - title: "Get Help", - url: "#", - icon: , - }, - { - title: "Search", - url: "#", - icon: , - }, - ], - documents: [ - { - name: "Data Library", - url: "#", - icon: , - }, - { - name: "Reports", - url: "#", - icon: , - }, - { - name: "Word Assistant", - url: "#", - icon: , - }, - ], + { + title: "Settings", + url: "/dashboard/settings", + icon: , + }, +]; + +const navSecondary = [ + { + title: "Get Help", + url: "#", + icon: , + }, +]; + +type AppSidebarProps = React.ComponentProps & { + user: { + name: string; + email: string; + avatar: string; + }; }; -export function AppSidebar({ ...props }: React.ComponentProps) { + +export function AppSidebar({ user, ...props }: AppSidebarProps) { return ( @@ -157,21 +70,20 @@ export function AppSidebar({ ...props }: React.ComponentProps) { } + render={} > - Acme Inc. + CreemKit - - - + + - + ); diff --git a/src/features/dashboard/components/nav-main.tsx b/src/features/dashboard/components/nav-main.tsx index 4b2a6ef..da7b098 100644 --- a/src/features/dashboard/components/nav-main.tsx +++ b/src/features/dashboard/components/nav-main.tsx @@ -1,6 +1,5 @@ "use client"; -import { Button } from "@/components/ui/button"; import { SidebarGroup, SidebarGroupContent, @@ -8,7 +7,7 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; -import { IconCirclePlusFilled, IconMail } from "@tabler/icons-react"; +import Link from "next/link"; export function NavMain({ items, @@ -22,29 +21,10 @@ export function NavMain({ return ( - - - - - Quick Create - - - - {items.map((item) => ( - + }> {item.icon} {item.title} diff --git a/src/features/dashboard/components/nav-user.tsx b/src/features/dashboard/components/nav-user.tsx index e3ea84e..5a16171 100644 --- a/src/features/dashboard/components/nav-user.tsx +++ b/src/features/dashboard/components/nav-user.tsx @@ -20,9 +20,11 @@ import { IconDotsVertical, IconUserCircle, IconCreditCard, - IconNotification, + IconCoins, IconLogout, } from "@tabler/icons-react"; +import { logout } from "@/features/auth/actions"; +import Link from "next/link"; export function NavUser({ user, @@ -34,6 +36,14 @@ export function NavUser({ }; }) { const { isMobile } = useSidebar(); + + const initials = user.name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); + return ( @@ -45,7 +55,7 @@ export function NavUser({ > - CN + {initials}
{user.name} @@ -66,7 +76,9 @@ export function NavUser({
- CN + + {initials} +
{user.name} @@ -79,24 +91,26 @@ export function NavUser({ - + }> Account - + }> Billing - - - Notifications + }> + + Credits - - - Log out - +
+ }> + + Log out + +
diff --git a/src/lib/action-result.ts b/src/lib/action-result.ts new file mode 100644 index 0000000..6d616c5 --- /dev/null +++ b/src/lib/action-result.ts @@ -0,0 +1,3 @@ +export type ActionResult = + | { success: true; data?: T } + | { success: false; error: string }; diff --git a/src/lib/creem/__tests__/client.test.ts b/src/lib/creem/__tests__/client.test.ts new file mode 100644 index 0000000..f5d9ba5 --- /dev/null +++ b/src/lib/creem/__tests__/client.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as crypto from "node:crypto"; + +// We test verifyWebhookSignature in isolation by importing it after +// setting up the env var +describe("verifyWebhookSignature", () => { + const secret = "test-webhook-secret"; + const body = JSON.stringify({ event_type: "checkout.completed" }); + + beforeEach(() => { + process.env.CREEM_WEBHOOK_SECRET = secret; + }); + + it("returns true for a valid signature", async () => { + const { verifyWebhookSignature } = await import("../client"); + const sig = crypto + .createHmac("sha256", secret) + .update(body) + .digest("hex"); + + expect(verifyWebhookSignature(body, sig)).toBe(true); + }); + + it("returns false for an invalid signature", async () => { + const { verifyWebhookSignature } = await import("../client"); + expect(verifyWebhookSignature(body, "bad-signature")).toBe(false); + }); + + it("returns false when signature is empty", async () => { + const { verifyWebhookSignature } = await import("../client"); + expect(verifyWebhookSignature(body, "")).toBe(false); + }); +}); + +describe("creemFetch error handling", () => { + it("throws on non-2xx responses", async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => "Unauthorized", + } as Response); + + process.env.CREEM_API_KEY = "test-key"; + + const { createCheckout } = await import("../client"); + + await expect( + createCheckout({ + productId: "prod_123", + successUrl: "http://localhost:3000/success", + customerEmail: "test@example.com", + }), + ).rejects.toThrow("Creem API error 401"); + }); +}); diff --git a/src/lib/creem/client.ts b/src/lib/creem/client.ts index e69de29..d3e6287 100644 --- a/src/lib/creem/client.ts +++ b/src/lib/creem/client.ts @@ -0,0 +1,141 @@ +import * as crypto from "node:crypto"; + +const CREEM_BASE_URL = + process.env.NODE_ENV === "production" + ? "https://api.creem.io" + : "https://test-api.creem.io"; + +// ---------- Types ---------- +export type CreemSubscription = { + id: string; + customer: { id: string; email: string; name?: string }; + product_id: string; + status: string; + current_period_start?: string; + current_period_end?: string; + cancel_at_period_end?: boolean; + metadata?: Record; +}; + +// ---------- Internal helper ---------- +async function creemFetch( + path: string, + options?: { + method?: string; + body?: unknown; + params?: Record; + }, +): Promise { + const url = new URL(`${CREEM_BASE_URL}${path}`); + if (options?.params) { + for (const [key, value] of Object.entries(options.params)) { + url.searchParams.set(key, value); + } + } + + const res = await fetch(url.toString(), { + method: options?.method ?? "GET", + headers: { + "Content-Type": "application/json", + "x-api-key": process.env.CREEM_API_KEY!, + }, + ...(options?.body ? { body: JSON.stringify(options.body) } : {}), + }); + + if (!res.ok) { + const text = await res.text().catch(() => "unknown error"); + throw new Error( + `Creem API error ${res.status} on ${options?.method ?? "GET"} ${path}: ${text}`, + ); + } + + return res.json() as Promise; +} + +// ---------- Checkout ---------- +export async function createCheckout(params: { + productId: string; + successUrl: string; + customerEmail: string; + metadata?: Record; +}): Promise<{ checkout_url: string }> { + return creemFetch<{ checkout_url: string }>("/v1/checkouts", { + method: "POST", + body: { + product_id: params.productId, + success_url: params.successUrl, + customer_email: params.customerEmail, + metadata: params.metadata, + }, + }); +} + +// ---------- Subscriptions ---------- +export async function getSubscription( + subscriptionId: string, +): Promise { + return creemFetch("/v1/subscriptions", { + params: { subscription_id: subscriptionId }, + }); +} + +export async function cancelSubscription( + subscriptionId: string, + mode: "scheduled" | "immediate", +): Promise { + await creemFetch(`/v1/subscriptions/${subscriptionId}/cancel`, { + method: "POST", + body: { mode }, + }); +} + +export async function pauseSubscription( + subscriptionId: string, +): Promise { + await creemFetch(`/v1/subscriptions/${subscriptionId}/pause`, { + method: "POST", + }); +} + +export async function resumeSubscription( + subscriptionId: string, +): Promise { + await creemFetch(`/v1/subscriptions/${subscriptionId}/resume`, { + method: "POST", + }); +} + +export async function upgradeSubscription( + subscriptionId: string, + productId: string, +): Promise { + await creemFetch(`/v1/subscriptions/${subscriptionId}/upgrade`, { + method: "POST", + body: { + product_id: productId, + update_behavior: "proration-charge-immediately", + }, + }); +} + +// ---------- Customer Portal ---------- +export async function getCustomerPortalLink( + customerId: string, +): Promise<{ customer_portal_link: string }> { + return creemFetch<{ customer_portal_link: string }>("/v1/customers/billing", { + method: "POST", + body: { customer_id: customerId }, + }); +} + +// ---------- Webhook Verification ---------- +export function verifyWebhookSignature( + rawBody: string, + signature: string, +): boolean { + const expected = crypto + .createHmac("sha256", process.env.CREEM_WEBHOOK_SECRET!) + .update(rawBody) + .digest("hex"); + return expected === signature; +} diff --git a/src/lib/supabase/admin.ts b/src/lib/supabase/admin.ts index e69de29..3c70b15 100644 --- a/src/lib/supabase/admin.ts +++ b/src/lib/supabase/admin.ts @@ -0,0 +1,8 @@ +import { createClient } from "@supabase/supabase-js"; + +export function createAdminClient() { + return createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY!, + ); +} diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts index e69de29..2ad2591 100644 --- a/src/lib/supabase/client.ts +++ b/src/lib/supabase/client.ts @@ -0,0 +1,8 @@ +import { createBrowserClient } from "@supabase/ssr"; + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + ); +} diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts index e69de29..4dec608 100644 --- a/src/lib/supabase/server.ts +++ b/src/lib/supabase/server.ts @@ -0,0 +1,28 @@ +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; + +export async function createClient() { + const cookieStore = await cookies(); + + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + for (const { name, value, options } of cookiesToSet) { + cookieStore.set(name, value, options); + } + } catch { + // Called from a Server Component — can be ignored + // if middleware is refreshing sessions + } + }, + }, + }, + ); +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..cb213b3 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,53 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { createServerClient } from "@supabase/ssr"; + +const PROTECTED_PATHS = ["/dashboard"]; +const AUTH_PATHS = ["/login", "/signup"]; + +export async function middleware(request: NextRequest) { + let response = NextResponse.next({ request }); + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + for (const { name, value } of cookiesToSet) { + request.cookies.set(name, value); + } + response = NextResponse.next({ request }); + for (const { name, value, options } of cookiesToSet) { + response.cookies.set(name, value, options); + } + }, + }, + }, + ); + + // Refresh session — MUST call getUser() not getSession() + const { + data: { user }, + } = await supabase.auth.getUser(); + + const path = request.nextUrl.pathname; + const isProtected = PROTECTED_PATHS.some((p) => path.startsWith(p)); + const isAuthPage = AUTH_PATHS.some((p) => path.startsWith(p)); + + if (isProtected && !user) { + return NextResponse.redirect(new URL("/login", request.url)); + } + + if (isAuthPage && user) { + return NextResponse.redirect(new URL("/dashboard", request.url)); + } + + return response; +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon\\.svg|api/).*)"], +}; diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/supabase/migrations/004_profiles_creem_customer_id.sql b/supabase/migrations/004_profiles_creem_customer_id.sql new file mode 100644 index 0000000..b2ca652 --- /dev/null +++ b/supabase/migrations/004_profiles_creem_customer_id.sql @@ -0,0 +1,2 @@ +alter table public.profiles add column if not exists creem_customer_id text; +create index if not exists profiles_creem_customer_id_idx on public.profiles (creem_customer_id); diff --git a/supabase/migrations/005_spend_credits_rpc.sql b/supabase/migrations/005_spend_credits_rpc.sql new file mode 100644 index 0000000..af92965 --- /dev/null +++ b/supabase/migrations/005_spend_credits_rpc.sql @@ -0,0 +1,32 @@ +create or replace function public.spend_credits( + p_user_id uuid, + p_amount integer, + p_description text default null +) +returns boolean +language plpgsql +security definer set search_path = public +as $$ +declare + v_balance integer; +begin + -- Lock the row to prevent race conditions + select balance into v_balance + from public.credits + where user_id = p_user_id + for update; + + if v_balance is null or v_balance < p_amount then + return false; + end if; + + update public.credits + set balance = balance - p_amount + where user_id = p_user_id; + + insert into public.credit_transactions (user_id, amount, type, description) + values (p_user_id, -p_amount, 'spend', p_description); + + return true; +end; +$$; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..377f5b4 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import path from "node:path"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test/setup.ts"], + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); From 93fa23847da428c07743cf98f9877e7ea2ae0270 Mon Sep 17 00:00:00 2001 From: aaron Date: Sun, 29 Mar 2026 23:06:16 +0800 Subject: [PATCH 02/49] docs: update README, ARCHITECTURE, and gitignore Expand README with quick start guide, table of contents, full setup instructions, and webhook integration docs. Add testing strategy section to ARCHITECTURE.md with colocated vs. cross-feature test conventions. Unignore TODO.md so it's tracked. --- .gitignore | 2 +- ARCHITECTURE.md | 20 ++++++ README.md | 179 ++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 172 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index d5d5b79..1acbca5 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,4 @@ pnpm-debug.log* next-env.d.ts # local -TODO.md +# TODO.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 55a9cbb..30e7a94 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -116,8 +116,27 @@ supabase/ ├── 001_profiles.sql # profiles + auth trigger ├── 002_subscriptions.sql # Creem subscription tracking └── 003_credits.sql # wallet + ledger + +tests/ # App-level and cross-feature test suites +├── integration/ # Multi-feature contracts (auth + billing + credits) +└── e2e/ # Playwright user journeys across route groups ``` +## Testing Strategy + +Use a hybrid testing layout so ownership is clear and suites scale with the codebase. + +- Colocate unit and component tests with the feature they validate (`src/features/**`) and with shared UI in `src/components/**`. +- Keep cross-feature integration tests in `tests/integration/` when behavior spans multiple domains. +- Keep end-to-end tests in `tests/e2e/` to validate full user journeys at the app boundary. + +### Example Test Paths + +- `src/features/auth/components/login-form.test.tsx` +- `src/features/billing/actions/create-checkout.test.ts` +- `tests/integration/subscription-lifecycle.test.ts` +- `tests/e2e/signup-to-upgrade.spec.ts` + ## Principles | Rule | How it's applied | @@ -127,6 +146,7 @@ supabase/ | `lib/` = zero business logic | Only infrastructure clients (Supabase, Creem SDK) | | `components/ui/` = shadcn only | No feature logic leaks into shared UI primitives | | Webhooks co-located with billing | `features/billing/webhooks/` not `app/api/webhooks/` | +| Tests follow feature boundaries | Unit/component tests are colocated; integration + e2e tests live in `tests/` | ## Why Feature-Based? diff --git a/README.md b/README.md index 9198430..3293fda 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,67 @@ # Next.js + Supabase + Creem Starter + + [![CI](https://github.com/ubergonmx/nextjs-supabase-creem-starter/actions/workflows/ci.yml/badge.svg)](https://github.com/ubergonmx/nextjs-supabase-creem-starter/actions/workflows/ci.yml) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/ubergonmx/nextjs-supabase-creem-starter) ![Status](https://img.shields.io/badge/status-work--in--progress-yellow) ![License](https://img.shields.io/badge/license-MIT-blue) -> 🚧 This project is actively under development. - -Production-ready Next.js starter with Supabase auth and Creem payments pre-integrated. +The most comprehensive, production-ready Next.js starter with Supabase auth and Creem payments pre-integrated. Clone, configure, and start selling — no boilerplate to write. -## Tech Stack +## Live Demo + +> 🔗 **[nextjs-supabase-creem-starter.vercel.app](https://nextjs-supabase-creem-starter.vercel.app)** + +- Use Creem's test card: `4242 4242 4242 4242` (any future expiry, any CVC) +- Try with discount code: `CREEMSTARTER2026` + +## Quick Start + +### One-click deploy + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/ubergonmx/nextjs-supabase-creem-starter) + +### Run locally (demo, no `.env.local` needed) + +```bash +git clone https://github.com/ubergonmx/nextjs-supabase-creem-starter.git +cd nextjs-supabase-creem-starter +npm install +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000). + +--- + +## Table of Contents + +- [About](#about) + - [Tech Stack](#tech-stack) + - [Features](#features) + - [Webhook Integrations](#webhook-integrations) + - [Architecture](#architecture) + - [Database Schema](#database-schema) + - [Testing](#testing) +- [Setup](#setup) + - [1. Set up Supabase](#1-set-up-supabase) + - [2. Set up Creem](#2-set-up-creem) + - [3. Configure environment variables](#3-configure-environment-variables) + - [4. Set up the database](#4-set-up-the-database) + - [5. Configure OAuth (optional)](#5-configure-oauth-optional) + - [6. Verify everything](#6-verify-everything) +- [Extras](#extras) + - [AI Skills](#ai-skills) + - [Always Check](#always-check) +- [License](#license) + +--- + +## About + +### Tech Stack - **Framework** — [Next.js](https://nextjs.org) (App Router, Server Components) - **Auth & Database** — [Supabase](https://supabase.com) @@ -19,27 +70,59 @@ Clone, configure, and start selling — no boilerplate to write. - **UI Components** — [shadcn/ui](https://ui.shadcn.com) - **Language** — TypeScript (strict mode) -## Features +### Features > Coming soon -## Getting Started +### Webhook Integrations -### Prerequisites +> Coming soon -- Node.js 20+ -- A [Supabase](https://supabase.com) project -- A [Creem](https://creem.io) account with at least one product created +### Architecture -### 1. Clone the repo +> Coming soon -```bash -git clone https://github.com/ubergonmx/nextjs-supabase-creem-starter.git -cd nextjs-supabase-creem-starter -npm install -``` +### Database Schema -### 2. Configure environment variables +> Coming soon + +### Testing + +> Coming soon + +--- + +## Setup + +### 1. Set up Supabase + +1. Create a new [Supabase project](https://database.new) + + + +### 2. Set up Creem + +1. Create an account at [creem.io](https://www.creem.io) or, if you don't have an existing store, [create a new one](https://www.creem.io/dashboard/create) +2. Enable **Test Mode** in the bottom-left of the sidebar +3. Go to **Developers > API & Webhooks** in the left sidebar +4. On the **API Keys** tab, click **+ Create API Key**, name it anything (e.g. `next-supabase-creem-starter`), toggle **Full Access** on, click **Create Key**, and copy the key +5. Create three subscription products — go to **Commerce > Products** in the left sidebar, click **Create Product**. For each product: + - **Section 1 (Product Details)**: Enter the product name and description + - **Section 2 (Payment Details)**: Click the **Subscription** tab, set Currency to **USD**, enter the price, set Subscription interval to **Monthly**, Tax category to **Digital goods or services** + - **Sections 3–6**: Skip (image, features, advanced options, and abandoned cart are all optional) + - Click **Create Product** + + Create all three: + + | **Product name** | **Price** | + | ---------------- | --------- | + | Starter | | + | Pro | | + | Enterprise | | + +6. After creating each product, copy its `prod_` ID (shown on the product detail page) into your `.env.local` + +### 3. Configure environment variables ```bash cp .env.example .env.local @@ -57,9 +140,9 @@ Fill in `.env.local` with your credentials: | `NEXT_PUBLIC_CREEM_PRODUCT_ID_PRO` | Creem → Products | | `NEXT_PUBLIC_CREEM_PRODUCT_ID_CREDITS` | Creem → Products | -### 3. Set up the database +### 4. Set up the database -Run the migrations in order against your Supabase project: +Run the migrations against your Supabase project: ```bash # Using the Supabase CLI @@ -71,24 +154,64 @@ supabase db push # supabase/migrations/003_credits.sql ``` -### 4. Configure OAuth (optional) +### 5. Configure OAuth (optional) -Enable Google and GitHub providers in your Supabase dashboard under -**Authentication → Providers**. +Enable Google and GitHub providers in your Supabase dashboard under **Authentication → Providers**. -### 5. Run the dev server +### 6. Verify everything + +Before deploying, make sure everything passes: ```bash -npm run dev +npm run check # lint + type-check +npm run test:coverage # run tests with coverage +npm run build # production build ``` -Open [http://localhost:3000](http://localhost:3000). +If all three pass, you're ready to deploy. 🚀 -## Deployment +--- -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/ubergonmx/nextjs-supabase-creem-starter) +## Extras + +### AI Skills + +Skills that help you (and AI) build faster when adding features: + +#### Creem + +- Check the [original docs](https://docs.creem.io/code/sdks/ai-agents) or install the skill: + ```bash + npx skills add https://github.com/armitage-labs/creem-skills --skill creem + ``` + +#### Supabase Query Tuning + +```bash +npx skills add https://github.com/sickn33/antigravity-awesome-skills --skill supabase-postgres-best-practices +``` + +#### Next.js + Supabase Best Practices + +```bash +npx skills add https://github.com/sickn33/antigravity-awesome-skills --skill nextjs-supabase-auth +``` + +#### UI + +Since this starter uses shadcn/ui, check out the [shadcn registry directory](https://ui.shadcn.com/docs/directory) for more UI components and blocks. + +```bash +npx skills add raphaelsalaja/userinterface-wiki +npx skills add jakubkrehel/make-interfaces-feel-better +``` + +### Always Check + +- **[vercel-doctor.com](https://vercel-doctor.com)** — if deploying to Vercel +- **[suparalph.vibeship.co](https://suparalph.vibeship.co/)** — scan for Supabase vulnerabilities -Set the same environment variables from `.env.example` in your Vercel project settings. +--- ## Contributing From e4753a209d3ca52571245a5d3f15bb319ebcf82b Mon Sep 17 00:00:00 2001 From: aaron Date: Sun, 29 Mar 2026 23:24:53 +0800 Subject: [PATCH 03/49] feat: update components to use Next.js Link and Image for improved navigation and performance --- src/components/ui/breadcrumb.tsx | 1 - src/components/ui/input-group.tsx | 3 ++- src/components/ui/label.tsx | 3 ++- src/components/ui/text-effect.tsx | 10 ++++++---- .../dashboard/components/app-sidebar.tsx | 7 ++++++- .../dashboard/components/nav-documents.tsx | 10 +++++++++- .../dashboard/components/nav-secondary.tsx | 9 ++++++++- .../landing/components/features-section.tsx | 16 ++++++++++------ 8 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx index 9de03c9..4986cdd 100644 --- a/src/components/ui/breadcrumb.tsx +++ b/src/components/ui/breadcrumb.tsx @@ -63,7 +63,6 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { return ( { + onMouseDown={(e) => { if ((e.target as HTMLElement).closest('button')) { return } + e.preventDefault() e.currentTarget.parentElement?.querySelector('input')?.focus() }} {...props} diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx index 30d9cbb..a35698b 100644 --- a/src/components/ui/label.tsx +++ b/src/components/ui/label.tsx @@ -4,10 +4,11 @@ import * as React from 'react' import { cn } from '@/lib/utils' -function Label({ className, ...props }: React.ComponentProps<'label'>) { +function Label({ className, htmlFor, ...props }: React.ComponentProps<'label'>) { return (
))} @@ -119,12 +122,13 @@ const CodeReviewIllustration = () => {
- M Irung
Méschac Irung From 1b29f81d15fea612ec8c2a046f926196ae327247 Mon Sep 17 00:00:00 2001 From: aaron Date: Mon, 30 Mar 2026 05:05:55 +0800 Subject: [PATCH 04/49] fix(env): correct Supabase key variable name in .env.example --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 29d17d1..093dd55 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000 # Supabase NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= SUPABASE_SERVICE_ROLE_KEY= # Creem From d3f4add20bc80bcdfabe71a0fda8d63b5a13ee13 Mon Sep 17 00:00:00 2001 From: aaron Date: Mon, 30 Mar 2026 06:34:51 +0800 Subject: [PATCH 05/49] feat(database): enhance profiles, subscriptions, and credits tables with new fields and policies --- supabase/migrations/001_profiles.sql | 32 +++-- supabase/migrations/002_subscriptions.sql | 10 +- supabase/migrations/003_credits.sql | 126 ++++++++++++++++-- .../004_profiles_creem_customer_id.sql | 2 - supabase/migrations/005_spend_credits_rpc.sql | 32 ----- 5 files changed, 143 insertions(+), 59 deletions(-) delete mode 100644 supabase/migrations/004_profiles_creem_customer_id.sql delete mode 100644 supabase/migrations/005_spend_credits_rpc.sql diff --git a/supabase/migrations/001_profiles.sql b/supabase/migrations/001_profiles.sql index 83ce8fc..2acd30e 100644 --- a/supabase/migrations/001_profiles.sql +++ b/supabase/migrations/001_profiles.sql @@ -4,18 +4,25 @@ -- ============================================================ create table if not exists public.profiles ( - id uuid primary key references auth.users (id) on delete cascade, - email text unique not null, - full_name text, - avatar_url text, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now() + id uuid primary key references auth.users (id) on delete cascade, + email text unique not null, + full_name text, + avatar_url text, + creem_customer_id text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() ); +-- Partial unique index: only enforce uniqueness when creem_customer_id is set +create unique index if not exists profiles_creem_customer_id_idx + on public.profiles (creem_customer_id) + where creem_customer_id is not null; + -- Auto-update updated_at create or replace function public.handle_updated_at() returns trigger language plpgsql +set search_path = '' as $$ begin new.updated_at = now(); @@ -31,7 +38,7 @@ create trigger profiles_updated_at create or replace function public.handle_new_user() returns trigger language plpgsql -security definer set search_path = public +security definer set search_path = '' as $$ begin insert into public.profiles (id, email, full_name, avatar_url) @@ -57,9 +64,14 @@ alter table public.profiles enable row level security; create policy "Users can view their own profile" on public.profiles for select - using (auth.uid() = id); + using ((select auth.uid()) = id); create policy "Users can update their own profile" on public.profiles for update - using (auth.uid() = id) - with check (auth.uid() = id); + using ((select auth.uid()) = id) + with check ((select auth.uid()) = id); + +alter table public.profiles force row level security; + +-- Prevent users from changing fields managed by the system +revoke update (email, creem_customer_id) on public.profiles from authenticated; diff --git a/supabase/migrations/002_subscriptions.sql b/supabase/migrations/002_subscriptions.sql index f8ce275..81d18ac 100644 --- a/supabase/migrations/002_subscriptions.sql +++ b/supabase/migrations/002_subscriptions.sql @@ -27,7 +27,7 @@ create table if not exists public.subscriptions ( ); create index subscriptions_user_id_idx on public.subscriptions (user_id); -create index subscriptions_creem_subscription_id_idx on public.subscriptions (creem_subscription_id); +-- creem_subscription_id unique constraint already creates an implicit index; no explicit one needed -- Auto-update updated_at create trigger subscriptions_updated_at @@ -42,9 +42,7 @@ alter table public.subscriptions enable row level security; create policy "Users can view their own subscription" on public.subscriptions for select - using (auth.uid() = user_id); + using ((select auth.uid()) = user_id); --- Only the service role can insert/update subscriptions (via webhooks) -create policy "Service role can manage subscriptions" - on public.subscriptions for all - using (auth.role() = 'service_role'); +-- Note: service_role bypasses RLS entirely — no explicit policy needed +alter table public.subscriptions force row level security; diff --git a/supabase/migrations/003_credits.sql b/supabase/migrations/003_credits.sql index 46bb47c..52b85cc 100644 --- a/supabase/migrations/003_credits.sql +++ b/supabase/migrations/003_credits.sql @@ -18,7 +18,7 @@ create trigger credits_updated_at create or replace function public.handle_new_profile_credits() returns trigger language plpgsql -security definer set search_path = public +security definer set search_path = '' as $$ begin insert into public.credits (user_id) values (new.id); @@ -63,18 +63,126 @@ alter table public.credits enable row level security; create policy "Users can view their own credit balance" on public.credits for select - using (auth.uid() = user_id); + using ((select auth.uid()) = user_id); -create policy "Service role can manage credits" - on public.credits for all - using (auth.role() = 'service_role'); +-- Note: service_role bypasses RLS entirely — no explicit policy needed +alter table public.credits force row level security; alter table public.credit_transactions enable row level security; create policy "Users can view their own transactions" on public.credit_transactions for select - using (auth.uid() = user_id); + using ((select auth.uid()) = user_id); -create policy "Service role can manage credit transactions" - on public.credit_transactions for all - using (auth.role() = 'service_role'); +-- Note: service_role bypasses RLS entirely — no explicit policy needed +alter table public.credit_transactions force row level security; + +-- ============================================================ +-- RPCs: spend_credits, add_credits +-- +-- These live in the same file as the schema because they are +-- tightly coupled to the credits/credit_transactions tables — +-- they encode the only safe ways to mutate balances (atomic +-- upsert, FOR UPDATE row lock, input validation). Keeping +-- schema and its business-logic RPCs together makes it easier +-- to reason about the full credits domain in one place. +-- +-- Both functions are security definer with search_path = '' to +-- prevent search_path hijacking. Execute is revoked from all +-- non-service roles because they are only ever called from the +-- server-side admin client, never directly by users. +-- ============================================================ + +create or replace function public.spend_credits( + p_user_id uuid, + p_amount integer, + p_description text default null +) +returns boolean +language plpgsql +security definer set search_path = '' +as $$ +declare + v_balance integer; +begin + if p_amount <= 0 then + return false; + end if; + + -- Lock the row to prevent race conditions on concurrent webhook retries + select balance into v_balance + from public.credits + where user_id = p_user_id + for update; + + if v_balance is null or v_balance < p_amount then + return false; + end if; + + update public.credits + set balance = balance - p_amount + where user_id = p_user_id; + + insert into public.credit_transactions (user_id, amount, type, description) + values (p_user_id, -p_amount, 'spend', p_description); + + return true; +end; +$$; + +create or replace function public.add_credits( + p_user_id uuid, + p_amount integer, + p_type text, + p_description text default null +) +returns void +language plpgsql +security definer set search_path = '' +as $$ +begin + if p_amount <= 0 then + return; + end if; + + -- Atomic upsert prevents double-grant on concurrent webhook retries + insert into public.credits (user_id, balance) + values (p_user_id, p_amount) + on conflict (user_id) + do update set balance = public.credits.balance + excluded.balance; + + insert into public.credit_transactions (user_id, amount, type, description) + values (p_user_id, p_amount, p_type::public.credit_transaction_type, p_description); +end; +$$; + +-- Dedicated RPC for credit deductions (refunds, adjustments). +-- Separate from spend_credits (which requires sufficient balance) because refunds +-- should always succeed even if the user already spent the credits — balance floors at 0. +create or replace function public.deduct_credits( + p_user_id uuid, + p_amount integer, + p_description text default null +) +returns void +language plpgsql +security definer set search_path = '' +as $$ +begin + if p_amount <= 0 then + return; + end if; + + -- Floor at 0 — refunds should not fail if the user already spent the credits + update public.credits + set balance = greatest(0, balance - p_amount) + where user_id = p_user_id; + + insert into public.credit_transactions (user_id, amount, type, description) + values (p_user_id, -p_amount, 'refund', p_description); +end; +$$; + +revoke execute on function public.spend_credits(uuid, integer, text) from public, anon, authenticated; +revoke execute on function public.add_credits(uuid, integer, text, text) from public, anon, authenticated; +revoke execute on function public.deduct_credits(uuid, integer, text) from public, anon, authenticated; diff --git a/supabase/migrations/004_profiles_creem_customer_id.sql b/supabase/migrations/004_profiles_creem_customer_id.sql deleted file mode 100644 index b2ca652..0000000 --- a/supabase/migrations/004_profiles_creem_customer_id.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table public.profiles add column if not exists creem_customer_id text; -create index if not exists profiles_creem_customer_id_idx on public.profiles (creem_customer_id); diff --git a/supabase/migrations/005_spend_credits_rpc.sql b/supabase/migrations/005_spend_credits_rpc.sql deleted file mode 100644 index af92965..0000000 --- a/supabase/migrations/005_spend_credits_rpc.sql +++ /dev/null @@ -1,32 +0,0 @@ -create or replace function public.spend_credits( - p_user_id uuid, - p_amount integer, - p_description text default null -) -returns boolean -language plpgsql -security definer set search_path = public -as $$ -declare - v_balance integer; -begin - -- Lock the row to prevent race conditions - select balance into v_balance - from public.credits - where user_id = p_user_id - for update; - - if v_balance is null or v_balance < p_amount then - return false; - end if; - - update public.credits - set balance = balance - p_amount - where user_id = p_user_id; - - insert into public.credit_transactions (user_id, amount, type, description) - values (p_user_id, -p_amount, 'spend', p_description); - - return true; -end; -$$; From 683fbb7f7af04de6f10a601fd79338c5de7311b3 Mon Sep 17 00:00:00 2001 From: aaron Date: Mon, 30 Mar 2026 06:44:46 +0800 Subject: [PATCH 06/49] fix(env): rename NEXT_PUBLIC_SUPABASE_ANON_KEY to NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY Update all references in Supabase clients, middleware, CI, and env example to use the new publishable key env var name. --- .env.example | 5 +---- .github/workflows/ci.yml | 2 +- src/features/auth/components/signup-form.tsx | 3 +++ src/lib/supabase/client.ts | 2 +- src/lib/supabase/server.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 093dd55..01cecbc 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,4 @@ CREEM_API_KEY= CREEM_WEBHOOK_SECRET= NEXT_PUBLIC_CREEM_PRODUCT_ID_PRO= NEXT_PUBLIC_CREEM_PRODUCT_ID_CREDITS= -NEXT_PUBLIC_CREEM_PRODUCT_ID_BUSINESS= - -# OAuth (configured in Supabase dashboard) -# GOOGLE_CLIENT_ID and GITHUB_CLIENT_ID are set in Supabase, not here +NEXT_PUBLIC_CREEM_PRODUCT_ID_BUSINESS= \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c17716..6b4b3c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: - run: npm run build env: NEXT_PUBLIC_SUPABASE_URL: http://localhost:54321 - NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: placeholder NEXT_PUBLIC_APP_URL: http://localhost:3000 NEXT_PUBLIC_CREEM_PRODUCT_ID_PRO: placeholder NEXT_PUBLIC_CREEM_PRODUCT_ID_BUSINESS: placeholder diff --git a/src/features/auth/components/signup-form.tsx b/src/features/auth/components/signup-form.tsx index 37e209a..0eb98cb 100644 --- a/src/features/auth/components/signup-form.tsx +++ b/src/features/auth/components/signup-form.tsx @@ -108,6 +108,9 @@ export function SignupForm({ {state?.error && (

{state.error}

)} + {state?.message && ( +

{state.message}

+ )} + )} @@ -124,12 +131,15 @@ export function PricingSection() { - - Get Started - + {businessProductId ? ( + + Get Started + + ) : ( + + )}
diff --git a/src/features/billing/schema.ts b/src/features/billing/schema.ts new file mode 100644 index 0000000..57748bb --- /dev/null +++ b/src/features/billing/schema.ts @@ -0,0 +1,39 @@ +import * as z from "zod"; + +// ---------- Action input schemas ---------- + +export const createCheckoutSchema = z.object({ + productId: z.string().min(1, "Product ID is required"), +}); + +// ---------- Creem webhook payload schemas ---------- + +export const creemCustomerSchema = z.object({ + id: z.string(), + email: z.email(), + name: z.string().optional(), +}); + +export const creemSubscriptionSchema = z.object({ + id: z.string(), + customer: creemCustomerSchema, + product_id: z.string(), + status: z.string(), + current_period_start: z.string().optional(), + current_period_end: z.string().optional(), + cancel_at_period_end: z.boolean().optional(), + metadata: z.record(z.string(), z.string()).optional(), +}); + +export const creemCheckoutSchema = z.object({ + id: z.string(), + customer: creemCustomerSchema, + subscription: creemSubscriptionSchema.optional(), + product_id: z.string(), + metadata: z.record(z.string(), z.string()).optional(), +}); + +export const creemWebhookEventSchema = z.object({ + event_type: z.string(), + object: z.unknown(), +}); diff --git a/src/features/billing/types.ts b/src/features/billing/types.ts index 0b5cd84..2137d24 100644 --- a/src/features/billing/types.ts +++ b/src/features/billing/types.ts @@ -1,5 +1,3 @@ -import * as z from "zod"; - // ---------- Creem webhook event types ---------- export type CreemWebhookEventType = | "checkout.completed" @@ -12,37 +10,6 @@ export type CreemWebhookEventType = | "subscription.update" | "refund.created"; -// ---------- Zod schemas for webhook payloads ---------- -export const creemCustomerSchema = z.object({ - id: z.string(), - email: z.string().email(), - name: z.string().optional(), -}); - -export const creemSubscriptionSchema = z.object({ - id: z.string(), - customer: creemCustomerSchema, - product_id: z.string(), - status: z.string(), - current_period_start: z.string().optional(), - current_period_end: z.string().optional(), - cancel_at_period_end: z.boolean().optional(), - metadata: z.record(z.string(), z.string()).optional(), -}); - -export const creemCheckoutSchema = z.object({ - id: z.string(), - customer: creemCustomerSchema, - subscription: creemSubscriptionSchema.optional(), - product_id: z.string(), - metadata: z.record(z.string(), z.string()).optional(), -}); - -export const creemWebhookEventSchema = z.object({ - event_type: z.string(), - object: z.unknown(), -}); - // ---------- App-level types ---------- export type SubscriptionStatus = | "active" diff --git a/src/features/billing/webhooks/index.ts b/src/features/billing/webhooks/index.ts index 661526b..b5824da 100644 --- a/src/features/billing/webhooks/index.ts +++ b/src/features/billing/webhooks/index.ts @@ -3,10 +3,12 @@ import { creemWebhookEventSchema, creemCheckoutSchema, creemSubscriptionSchema, - PLANS, -} from "../types"; +} from "../schema"; +import { PLANS } from "../types"; import type { CreemWebhookEventType, SubscriptionStatus } from "../types"; +const ONE_TIME_CREDITS_PURCHASE_AMOUNT = 500; + function mapCreemStatus(status: string): SubscriptionStatus { switch (status) { case "active": @@ -29,11 +31,19 @@ function creditsForProductId(productId: string): number { if (productId === process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_PRO) { return PLANS.pro.credits; } + if (productId === process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_BUSINESS) { return -1; // unlimited — no top-up needed } - // credits product or unknown — default to 500 - return 500; + + if (productId === process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_CREDITS) { + return ONE_TIME_CREDITS_PURCHASE_AMOUNT; + } + + console.warn( + `creditsForProductId: unknown product_id "${productId}" — no credits granted`, + ); + return 0; } export async function handleWebhookEvent(rawBody: string): Promise { @@ -95,29 +105,16 @@ async function handleCheckoutCompleted(object: unknown): Promise { // One-time credit purchase const credits = creditsForProductId(checkout.product_id); if (credits > 0) { - const { data: existing } = await admin - .from("credits") - .select("balance") - .eq("user_id", userId) - .single(); - - if (existing) { - await admin - .from("credits") - .update({ balance: existing.balance + credits }) - .eq("user_id", userId); - } else { - await admin - .from("credits") - .insert({ user_id: userId, balance: credits }); - } - - await admin.from("credit_transactions").insert({ - user_id: userId, - amount: credits, - type: "purchase", - description: `Purchased ${credits} credits`, + const { error } = await admin.rpc("add_credits", { + p_user_id: userId, + p_amount: credits, + p_type: "purchase", + p_description: `Purchased ${credits} credits`, }); + + if (error) { + throw new Error(`add_credits RPC failed: ${error.message}`); + } } } } @@ -161,29 +158,16 @@ async function handleSubscriptionUpsert( if (eventType === "subscription.paid") { const credits = creditsForProductId(sub.product_id); if (credits > 0) { - const { data: existing } = await admin - .from("credits") - .select("balance") - .eq("user_id", profile.id) - .single(); - - if (existing) { - await admin - .from("credits") - .update({ balance: existing.balance + credits }) - .eq("user_id", profile.id); - } else { - await admin - .from("credits") - .insert({ user_id: profile.id, balance: credits }); - } - - await admin.from("credit_transactions").insert({ - user_id: profile.id, - amount: credits, - type: "topup", - description: `Monthly credit top-up`, + const { error } = await admin.rpc("add_credits", { + p_user_id: profile.id, + p_amount: credits, + p_type: "topup", + p_description: "Monthly credit top-up", }); + + if (error) { + throw new Error(`add_credits RPC failed: ${error.message}`); + } } } } @@ -227,25 +211,14 @@ async function handleRefundCreated(object: unknown): Promise { const refundAmount = typeof refund.amount === "number" ? refund.amount : 0; if (refundAmount > 0) { - const { data: existing } = await admin - .from("credits") - .select("balance") - .eq("user_id", profile.id) - .single(); - - if (existing) { - const newBalance = Math.max(0, existing.balance - refundAmount); - await admin - .from("credits") - .update({ balance: newBalance }) - .eq("user_id", profile.id); - - await admin.from("credit_transactions").insert({ - user_id: profile.id, - amount: -refundAmount, - type: "refund", - description: "Refund processed", - }); + const { error } = await admin.rpc("deduct_credits", { + p_user_id: profile.id, + p_amount: refundAmount, + p_description: "Refund processed", + }); + + if (error) { + throw new Error(`deduct_credits RPC failed: ${error.message}`); } } } From 6a09fba3f54cf35d048dd690c396fc7550210a83 Mon Sep 17 00:00:00 2001 From: aaron Date: Mon, 30 Mar 2026 06:45:05 +0800 Subject: [PATCH 09/49] feat(credits): extract Zod schema and validate spendCredits input - Colocate spendCreditsSchema in credits/schema.ts - Replace manual Number.isInteger guard with Zod safeParse - Use z.int().min(1) per Zod v4 API --- src/features/credits/actions/index.ts | 6 ++++++ src/features/credits/schema.ts | 6 ++++++ 2 files changed, 12 insertions(+) create mode 100644 src/features/credits/schema.ts diff --git a/src/features/credits/actions/index.ts b/src/features/credits/actions/index.ts index ddeb083..3548be2 100644 --- a/src/features/credits/actions/index.ts +++ b/src/features/credits/actions/index.ts @@ -5,6 +5,7 @@ import { createAdminClient } from "@/lib/supabase/admin"; import { createCheckout } from "@/lib/creem/client"; import { redirect } from "next/navigation"; import type { CreditTransaction } from "../types"; +import { spendCreditsSchema } from "../schema"; const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; @@ -47,6 +48,11 @@ export async function spendCredits( amount: number, description: string, ): Promise<{ success: boolean; error?: string }> { + const validation = spendCreditsSchema.safeParse({ amount, description }); + if (!validation.success) { + return { success: false, error: validation.error.issues[0].message }; + } + const supabase = await createClient(); const { data: { user }, diff --git a/src/features/credits/schema.ts b/src/features/credits/schema.ts new file mode 100644 index 0000000..51536f7 --- /dev/null +++ b/src/features/credits/schema.ts @@ -0,0 +1,6 @@ +import * as z from "zod"; + +export const spendCreditsSchema = z.object({ + amount: z.int().min(1, "Amount must be a positive integer"), + description: z.string(), +}); From 462feba0475841b8d21cbd65577a59c383884be7 Mon Sep 17 00:00:00 2001 From: aaron Date: Mon, 30 Mar 2026 06:45:09 +0800 Subject: [PATCH 10/49] fix(creem): use timing-safe comparison for webhook signature verification Replace string equality check with crypto.timingSafeEqual to prevent timing-based signature oracle attacks on the webhook endpoint. --- src/lib/creem/client.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/lib/creem/client.ts b/src/lib/creem/client.ts index d3e6287..7ad0929 100644 --- a/src/lib/creem/client.ts +++ b/src/lib/creem/client.ts @@ -137,5 +137,13 @@ export function verifyWebhookSignature( .createHmac("sha256", process.env.CREEM_WEBHOOK_SECRET!) .update(rawBody) .digest("hex"); - return expected === signature; + + const expectedBuf = Buffer.from(expected, "hex"); + const signatureBuf = Buffer.from(signature, "hex"); + + if (expectedBuf.length !== signatureBuf.length) { + return false; + } + + return crypto.timingSafeEqual(expectedBuf, signatureBuf); } From 19119b82cf8acd150b778b25c350b301c07f42a1 Mon Sep 17 00:00:00 2001 From: aaron Date: Mon, 30 Mar 2026 06:46:02 +0800 Subject: [PATCH 11/49] fix(middleware): restore and update env var for session refresh Middleware was accidentally deleted. Restored with NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY to match the renamed env var. Required for Supabase session cookie refresh and route protection on /dashboard. --- src/middleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/middleware.ts b/src/middleware.ts index cb213b3..70bd577 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -9,7 +9,7 @@ export async function middleware(request: NextRequest) { const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, { cookies: { getAll() { From 76638006138eef538954bf14d753361aa6083476 Mon Sep 17 00:00:00 2001 From: aaron Date: Mon, 30 Mar 2026 06:53:06 +0800 Subject: [PATCH 12/49] feat(proxy): implement proxy middleware for authentication and session management --- src/{middleware.ts => proxy.ts} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/{middleware.ts => proxy.ts} (96%) diff --git a/src/middleware.ts b/src/proxy.ts similarity index 96% rename from src/middleware.ts rename to src/proxy.ts index 70bd577..bdb49d4 100644 --- a/src/middleware.ts +++ b/src/proxy.ts @@ -4,7 +4,7 @@ import { createServerClient } from "@supabase/ssr"; const PROTECTED_PATHS = ["/dashboard"]; const AUTH_PATHS = ["/login", "/signup"]; -export async function middleware(request: NextRequest) { +export async function proxy(request: NextRequest) { let response = NextResponse.next({ request }); const supabase = createServerClient( @@ -50,4 +50,4 @@ export async function middleware(request: NextRequest) { export const config = { matcher: ["/((?!_next/static|_next/image|favicon\\.svg|api/).*)"], -}; +}; \ No newline at end of file From ef5ee1e820eb0ebdde990a10f5e078c9f3e6e478 Mon Sep 17 00:00:00 2001 From: aaron Date: Mon, 30 Mar 2026 06:53:49 +0800 Subject: [PATCH 13/49] feat(docs): update README setup instructions for Supabase and Creem integration --- README.md | 49 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3293fda..5b8099b 100644 --- a/README.md +++ b/README.md @@ -94,13 +94,51 @@ Open [http://localhost:3000](http://localhost:3000). ## Setup -### 1. Set up Supabase +### 1. Clone and run locally +```bash +git clone +``` + +```bash +cp .env.example .env.local +``` + +### 2. Set up Supabase 1. Create a new [Supabase project](https://database.new) + BqKXZJN3QsvUUdpb +2. Set these values in `.env.local`: + - `NEXT_PUBLIC_SUPABASE_URL`: Copy this in **Project Overview** (should look like `https://urcryetpnmgoatkitnumxb.supabase.co`) + - `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`: Copy this in **Project Overview** (open dropdown) or go to **Project Settings** (bottom of left sidebar) > **API Keys** > copy the key under **Publishable key** (starts with `sb_publishable_...`). If you're using legacy, copy `anon public` key (starts with `eyJ...`) in "Legacy anon, service_role API keys" tab. + - `SUPABASE_SERVICE_ROLE_KEY`: Same page, copy the key under **Secret keys** (starts with `sb_secret_***`). If you're using legacy, copy `service_role` secret key. +3. Run the SQL migrations + - Go to **SQL Editor** in the left sidebar (terminal icon) + - Copy-paste and run each file in `supabase/migrations/` in order: + - `001_profiles.sql` + - `002_subscriptions.sql` + - `003_credits.sql` + - If you prefer using the Supabase CLI, run `supabase db push` +4. Enable auth providers: + - Go to **Authentication** (left sidebar, lock icon) > under **CONFIGURATION** , click **Sign In / Providers** + - Under **Auth Providers**, enable sign in of the following: + - **Google**: + - Enabling should show a right sidebar, copy **Callback URL (for OAuth)** (should look like `https://urcryetpnmgoatkitnumxb.supabase.co/auth/v1/callback`) + - Create an OAuth app in [Google Cloud Console](https://console.cloud.google.com/apis/credentials) (setup Consent screen if you haven't already). During creation, under **Authorized redirect URIs**, click "+ Add URI" and paste the callback URL. + - Click "Create", download JSON for backup and copy the client ID and secret into the Supabase sidebar, then save. + - **GitHub**: + - Create an OAuth app in [GitHub Developer Settings](https://github.com/settings/developers) + - Copy-paste redirect URL from the Supabase sidebar (should look like `https://urcryetpnmgoatkitnumxb.supabase.co/auth/v1/callback`) into the GitHub app's **Authorization callback URL** field, then save. + - After saving, copy the client ID and secret into the Supabase sidebar, then save. +5. Set redirect URL: + - Still in the Authentication secondary sidebar, click **URL Configuration** (under CONFIGURATION) + - Under **Redirect URLs**, click **Add URL** + - Add: `http://localhost:3000/auth/callback` + - (You'll come back and add your production URL after deploying -- see step 5 below) + -### 2. Set up Creem +### 3. Set up Creem 1. Create an account at [creem.io](https://www.creem.io) or, if you don't have an existing store, [create a new one](https://www.creem.io/dashboard/create) 2. Enable **Test Mode** in the bottom-left of the sidebar @@ -133,7 +171,7 @@ Fill in `.env.local` with your credentials: | Variable | Where to find it | | -------------------------------------- | --------------------------------- | | `NEXT_PUBLIC_SUPABASE_URL` | Supabase → Project Settings → API | -| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase → Project Settings → API | +| `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` | Supabase → Project Settings → API | | `SUPABASE_SERVICE_ROLE_KEY` | Supabase → Project Settings → API | | `CREEM_API_KEY` | Creem → Developer → API Keys | | `CREEM_WEBHOOK_SECRET` | Creem → Developer → Webhooks | @@ -163,8 +201,9 @@ Enable Google and GitHub providers in your Supabase dashboard under **Authentica Before deploying, make sure everything passes: ```bash -npm run check # lint + type-check -npm run test:coverage # run tests with coverage +npm run lint # lint +npm run typecheck # type-check +npm test # run tests npm run build # production build ``` From 4d0634bf89333645d99496321449f7437964d859 Mon Sep 17 00:00:00 2001 From: aaron Date: Mon, 30 Mar 2026 06:53:58 +0800 Subject: [PATCH 14/49] feat(auth): implement callback route for handling authentication with Supabase --- src/app/(auth)/auth/callback/route.ts | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/app/(auth)/auth/callback/route.ts diff --git a/src/app/(auth)/auth/callback/route.ts b/src/app/(auth)/auth/callback/route.ts new file mode 100644 index 0000000..ee7494c --- /dev/null +++ b/src/app/(auth)/auth/callback/route.ts @@ -0,0 +1,32 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +function getSafeNextPath(nextParam: string | null): string { + if (!nextParam) { + return "/dashboard"; + } + + if (!nextParam.startsWith("/") || nextParam.startsWith("//")) { + return "/dashboard"; + } + + return nextParam; +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const code = searchParams.get("code"); + const nextPath = getSafeNextPath(searchParams.get("next")); + + if (code) { + const supabase = await createClient(); + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (!error) { + return NextResponse.redirect(new URL(nextPath, request.url)); + } + } + + return NextResponse.redirect( + new URL("/login?error=auth_callback_failed", request.url), + ); +} From d955e417bb2aa6f78f5445335cda4afb08f775bd Mon Sep 17 00:00:00 2001 From: aaron Date: Mon, 30 Mar 2026 06:54:19 +0800 Subject: [PATCH 15/49] fix(features): add id attribute to features section for better accessibility --- src/features/landing/components/features-section.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/landing/components/features-section.tsx b/src/features/landing/components/features-section.tsx index 74c33c0..29066e2 100644 --- a/src/features/landing/components/features-section.tsx +++ b/src/features/landing/components/features-section.tsx @@ -20,7 +20,7 @@ const GLODIE_AVATAR = 'https://avatars.githubusercontent.com/u/99137927?v=4' export default function FeaturesSection() { return ( -
+
From 310360b0d818c348600413819b9aacc46095663e Mon Sep 17 00:00:00 2001 From: aaron Date: Mon, 30 Mar 2026 06:54:26 +0800 Subject: [PATCH 16/49] fix(faqs): clarify deployment instructions in FAQs section --- src/features/billing/components/faqs-section.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/billing/components/faqs-section.tsx b/src/features/billing/components/faqs-section.tsx index 79fc02c..815c0b3 100644 --- a/src/features/billing/components/faqs-section.tsx +++ b/src/features/billing/components/faqs-section.tsx @@ -38,7 +38,7 @@ export function FaqsSection() { id: 'item-5', question: 'How do I deploy this to production?', answer: - 'Click the "Deploy with Vercel" button in the README, set your environment variables (Supabase and Creem keys), and you\'re live. The entire process takes under 5 minutes.', + 'Click the "Deploy with Vercel" button in the README, set your environment variables (Supabase and Creem keys), create your products in Creem, and you\'re live. Plan for 15–30 minutes the first time.', }, ] From de195a2a8154b4083c0203e8e4c6e1863b9a3882 Mon Sep 17 00:00:00 2001 From: aaron Date: Mon, 30 Mar 2026 06:55:19 +0800 Subject: [PATCH 17/49] refactor(auth): remove obsolete callback route implementation --- src/app/auth/callback/route.ts | 32 -------------------------------- 1 file changed, 32 deletions(-) delete mode 100644 src/app/auth/callback/route.ts diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts deleted file mode 100644 index ee7494c..0000000 --- a/src/app/auth/callback/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextResponse, type NextRequest } from "next/server"; -import { createClient } from "@/lib/supabase/server"; - -function getSafeNextPath(nextParam: string | null): string { - if (!nextParam) { - return "/dashboard"; - } - - if (!nextParam.startsWith("/") || nextParam.startsWith("//")) { - return "/dashboard"; - } - - return nextParam; -} - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const code = searchParams.get("code"); - const nextPath = getSafeNextPath(searchParams.get("next")); - - if (code) { - const supabase = await createClient(); - const { error } = await supabase.auth.exchangeCodeForSession(code); - if (!error) { - return NextResponse.redirect(new URL(nextPath, request.url)); - } - } - - return NextResponse.redirect( - new URL("/login?error=auth_callback_failed", request.url), - ); -} From efaafd7bd32918e38d115d6313228ce0a8e81778 Mon Sep 17 00:00:00 2001 From: aaron Date: Mon, 30 Mar 2026 23:52:49 +0800 Subject: [PATCH 18/49] fix(auth): fix email confirmation redirect and post-signup toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add emailRedirectTo pointing to /auth/callback so Supabase sends the PKCE code to the callback route instead of the landing page - Redirect to /login?signup=pending after signup instead of returning an inline message; show a top-center Sonner toast on the login page - Add useRef guard in SignupPendingToast to prevent double-fire under React Strict Mode in development - Add useTransition to OAuth buttons so UI shows "Redirecting…" state - Clean up auth layout title template (remove redundant "Auth" segment) --- src/app/(auth)/layout.tsx | 2 +- src/app/(auth)/login/page.tsx | 10 ++++++++- src/features/auth/actions/index.ts | 10 ++++++--- src/features/auth/components/login-form.tsx | 13 ++++++----- src/features/auth/components/signup-form.tsx | 3 --- .../auth/components/signup-pending-toast.tsx | 22 +++++++++++++++++++ 6 files changed, 47 insertions(+), 13 deletions(-) create mode 100644 src/features/auth/components/signup-pending-toast.tsx diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index 8cb8ecb..ddace57 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; export const metadata: Metadata = { title: { - template: "%s | Auth | CreemKit", + template: "%s | CreemKit", default: "Auth | CreemKit", }, description: "Authentication pages for your CreemKit workspace.", diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 2e46be8..7962640 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { LoginForm } from "@/features/auth/components/login-form"; +import { SignupPendingToast } from "@/features/auth/components/signup-pending-toast"; import { Logo } from "@/components/logo"; import Link from "next/link"; @@ -8,9 +9,16 @@ export const metadata: Metadata = { description: "Access your CreemKit account to manage subscriptions and credits.", }; -export default function LoginPage() { +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ signup?: string }>; +}) { + const { signup } = await searchParams; + return (
+ {signup === "pending" && }
) { const [state, action, pending] = useActionState(login, undefined); + const [oauthPending, startOAuthTransition] = useTransition(); return (
@@ -44,18 +45,20 @@ export function LoginForm({ diff --git a/src/features/auth/components/signup-form.tsx b/src/features/auth/components/signup-form.tsx index 0eb98cb..37e209a 100644 --- a/src/features/auth/components/signup-form.tsx +++ b/src/features/auth/components/signup-form.tsx @@ -108,9 +108,6 @@ export function SignupForm({ {state?.error && (

{state.error}

)} - {state?.message && ( -

{state.message}

- )}
)} {PLANS.business.productId && ( @@ -80,12 +87,15 @@ export default async function BillingPage() {

Unlimited credits

- - {subscription ? "Switch to Business" : "Get Business"} - + {subscription ? ( + + Switch to Business + + ) : ( + + Get Business + + )}
)}
diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 1c03f97..43419f7 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -4,6 +4,7 @@ import { DataTable } from "@/features/dashboard/components/data-table"; import { SectionCards } from "@/features/dashboard/components/section-cards"; import { SiteHeader } from "@/features/dashboard/components/site-header"; import { SubscriptionCard } from "@/features/billing/components/subscription-card"; +import { CheckoutSuccessToast } from "@/features/billing/components/checkout-success-toast"; import { getUserSubscription } from "@/features/billing/actions"; import { getCreditsBalance } from "@/features/credits/actions"; @@ -14,14 +15,20 @@ export const metadata: Metadata = { description: "View your key metrics, usage data, and recent activity.", }; -export default async function Page() { - const [subscription, creditsBalance] = await Promise.all([ +export default async function Page({ + searchParams, +}: { + searchParams: Promise<{ checkout?: string }>; +}) { + const [{ checkout }, subscription, creditsBalance] = await Promise.all([ + searchParams, getUserSubscription(), getCreditsBalance(), ]); return ( <> + {checkout === "success" && }
diff --git a/src/app/api/webhooks/creem/route.ts b/src/app/api/webhooks/creem/route.ts index ad06a78..23d8cdb 100644 --- a/src/app/api/webhooks/creem/route.ts +++ b/src/app/api/webhooks/creem/route.ts @@ -1,20 +1,7 @@ -import { NextResponse, type NextRequest } from "next/server"; -import { verifyWebhookSignature } from "@/lib/creem/client"; -import { handleWebhookEvent } from "@/features/billing/webhooks"; +import { Webhook } from "@creem_io/nextjs"; +import { webhookHandlers } from "@/features/billing/webhooks"; -export async function POST(request: NextRequest) { - const rawBody = await request.text(); - const signature = request.headers.get("creem-signature"); - - if (!signature || !verifyWebhookSignature(rawBody, signature)) { - return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); - } - - try { - await handleWebhookEvent(rawBody); - return NextResponse.json({ received: true }, { status: 200 }); - } catch (error) { - console.error("Webhook processing error:", error); - return NextResponse.json({ error: "Processing failed" }, { status: 500 }); - } -} +export const POST = Webhook({ + webhookSecret: process.env.CREEM_WEBHOOK_SECRET!, + ...webhookHandlers, +}); diff --git a/src/features/billing/actions/index.ts b/src/features/billing/actions/index.ts index a9043d6..a760902 100644 --- a/src/features/billing/actions/index.ts +++ b/src/features/billing/actions/index.ts @@ -46,14 +46,21 @@ export async function createCheckoutSession( if (!user) redirect("/login"); - const { checkout_url } = await createCheckout({ - productId, - successUrl: `${APP_URL}/dashboard?checkout=success`, - customerEmail: user.email!, - metadata: { user_id: user.id }, - }); - - redirect(checkout_url); + let checkoutUrl: string; + try { + const result = await createCheckout({ + productId, + successUrl: `${APP_URL}/dashboard?checkout=success`, + customerEmail: user.email!, + metadata: { user_id: user.id }, + }); + checkoutUrl = result.checkout_url; + } catch (e) { + const message = e instanceof Error ? e.message : "Checkout unavailable"; + redirect(`/pricing?error=${encodeURIComponent(message)}`); + } + + redirect(checkoutUrl); } // ---------- Subscription queries ---------- @@ -96,11 +103,16 @@ export async function openCustomerPortal(): Promise { redirect("/dashboard/billing?error=no_customer"); } - const { customer_portal_link } = await getCustomerPortalLink( - profile.creem_customer_id, - ); + let portalUrl: string; + try { + const result = await getCustomerPortalLink(profile.creem_customer_id); + portalUrl = result.customer_portal_link; + } catch (e) { + const message = e instanceof Error ? e.message : "Portal unavailable"; + redirect(`/dashboard/billing?error=${encodeURIComponent(message)}`); + } - redirect(customer_portal_link); + redirect(portalUrl); } // ---------- Cancel ---------- @@ -118,7 +130,10 @@ export async function cancelUserSubscription( .from("subscriptions") .select("creem_subscription_id") .eq("user_id", user.id) - .single(); + .not("status", "eq", "canceled") + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); if (!sub?.creem_subscription_id) return { error: "No active subscription" }; @@ -154,7 +169,10 @@ export async function resumeUserSubscription(): Promise<{ error?: string }> { .from("subscriptions") .select("creem_subscription_id") .eq("user_id", user.id) - .single(); + .not("status", "eq", "canceled") + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); if (!sub?.creem_subscription_id) return { error: "No active subscription" }; @@ -185,7 +203,10 @@ export async function upgradeUserSubscription( .from("subscriptions") .select("creem_subscription_id") .eq("user_id", user.id) - .single(); + .not("status", "eq", "canceled") + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); if (!sub?.creem_subscription_id) redirect("/dashboard/billing"); @@ -216,7 +237,10 @@ export async function pauseUserSubscription(): Promise<{ error?: string }> { .from("subscriptions") .select("creem_subscription_id") .eq("user_id", user.id) - .single(); + .not("status", "eq", "canceled") + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); if (!sub?.creem_subscription_id) return { error: "No active subscription" }; diff --git a/src/features/billing/components/checkout-success-toast.tsx b/src/features/billing/components/checkout-success-toast.tsx new file mode 100644 index 0000000..89e4a21 --- /dev/null +++ b/src/features/billing/components/checkout-success-toast.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; + +export function CheckoutSuccessToast() { + const router = useRouter(); + const fired = useRef(false); + + useEffect(() => { + if (fired.current) return; + fired.current = true; + toast.success("Payment successful! Your plan will update shortly.", { + position: "top-center", + duration: 6000, + }); + router.replace("/dashboard"); + }, [router]); + + return null; +} diff --git a/src/features/billing/components/subscription-card.tsx b/src/features/billing/components/subscription-card.tsx index 70ce867..b5d8c5e 100644 --- a/src/features/billing/components/subscription-card.tsx +++ b/src/features/billing/components/subscription-card.tsx @@ -103,7 +103,7 @@ export function SubscriptionCard({ subscription, creditsBalance }: Props) { ) : ( <> {PLANS.pro.productId && ( - )} @@ -126,7 +126,7 @@ export function SubscriptionCard({ subscription, creditsBalance }: Props) { ? "∞" : creditsBalance.toLocaleString()}

- diff --git a/src/features/billing/components/upgrade-button.tsx b/src/features/billing/components/upgrade-button.tsx new file mode 100644 index 0000000..32a7ca7 --- /dev/null +++ b/src/features/billing/components/upgrade-button.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { upgradeUserSubscription } from "@/features/billing/actions"; +import { useTransition } from "react"; + +export function UpgradeButton({ + productId, + children, + variant = "default", +}: { + productId: string; + children: React.ReactNode; + variant?: "default" | "outline"; +}) { + const [pending, startTransition] = useTransition(); + + return ( + + ); +} diff --git a/src/features/billing/webhooks/__tests__/index.test.ts b/src/features/billing/webhooks/__tests__/index.test.ts index 24789ce..2d7b2f5 100644 --- a/src/features/billing/webhooks/__tests__/index.test.ts +++ b/src/features/billing/webhooks/__tests__/index.test.ts @@ -6,7 +6,7 @@ vi.mock("@/lib/supabase/admin", () => ({ })); import { createAdminClient } from "@/lib/supabase/admin"; -import { handleWebhookEvent } from "../index"; +import { webhookHandlers, creditsForProductId, mapStatus } from "../index"; /** Creates a Supabase-like chainable mock where every method returns `this`, * and the chain is also a Promise that resolves to { data, error: null }. @@ -14,108 +14,161 @@ import { handleWebhookEvent } from "../index"; function makeChain(returnData: unknown = null) { const result = { data: returnData, error: null }; - // Make the chain itself thenable so `await chain.update().eq()` works const chain: Record = { - then: ( - resolve: (v: unknown) => unknown, - _reject?: (e: unknown) => unknown, - ) => Promise.resolve(result).then(resolve), - catch: (reject: (e: unknown) => unknown) => - Promise.resolve(result).catch(reject), + then: (resolve: (v: unknown) => unknown) => Promise.resolve(result).then(resolve), + catch: (reject: (e: unknown) => unknown) => Promise.resolve(result).catch(reject), }; const methods = [ - "from", - "select", - "update", - "insert", - "upsert", - "eq", - "single", - "order", - "limit", - "rpc", + "from", "select", "update", "insert", "upsert", + "eq", "single", "maybeSingle", "order", "limit", "rpc", ]; for (const method of methods) { chain[method] = vi.fn().mockReturnValue(chain); } - // single() should still resolve to { data, error: null } (chain.single as ReturnType).mockResolvedValue(result); + (chain.maybeSingle as ReturnType).mockResolvedValue({ data: null, error: null }); return chain; } -describe("handleWebhookEvent", () => { +// Base event fields shared across all webhook events +const baseEvent = { webhookId: "wh_test_123" }; + +describe("webhookHandlers", () => { beforeEach(() => { process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_PRO = "prod_pro"; process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_BUSINESS = "prod_business"; + process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_CREDITS = "prod_credits"; }); - it("handles checkout.completed with subscription", async () => { + it("onCheckoutCompleted — upserts subscription when subscription present", async () => { const chain = makeChain({ id: "user-123" }); + // Second maybeSingle call (idempotency check) returns null = not a duplicate + (chain.maybeSingle as ReturnType).mockResolvedValueOnce({ data: null, error: null }); vi.mocked(createAdminClient).mockReturnValue(chain as never); - const payload = JSON.stringify({ - event_type: "checkout.completed", - object: { - id: "checkout_1", - customer: { id: "cus_1", email: "user@example.com" }, - product_id: "prod_pro", - metadata: { user_id: "user-123" }, - subscription: { - id: "sub_1", - customer: { id: "cus_1", email: "user@example.com" }, - product_id: "prod_pro", - status: "active", - }, + await webhookHandlers.onCheckoutCompleted!({ + ...baseEvent, + customer: { id: "cus_1", email: "user@example.com", name: "Test User" }, + product: { id: "prod_pro", name: "Pro" }, + metadata: { user_id: "user-123" }, + subscription: { + id: "sub_1", + status: "active", + current_period_start_date: new Date("2024-01-01"), + current_period_end_date: new Date("2024-02-01"), }, - }); + } as never); - await expect(handleWebhookEvent(payload)).resolves.toBeUndefined(); expect(chain.upsert).toHaveBeenCalled(); }); - it("handles subscription.canceled", async () => { - const chain = makeChain({ id: "user-123" }); + it("onCheckoutCompleted — skips when no user_id in metadata", async () => { + const chain = makeChain(null); + (chain.maybeSingle as ReturnType).mockResolvedValueOnce({ data: null, error: null }); vi.mocked(createAdminClient).mockReturnValue(chain as never); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); - const payload = JSON.stringify({ - event_type: "subscription.canceled", - object: { - id: "sub_1", - customer: { id: "cus_1", email: "user@example.com" }, - product_id: "prod_pro", - status: "canceled", - }, + await webhookHandlers.onCheckoutCompleted!({ + ...baseEvent, + customer: { id: "cus_1", email: "user@example.com" }, + product: { id: "prod_pro", name: "Pro" }, + metadata: {}, + } as never); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("no user_id")); + warnSpy.mockRestore(); + }); + + it("onCheckoutCompleted — skips duplicate webhookId", async () => { + const chain = makeChain({ id: "wh_test_123" }); + // Return existing row = duplicate + (chain.maybeSingle as ReturnType).mockResolvedValueOnce({ + data: { id: "wh_test_123" }, + error: null, }); + vi.mocked(createAdminClient).mockReturnValue(chain as never); + + await webhookHandlers.onCheckoutCompleted!({ + ...baseEvent, + customer: { id: "cus_1", email: "user@example.com" }, + product: { id: "prod_pro", name: "Pro" }, + metadata: { user_id: "user-123" }, + } as never); - await expect(handleWebhookEvent(payload)).resolves.toBeUndefined(); - expect(chain.update).toHaveBeenCalled(); + // Should not upsert/update profiles since it returned early + expect(chain.upsert).not.toHaveBeenCalled(); + expect(chain.update).not.toHaveBeenCalled(); }); - it("throws on invalid Zod payload", async () => { - // event_type is missing — creemWebhookEventSchema will throw - const payload = JSON.stringify({ bad: "payload" }); - await expect(handleWebhookEvent(payload)).rejects.toThrow(); + it("onSubscriptionCanceled — updates status to canceled", async () => { + const chain = makeChain(null); + (chain.maybeSingle as ReturnType).mockResolvedValueOnce({ data: null, error: null }); + vi.mocked(createAdminClient).mockReturnValue(chain as never); + + await webhookHandlers.onSubscriptionCanceled!({ + ...baseEvent, + id: "sub_1", + } as never); + + expect(chain.update).toHaveBeenCalledWith({ status: "canceled" }); }); - it("logs a warning for unhandled event types", async () => { - const consoleSpy = vi - .spyOn(console, "warn") - .mockImplementation(() => undefined); + it("onSubscriptionPaused — updates status to paused", async () => { + const chain = makeChain(null); + (chain.maybeSingle as ReturnType).mockResolvedValueOnce({ data: null, error: null }); + vi.mocked(createAdminClient).mockReturnValue(chain as never); + + await webhookHandlers.onSubscriptionPaused!({ + ...baseEvent, + id: "sub_1", + } as never); - const payload = JSON.stringify({ - event_type: "unknown.event", - object: {}, - }); + expect(chain.update).toHaveBeenCalledWith({ status: "paused" }); + }); +}); + +describe("creditsForProductId", () => { + beforeEach(() => { + process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_PRO = "prod_pro"; + process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_BUSINESS = "prod_business"; + process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_CREDITS = "prod_credits"; + }); + + it("returns pro credits for pro product", () => { + expect(creditsForProductId("prod_pro")).toBeGreaterThan(0); + }); - await handleWebhookEvent(payload); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("Unhandled webhook event"), - ); + it("returns -1 for business (unlimited)", () => { + expect(creditsForProductId("prod_business")).toBe(-1); + }); + + it("returns 500 for one-time credits purchase", () => { + expect(creditsForProductId("prod_credits")).toBe(500); + }); + + it("returns 0 and warns for unknown product", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + expect(creditsForProductId("prod_unknown")).toBe(0); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); +}); + +describe("mapStatus", () => { + it("maps known statuses correctly", () => { + expect(mapStatus("active")).toBe("active"); + expect(mapStatus("trialing")).toBe("trialing"); + expect(mapStatus("canceled")).toBe("canceled"); + expect(mapStatus("cancelled")).toBe("canceled"); // Creem UK spelling + expect(mapStatus("past_due")).toBe("past_due"); + expect(mapStatus("paused")).toBe("paused"); + }); - consoleSpy.mockRestore(); + it("falls back to incomplete for unknown status", () => { + expect(mapStatus("unknown_status")).toBe("incomplete"); }); }); diff --git a/src/features/billing/webhooks/index.ts b/src/features/billing/webhooks/index.ts index b5824da..13f6005 100644 --- a/src/features/billing/webhooks/index.ts +++ b/src/features/billing/webhooks/index.ts @@ -1,224 +1,238 @@ import { createAdminClient } from "@/lib/supabase/admin"; -import { - creemWebhookEventSchema, - creemCheckoutSchema, - creemSubscriptionSchema, -} from "../schema"; -import { PLANS } from "../types"; -import type { CreemWebhookEventType, SubscriptionStatus } from "../types"; +import { PLANS } from "@/features/billing/types"; +import type { SubscriptionStatus } from "@/features/billing/types"; +import type { Webhook } from "@creem_io/nextjs"; -const ONE_TIME_CREDITS_PURCHASE_AMOUNT = 500; - -function mapCreemStatus(status: string): SubscriptionStatus { - switch (status) { - case "active": - return "active"; - case "trialing": - return "trialing"; - case "canceled": - case "cancelled": - return "canceled"; - case "past_due": - return "past_due"; - case "paused": - return "paused"; - default: - return "incomplete"; - } -} +// ---------- Types ---------- -function creditsForProductId(productId: string): number { - if (productId === process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_PRO) { - return PLANS.pro.credits; - } +type WebhookHandlers = Omit[0]>, "webhookSecret">; - if (productId === process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_BUSINESS) { - return -1; // unlimited — no top-up needed - } +// ---------- Helpers ---------- - if (productId === process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_CREDITS) { - return ONE_TIME_CREDITS_PURCHASE_AMOUNT; - } +const ONE_TIME_CREDITS_PURCHASE_AMOUNT = 500; - console.warn( - `creditsForProductId: unknown product_id "${productId}" — no credits granted`, - ); +export function creditsForProductId(productId: string): number { + if (productId === process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_PRO) return PLANS.pro.credits; + if (productId === process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_BUSINESS) return -1; // unlimited + if (productId === process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_CREDITS) return ONE_TIME_CREDITS_PURCHASE_AMOUNT; + console.warn(`creditsForProductId: unknown product_id "${productId}" — no credits granted`); return 0; } -export async function handleWebhookEvent(rawBody: string): Promise { - const parsed = creemWebhookEventSchema.parse(JSON.parse(rawBody)); - const eventType = parsed.event_type as CreemWebhookEventType; - - switch (eventType) { - case "checkout.completed": - return handleCheckoutCompleted(parsed.object); - case "subscription.active": - case "subscription.paid": - case "subscription.trialing": - case "subscription.update": - return handleSubscriptionUpsert(parsed.object, eventType); - case "subscription.canceled": - case "subscription.expired": - return handleSubscriptionEnded(parsed.object); - case "subscription.paused": - return handleSubscriptionPaused(parsed.object); - case "refund.created": - return handleRefundCreated(parsed.object); - default: - console.warn(`Unhandled webhook event: ${eventType}`); - } -} - -async function handleCheckoutCompleted(object: unknown): Promise { - const checkout = creemCheckoutSchema.parse(object); - const admin = createAdminClient(); - - const userId = checkout.metadata?.user_id; - if (!userId) { - console.warn("checkout.completed: no user_id in metadata"); - return; - } - - // Store creem_customer_id on profiles - await admin - .from("profiles") - .update({ creem_customer_id: checkout.customer.id }) - .eq("id", userId); - - if (checkout.subscription) { - const sub = checkout.subscription; - await admin.from("subscriptions").upsert( - { - user_id: userId, - creem_subscription_id: sub.id, - creem_customer_id: checkout.customer.id, - plan_id: sub.product_id, - status: mapCreemStatus(sub.status), - current_period_start: sub.current_period_start ?? null, - current_period_end: sub.current_period_end ?? null, - cancel_at_period_end: sub.cancel_at_period_end ?? false, - }, - { onConflict: "creem_subscription_id" }, - ); - } else { - // One-time credit purchase - const credits = creditsForProductId(checkout.product_id); - if (credits > 0) { - const { error } = await admin.rpc("add_credits", { - p_user_id: userId, - p_amount: credits, - p_type: "purchase", - p_description: `Purchased ${credits} credits`, - }); - - if (error) { - throw new Error(`add_credits RPC failed: ${error.message}`); - } - } - } +export function mapStatus(raw: string): SubscriptionStatus { + const map: Record = { + active: "active", + trialing: "trialing", + canceled: "canceled", + cancelled: "canceled", + past_due: "past_due", + paused: "paused", + }; + return map[raw] ?? "incomplete"; } -async function handleSubscriptionUpsert( - object: unknown, - eventType: CreemWebhookEventType, -): Promise { - const sub = creemSubscriptionSchema.parse(object); +/** Inserts a webhook_events row for idempotency. Returns true if already processed. */ +export async function isDuplicate(webhookId: string, eventType: string): Promise { const admin = createAdminClient(); - - // Look up user_id via creem_customer_id on profiles - const { data: profile } = await admin - .from("profiles") + const { data: existing } = await admin + .from("webhook_events") .select("id") - .eq("creem_customer_id", sub.customer.id) - .single(); - - if (!profile) { - console.warn( - `handleSubscriptionUpsert: no profile for customer ${sub.customer.id}`, - ); - return; - } - - await admin.from("subscriptions").upsert( - { - user_id: profile.id, - creem_subscription_id: sub.id, - creem_customer_id: sub.customer.id, - plan_id: sub.product_id, - status: mapCreemStatus(sub.status), - current_period_start: sub.current_period_start ?? null, - current_period_end: sub.current_period_end ?? null, - cancel_at_period_end: sub.cancel_at_period_end ?? false, - }, - { onConflict: "creem_subscription_id" }, - ); - - // On subscription.paid → top up credits based on plan - if (eventType === "subscription.paid") { - const credits = creditsForProductId(sub.product_id); - if (credits > 0) { - const { error } = await admin.rpc("add_credits", { - p_user_id: profile.id, - p_amount: credits, - p_type: "topup", - p_description: "Monthly credit top-up", - }); + .eq("id", webhookId) + .maybeSingle(); - if (error) { - throw new Error(`add_credits RPC failed: ${error.message}`); - } - } - } -} + if (existing) return true; -async function handleSubscriptionEnded(object: unknown): Promise { - const sub = creemSubscriptionSchema.parse(object); - const admin = createAdminClient(); - - await admin - .from("subscriptions") - .update({ status: "canceled" }) - .eq("creem_subscription_id", sub.id); -} - -async function handleSubscriptionPaused(object: unknown): Promise { - const sub = creemSubscriptionSchema.parse(object); - const admin = createAdminClient(); - - await admin - .from("subscriptions") - .update({ status: "paused" }) - .eq("creem_subscription_id", sub.id); + await admin.from("webhook_events").insert({ id: webhookId, event_type: eventType }); + return false; } -async function handleRefundCreated(object: unknown): Promise { - // Refund payload has minimal shape — just need subscription or customer ref - const refund = object as Record; - const customerId = (refund.customer as { id?: string } | undefined)?.id; - if (!customerId) return; - - const admin = createAdminClient(); - - const { data: profile } = await admin - .from("profiles") - .select("id") - .eq("creem_customer_id", customerId) - .single(); +// ---------- Handlers ---------- - if (!profile) return; +export const webhookHandlers: WebhookHandlers = { + onCheckoutCompleted: async (event) => { + if (await isDuplicate(event.webhookId, "checkout.completed")) return; - const refundAmount = typeof refund.amount === "number" ? refund.amount : 0; - - if (refundAmount > 0) { - const { error } = await admin.rpc("deduct_credits", { - p_user_id: profile.id, - p_amount: refundAmount, - p_description: "Refund processed", - }); + const userId = (event.metadata as Record | undefined)?.user_id; + if (!userId) { + console.warn("[webhook] checkout.completed: no user_id in metadata, skipping"); + return; + } - if (error) { - throw new Error(`deduct_credits RPC failed: ${error.message}`); + const admin = createAdminClient(); + + await admin + .from("profiles") + .update({ creem_customer_id: event.customer.id }) + .eq("id", userId); + + if (event.subscription) { + const sub = event.subscription; + await admin.from("subscriptions").upsert( + { + user_id: userId, + creem_subscription_id: sub.id, + creem_customer_id: event.customer.id, + plan_id: event.product.id, + status: mapStatus(sub.status ?? "active"), + current_period_start: sub.current_period_start_date + ? new Date(sub.current_period_start_date).toISOString() + : null, + current_period_end: sub.current_period_end_date + ? new Date(sub.current_period_end_date).toISOString() + : null, + cancel_at_period_end: false, + }, + { onConflict: "creem_subscription_id" }, + ); + } else { + // One-time credit purchase + const credits = creditsForProductId(event.product.id); + if (credits > 0) { + const { error } = await admin.rpc("add_credits", { + p_user_id: userId, + p_amount: credits, + p_type: "purchase", + p_description: `Purchased ${credits} credits`, + }); + if (error) throw new Error(`add_credits RPC failed: ${error.message}`); + } } - } -} + }, + + onSubscriptionActive: async (event) => { + if (await isDuplicate(event.webhookId, "subscription.active")) return; + const admin = createAdminClient(); + await admin + .from("subscriptions") + .update({ + status: "active", + current_period_end: event.current_period_end_date + ? new Date(event.current_period_end_date).toISOString() + : undefined, + }) + .eq("creem_subscription_id", event.id); + }, + + onSubscriptionPaid: async (event) => { + if (await isDuplicate(event.webhookId, "subscription.paid")) return; + const admin = createAdminClient(); + + await admin + .from("subscriptions") + .update({ + status: "active", + current_period_end: event.current_period_end_date + ? new Date(event.current_period_end_date).toISOString() + : undefined, + }) + .eq("creem_subscription_id", event.id); + + // Top up credits on each billing cycle + const { data: sub } = await admin + .from("subscriptions") + .select("user_id, plan_id") + .eq("creem_subscription_id", event.id) + .single(); + + if (sub?.plan_id) { + const credits = creditsForProductId(sub.plan_id as string); + if (credits > 0) { + const { error } = await admin.rpc("add_credits", { + p_user_id: sub.user_id, + p_amount: credits, + p_type: "topup", + p_description: "Monthly credit top-up", + }); + if (error) throw new Error(`add_credits RPC failed: ${error.message}`); + } + } + }, + + onSubscriptionTrialing: async (event) => { + if (await isDuplicate(event.webhookId, "subscription.trialing")) return; + const admin = createAdminClient(); + await admin + .from("subscriptions") + .update({ + status: "trialing", + current_period_end: event.current_period_end_date + ? new Date(event.current_period_end_date).toISOString() + : undefined, + }) + .eq("creem_subscription_id", event.id); + }, + + onSubscriptionCanceled: async (event) => { + if (await isDuplicate(event.webhookId, "subscription.canceled")) return; + const admin = createAdminClient(); + await admin + .from("subscriptions") + .update({ status: "canceled" }) + .eq("creem_subscription_id", event.id); + }, + + onSubscriptionExpired: async (event) => { + if (await isDuplicate(event.webhookId, "subscription.expired")) return; + const admin = createAdminClient(); + await admin + .from("subscriptions") + .update({ status: "canceled" }) + .eq("creem_subscription_id", event.id); + }, + + onSubscriptionPaused: async (event) => { + if (await isDuplicate(event.webhookId, "subscription.paused")) return; + const admin = createAdminClient(); + await admin + .from("subscriptions") + .update({ status: "paused" }) + .eq("creem_subscription_id", event.id); + }, + + onSubscriptionUpdate: async (event) => { + if (await isDuplicate(event.webhookId, "subscription.update")) return; + const admin = createAdminClient(); + const productId = + typeof event.product === "string" ? event.product : (event.product as { id: string }).id; + await admin + .from("subscriptions") + .update({ + plan_id: productId, + status: mapStatus(event.status ?? "active"), + current_period_end: event.current_period_end_date + ? new Date(event.current_period_end_date).toISOString() + : undefined, + }) + .eq("creem_subscription_id", event.id); + }, + + onRefundCreated: async (event) => { + if (await isDuplicate(event.webhookId, "refund.created")) return; + const admin = createAdminClient(); + + const subscriptionId = + typeof event.subscription === "string" + ? event.subscription + : (event.subscription as { id?: string } | undefined)?.id; + + if (!subscriptionId) return; + + const { data: sub } = await admin + .from("subscriptions") + .select("user_id, plan_id") + .eq("creem_subscription_id", subscriptionId) + .maybeSingle(); + + if (!sub) return; + + const creditsToDeduct = sub.plan_id ? creditsForProductId(sub.plan_id as string) : 0; + if (creditsToDeduct > 0) { + const { error } = await admin.rpc("deduct_credits", { + p_user_id: sub.user_id, + p_amount: creditsToDeduct, + p_description: "Refund processed", + }); + if (error) throw new Error(`deduct_credits RPC failed: ${error.message}`); + } + }, +}; diff --git a/src/features/credits/actions/index.ts b/src/features/credits/actions/index.ts index 3548be2..e07cb35 100644 --- a/src/features/credits/actions/index.ts +++ b/src/features/credits/actions/index.ts @@ -27,6 +27,11 @@ export async function getCreditsBalance(): Promise { } export async function purchaseCredits(): Promise { + const productId = process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_CREDITS; + if (!productId) { + redirect("/dashboard/credits?error=product_not_configured"); + } + const supabase = await createClient(); const { data: { user }, @@ -34,14 +39,21 @@ export async function purchaseCredits(): Promise { if (!user) redirect("/login"); - const { checkout_url } = await createCheckout({ - productId: process.env.NEXT_PUBLIC_CREEM_PRODUCT_ID_CREDITS!, - successUrl: `${APP_URL}/dashboard/credits?purchased=1`, - customerEmail: user.email!, - metadata: { user_id: user.id }, - }); + let checkoutUrl: string; + try { + const result = await createCheckout({ + productId, + successUrl: `${APP_URL}/dashboard/credits?purchased=1`, + customerEmail: user.email!, + metadata: { user_id: user.id }, + }); + checkoutUrl = result.checkout_url; + } catch (e) { + const message = e instanceof Error ? e.message : "Checkout unavailable"; + redirect(`/dashboard/credits?error=${encodeURIComponent(message)}`); + } - redirect(checkout_url); + redirect(checkoutUrl); } export async function spendCredits( diff --git a/src/lib/creem/client.ts b/src/lib/creem/client.ts index 7ad0929..e7b7489 100644 --- a/src/lib/creem/client.ts +++ b/src/lib/creem/client.ts @@ -1,5 +1,3 @@ -import * as crypto from "node:crypto"; - const CREEM_BASE_URL = process.env.NODE_ENV === "production" ? "https://api.creem.io" @@ -64,7 +62,7 @@ export async function createCheckout(params: { body: { product_id: params.productId, success_url: params.successUrl, - customer_email: params.customerEmail, + customer: { email: params.customerEmail }, metadata: params.metadata, }, }); @@ -128,22 +126,3 @@ export async function getCustomerPortalLink( }); } -// ---------- Webhook Verification ---------- -export function verifyWebhookSignature( - rawBody: string, - signature: string, -): boolean { - const expected = crypto - .createHmac("sha256", process.env.CREEM_WEBHOOK_SECRET!) - .update(rawBody) - .digest("hex"); - - const expectedBuf = Buffer.from(expected, "hex"); - const signatureBuf = Buffer.from(signature, "hex"); - - if (expectedBuf.length !== signatureBuf.length) { - return false; - } - - return crypto.timingSafeEqual(expectedBuf, signatureBuf); -} diff --git a/supabase/migrations/004_webhook_events.sql b/supabase/migrations/004_webhook_events.sql new file mode 100644 index 0000000..12dda98 --- /dev/null +++ b/supabase/migrations/004_webhook_events.sql @@ -0,0 +1,20 @@ +-- ============================================================ +-- webhook_events table +-- Idempotency log for Creem webhook events (admin visibility) +-- ============================================================ + +create table if not exists public.webhook_events ( + id text primary key, -- Creem webhookId — natural dedup key + event_type text not null, + received_at timestamptz not null default now() +); + +-- ============================================================ +-- Row Level Security — admin (service_role) only +-- ============================================================ + +alter table public.webhook_events enable row level security; + +-- No SELECT policy for end-users; service_role bypasses RLS entirely. +-- Add an authenticated read policy only if you build an admin UI. +alter table public.webhook_events force row level security; From 95f1dac02d76fb05e0b18092b063382c2ffe70c9 Mon Sep 17 00:00:00 2001 From: aaron Date: Mon, 30 Mar 2026 23:53:16 +0800 Subject: [PATCH 20/49] fix(dashboard): use Logo component in sidebar, clean up nav - Replace manual IconInnerShadowTop + text markup in AppSidebar header with the shared component; scale via [&>svg]:w-36 h-auto - Remove stray sr-only spans from inside render={} props in AppSidebar and NavSecondary; these conflicted with base-ui useRender children merging and caused logo/text to disappear - Delete nav-documents.tsx (unused shadcn scaffold component) - Add smooth scroll behaviour to root html element - Add allowed ngrok dev origin to next.config.ts --- next.config.ts | 5 + src/app/layout.tsx | 1 + .../dashboard/components/app-sidebar.tsx | 78 ++++++++------- .../dashboard/components/nav-documents.tsx | 97 ------------------- .../dashboard/components/nav-features.tsx | 75 ++++++++++++++ .../dashboard/components/nav-secondary.tsx | 6 +- 6 files changed, 126 insertions(+), 136 deletions(-) delete mode 100644 src/features/dashboard/components/nav-documents.tsx create mode 100644 src/features/dashboard/components/nav-features.tsx diff --git a/next.config.ts b/next.config.ts index e607744..e52ca63 100644 --- a/next.config.ts +++ b/next.config.ts @@ -10,6 +10,11 @@ const nextConfig: NextConfig = { // { protocol: 'https', hostname: 'avatars.githubusercontent.com' }, ], }, + allowedDevOrigins: [ + // Add ngrok or other development origins here, e.g.: + // 'random-id.ngrok-free.dev', + 'reasoningly-ecesic-saylor.ngrok-free.dev', + ], } const withMDX = createMDX({ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ecff0dd..e8a4e6f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -59,6 +59,7 @@ export default function RootLayout({ lang="en" suppressHydrationWarning className={cn('antialiased', inter.variable, 'font-sans')} + data-scroll-behavior="smooth" > {process.env.NODE_ENV === 'development' && ( diff --git a/src/features/dashboard/components/app-sidebar.tsx b/src/features/dashboard/components/app-sidebar.tsx index 41551e0..49b3467 100644 --- a/src/features/dashboard/components/app-sidebar.tsx +++ b/src/features/dashboard/components/app-sidebar.tsx @@ -1,11 +1,12 @@ -"use client"; +'use client' -import * as React from "react"; -import Link from "next/link"; +import * as React from 'react' +import Link from 'next/link' -import { NavMain } from "@/features/dashboard/components/nav-main"; -import { NavSecondary } from "@/features/dashboard/components/nav-secondary"; -import { NavUser } from "@/features/dashboard/components/nav-user"; +import { NavMain } from '@/features/dashboard/components/nav-main' +import { NavFeatures } from './nav-features' +import { NavSecondary } from '@/features/dashboard/components/nav-secondary' +import { NavUser } from '@/features/dashboard/components/nav-user' import { Sidebar, SidebarContent, @@ -14,54 +15,67 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, -} from "@/components/ui/sidebar"; +} from '@/components/ui/sidebar' import { IconDashboard, IconSettings, IconHelp, IconCreditCard, IconCoins, - IconInnerShadowTop, -} from "@tabler/icons-react"; +} from '@tabler/icons-react' +import { Logo } from '@/components/logo' const navMain = [ { - title: "Dashboard", - url: "/dashboard", + title: 'Dashboard', + url: '/dashboard', icon: , }, { - title: "Billing", - url: "/dashboard/billing", + title: 'Billing', + url: '/dashboard/billing', icon: , }, { - title: "Credits", - url: "/dashboard/credits", + title: 'Credits', + url: '/dashboard/credits', icon: , }, { - title: "Settings", - url: "/dashboard/settings", + title: 'Settings', + url: '/dashboard/settings', icon: , }, -]; +] const navSecondary = [ { - title: "Get Help", - url: "#", + title: 'Get Help', + url: '#', icon: , }, -]; +] + +const navFeatures = [ + { + name: 'Feature 1', + url: '#', + icon: , + }, + { + name: 'Feature 2', + url: '#', + icon: , + }, +] type AppSidebarProps = React.ComponentProps & { user: { - name: string; - email: string; - avatar: string; - }; -}; + name: string + email: string + avatar: string + } +} export function AppSidebar({ user, ...props }: AppSidebarProps) { return ( @@ -71,25 +85,21 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) { - Dashboard - - } + render={} > - - CreemKit + + - ); + ) } diff --git a/src/features/dashboard/components/nav-documents.tsx b/src/features/dashboard/components/nav-documents.tsx deleted file mode 100644 index b34ad62..0000000 --- a/src/features/dashboard/components/nav-documents.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; - -import Link from "next/link"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - SidebarGroup, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuAction, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/components/ui/sidebar"; -import { - IconDots, - IconFolder, - IconShare3, - IconTrash, -} from "@tabler/icons-react"; - -export function NavDocuments({ - items, -}: { - items: { - name: string; - url: string; - icon: React.ReactNode; - }[]; -}) { - const { isMobile } = useSidebar(); - return ( - - Documents - - {items.map((item) => ( - - - {item.name} - - } - > - {item.icon} - {item.name} - - - - } - > - - More - - - - - Open - - - - Share - - - - - Delete - - - - - ))} - - - - More - - - - - ); -} diff --git a/src/features/dashboard/components/nav-features.tsx b/src/features/dashboard/components/nav-features.tsx new file mode 100644 index 0000000..69fef6e --- /dev/null +++ b/src/features/dashboard/components/nav-features.tsx @@ -0,0 +1,75 @@ +'use client' + +import Link from 'next/link' + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from '@/components/ui/sidebar' +import { IconDots, IconEyeOff, IconPin } from '@tabler/icons-react' + +export function NavFeatures({ + items, + label = 'Features', +}: { + items: { + name: string + url: string + icon: React.ReactNode + }[] + label?: string +}) { + const { isMobile } = useSidebar() + + return ( + + {label} + + + {items.map((item) => ( + + }> + {item.icon} + {item.name} + + + } + > + + Feature actions + + + + + Pin + + + + Hide + + + + + ))} + + + + ) +} diff --git a/src/features/dashboard/components/nav-secondary.tsx b/src/features/dashboard/components/nav-secondary.tsx index cafc6a4..ed8c85b 100644 --- a/src/features/dashboard/components/nav-secondary.tsx +++ b/src/features/dashboard/components/nav-secondary.tsx @@ -28,11 +28,7 @@ export function NavSecondary({ {items.map((item) => ( - {item.title} - - } + render={} > {item.icon} {item.title} From 26368a175f2ecfc003490b8b574d7004ff4e5411 Mon Sep 17 00:00:00 2001 From: aaron Date: Mon, 30 Mar 2026 23:54:55 +0800 Subject: [PATCH 21/49] feat(dependencies): add @creem_io/nextjs package --- package-lock.json | 26 ++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 27 insertions(+) diff --git a/package-lock.json b/package-lock.json index 0893e63..4fe3d38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@base-ui/react": "^1.3.0", + "@creem_io/nextjs": "^0.6.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -618,6 +619,19 @@ "specificity": "bin/cli.js" } }, + "node_modules/@creem_io/nextjs": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@creem_io/nextjs/-/nextjs-0.6.0.tgz", + "integrity": "sha512-ORUn7FBvf9kq2YyS18kexRf7x/kUw3W3vXD7nnWKWmGdT3/RbvMkPHqPoLOjZp30zEnig72y1C0ALe7tmZH6Kw==", + "license": "MIT", + "dependencies": { + "creem": "^1.3.6" + }, + "peerDependencies": { + "next": ">=13.0.0", + "react": ">=18.0.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -5255,6 +5269,18 @@ } } }, + "node_modules/creem": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/creem/-/creem-1.4.4.tgz", + "integrity": "sha512-hfnHOIqNERf783FMJOOpbmD701y4t6HuVKR8kIm16X3l5OK6yMM0ifd2zY8CbRqSLeqK/iQV1D8FLgZEVs7Alg==", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "bin": { + "mcp": "bin/mcp-server.js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/package.json b/package.json index 3e33bc6..1412c12 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@base-ui/react": "^1.3.0", + "@creem_io/nextjs": "^0.6.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", From c15a378ee861b9ee11b14d97db24fc843b3dfd5f Mon Sep 17 00:00:00 2001 From: aaron Date: Tue, 31 Mar 2026 02:38:14 +0800 Subject: [PATCH 22/49] fix(billing): improve idempotency handling in webhook event processing --- .../billing/webhooks/__tests__/index.test.ts | 37 ++++++++++++++++--- src/features/billing/webhooks/index.ts | 14 +++---- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/features/billing/webhooks/__tests__/index.test.ts b/src/features/billing/webhooks/__tests__/index.test.ts index 2d7b2f5..0879be5 100644 --- a/src/features/billing/webhooks/__tests__/index.test.ts +++ b/src/features/billing/webhooks/__tests__/index.test.ts @@ -84,11 +84,14 @@ describe("webhookHandlers", () => { }); it("onCheckoutCompleted — skips duplicate webhookId", async () => { - const chain = makeChain({ id: "wh_test_123" }); - // Return existing row = duplicate - (chain.maybeSingle as ReturnType).mockResolvedValueOnce({ - data: { id: "wh_test_123" }, - error: null, + const chain = makeChain(null); + // Atomic insert reports duplicate via unique violation. + (chain.insert as ReturnType).mockResolvedValueOnce({ + data: null, + error: { + code: "23505", + message: "duplicate key value violates unique constraint", + }, }); vi.mocked(createAdminClient).mockReturnValue(chain as never); @@ -104,6 +107,30 @@ describe("webhookHandlers", () => { expect(chain.update).not.toHaveBeenCalled(); }); + it("onCheckoutCompleted — throws when idempotency insert fails unexpectedly", async () => { + const chain = makeChain(null); + (chain.insert as ReturnType).mockResolvedValueOnce({ + data: null, + error: { + code: "08006", + message: "connection failure", + }, + }); + vi.mocked(createAdminClient).mockReturnValue(chain as never); + + await expect( + webhookHandlers.onCheckoutCompleted!({ + ...baseEvent, + customer: { id: "cus_1", email: "user@example.com" }, + product: { id: "prod_pro", name: "Pro" }, + metadata: { user_id: "user-123" }, + } as never), + ).rejects.toThrow("webhook_events idempotency insert failed"); + + expect(chain.upsert).not.toHaveBeenCalled(); + expect(chain.update).not.toHaveBeenCalled(); + }); + it("onSubscriptionCanceled — updates status to canceled", async () => { const chain = makeChain(null); (chain.maybeSingle as ReturnType).mockResolvedValueOnce({ data: null, error: null }); diff --git a/src/features/billing/webhooks/index.ts b/src/features/billing/webhooks/index.ts index 13f6005..bbb4379 100644 --- a/src/features/billing/webhooks/index.ts +++ b/src/features/billing/webhooks/index.ts @@ -34,16 +34,16 @@ export function mapStatus(raw: string): SubscriptionStatus { /** Inserts a webhook_events row for idempotency. Returns true if already processed. */ export async function isDuplicate(webhookId: string, eventType: string): Promise { const admin = createAdminClient(); - const { data: existing } = await admin + const { error } = await admin .from("webhook_events") - .select("id") - .eq("id", webhookId) - .maybeSingle(); + .insert({ id: webhookId, event_type: eventType }); - if (existing) return true; + if (!error) return false; - await admin.from("webhook_events").insert({ id: webhookId, event_type: eventType }); - return false; + // Duplicate webhook id means this event was already processed. + if (error.code === "23505") return true; + + throw new Error(`webhook_events idempotency insert failed: ${error.message}`); } // ---------- Handlers ---------- From 8ed9e980366f00a69651e451cf17972a239ddca2 Mon Sep 17 00:00:00 2001 From: aaron Date: Tue, 31 Mar 2026 02:44:21 +0800 Subject: [PATCH 23/49] fix(review): address remaining PR review comments - Remove misleading "no .env.local needed" claim from README quick start; NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY are required even for local dev - Check and throw on Supabase error from profiles update in onCheckoutCompleted so Creem retries on DB failure instead of silently succeeding - Check and throw on Supabase error from subscriptions upsert in onCheckoutCompleted for the same reason - Migrate auth actions from deprecated error.flatten() to z.flattenError() (Zod v4) --- README.md | 95 +++++++++++--------------- src/features/auth/actions/index.ts | 21 +++--- src/features/billing/webhooks/index.ts | 6 +- 3 files changed, 56 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 5b8099b..b73bd73 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,14 @@ Clone, configure, and start selling — no boilerplate to write. [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/ubergonmx/nextjs-supabase-creem-starter) -### Run locally (demo, no `.env.local` needed) +### Run locally ```bash git clone https://github.com/ubergonmx/nextjs-supabase-creem-starter.git cd nextjs-supabase-creem-starter npm install +cp .env.example .env.local +# Fill in NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY at minimum npm run dev ``` @@ -94,9 +96,9 @@ Open [http://localhost:3000](http://localhost:3000). ## Setup -### 1. Clone and run locally +### 1. Set up the project ```bash -git clone +git clone https://github.com/ubergonmx/creemkit.git ``` ```bash @@ -117,8 +119,9 @@ cp .env.example .env.local - `001_profiles.sql` - `002_subscriptions.sql` - `003_credits.sql` + - `004_webhook_events.sql` - If you prefer using the Supabase CLI, run `supabase db push` -4. Enable auth providers: +4. Setting up auth providers and redirect URL: - Go to **Authentication** (left sidebar, lock icon) > under **CONFIGURATION** , click **Sign In / Providers** - Under **Auth Providers**, enable sign in of the following: - **Google**: @@ -129,24 +132,20 @@ cp .env.example .env.local - Create an OAuth app in [GitHub Developer Settings](https://github.com/settings/developers) - Copy-paste redirect URL from the Supabase sidebar (should look like `https://urcryetpnmgoatkitnumxb.supabase.co/auth/v1/callback`) into the GitHub app's **Authorization callback URL** field, then save. - After saving, copy the client ID and secret into the Supabase sidebar, then save. -5. Set redirect URL: - - Still in the Authentication secondary sidebar, click **URL Configuration** (under CONFIGURATION) + - On the same page, under **CONFIGURATION**, go to **URL Configuration** - Under **Redirect URLs**, click **Add URL** - Add: `http://localhost:3000/auth/callback` - - (You'll come back and add your production URL after deploying -- see step 5 below) - - - + - (For production URL, see step 5 below) ### 3. Set up Creem 1. Create an account at [creem.io](https://www.creem.io) or, if you don't have an existing store, [create a new one](https://www.creem.io/dashboard/create) 2. Enable **Test Mode** in the bottom-left of the sidebar 3. Go to **Developers > API & Webhooks** in the left sidebar -4. On the **API Keys** tab, click **+ Create API Key**, name it anything (e.g. `next-supabase-creem-starter`), toggle **Full Access** on, click **Create Key**, and copy the key +4. On the **API Keys** tab, click **+ Create API Key**, name it anything (e.g. `creemkit`), toggle **Full Access** on, click **Create Key**, and copy the key 5. Create three subscription products — go to **Commerce > Products** in the left sidebar, click **Create Product**. For each product: - **Section 1 (Product Details)**: Enter the product name and description - - **Section 2 (Payment Details)**: Click the **Subscription** tab, set Currency to **USD**, enter the price, set Subscription interval to **Monthly**, Tax category to **Digital goods or services** + - **Section 2 (Payment Details)**: Click the **Subscription** tab, set Currency to **USD**, enter the price, set Subscription interval to **Monthly**, Tax category to **Software as a Service** - **Sections 3–6**: Skip (image, features, advanced options, and abandoned cart are all optional) - Click **Create Product** @@ -154,47 +153,42 @@ cp .env.example .env.local | **Product name** | **Price** | | ---------------- | --------- | - | Starter | | - | Pro | | - | Enterprise | | + | Pro | 19 | + | Business | 29 | 6. After creating each product, copy its `prod_` ID (shown on the product detail page) into your `.env.local` -### 3. Configure environment variables - -```bash -cp .env.example .env.local -``` - -Fill in `.env.local` with your credentials: -| Variable | Where to find it | -| -------------------------------------- | --------------------------------- | -| `NEXT_PUBLIC_SUPABASE_URL` | Supabase → Project Settings → API | -| `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` | Supabase → Project Settings → API | -| `SUPABASE_SERVICE_ROLE_KEY` | Supabase → Project Settings → API | -| `CREEM_API_KEY` | Creem → Developer → API Keys | -| `CREEM_WEBHOOK_SECRET` | Creem → Developer → Webhooks | -| `NEXT_PUBLIC_CREEM_PRODUCT_ID_PRO` | Creem → Products | -| `NEXT_PUBLIC_CREEM_PRODUCT_ID_CREDITS` | Creem → Products | - -### 4. Set up the database - -Run the migrations against your Supabase project: +### 4. Run locally ```bash -# Using the Supabase CLI -supabase db push - -# Or run each file manually in the Supabase SQL editor: -# supabase/migrations/001_profiles.sql -# supabase/migrations/002_subscriptions.sql -# supabase/migrations/003_credits.sql +npm run dev ``` - -### 5. Configure OAuth (optional) - -Enable Google and GitHub providers in your Supabase dashboard under **Authentication → Providers**. +Open [http://localhost:3000](http://localhost:3000). +Everything should work except for checkouts, which require webhooks to be set up. You can skip to #5 if you want to deploy to production. + +#### Optional: Ngrok for local development with webhooks +1. Run `npx ngrok http 3000` to create a secure tunnel to your localhost +2. Copy the generated forwarding URL (should look like `https://abc123.ngrok-free.dev`) and do the following: + - In `.env.local`, paste the URL to `NEXT_PUBLIC_APP_URL` + - In `nextjs.config.ts`, add the URL to the `allowedOrigins` array + - In Supabase, go to **Authentication > URL Configuration**, set the URL in **Site URL** and add it to **Redirect URLs** + - In Creem, go to **Developers > API & Webhooks**, click the **Webhooks** tab, click **+ Create Webhook**, enter `/api/webhooks/creem` (e.g. `https://abc123.ngrok-free.dev/api/webhooks/creem`), select "All events", and create. Copy the generated secret and add it to `CREEM_WEBHOOK_SECRET` in your `.env.local`. +3. Instead of `http://localhost:3000`, use the ngrok URL (e.g. `https://abc123.ngrok-free.dev`) to test your app with webhooks locally. + +### 5. Deploy to Vercel + +1. Make sure your project is pushed to a GitHub repository +2. Go to [vercel.com](https://vercel.com) and log in or register +3. Click **Add New...** > **Project** +4. Under **Import Git Repository**, select your repo and click **Import** +5. On the **New Project** setup page: + - **Framework Preset** should automatically detect **Next.js** — no need to change it + - **Root Directory** can stay as `./` + - Expand the **Environment Variables** section + - Enter each key-value pair from your `.env.local` file (key on the left, value on the right). You can skip `CREEM_WEBHOOK_SECRET` for now — this will be configured after the initial deployment +6. Click **Deploy** and wait for the build to complete +7. Once done, copy your production URL from the Vercel dashboard (e.g. `https://your-app.vercel.app`) ### 6. Verify everything @@ -217,14 +211,7 @@ If all three pass, you're ready to deploy. 🚀 Skills that help you (and AI) build faster when adding features: -#### Creem - -- Check the [original docs](https://docs.creem.io/code/sdks/ai-agents) or install the skill: - ```bash - npx skills add https://github.com/armitage-labs/creem-skills --skill creem - ``` - -#### Supabase Query Tuning +#### Supabase Best Practices (Query, RLS policies, etc.) ```bash npx skills add https://github.com/sickn33/antigravity-awesome-skills --skill supabase-postgres-best-practices diff --git a/src/features/auth/actions/index.ts b/src/features/auth/actions/index.ts index a8b7ae6..e146382 100644 --- a/src/features/auth/actions/index.ts +++ b/src/features/auth/actions/index.ts @@ -1,5 +1,6 @@ "use server"; +import * as z from "zod"; import { createClient } from "@/lib/supabase/server"; import { redirect } from "next/navigation"; import { headers } from "next/headers"; @@ -41,13 +42,13 @@ export async function login( formData: FormData, ): Promise { const raw = { - email: formData.get("email"), - password: formData.get("password"), + email: String(formData.get("email") ?? ""), + password: String(formData.get("password") ?? ""), }; const result = loginSchema.safeParse(raw); if (!result.success) { - return { fieldErrors: result.error.flatten().fieldErrors }; + return { fieldErrors: z.flattenError(result.error).fieldErrors, inputs: raw }; } const supabase = await createClient(); @@ -57,7 +58,7 @@ export async function login( }); if (error) { - return { error: "Invalid email or password" }; + return { error: "Invalid email or password", inputs: raw }; } redirect("/dashboard"); @@ -68,15 +69,15 @@ export async function signup( formData: FormData, ): Promise { const raw = { - fullName: formData.get("fullName"), - email: formData.get("email"), - password: formData.get("password"), - confirmPassword: formData.get("confirmPassword"), + fullName: String(formData.get("fullName") ?? ""), + email: String(formData.get("email") ?? ""), + password: String(formData.get("password") ?? ""), + confirmPassword: String(formData.get("confirmPassword") ?? ""), }; const result = signupSchema.safeParse(raw); if (!result.success) { - return { fieldErrors: result.error.flatten().fieldErrors }; + return { fieldErrors: z.flattenError(result.error).fieldErrors, inputs: raw }; } const headersList = await headers(); @@ -95,7 +96,7 @@ export async function signup( }); if (error) { - return { error: error.message }; + return { error: error.message, inputs: raw }; } if (!data.session) { diff --git a/src/features/billing/webhooks/index.ts b/src/features/billing/webhooks/index.ts index bbb4379..12f2723 100644 --- a/src/features/billing/webhooks/index.ts +++ b/src/features/billing/webhooks/index.ts @@ -60,14 +60,15 @@ export const webhookHandlers: WebhookHandlers = { const admin = createAdminClient(); - await admin + const { error: profileError } = await admin .from("profiles") .update({ creem_customer_id: event.customer.id }) .eq("id", userId); + if (profileError) throw new Error(`profiles update failed: ${profileError.message}`); if (event.subscription) { const sub = event.subscription; - await admin.from("subscriptions").upsert( + const { error: upsertError } = await admin.from("subscriptions").upsert( { user_id: userId, creem_subscription_id: sub.id, @@ -84,6 +85,7 @@ export const webhookHandlers: WebhookHandlers = { }, { onConflict: "creem_subscription_id" }, ); + if (upsertError) throw new Error(`subscriptions upsert failed: ${upsertError.message}`); } else { // One-time credit purchase const credits = creditsForProductId(event.product.id); From 1d4fdb8844c947585ea3dc8c4aed082364d11374 Mon Sep 17 00:00:00 2001 From: aaron Date: Tue, 31 Mar 2026 02:49:29 +0800 Subject: [PATCH 24/49] feat(auth): update login and signup actions to handle inputs and errors more effectively --- src/features/auth/actions/index.ts | 15 +++++++++++---- src/features/auth/components/signup-form.tsx | 2 ++ src/features/auth/types.ts | 6 ++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/features/auth/actions/index.ts b/src/features/auth/actions/index.ts index e146382..324c20c 100644 --- a/src/features/auth/actions/index.ts +++ b/src/features/auth/actions/index.ts @@ -48,7 +48,7 @@ export async function login( const result = loginSchema.safeParse(raw); if (!result.success) { - return { fieldErrors: z.flattenError(result.error).fieldErrors, inputs: raw }; + return { fieldErrors: z.flattenError(result.error).fieldErrors }; } const supabase = await createClient(); @@ -58,7 +58,7 @@ export async function login( }); if (error) { - return { error: "Invalid email or password", inputs: raw }; + return { error: "Invalid email or password" }; } redirect("/dashboard"); @@ -74,10 +74,17 @@ export async function signup( password: String(formData.get("password") ?? ""), confirmPassword: String(formData.get("confirmPassword") ?? ""), }; + const signupInputs = { + fullName: raw.fullName, + email: raw.email, + }; const result = signupSchema.safeParse(raw); if (!result.success) { - return { fieldErrors: z.flattenError(result.error).fieldErrors, inputs: raw }; + return { + fieldErrors: z.flattenError(result.error).fieldErrors, + inputs: signupInputs, + }; } const headersList = await headers(); @@ -96,7 +103,7 @@ export async function signup( }); if (error) { - return { error: error.message, inputs: raw }; + return { error: error.message, inputs: signupInputs }; } if (!data.session) { diff --git a/src/features/auth/components/signup-form.tsx b/src/features/auth/components/signup-form.tsx index 37e209a..653ac72 100644 --- a/src/features/auth/components/signup-form.tsx +++ b/src/features/auth/components/signup-form.tsx @@ -45,6 +45,7 @@ export function SignupForm({ name="fullName" type="text" placeholder="John Doe" + defaultValue={state?.inputs?.fullName} required /> {state?.fieldErrors?.fullName && ( @@ -60,6 +61,7 @@ export function SignupForm({ name="email" type="email" placeholder="m@example.com" + defaultValue={state?.inputs?.email} required /> {state?.fieldErrors?.email && ( diff --git a/src/features/auth/types.ts b/src/features/auth/types.ts index 926c220..dc7297a 100644 --- a/src/features/auth/types.ts +++ b/src/features/auth/types.ts @@ -1,8 +1,14 @@ +export type AuthActionInputs = Partial<{ + fullName: string; + email: string; +}>; + export type AuthActionState = | { error?: string; message?: string; fieldErrors?: Record; + inputs?: AuthActionInputs; } | undefined; From 55751afd3bf3e2c1913f45e87f94c09b8a3f39c1 Mon Sep 17 00:00:00 2001 From: aaron Date: Tue, 31 Mar 2026 03:07:33 +0800 Subject: [PATCH 25/49] fix(webhooks): strip whsec_ prefix from CREEM_WEBHOOK_SECRET Creem displays webhook secrets with a whsec_ prefix in the dashboard (visual indicator, like sk_test_ for API keys) but the actual HMAC key is the value after the prefix. The @creem_io/nextjs library uses the secret as-is, causing every signature verification to fail. --- src/app/api/webhooks/creem/route.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/api/webhooks/creem/route.ts b/src/app/api/webhooks/creem/route.ts index 23d8cdb..20bfef6 100644 --- a/src/app/api/webhooks/creem/route.ts +++ b/src/app/api/webhooks/creem/route.ts @@ -1,7 +1,10 @@ import { Webhook } from "@creem_io/nextjs"; import { webhookHandlers } from "@/features/billing/webhooks"; +const rawSecret = process.env.CREEM_WEBHOOK_SECRET ?? ""; +const webhookSecret = rawSecret.startsWith("whsec_") ? rawSecret.slice(6) : rawSecret; + export const POST = Webhook({ - webhookSecret: process.env.CREEM_WEBHOOK_SECRET!, + webhookSecret, ...webhookHandlers, }); From 84c3f0be44aecb780ca8be07f9b16efcb50adfe1 Mon Sep 17 00:00:00 2001 From: aaron Date: Tue, 31 Mar 2026 03:21:53 +0800 Subject: [PATCH 26/49] fix(webhooks): use full CREEM_WEBHOOK_SECRET as HMAC key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debug confirmed Creem signs with the entire whsec_ string as the key — do not strip the prefix. Also move env var read inside the handler to avoid module-level evaluation before env is loaded. --- src/app/api/webhooks/creem/route.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/app/api/webhooks/creem/route.ts b/src/app/api/webhooks/creem/route.ts index 20bfef6..dbd28ea 100644 --- a/src/app/api/webhooks/creem/route.ts +++ b/src/app/api/webhooks/creem/route.ts @@ -1,10 +1,14 @@ import { Webhook } from "@creem_io/nextjs"; import { webhookHandlers } from "@/features/billing/webhooks"; -const rawSecret = process.env.CREEM_WEBHOOK_SECRET ?? ""; -const webhookSecret = rawSecret.startsWith("whsec_") ? rawSecret.slice(6) : rawSecret; +export async function POST(request: Request) { + const webhookSecret = process.env.CREEM_WEBHOOK_SECRET ?? ""; -export const POST = Webhook({ - webhookSecret, - ...webhookHandlers, -}); + const cloned = new Request(request.url, { + method: request.method, + headers: request.headers, + body: await request.text(), + }); + + return Webhook({ webhookSecret, ...webhookHandlers })(cloned); +} From 70a139860f4f20f5c626afbf8fb42da4e97a63f6 Mon Sep 17 00:00:00 2001 From: aaron Date: Tue, 31 Mar 2026 05:26:13 +0800 Subject: [PATCH 27/49] refactor(cleanup): remove debug log from signup action and update icons for feature navigation --- src/features/auth/actions/index.ts | 2 -- src/features/dashboard/components/app-sidebar.tsx | 6 ++++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/features/auth/actions/index.ts b/src/features/auth/actions/index.ts index 324c20c..19a2d36 100644 --- a/src/features/auth/actions/index.ts +++ b/src/features/auth/actions/index.ts @@ -90,8 +90,6 @@ export async function signup( const headersList = await headers(); const baseUrl = getAuthRedirectBaseUrl(headersList); - console.log("Signup action - baseUrl:", baseUrl); // Debug log for base URL - const supabase = await createClient(); const { data, error } = await supabase.auth.signUp({ email: result.data.email, diff --git a/src/features/dashboard/components/app-sidebar.tsx b/src/features/dashboard/components/app-sidebar.tsx index 49b3467..f061f94 100644 --- a/src/features/dashboard/components/app-sidebar.tsx +++ b/src/features/dashboard/components/app-sidebar.tsx @@ -22,6 +22,8 @@ import { IconHelp, IconCreditCard, IconCoins, + IconCalendarWeek, + IconMessageCircleUser, } from '@tabler/icons-react' import { Logo } from '@/components/logo' @@ -60,12 +62,12 @@ const navFeatures = [ { name: 'Feature 1', url: '#', - icon: , + icon: , }, { name: 'Feature 2', url: '#', - icon: , + icon: , }, ] From cfe9de6341373c29aec202ed2bf340e186ef6253 Mon Sep 17 00:00:00 2001 From: aaron Date: Tue, 31 Mar 2026 06:55:42 +0800 Subject: [PATCH 28/49] feat(security): add security headers to Next.js configuration - Implement security headers to enhance application security, including X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and Permissions-Policy. - Update next.config.ts to include an async headers function that returns the defined security headers for all routes. --- next.config.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/next.config.ts b/next.config.ts index e52ca63..4bfb63a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,17 @@ import createMDX from '@next/mdx' import type { NextConfig } from 'next' +const securityHeaders = [ + // Prevents MIME-type sniffing (CVE-2006-3396) + { key: 'X-Content-Type-Options', value: 'nosniff' }, + // Blocks the page from being embedded in iframes (clickjacking) + { key: 'X-Frame-Options', value: 'DENY' }, + // Controls how much referrer info is sent with requests + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + // Restricts access to browser features not used by the app + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, +] + const nextConfig: NextConfig = { reactStrictMode: true, pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'], @@ -15,6 +26,14 @@ const nextConfig: NextConfig = { // 'random-id.ngrok-free.dev', 'reasoningly-ecesic-saylor.ngrok-free.dev', ], + async headers() { + return [ + { + source: '/(.*)', + headers: securityHeaders, + }, + ] + }, } const withMDX = createMDX({ From d9df6e72466886012627a58c7d53c86d8b26f105 Mon Sep 17 00:00:00 2001 From: aaron Date: Tue, 31 Mar 2026 07:20:46 +0800 Subject: [PATCH 29/49] feat(security): implement explicit permission hardening for public schema functions - Revoke EXECUTE permissions on trigger and utility functions from anon and authenticated roles to enhance security. - Grant EXECUTE permissions on credit RPCs exclusively to the service_role, ensuring continued access for admin clients through future permission resets. --- .../migrations/005_security_hardening.sql | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 supabase/migrations/005_security_hardening.sql diff --git a/supabase/migrations/005_security_hardening.sql b/supabase/migrations/005_security_hardening.sql new file mode 100644 index 0000000..d1609ff --- /dev/null +++ b/supabase/migrations/005_security_hardening.sql @@ -0,0 +1,41 @@ +-- ============================================================ +-- Explicit permission hardening for public schema functions +-- +-- Context: 003_credits.sql already revokes execute on the +-- three credit RPCs from public/anon/authenticated. This +-- migration completes the surface by: +-- +-- 1. Revoking EXECUTE on trigger/utility functions from +-- anon and authenticated. PostgreSQL already blocks +-- direct calls on trigger-returning functions, but +-- explicit revoke removes them from PostgREST RPC +-- discovery and makes the permission model unambiguous. +-- +-- 2. Explicitly granting EXECUTE on credit RPCs to +-- service_role. The REVOKE in 003 stripped the PUBLIC +-- grant; this grant ensures the service_role (used by +-- the admin client in webhooks) retains access through +-- any future permission resets or Supabase upgrades. +-- ============================================================ + +-- ---- Trigger / utility functions ---- + +revoke execute on function public.handle_updated_at() + from anon, authenticated; + +revoke execute on function public.handle_new_user() + from anon, authenticated; + +revoke execute on function public.handle_new_profile_credits() + from anon, authenticated; + +-- ---- Credit RPCs: service_role-only ---- + +grant execute on function public.spend_credits(uuid, integer, text) + to service_role; + +grant execute on function public.add_credits(uuid, integer, text, text) + to service_role; + +grant execute on function public.deduct_credits(uuid, integer, text) + to service_role; From 141e7d42c772a240db1eb453fda4ca4445e88396 Mon Sep 17 00:00:00 2001 From: aaron Date: Tue, 31 Mar 2026 07:25:30 +0800 Subject: [PATCH 30/49] refactor(webhooks): simplify Creem webhook handling by exporting handler directly - Replace the existing POST function in the Creem webhook route with a direct export of the handleCreemWebhook function from the billing webhooks feature. - Update import statements in billing actions for consistency and clarity, including normalization of string quotes and minor adjustments to error handling and redirection logic. --- src/app/api/webhooks/creem/route.ts | 15 +-- src/features/billing/actions/index.ts | 168 ++++++++++++++------------ 2 files changed, 90 insertions(+), 93 deletions(-) diff --git a/src/app/api/webhooks/creem/route.ts b/src/app/api/webhooks/creem/route.ts index dbd28ea..9a483da 100644 --- a/src/app/api/webhooks/creem/route.ts +++ b/src/app/api/webhooks/creem/route.ts @@ -1,14 +1 @@ -import { Webhook } from "@creem_io/nextjs"; -import { webhookHandlers } from "@/features/billing/webhooks"; - -export async function POST(request: Request) { - const webhookSecret = process.env.CREEM_WEBHOOK_SECRET ?? ""; - - const cloned = new Request(request.url, { - method: request.method, - headers: request.headers, - body: await request.text(), - }); - - return Webhook({ webhookSecret, ...webhookHandlers })(cloned); -} +export { handleCreemWebhook as POST } from '@/features/billing/webhooks'; diff --git a/src/features/billing/actions/index.ts b/src/features/billing/actions/index.ts index a760902..19514ec 100644 --- a/src/features/billing/actions/index.ts +++ b/src/features/billing/actions/index.ts @@ -1,6 +1,6 @@ -"use server"; +'use server'; -import { createClient } from "@/lib/supabase/server"; +import { createClient } from '@/lib/supabase/server'; import { createCheckout, getCustomerPortalLink, @@ -8,13 +8,12 @@ import { resumeSubscription, upgradeSubscription, pauseSubscription, -} from "@/lib/creem/client"; -import { redirect } from "next/navigation"; -import type { Subscription } from "../types"; -import { createCheckoutSchema } from "../schema"; +} from '@/lib/creem/client'; +import { redirect } from 'next/navigation'; +import { PLANS, type Subscription } from '../types'; +import { createCheckoutSchema } from '../schema'; -const APP_URL = - process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; +const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000'; // ---------- Helpers ---------- function mapRow(row: Record): Subscription { @@ -24,7 +23,7 @@ function mapRow(row: Record): Subscription { creemSubscriptionId: (row.creem_subscription_id as string) ?? null, creemCustomerId: (row.creem_customer_id as string) ?? null, planId: (row.plan_id as string) ?? null, - status: row.status as Subscription["status"], + status: row.status as Subscription['status'], currentPeriodStart: (row.current_period_start as string) ?? null, currentPeriodEnd: (row.current_period_end as string) ?? null, cancelAtPeriodEnd: (row.cancel_at_period_end as boolean) ?? false, @@ -32,11 +31,21 @@ function mapRow(row: Record): Subscription { } // ---------- Checkout ---------- -export async function createCheckoutSession( - productId: string, -): Promise { - if (!createCheckoutSchema.safeParse({ productId }).success) { - redirect("/pricing?error=product_not_configured"); +export async function createCheckoutSession(productId: string): Promise { + const normalizedProductId = productId.trim(); + + if (!createCheckoutSchema.safeParse({ productId: normalizedProductId }).success) { + redirect('/pricing?error=product_not_configured'); + } + + const allowedProductIds = new Set( + [PLANS.pro.productId, PLANS.business.productId].filter( + (id): id is string => typeof id === 'string' && id.length > 0, + ), + ); + + if (!allowedProductIds.has(normalizedProductId)) { + redirect('/pricing?error=product_not_configured'); } const supabase = await createClient(); @@ -44,20 +53,26 @@ export async function createCheckoutSession( data: { user }, } = await supabase.auth.getUser(); - if (!user) redirect("/login"); + if (!user) redirect('/login'); + + const customerEmail = user.email?.trim(); + if (!customerEmail) { + redirect('/pricing?error=missing_email'); + } let checkoutUrl: string; try { const result = await createCheckout({ - productId, + productId: normalizedProductId, successUrl: `${APP_URL}/dashboard?checkout=success`, - customerEmail: user.email!, + customerEmail, metadata: { user_id: user.id }, }); checkoutUrl = result.checkout_url; } catch (e) { - const message = e instanceof Error ? e.message : "Checkout unavailable"; - redirect(`/pricing?error=${encodeURIComponent(message)}`); + const message = e instanceof Error ? e.message : 'Checkout unavailable'; + console.error('createCheckoutSession failed: ', message); + redirect('/pricing?error=checkout_unavailable'); } redirect(checkoutUrl); @@ -73,10 +88,10 @@ export async function getUserSubscription(): Promise { if (!user) return null; const { data } = await supabase - .from("subscriptions") - .select("*") - .eq("user_id", user.id) - .order("created_at", { ascending: false }) + .from('subscriptions') + .select('*') + .eq('user_id', user.id) + .order('created_at', { ascending: false }) .limit(1) .single(); @@ -91,16 +106,16 @@ export async function openCustomerPortal(): Promise { data: { user }, } = await supabase.auth.getUser(); - if (!user) redirect("/login"); + if (!user) redirect('/login'); const { data: profile } = await supabase - .from("profiles") - .select("creem_customer_id") - .eq("id", user.id) + .from('profiles') + .select('creem_customer_id') + .eq('id', user.id) .single(); if (!profile?.creem_customer_id) { - redirect("/dashboard/billing?error=no_customer"); + redirect('/dashboard/billing?error=no_customer'); } let portalUrl: string; @@ -108,7 +123,7 @@ export async function openCustomerPortal(): Promise { const result = await getCustomerPortalLink(profile.creem_customer_id); portalUrl = result.customer_portal_link; } catch (e) { - const message = e instanceof Error ? e.message : "Portal unavailable"; + const message = e instanceof Error ? e.message : 'Portal unavailable'; redirect(`/dashboard/billing?error=${encodeURIComponent(message)}`); } @@ -117,38 +132,38 @@ export async function openCustomerPortal(): Promise { // ---------- Cancel ---------- export async function cancelUserSubscription( - mode: "scheduled" | "immediate", + mode: 'scheduled' | 'immediate', ): Promise<{ error?: string }> { const supabase = await createClient(); const { data: { user }, } = await supabase.auth.getUser(); - if (!user) return { error: "Not authenticated" }; + if (!user) return { error: 'Not authenticated' }; const { data: sub } = await supabase - .from("subscriptions") - .select("creem_subscription_id") - .eq("user_id", user.id) - .not("status", "eq", "canceled") - .order("created_at", { ascending: false }) + .from('subscriptions') + .select('creem_subscription_id') + .eq('user_id', user.id) + .not('status', 'eq', 'canceled') + .order('created_at', { ascending: false }) .limit(1) .maybeSingle(); - if (!sub?.creem_subscription_id) return { error: "No active subscription" }; + if (!sub?.creem_subscription_id) return { error: 'No active subscription' }; try { await cancelSubscription(sub.creem_subscription_id, mode); - if (mode === "scheduled") { + if (mode === 'scheduled') { await supabase - .from("subscriptions") + .from('subscriptions') .update({ cancel_at_period_end: true }) - .eq("creem_subscription_id", sub.creem_subscription_id); + .eq('creem_subscription_id', sub.creem_subscription_id); } else { await supabase - .from("subscriptions") - .update({ status: "canceled" }) - .eq("creem_subscription_id", sub.creem_subscription_id); + .from('subscriptions') + .update({ status: 'canceled' }) + .eq('creem_subscription_id', sub.creem_subscription_id); } return {}; } catch (e) { @@ -163,25 +178,25 @@ export async function resumeUserSubscription(): Promise<{ error?: string }> { data: { user }, } = await supabase.auth.getUser(); - if (!user) return { error: "Not authenticated" }; + if (!user) return { error: 'Not authenticated' }; const { data: sub } = await supabase - .from("subscriptions") - .select("creem_subscription_id") - .eq("user_id", user.id) - .not("status", "eq", "canceled") - .order("created_at", { ascending: false }) + .from('subscriptions') + .select('creem_subscription_id') + .eq('user_id', user.id) + .not('status', 'eq', 'canceled') + .order('created_at', { ascending: false }) .limit(1) .maybeSingle(); - if (!sub?.creem_subscription_id) return { error: "No active subscription" }; + if (!sub?.creem_subscription_id) return { error: 'No active subscription' }; try { await resumeSubscription(sub.creem_subscription_id); await supabase - .from("subscriptions") - .update({ cancel_at_period_end: false, status: "active" }) - .eq("creem_subscription_id", sub.creem_subscription_id); + .from('subscriptions') + .update({ cancel_at_period_end: false, status: 'active' }) + .eq('creem_subscription_id', sub.creem_subscription_id); return {}; } catch (e) { return { error: (e as Error).message }; @@ -189,39 +204,34 @@ export async function resumeUserSubscription(): Promise<{ error?: string }> { } // ---------- Upgrade ---------- -export async function upgradeUserSubscription( - newProductId: string, -): Promise { +export async function upgradeUserSubscription(newProductId: string): Promise { const supabase = await createClient(); const { data: { user }, } = await supabase.auth.getUser(); - if (!user) redirect("/login"); + if (!user) redirect('/login'); const { data: sub } = await supabase - .from("subscriptions") - .select("creem_subscription_id") - .eq("user_id", user.id) - .not("status", "eq", "canceled") - .order("created_at", { ascending: false }) + .from('subscriptions') + .select('creem_subscription_id') + .eq('user_id', user.id) + .not('status', 'eq', 'canceled') + .order('created_at', { ascending: false }) .limit(1) .maybeSingle(); - if (!sub?.creem_subscription_id) redirect("/dashboard/billing"); + if (!sub?.creem_subscription_id) redirect('/dashboard/billing'); try { await upgradeSubscription(sub.creem_subscription_id, newProductId); } catch (e) { - const message = - e instanceof Error ? e.message : "Unable to upgrade subscription"; - redirect( - `/dashboard/billing?error=${encodeURIComponent(message)}`, - ); + const message = e instanceof Error ? e.message : 'Unable to upgrade subscription'; + redirect(`/dashboard/billing?error=${encodeURIComponent(message)}`); } // Actual status change comes back via webhook - redirect("/dashboard/billing?upgraded=1"); + redirect('/dashboard/billing?upgraded=1'); } // ---------- Pause ---------- @@ -231,25 +241,25 @@ export async function pauseUserSubscription(): Promise<{ error?: string }> { data: { user }, } = await supabase.auth.getUser(); - if (!user) return { error: "Not authenticated" }; + if (!user) return { error: 'Not authenticated' }; const { data: sub } = await supabase - .from("subscriptions") - .select("creem_subscription_id") - .eq("user_id", user.id) - .not("status", "eq", "canceled") - .order("created_at", { ascending: false }) + .from('subscriptions') + .select('creem_subscription_id') + .eq('user_id', user.id) + .not('status', 'eq', 'canceled') + .order('created_at', { ascending: false }) .limit(1) .maybeSingle(); - if (!sub?.creem_subscription_id) return { error: "No active subscription" }; + if (!sub?.creem_subscription_id) return { error: 'No active subscription' }; try { await pauseSubscription(sub.creem_subscription_id); await supabase - .from("subscriptions") - .update({ status: "paused" }) - .eq("creem_subscription_id", sub.creem_subscription_id); + .from('subscriptions') + .update({ status: 'paused' }) + .eq('creem_subscription_id', sub.creem_subscription_id); return {}; } catch (e) { return { error: (e as Error).message }; From b52ff16071cb8ff6dcf7b00c2d073ae0b8cdfdd1 Mon Sep 17 00:00:00 2001 From: aaron Date: Tue, 31 Mar 2026 07:27:28 +0800 Subject: [PATCH 31/49] chore(config): update formatting and linting configurations - Remove unnecessary options from .oxfmtrc.json, including semi and trailingComma. - Simplify ignorePatterns in .oxlintrc.json by consolidating array elements into a single line for better readability. --- .oxfmtrc.json | 2 - .oxlintrc.json | 8 +- next.config.ts | 14 +- src/app/(auth)/auth/callback/route.ts | 18 +- src/app/(auth)/layout.tsx | 14 +- src/app/(auth)/login/page.tsx | 21 +- src/app/(auth)/signup/page.tsx | 17 +- .../(dashboard)/dashboard/billing/page.tsx | 61 +-- .../(dashboard)/dashboard/credits/page.tsx | 16 +- src/app/(dashboard)/dashboard/page.tsx | 31 +- .../(dashboard)/dashboard/settings/page.tsx | 21 +- src/app/(dashboard)/layout.tsx | 35 +- src/app/(legal)/layout.tsx | 10 +- src/app/(legal)/privacy/page.mdx | 6 +- src/app/(legal)/terms/page.mdx | 6 +- src/app/(marketing)/layout.tsx | 12 +- src/app/(marketing)/page.tsx | 13 +- src/app/(marketing)/pricing/page.tsx | 10 +- src/app/globals.css | 3 +- src/app/layout.tsx | 34 +- src/app/not-found.tsx | 6 +- src/components/header.tsx | 74 +-- src/components/logo.tsx | 6 +- src/components/theme-provider.tsx | 36 +- src/components/ui/accordion.tsx | 22 +- src/components/ui/alert-dialog.tsx | 52 +- src/components/ui/animated-group.tsx | 68 +-- src/components/ui/avatar.tsx | 78 ++- src/components/ui/badge.tsx | 18 +- src/components/ui/breadcrumb.tsx | 90 ++-- src/components/ui/button-group.tsx | 28 +- src/components/ui/button.tsx | 16 +- src/components/ui/card.tsx | 28 +- src/components/ui/chart.tsx | 254 ++++----- src/components/ui/checkbox.tsx | 14 +- src/components/ui/drawer.tsx | 76 ++- src/components/ui/dropdown-menu.tsx | 70 +-- src/components/ui/field.tsx | 68 +-- src/components/ui/infinite-slider.tsx | 80 +-- src/components/ui/input-group.tsx | 50 +- src/components/ui/input.tsx | 12 +- src/components/ui/item.tsx | 52 +- src/components/ui/label.tsx | 12 +- src/components/ui/portal.tsx | 40 +- src/components/ui/progressive-blur.tsx | 34 +- src/components/ui/radio-group.tsx | 16 +- src/components/ui/select.tsx | 90 ++-- src/components/ui/separator.tsx | 12 +- src/components/ui/sheet.tsx | 79 ++- src/components/ui/sidebar.tsx | 493 ++++++++---------- src/components/ui/skeleton.tsx | 10 +- src/components/ui/slider.tsx | 16 +- src/components/ui/sonner.tsx | 54 +- src/components/ui/switch.tsx | 14 +- src/components/ui/table.tsx | 87 ++-- src/components/ui/tabs.tsx | 53 +- src/components/ui/text-effect.tsx | 124 ++--- src/components/ui/textarea.tsx | 10 +- src/components/ui/toggle-group.tsx | 58 +-- src/components/ui/toggle.tsx | 34 +- src/components/ui/tooltip.tsx | 40 +- src/features/auth/actions/index.ts | 58 +-- src/features/auth/actions/profile.ts | 16 +- src/features/auth/components/login-form.tsx | 74 +-- .../auth/components/provider-icons.tsx | 9 +- .../auth/components/settings-profile-card.tsx | 55 +- src/features/auth/components/signup-form.tsx | 88 +--- .../auth/components/signup-pending-toast.tsx | 24 +- src/features/auth/hooks/use-user.ts | 8 +- src/features/auth/schema.ts | 18 +- src/features/auth/types.ts | 2 +- .../billing/components/checkout-button.tsx | 18 +- .../components/checkout-success-toast.tsx | 14 +- .../billing/components/faqs-section.tsx | 10 +- .../components/manage-subscription.tsx | 98 ++-- .../billing/components/pricing-section.tsx | 52 +- .../billing/components/subscription-card.tsx | 90 ++-- .../billing/components/upgrade-button.tsx | 18 +- .../billing/hooks/use-subscription.ts | 8 +- src/features/billing/schema.ts | 4 +- src/features/billing/types.ts | 36 +- .../billing/webhooks/__tests__/index.test.ts | 163 +++--- src/features/billing/webhooks/index.ts | 165 +++--- .../actions/__tests__/spend-credits.test.ts | 42 +- src/features/credits/actions/index.ts | 48 +- .../components/credits-balance-card.tsx | 16 +- .../components/transaction-history.tsx | 73 +-- src/features/credits/hooks/use-credits.ts | 6 +- src/features/credits/schema.ts | 4 +- src/features/credits/types.ts | 2 +- .../dashboard/components/app-sidebar.tsx | 38 +- .../components/chart-area-interactive.tsx | 267 +++++----- .../dashboard/components/data-table.tsx | 275 ++++------ .../dashboard/components/nav-features.tsx | 24 +- .../dashboard/components/nav-main.tsx | 6 +- .../dashboard/components/nav-secondary.tsx | 12 +- .../dashboard/components/nav-user.tsx | 38 +- .../dashboard/components/section-cards.tsx | 16 +- .../dashboard/components/site-header.tsx | 9 +- .../landing/components/call-to-action.tsx | 6 +- .../landing/components/features-section.tsx | 33 +- .../landing/components/footer-section.tsx | 10 +- .../landing/components/hero-section.tsx | 18 +- src/features/landing/components/logo-bar.tsx | 22 +- .../landing/components/svg/baseui.tsx | 2 +- src/features/landing/components/svg/creem.tsx | 2 +- .../landing/components/svg/nextjs.tsx | 2 +- src/features/landing/components/svg/oxc.tsx | 2 +- .../landing/components/svg/shadcn.tsx | 2 +- .../landing/components/svg/supabase.tsx | 2 +- .../landing/components/svg/tailwindfull.tsx | 4 +- .../landing/components/svg/vercel.tsx | 2 +- src/hooks/use-mobile.ts | 22 +- src/hooks/use-scroll.ts | 28 +- src/lib/creem/__tests__/client.test.ts | 49 +- src/lib/creem/client.ts | 49 +- src/lib/supabase/admin.ts | 2 +- src/lib/supabase/client.ts | 2 +- src/lib/supabase/server.ts | 4 +- src/lib/utils.ts | 6 +- src/mdx-components.tsx | 4 +- src/proxy.ts | 16 +- src/test/setup.ts | 2 +- vitest.config.ts | 12 +- 124 files changed, 2243 insertions(+), 2790 deletions(-) diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 5365c75..e0cecaf 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,10 +1,8 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", "endOfLine": "lf", - "semi": false, "singleQuote": true, "tabWidth": 2, - "trailingComma": "es5", "printWidth": 100, "sortTailwindcss": { "stylesheet": "src/app/globals.css", diff --git a/.oxlintrc.json b/.oxlintrc.json index da5c2a4..2e24502 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -7,13 +7,7 @@ "env": { "builtin": true }, - "ignorePatterns": [ - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", - "**/*.mdx" - ], + "ignorePatterns": [".next/**", "out/**", "build/**", "next-env.d.ts", "**/*.mdx"], "rules": { "@next/next/google-font-display": "warn", "@next/next/google-font-preconnect": "warn", diff --git a/next.config.ts b/next.config.ts index 4bfb63a..d9ce3e5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,5 @@ -import createMDX from '@next/mdx' -import type { NextConfig } from 'next' +import createMDX from '@next/mdx'; +import type { NextConfig } from 'next'; const securityHeaders = [ // Prevents MIME-type sniffing (CVE-2006-3396) @@ -10,7 +10,7 @@ const securityHeaders = [ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, // Restricts access to browser features not used by the app { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, -] +]; const nextConfig: NextConfig = { reactStrictMode: true, @@ -32,15 +32,15 @@ const nextConfig: NextConfig = { source: '/(.*)', headers: securityHeaders, }, - ] + ]; }, -} +}; const withMDX = createMDX({ options: { remarkPlugins: [], rehypePlugins: [], }, -}) +}); -export default withMDX(nextConfig) +export default withMDX(nextConfig); diff --git a/src/app/(auth)/auth/callback/route.ts b/src/app/(auth)/auth/callback/route.ts index ee7494c..7412509 100644 --- a/src/app/(auth)/auth/callback/route.ts +++ b/src/app/(auth)/auth/callback/route.ts @@ -1,13 +1,13 @@ -import { NextResponse, type NextRequest } from "next/server"; -import { createClient } from "@/lib/supabase/server"; +import { NextResponse, type NextRequest } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; function getSafeNextPath(nextParam: string | null): string { if (!nextParam) { - return "/dashboard"; + return '/dashboard'; } - if (!nextParam.startsWith("/") || nextParam.startsWith("//")) { - return "/dashboard"; + if (!nextParam.startsWith('/') || nextParam.startsWith('//')) { + return '/dashboard'; } return nextParam; @@ -15,8 +15,8 @@ function getSafeNextPath(nextParam: string | null): string { export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); - const code = searchParams.get("code"); - const nextPath = getSafeNextPath(searchParams.get("next")); + const code = searchParams.get('code'); + const nextPath = getSafeNextPath(searchParams.get('next')); if (code) { const supabase = await createClient(); @@ -26,7 +26,5 @@ export async function GET(request: NextRequest) { } } - return NextResponse.redirect( - new URL("/login?error=auth_callback_failed", request.url), - ); + return NextResponse.redirect(new URL('/login?error=auth_callback_failed', request.url)); } diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index ddace57..ed67349 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -1,21 +1,17 @@ -import type { Metadata } from "next"; +import type { Metadata } from 'next'; export const metadata: Metadata = { title: { - template: "%s | CreemKit", - default: "Auth | CreemKit", + template: '%s | CreemKit', + default: 'Auth | CreemKit', }, - description: "Authentication pages for your CreemKit workspace.", + description: 'Authentication pages for your CreemKit workspace.', robots: { index: false, follow: false, }, }; -export default function AuthLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default function AuthLayout({ children }: { children: React.ReactNode }) { return <>{children}; } diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 7962640..5b4676b 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,12 +1,12 @@ -import type { Metadata } from "next"; -import { LoginForm } from "@/features/auth/components/login-form"; -import { SignupPendingToast } from "@/features/auth/components/signup-pending-toast"; -import { Logo } from "@/components/logo"; -import Link from "next/link"; +import type { Metadata } from 'next'; +import { LoginForm } from '@/features/auth/components/login-form'; +import { SignupPendingToast } from '@/features/auth/components/signup-pending-toast'; +import { Logo } from '@/components/logo'; +import Link from 'next/link'; export const metadata: Metadata = { - title: "Log in", - description: "Access your CreemKit account to manage subscriptions and credits.", + title: 'Log in', + description: 'Access your CreemKit account to manage subscriptions and credits.', }; export default async function LoginPage({ @@ -18,12 +18,9 @@ export default async function LoginPage({ return (
- {signup === "pending" && } + {signup === 'pending' && }
- + diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index d6574f5..7f852ec 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -1,21 +1,18 @@ -import type { Metadata } from "next"; -import { SignupForm } from "@/features/auth/components/signup-form"; -import { Logo } from "@/components/logo"; -import Link from "next/link"; +import type { Metadata } from 'next'; +import { SignupForm } from '@/features/auth/components/signup-form'; +import { Logo } from '@/components/logo'; +import Link from 'next/link'; export const metadata: Metadata = { - title: "Create account", - description: "Create your CreemKit account and start selling with Supabase and Creem.", + title: 'Create account', + description: 'Create your CreemKit account and start selling with Supabase and Creem.', }; export default function SignupPage() { return (
- + diff --git a/src/app/(dashboard)/dashboard/billing/page.tsx b/src/app/(dashboard)/dashboard/billing/page.tsx index 416d91f..a273bf3 100644 --- a/src/app/(dashboard)/dashboard/billing/page.tsx +++ b/src/app/(dashboard)/dashboard/billing/page.tsx @@ -1,23 +1,17 @@ -import type { Metadata } from "next"; -import { SiteHeader } from "@/features/dashboard/components/site-header"; -import { getUserSubscription } from "@/features/billing/actions"; -import { SubscriptionCard } from "@/features/billing/components/subscription-card"; -import { ManageSubscription } from "@/features/billing/components/manage-subscription"; -import { getCreditsBalance } from "@/features/credits/actions"; -import { PLANS } from "@/features/billing/types"; -import { CheckoutButton } from "@/features/billing/components/checkout-button"; -import { UpgradeButton } from "@/features/billing/components/upgrade-button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import type { Metadata } from 'next'; +import { SiteHeader } from '@/features/dashboard/components/site-header'; +import { getUserSubscription } from '@/features/billing/actions'; +import { SubscriptionCard } from '@/features/billing/components/subscription-card'; +import { ManageSubscription } from '@/features/billing/components/manage-subscription'; +import { getCreditsBalance } from '@/features/credits/actions'; +import { PLANS } from '@/features/billing/types'; +import { CheckoutButton } from '@/features/billing/components/checkout-button'; +import { UpgradeButton } from '@/features/billing/components/upgrade-button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; export const metadata: Metadata = { - title: "Billing", - description: "Manage your subscription and billing details.", + title: 'Billing', + description: 'Manage your subscription and billing details.', }; export default async function BillingPage() { @@ -32,15 +26,12 @@ export default async function BillingPage() {

Billing

-

+

Manage your subscription and payment details.

- + {subscription && } @@ -53,40 +44,30 @@ export default async function BillingPage() {
{PLANS.pro.productId && ( -
+

{PLANS.pro.name}

${(PLANS.pro.price / 100).toFixed(0)} - - /mo - + /mo

{PLANS.pro.credits.toLocaleString()} credits/month

{subscription ? ( - - Switch to Pro - + Switch to Pro ) : ( - - Get Pro - + Get Pro )}
)} {PLANS.business.productId && ( -
+

{PLANS.business.name}

${(PLANS.business.price / 100).toFixed(0)} - - /mo - -

-

- Unlimited credits + /mo

+

Unlimited credits

{subscription ? ( Switch to Business diff --git a/src/app/(dashboard)/dashboard/credits/page.tsx b/src/app/(dashboard)/dashboard/credits/page.tsx index 8a486a1..f34951d 100644 --- a/src/app/(dashboard)/dashboard/credits/page.tsx +++ b/src/app/(dashboard)/dashboard/credits/page.tsx @@ -1,12 +1,12 @@ -import type { Metadata } from "next"; -import { SiteHeader } from "@/features/dashboard/components/site-header"; -import { CreditsBalanceCard } from "@/features/credits/components/credits-balance-card"; -import { TransactionHistory } from "@/features/credits/components/transaction-history"; -import { getCreditsBalance, getCreditTransactions } from "@/features/credits/actions"; +import type { Metadata } from 'next'; +import { SiteHeader } from '@/features/dashboard/components/site-header'; +import { CreditsBalanceCard } from '@/features/credits/components/credits-balance-card'; +import { TransactionHistory } from '@/features/credits/components/transaction-history'; +import { getCreditsBalance, getCreditTransactions } from '@/features/credits/actions'; export const metadata: Metadata = { - title: "Credits", - description: "View your credits balance and transaction history.", + title: 'Credits', + description: 'View your credits balance and transaction history.', }; export default async function CreditsPage() { @@ -21,7 +21,7 @@ export default async function CreditsPage() {

Credits

-

+

Your credit balance and transaction history.

diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 43419f7..083009f 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -1,18 +1,18 @@ -import type { Metadata } from "next"; -import { ChartAreaInteractive } from "@/features/dashboard/components/chart-area-interactive"; -import { DataTable } from "@/features/dashboard/components/data-table"; -import { SectionCards } from "@/features/dashboard/components/section-cards"; -import { SiteHeader } from "@/features/dashboard/components/site-header"; -import { SubscriptionCard } from "@/features/billing/components/subscription-card"; -import { CheckoutSuccessToast } from "@/features/billing/components/checkout-success-toast"; -import { getUserSubscription } from "@/features/billing/actions"; -import { getCreditsBalance } from "@/features/credits/actions"; +import type { Metadata } from 'next'; +import { ChartAreaInteractive } from '@/features/dashboard/components/chart-area-interactive'; +import { DataTable } from '@/features/dashboard/components/data-table'; +import { SectionCards } from '@/features/dashboard/components/section-cards'; +import { SiteHeader } from '@/features/dashboard/components/site-header'; +import { SubscriptionCard } from '@/features/billing/components/subscription-card'; +import { CheckoutSuccessToast } from '@/features/billing/components/checkout-success-toast'; +import { getUserSubscription } from '@/features/billing/actions'; +import { getCreditsBalance } from '@/features/credits/actions'; -import data from "./data.json"; +import data from './data.json'; export const metadata: Metadata = { - title: "Overview", - description: "View your key metrics, usage data, and recent activity.", + title: 'Overview', + description: 'View your key metrics, usage data, and recent activity.', }; export default async function Page({ @@ -28,16 +28,13 @@ export default async function Page({ return ( <> - {checkout === "success" && } + {checkout === 'success' && }
- +
diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index 2117fed..06c6e90 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -1,11 +1,11 @@ -import type { Metadata } from "next"; -import { createClient } from "@/lib/supabase/server"; -import { SiteHeader } from "@/features/dashboard/components/site-header"; -import { SettingsProfileCard } from "@/features/auth/components/settings-profile-card"; +import type { Metadata } from 'next'; +import { createClient } from '@/lib/supabase/server'; +import { SiteHeader } from '@/features/dashboard/components/site-header'; +import { SettingsProfileCard } from '@/features/auth/components/settings-profile-card'; export const metadata: Metadata = { - title: "Settings", - description: "Manage your account settings and preferences.", + title: 'Settings', + description: 'Manage your account settings and preferences.', }; export default async function SettingsPage() { @@ -14,9 +14,8 @@ export default async function SettingsPage() { data: { user }, } = await supabase.auth.getUser(); - const fullName = - (user?.user_metadata?.full_name as string) ?? ""; - const email = user?.email ?? ""; + const fullName = (user?.user_metadata?.full_name as string) ?? ''; + const email = user?.email ?? ''; return ( <> @@ -24,9 +23,7 @@ export default async function SettingsPage() {

Settings

-

- Manage your account information. -

+

Manage your account information.

diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 4b8fe1c..f29581e 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,49 +1,44 @@ -import type { Metadata } from "next"; -import { createClient } from "@/lib/supabase/server"; -import { redirect } from "next/navigation"; -import { AppSidebar } from "@/features/dashboard/components/app-sidebar"; -import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import type { Metadata } from 'next'; +import { createClient } from '@/lib/supabase/server'; +import { redirect } from 'next/navigation'; +import { AppSidebar } from '@/features/dashboard/components/app-sidebar'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; export const metadata: Metadata = { title: { - template: "%s | Dashboard | CreemKit", - default: "Dashboard | CreemKit", + template: '%s | Dashboard | CreemKit', + default: 'Dashboard | CreemKit', }, - description: - "Private dashboard for managing subscriptions, credits, and product data.", + description: 'Private dashboard for managing subscriptions, credits, and product data.', robots: { index: false, follow: false, }, }; -export default async function DashboardLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default async function DashboardLayout({ children }: { children: React.ReactNode }) { const supabase = await createClient(); const { data: { user }, } = await supabase.auth.getUser(); - if (!user) redirect("/login"); + if (!user) redirect('/login'); return ( {children} diff --git a/src/app/(legal)/layout.tsx b/src/app/(legal)/layout.tsx index 6c9566d..448ebdb 100644 --- a/src/app/(legal)/layout.tsx +++ b/src/app/(legal)/layout.tsx @@ -1,6 +1,6 @@ -import type { Metadata } from 'next' -import { Header } from '@/components/header' -import { FooterSection } from '@/features/landing/components/footer-section' +import type { Metadata } from 'next'; +import { Header } from '@/components/header'; +import { FooterSection } from '@/features/landing/components/footer-section'; export const metadata: Metadata = { title: { @@ -8,7 +8,7 @@ export const metadata: Metadata = { default: 'Legal | CreemKit', }, description: 'Legal documents for CreemKit, including privacy policy and terms of service.', -} +}; export default function LegalLayout({ children }: { children: React.ReactNode }) { return ( @@ -19,5 +19,5 @@ export default function LegalLayout({ children }: { children: React.ReactNode })
- ) + ); } diff --git a/src/app/(legal)/privacy/page.mdx b/src/app/(legal)/privacy/page.mdx index cc41ca4..6083031 100644 --- a/src/app/(legal)/privacy/page.mdx +++ b/src/app/(legal)/privacy/page.mdx @@ -1,8 +1,8 @@ {/* src/app/(legal)/privacy/page.mdx */} export const metadata = { - title: "Privacy Policy", - description: "How CreemKit collects, uses, and protects your data.", + title: 'Privacy Policy', + description: 'How CreemKit collects, uses, and protects your data.', }; # Privacy Policy @@ -175,4 +175,4 @@ We encourage you to review this policy periodically. If you have any questions about this Privacy Policy, you can reach us at: -- **Email:** support@creemkit.com \ No newline at end of file +- **Email:** support@creemkit.com diff --git a/src/app/(legal)/terms/page.mdx b/src/app/(legal)/terms/page.mdx index f77abd4..beed413 100644 --- a/src/app/(legal)/terms/page.mdx +++ b/src/app/(legal)/terms/page.mdx @@ -1,8 +1,8 @@ {/* src/app/(legal)/terms/page.mdx */} export const metadata = { - title: "Terms of Service", - description: "Terms and conditions for using CreemKit.", + title: 'Terms of Service', + description: 'Terms and conditions for using CreemKit.', }; # Terms of Service @@ -192,4 +192,4 @@ Continued use of the Service after changes take effect constitutes your acceptan If you have any questions about these Terms, you can reach us at: -- **Email:** support@creemkit.com \ No newline at end of file +- **Email:** support@creemkit.com diff --git a/src/app/(marketing)/layout.tsx b/src/app/(marketing)/layout.tsx index f6b2cf6..5b293a6 100644 --- a/src/app/(marketing)/layout.tsx +++ b/src/app/(marketing)/layout.tsx @@ -1,7 +1,7 @@ -import type { Metadata } from 'next' -import { Header } from '@/components/header' -import { CallToAction } from '@/features/landing/components/call-to-action' -import { FooterSection } from '@/features/landing/components/footer-section' +import type { Metadata } from 'next'; +import { Header } from '@/components/header'; +import { CallToAction } from '@/features/landing/components/call-to-action'; +import { FooterSection } from '@/features/landing/components/footer-section'; export const metadata: Metadata = { title: { @@ -10,7 +10,7 @@ export const metadata: Metadata = { }, description: 'Production-ready Next.js starter with Supabase auth and Creem payments pre-integrated.', -} +}; export default function MarketingLayout({ children }: { children: React.ReactNode }) { return ( @@ -20,5 +20,5 @@ export default function MarketingLayout({ children }: { children: React.ReactNod - ) + ); } diff --git a/src/app/(marketing)/page.tsx b/src/app/(marketing)/page.tsx index a6811e3..76f131e 100644 --- a/src/app/(marketing)/page.tsx +++ b/src/app/(marketing)/page.tsx @@ -1,12 +1,11 @@ -import type { Metadata } from 'next' -import FeaturesSection from '@/features/landing/components/features-section' -import HeroSection from '@/features/landing/components/hero-section' +import type { Metadata } from 'next'; +import FeaturesSection from '@/features/landing/components/features-section'; +import HeroSection from '@/features/landing/components/hero-section'; export const metadata: Metadata = { title: 'Next.js + Supabase + Creem Starter', - description: - 'Launch your SaaS faster with prebuilt auth, billing, subscriptions, and credits.', -} + description: 'Launch your SaaS faster with prebuilt auth, billing, subscriptions, and credits.', +}; export default function Page() { return ( @@ -14,5 +13,5 @@ export default function Page() { - ) + ); } diff --git a/src/app/(marketing)/pricing/page.tsx b/src/app/(marketing)/pricing/page.tsx index cb33f53..887db31 100644 --- a/src/app/(marketing)/pricing/page.tsx +++ b/src/app/(marketing)/pricing/page.tsx @@ -1,11 +1,11 @@ -import type { Metadata } from 'next' -import { PricingSection } from '@/features/billing/components/pricing-section' -import { FaqsSection } from '@/features/billing/components/faqs-section' +import type { Metadata } from 'next'; +import { PricingSection } from '@/features/billing/components/pricing-section'; +import { FaqsSection } from '@/features/billing/components/faqs-section'; export const metadata: Metadata = { title: 'Pricing', description: 'Simple plans for side projects, growing SaaS products, and teams.', -} +}; export default function PricingPage() { return ( @@ -13,5 +13,5 @@ export default function PricingPage() { - ) + ); } diff --git a/src/app/globals.css b/src/app/globals.css index 68422d6..ae4f1f9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -129,8 +129,7 @@ } } - html, body { scroll-behavior: smooth; -} \ No newline at end of file +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e8a4e6f..8d39bbb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,26 +1,26 @@ -import type { Metadata } from 'next' -import { Inter } from 'next/font/google' +import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; -import './globals.css' -import { ThemeProvider } from '@/components/theme-provider' -import { cn } from '@/lib/utils' -import Script from 'next/script' -import { Toaster } from 'sonner' +import './globals.css'; +import { ThemeProvider } from '@/components/theme-provider'; +import { cn } from '@/lib/utils'; +import Script from 'next/script'; +import { Toaster } from 'sonner'; -const inter = Inter({ subsets: ['latin'], variable: '--font-sans' }) +const inter = Inter({ subsets: ['latin'], variable: '--font-sans' }); -const siteName = 'CreemKit' +const siteName = 'CreemKit'; const siteDescription = - 'Production-ready Next.js starter with Supabase auth and Creem payments pre-integrated.' + 'Production-ready Next.js starter with Supabase auth and Creem payments pre-integrated.'; function getMetadataBase() { - const fallbackUrl = 'http://localhost:3000' - const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? fallbackUrl + const fallbackUrl = 'http://localhost:3000'; + const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? fallbackUrl; try { - return new URL(appUrl) + return new URL(appUrl); } catch { - return new URL(fallbackUrl) + return new URL(fallbackUrl); } } @@ -47,12 +47,12 @@ export const metadata: Metadata = { title: siteName, description: siteDescription, }, -} +}; export default function RootLayout({ children, }: Readonly<{ - children: React.ReactNode + children: React.ReactNode; }>) { return ( - ) + ); } diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index c003f44..7a09ea5 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,5 +1,5 @@ -import Link from 'next/link' -import { Button } from '@/components/ui/button' +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; export default function NotFound() { return ( @@ -13,5 +13,5 @@ export default function NotFound() { Back to home
- ) + ); } diff --git a/src/components/header.tsx b/src/components/header.tsx index e85d79d..f5ce0a3 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -1,16 +1,16 @@ -"use client"; -import Link from "next/link"; -import { Logo } from "@/components/logo"; -import { IconMenu2, IconX } from "@tabler/icons-react"; -import { Button } from "@/components/ui/button"; -import React from "react"; -import { cn } from "@/lib/utils"; -import { AnimatePresence, motion } from "motion/react"; -import { useUser } from "@/features/auth/hooks/use-user"; +'use client'; +import Link from 'next/link'; +import { Logo } from '@/components/logo'; +import { IconMenu2, IconX } from '@tabler/icons-react'; +import { Button } from '@/components/ui/button'; +import React from 'react'; +import { cn } from '@/lib/utils'; +import { AnimatePresence, motion } from 'motion/react'; +import { useUser } from '@/features/auth/hooks/use-user'; const menuItems = [ - { name: "Features", href: "/#features" }, - { name: "Pricing", href: "/pricing" }, + { name: 'Features', href: '/#features' }, + { name: 'Pricing', href: '/pricing' }, ]; export const navLinks = menuItems.map((item) => ({ @@ -36,23 +36,17 @@ export const Header = () => { exit={{ opacity: 0 }} transition={{ duration: 0.2 }} onClick={close} - className="fixed inset-0 z-10 backdrop-blur-sm bg-background/20 lg:hidden" + className="fixed inset-0 z-10 bg-background/20 backdrop-blur-sm lg:hidden" /> )} -