├── .env.local.example
├── .github
├── CODEOWNERS
├── ISSUE_TEMPLATE
│ ├── bug_report.yaml
│ ├── config.yml
│ └── feature_request.yaml
├── dependabot.yml
└── workflows
│ └── test.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── SECURITY.md
├── components.json
├── eslint.config.mjs
├── hero.png
├── next.config.mjs
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── public
├── assets
│ ├── background
│ │ ├── checkout-bottom-gradient.svg
│ │ ├── checkout-top-gradient.svg
│ │ ├── grain-bg.svg
│ │ ├── grain-blur.svg
│ │ ├── grid-bg.svg
│ │ ├── login-gradient.svg
│ │ └── small-blur.svg
│ └── icons
│ │ ├── localization-icon.svg
│ │ ├── logo
│ │ ├── aeroedit-icon.svg
│ │ ├── aeroedit-logo-icon.svg
│ │ ├── aeroedit-success-icon.svg
│ │ ├── nextjs-logo.svg
│ │ ├── paddle-logo.svg
│ │ ├── paddle-white-logo.svg
│ │ ├── shadcn-logo.svg
│ │ ├── supabase-logo.svg
│ │ └── tailwind-logo.svg
│ │ ├── price-tiers
│ │ ├── basic-icon.svg
│ │ ├── free-icon.svg
│ │ └── pro-icon.svg
│ │ └── product-icons
│ │ ├── base-plan.png
│ │ ├── free-plan.png
│ │ └── pro-plan.png
└── logo.svg
├── src
├── app
│ ├── api
│ │ └── webhook
│ │ │ └── route.ts
│ ├── auth
│ │ └── callback
│ │ │ └── route.ts
│ ├── checkout
│ │ ├── [priceId]
│ │ │ └── page.tsx
│ │ └── success
│ │ │ └── page.tsx
│ ├── dashboard
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── payments
│ │ │ ├── [subscriptionId]
│ │ │ │ └── page.tsx
│ │ │ └── page.tsx
│ │ └── subscriptions
│ │ │ ├── [subscriptionId]
│ │ │ └── page.tsx
│ │ │ ├── actions.ts
│ │ │ └── page.tsx
│ ├── error
│ │ └── page.tsx
│ ├── favicon.ico
│ ├── layout.tsx
│ ├── login
│ │ ├── actions.ts
│ │ └── page.tsx
│ ├── opengraph-image.png
│ ├── page.tsx
│ └── signup
│ │ ├── actions.ts
│ │ └── page.tsx
├── components
│ ├── authentication
│ │ ├── authentication-form.tsx
│ │ ├── gh-login-button.tsx
│ │ ├── login-form.tsx
│ │ └── sign-up-form.tsx
│ ├── checkout
│ │ ├── checkout-contents.tsx
│ │ ├── checkout-header.tsx
│ │ ├── checkout-line-items.tsx
│ │ ├── checkout-price-amount.tsx
│ │ ├── checkout-price-container.tsx
│ │ ├── price-section.tsx
│ │ └── quantity-field.tsx
│ ├── dashboard
│ │ ├── landing
│ │ │ ├── components
│ │ │ │ ├── dashboard-subscription-card-group.tsx
│ │ │ │ ├── dashboard-team-members-card.tsx
│ │ │ │ ├── dashboard-tutorial-card.tsx
│ │ │ │ └── dashboard-usage-card-group.tsx
│ │ │ └── dashboard-landing-page.tsx
│ │ ├── layout
│ │ │ ├── dashboard-layout.tsx
│ │ │ ├── dashboard-page-header.tsx
│ │ │ ├── error-content.tsx
│ │ │ ├── loading-screen.tsx
│ │ │ ├── mobile-sidebar.tsx
│ │ │ ├── sidebar-user-info.tsx
│ │ │ └── sidebar.tsx
│ │ ├── payments
│ │ │ ├── components
│ │ │ │ ├── columns.tsx
│ │ │ │ └── data-table.tsx
│ │ │ └── payments-content.tsx
│ │ └── subscriptions
│ │ │ ├── components
│ │ │ ├── payment-method-details.tsx
│ │ │ ├── payment-method-section.tsx
│ │ │ ├── subscription-alerts.tsx
│ │ │ ├── subscription-cards.tsx
│ │ │ ├── subscription-detail.tsx
│ │ │ ├── subscription-header-action-button.tsx
│ │ │ ├── subscription-header.tsx
│ │ │ ├── subscription-line-items.tsx
│ │ │ ├── subscription-next-payment-card.tsx
│ │ │ └── subscription-past-payments-card.tsx
│ │ │ ├── subscriptions.tsx
│ │ │ └── views
│ │ │ ├── multiple-subscriptions-view.tsx
│ │ │ ├── no-subscription-view.tsx
│ │ │ └── subscription-error-view.tsx
│ ├── gradients
│ │ ├── checkout-form-gradients.tsx
│ │ ├── checkout-gradients.tsx
│ │ ├── dashboard-gradient.tsx
│ │ ├── featured-card-gradient.tsx
│ │ ├── home-page-background.tsx
│ │ ├── login-card-gradient.tsx
│ │ ├── login-gradient.tsx
│ │ └── success-page-gradients.tsx
│ ├── home
│ │ ├── footer
│ │ │ ├── built-using-tools.tsx
│ │ │ ├── footer.tsx
│ │ │ └── powered-by-paddle.tsx
│ │ ├── header
│ │ │ ├── country-dropdown.tsx
│ │ │ ├── header.tsx
│ │ │ └── localization-banner.tsx
│ │ ├── hero-section
│ │ │ └── hero-section.tsx
│ │ ├── home-page.tsx
│ │ └── pricing
│ │ │ ├── features-list.tsx
│ │ │ ├── price-amount.tsx
│ │ │ ├── price-cards.tsx
│ │ │ ├── price-title.tsx
│ │ │ └── pricing.tsx
│ ├── shared
│ │ ├── confirmation
│ │ │ └── confirmation.tsx
│ │ ├── select
│ │ │ └── select.tsx
│ │ ├── status
│ │ │ └── status.tsx
│ │ └── toggle
│ │ │ └── toggle.tsx
│ └── ui
│ │ ├── accordion.tsx
│ │ ├── alert.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── dialog.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── select.tsx
│ │ ├── separator.tsx
│ │ ├── sheet.tsx
│ │ ├── skeleton.tsx
│ │ ├── table.tsx
│ │ ├── tabs.tsx
│ │ ├── toast.tsx
│ │ ├── toaster.tsx
│ │ └── use-toast.ts
├── constants
│ ├── billing-frequency.ts
│ └── pricing-tier.ts
├── hooks
│ ├── usePaddlePrices.ts
│ ├── usePagination.ts
│ └── useUserInfo.ts
├── lib
│ ├── api.types.ts
│ ├── database.types.ts
│ └── utils.ts
├── middleware.ts
├── styles
│ ├── checkout.css
│ ├── dashboard.css
│ ├── globals.css
│ ├── home-page.css
│ ├── layout.css
│ └── login.css
└── utils
│ ├── paddle
│ ├── data-helpers.ts
│ ├── get-customer-id.ts
│ ├── get-paddle-instance.ts
│ ├── get-subscription.ts
│ ├── get-subscriptions.ts
│ ├── get-transactions.ts
│ ├── parse-money.ts
│ └── process-webhook.ts
│ └── supabase
│ ├── client.ts
│ ├── middleware.ts
│ ├── server-internal.ts
│ └── server.ts
├── supabase
├── .gitignore
├── config.toml
├── migrations
│ └── 20240907140223_initialize.sql
└── seed.sql
└── tsconfig.json
/.env.local.example:
--------------------------------------------------------------------------------
1 | # Supabase
2 | ## Private
3 | SUPABASE_SERVICE_ROLE_KEY=
4 |
5 | ## Public
6 | NEXT_PUBLIC_SUPABASE_URL=
7 | NEXT_PUBLIC_SUPABASE_ANON_KEY=
8 |
9 | # Paddle
10 | ## Private
11 | NEXT_PUBLIC_PADDLE_ENV=sandbox # or `production`
12 | PADDLE_API_KEY=
13 | PADDLE_NOTIFICATION_WEBHOOK_SECRET=
14 |
15 | ## Public
16 | NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=
17 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @PaddleHQ/developer-experience
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yaml:
--------------------------------------------------------------------------------
1 | name: Bug report
2 | description: Report a problem.
3 | title: '[Bug]: '
4 | labels: ['bug']
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Use this form to report a bug or problem with the Next.js starter kit for Paddle Billing.
10 |
11 | Remember to remove sensitive information from screenshots, videos, or code samples before submitting.
12 |
13 | **Do not create issues for potential security vulnerabilities.** Please see the [Paddle Vulnerability Disclosure Policy](https://www.paddle.com/vulnerability-disclosure-policy) and report any vulnerabilities [using our form](https://vdp.paddle.com/p/Report-a-Vulnerability).
14 |
15 | Thanks for helping to make the Paddle platform better for everyone!
16 | - type: textarea
17 | id: description
18 | attributes:
19 | label: What happened?
20 | description: Describe the bug in a sentence or two. Feel free to add screenshots or a video to better explain!
21 | validations:
22 | required: true
23 | - type: textarea
24 | id: reproduce
25 | attributes:
26 | label: Steps to reproduce
27 | description: Explain how to reproduce this issue. We prefer a step-by-step walkthrough, where possible.
28 | value: |
29 | 1.
30 | 2.
31 | 3.
32 | ...
33 | validations:
34 | required: true
35 | - type: textarea
36 | id: expected-behavior
37 | attributes:
38 | label: What did you expect to happen?
39 | description: Tell us what should happen when you encounter this bug.
40 | - type: textarea
41 | id: logs
42 | attributes:
43 | label: Logs
44 | description: Copy and paste any relevant logs. This is automatically formatted into code, so no need for backticks.
45 | render: shell
46 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: Get help
4 | url: https://developer.paddle.com/
5 | about: For help with the Paddle Node.js SDK or building your integration, contact our support team at [sellers@paddle.com](mailto:sellers@paddle.com).
6 | - name: Report a vulnerability
7 | url: https://vdp.paddle.com/p/Report-a-Vulnerability
8 | about: Please see the [Paddle Vulnerability Disclosure Policy](https://www.paddle.com/vulnerability-disclosure-policy) and report any vulnerabilities using https://vdp.paddle.com/p/Report-a-Vulnerability.
9 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yaml:
--------------------------------------------------------------------------------
1 | name: Feature request
2 | description: Suggest an idea.
3 | title: '[Feature]: '
4 | labels: ['feature']
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Use this form to send us suggestions for improvements to the Next.js starter kit for Paddle Billing.
10 |
11 | For general feedback about the Paddle API or developer platform, contact our DX team directly
12 | at [team-dx@paddle.com](mailto:team-dx@paddle.com).
13 |
14 | Thanks for helping to make the Paddle platform better for everyone!
15 | - type: textarea
16 | id: request
17 | attributes:
18 | label: Tell us about your feature request
19 | description: Describe what you'd like to see added or improved.
20 | validations:
21 | required: true
22 | - type: textarea
23 | id: problem
24 | attributes:
25 | label: What problem are you looking to solve?
26 | description: Tell us how and why would implementing your suggestion would help.
27 | validations:
28 | required: true
29 | - type: textarea
30 | id: additional-information
31 | attributes:
32 | label: Additional context
33 | description: Add any other context, screenshots, or illustrations about your suggestion here.
34 | - type: dropdown
35 | id: priority
36 | attributes:
37 | label: How important is this suggestion to you?
38 | options:
39 | - Nice to have
40 | - Important
41 | - Critical
42 | default: 0
43 | validations:
44 | required: true
45 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: 'npm'
4 | directory: '/'
5 | schedule:
6 | interval: 'weekly'
7 | day: 'monday'
8 | time: '08:00'
9 | timezone: 'UTC'
10 | open-pull-requests-limit: 10
11 | groups:
12 | minor-patch-dependencies:
13 | patterns:
14 | - '*'
15 | update-types:
16 | - 'minor'
17 | - 'patch'
18 | major-dependencies:
19 | patterns:
20 | - '*'
21 | update-types:
22 | - 'major'
23 | labels:
24 | - 'dependencies'
25 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 | on: pull_request
3 | jobs:
4 | test:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - name: Cancel running workflows
8 | uses: styfle/cancel-workflow-action@0.12.0
9 | with:
10 | access_token: ${{ github.token }}
11 | - name: Checkout repo
12 | uses: actions/checkout@v4
13 | - name: Set node version
14 | uses: actions/setup-node@v3
15 | with:
16 | node-version-file: '.nvmrc'
17 | - name: Install pnpm
18 | uses: pnpm/action-setup@v4
19 | with:
20 | version: 9
21 | - name: Cache node_modules
22 | id: node-modules-cache
23 | uses: actions/cache@v3
24 | with:
25 | path: '**/node_modules'
26 | key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
27 | - name: Install dependencies
28 | if: steps.node-modules-cache.outputs.cache-hit != 'true'
29 | run: pnpm install --frozen-lockfile
30 | - name: Run tests
31 | run: pnpm test
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 | .idea
23 |
24 | # debug
25 | npm-debug.log*
26 | yarn-debug.log*
27 | yarn-error.log*
28 |
29 | # local env files
30 | .env*.local
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 20
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | pnpm-lock.yaml
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "semi": true,
4 | "tabWidth": 2,
5 | "printWidth": 120,
6 | "trailingComma": "all",
7 | "bracketSpacing": true
8 | }
9 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ## Contributing
2 |
3 | If you've spotted a problem with this package or have a new feature request, please open an issue.
4 |
5 | For help with the Paddle API or building your integration, contact our support team at [sellers@paddle.com](mailto:sellers@paddle.com).
6 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | - [Security Policy](#security-policy)
2 | - [Reporting a Vulnerability](#reporting-a-vulnerability)
3 |
4 | # Security policy
5 |
6 | ## Reporting a vulnerability
7 |
8 | Please see the [Paddle Vulnerability Disclosure Policy](https://www.paddle.com/vulnerability-disclosure-policy) and
9 | report any vulnerabilities using https://vdp.paddle.com/p/Report-a-Vulnerability.
10 |
11 | > [!WARNING]
12 | > Do not create issues for potential security vulnerabilities. Issues are public and can be seen by potentially malicious actors.
13 |
14 | Thanks for helping to make the Paddle platform safe for everyone.
15 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "src/app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
22 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from 'path';
2 | import { fileURLToPath } from 'url';
3 | import { FlatCompat } from '@eslint/eslintrc';
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [...compat.extends('next/core-web-vitals', 'next/typescript')];
13 |
14 | export default eslintConfig;
15 |
--------------------------------------------------------------------------------
/hero.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PaddleHQ/paddle-nextjs-starter-kit/c3bfe45929fd5cc9af4f710e8b9089f9e457b2e9/hero.png
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | domains: ['cdn.simpleicons.org', 'localhost', 'paddle-billing.vercel.app'],
5 | },
6 | };
7 |
8 | export default nextConfig;
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@paddle/nextjs-starter-kit",
3 | "version": "0.1.0",
4 | "private": true,
5 | "engines": {
6 | "node": ">=20"
7 | },
8 | "scripts": {
9 | "dev": "next dev",
10 | "build": "next build",
11 | "start": "next start",
12 | "lint": "next lint",
13 | "lint:fix": "next lint --fix",
14 | "prettier": "prettier --write --ignore-unknown .",
15 | "prettier:check": "prettier --check --ignore-unknown .",
16 | "lint-staged": "lint-staged",
17 | "test": "pnpm lint && pnpm prettier:check"
18 | },
19 | "git": {
20 | "pre-commit": "lint-staged"
21 | },
22 | "lint-staged": {
23 | "*": "prettier --write --ignore-unknown"
24 | },
25 | "dependencies": {
26 | "@paddle/paddle-js": "^1.4.2",
27 | "@paddle/paddle-node-sdk": "^3.2.1",
28 | "@radix-ui/react-accordion": "^1.2.12",
29 | "@radix-ui/react-dialog": "^1.1.15",
30 | "@radix-ui/react-dropdown-menu": "^2.1.16",
31 | "@radix-ui/react-label": "^2.1.6",
32 | "@radix-ui/react-select": "^2.2.6",
33 | "@radix-ui/react-separator": "^1.1.6",
34 | "@radix-ui/react-slot": "^1.2.2",
35 | "@radix-ui/react-tabs": "^1.1.13",
36 | "@radix-ui/react-toast": "^1.2.15",
37 | "@supabase/ssr": "^0.7.0",
38 | "@supabase/supabase-js": "^2.57.4",
39 | "@tanstack/react-table": "^8.21.3",
40 | "class-variance-authority": "^0.7.1",
41 | "clsx": "^2.1.1",
42 | "dayjs": "^1.11.18",
43 | "lodash.throttle": "^4.1.1",
44 | "lucide-react": "^0.544.0",
45 | "next": "15.5.3",
46 | "react": "19.1.1",
47 | "react-dom": "19.1.1",
48 | "tailwind-merge": "^3.3.1",
49 | "tailwindcss-animate": "^1.0.7"
50 | },
51 | "devDependencies": {
52 | "@eslint/eslintrc": "^3.3.1",
53 | "@tailwindcss/postcss": "^4.1.13",
54 | "@types/lodash.throttle": "^4.1.9",
55 | "@types/node": "^24.5.2",
56 | "@types/react": "19.1.13",
57 | "@types/react-dom": "19.1.9",
58 | "@vercel/git-hooks": "^1.0.0",
59 | "eslint": "^9.36.0",
60 | "eslint-config-next": "15.5.3",
61 | "lint-staged": "^16.2.0",
62 | "postcss": "^8.5.6",
63 | "prettier": "^3.6.2",
64 | "tailwindcss": "^4.1.13",
65 | "typescript": "^5.9.2"
66 | },
67 | "pnpm": {
68 | "overrides": {
69 | "@types/react": "19.1.13",
70 | "@types/react-dom": "19.1.9"
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | '@tailwindcss/postcss': {},
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/public/assets/background/checkout-bottom-gradient.svg:
--------------------------------------------------------------------------------
1 |
30 |
--------------------------------------------------------------------------------
/public/assets/background/checkout-top-gradient.svg:
--------------------------------------------------------------------------------
1 |
30 |
--------------------------------------------------------------------------------
/public/assets/background/grain-blur.svg:
--------------------------------------------------------------------------------
1 |
30 |
--------------------------------------------------------------------------------
/public/assets/background/login-gradient.svg:
--------------------------------------------------------------------------------
1 |
30 |
--------------------------------------------------------------------------------
/public/assets/background/small-blur.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/icons/localization-icon.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/public/assets/icons/logo/nextjs-logo.svg:
--------------------------------------------------------------------------------
1 |
18 |
--------------------------------------------------------------------------------
/public/assets/icons/logo/paddle-logo.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/public/assets/icons/logo/paddle-white-logo.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/public/assets/icons/logo/tailwind-logo.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/assets/icons/price-tiers/basic-icon.svg:
--------------------------------------------------------------------------------
1 |
13 |
--------------------------------------------------------------------------------
/public/assets/icons/price-tiers/free-icon.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/public/assets/icons/price-tiers/pro-icon.svg:
--------------------------------------------------------------------------------
1 |
13 |
--------------------------------------------------------------------------------
/public/assets/icons/product-icons/base-plan.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PaddleHQ/paddle-nextjs-starter-kit/c3bfe45929fd5cc9af4f710e8b9089f9e457b2e9/public/assets/icons/product-icons/base-plan.png
--------------------------------------------------------------------------------
/public/assets/icons/product-icons/free-plan.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PaddleHQ/paddle-nextjs-starter-kit/c3bfe45929fd5cc9af4f710e8b9089f9e457b2e9/public/assets/icons/product-icons/free-plan.png
--------------------------------------------------------------------------------
/public/assets/icons/product-icons/pro-plan.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PaddleHQ/paddle-nextjs-starter-kit/c3bfe45929fd5cc9af4f710e8b9089f9e457b2e9/public/assets/icons/product-icons/pro-plan.png
--------------------------------------------------------------------------------
/src/app/api/webhook/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from 'next/server';
2 | import { ProcessWebhook } from '@/utils/paddle/process-webhook';
3 | import { getPaddleInstance } from '@/utils/paddle/get-paddle-instance';
4 |
5 | const webhookProcessor = new ProcessWebhook();
6 |
7 | export async function POST(request: NextRequest) {
8 | const signature = request.headers.get('paddle-signature') || '';
9 | const rawRequestBody = await request.text();
10 | const privateKey = process.env['PADDLE_NOTIFICATION_WEBHOOK_SECRET'] || '';
11 |
12 | try {
13 | if (!signature || !rawRequestBody) {
14 | return Response.json({ error: 'Missing signature from header' }, { status: 400 });
15 | }
16 |
17 | const paddle = getPaddleInstance();
18 | const eventData = await paddle.webhooks.unmarshal(rawRequestBody, privateKey, signature);
19 | const eventName = eventData?.eventType ?? 'Unknown event';
20 |
21 | if (eventData) {
22 | await webhookProcessor.processEvent(eventData);
23 | }
24 |
25 | return Response.json({ status: 200, eventName });
26 | } catch (e) {
27 | console.log(e);
28 | return Response.json({ error: 'Internal server error' }, { status: 500 });
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/auth/callback/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { createClient } from '@/utils/supabase/server';
3 |
4 | export async function GET(request: Request) {
5 | const { searchParams, origin } = new URL(request.url);
6 | const code = searchParams.get('code');
7 | // if "next" is in param, use it as the redirect URL
8 | const next = searchParams.get('next') ?? '/';
9 |
10 | if (code) {
11 | const supabase = await createClient();
12 | const { error } = await supabase.auth.exchangeCodeForSession(code);
13 | if (!error) {
14 | return NextResponse.redirect(`${origin}${next}`);
15 | }
16 | }
17 |
18 | // return the user to an error page with instructions
19 | return NextResponse.redirect(`${origin}/auth/auth-code-error`);
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/checkout/[priceId]/page.tsx:
--------------------------------------------------------------------------------
1 | import { CheckoutGradients } from '@/components/gradients/checkout-gradients';
2 | import '../../../styles/checkout.css';
3 | import { CheckoutHeader } from '@/components/checkout/checkout-header';
4 | import { CheckoutContents } from '@/components/checkout/checkout-contents';
5 | import { createClient } from '@/utils/supabase/server';
6 |
7 | export default async function CheckoutPage() {
8 | const supabase = await createClient();
9 | const { data } = await supabase.auth.getUser();
10 | return (
11 |
12 |
13 |
16 |
17 |
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/checkout/success/page.tsx:
--------------------------------------------------------------------------------
1 | import { SuccessPageGradients } from '@/components/gradients/success-page-gradients';
2 | import Image from 'next/image';
3 | import { Button } from '@/components/ui/button';
4 | import Link from 'next/link';
5 | import { PoweredByPaddle } from '@/components/home/footer/powered-by-paddle';
6 | import '../../../styles/checkout.css';
7 | import { createClient } from '@/utils/supabase/server';
8 |
9 | export default async function SuccessPage() {
10 | const supabase = await createClient();
11 | const { data } = await supabase.auth.getUser();
12 |
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
26 |
27 | Payment successful
28 |
29 |
Success! Your payment is complete, and you’re all set.
30 |
33 |
34 |
35 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/app/dashboard/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 | import { DashboardLayout } from '@/components/dashboard/layout/dashboard-layout';
3 | import { createClient } from '@/utils/supabase/server';
4 | import { redirect } from 'next/navigation';
5 |
6 | interface Props {
7 | children: ReactNode;
8 | }
9 |
10 | export default async function Layout({ children }: Props) {
11 | const supabase = await createClient();
12 | const { data } = await supabase.auth.getUser();
13 | if (!data.user) {
14 | redirect('/login');
15 | }
16 | return {children};
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | import { DashboardPageHeader } from '@/components/dashboard/layout/dashboard-page-header';
2 | import { DashboardLandingPage } from '@/components/dashboard/landing/dashboard-landing-page';
3 |
4 | export default function LandingPage() {
5 | return (
6 |
7 |
8 |
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/dashboard/payments/[subscriptionId]/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { DashboardPageHeader } from '@/components/dashboard/layout/dashboard-page-header';
4 | import { PaymentsContent } from '@/components/dashboard/payments/payments-content';
5 | import { LoadingScreen } from '@/components/dashboard/layout/loading-screen';
6 | import { Suspense } from 'react';
7 | import { useParams } from 'next/navigation';
8 |
9 | export default function SubscriptionsPaymentPage() {
10 | const { subscriptionId } = useParams<{ subscriptionId: string }>();
11 |
12 | return (
13 |
14 |
15 | }>
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/dashboard/payments/page.tsx:
--------------------------------------------------------------------------------
1 | import { DashboardPageHeader } from '@/components/dashboard/layout/dashboard-page-header';
2 | import { PaymentsContent } from '@/components/dashboard/payments/payments-content';
3 | import { LoadingScreen } from '@/components/dashboard/layout/loading-screen';
4 | import { Suspense } from 'react';
5 |
6 | export default async function PaymentsPage() {
7 | return (
8 |
9 |
10 | }>
11 |
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/dashboard/subscriptions/[subscriptionId]/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { LoadingScreen } from '@/components/dashboard/layout/loading-screen';
4 | import { Suspense } from 'react';
5 | import { useParams } from 'next/navigation';
6 | import { SubscriptionDetail } from '@/components/dashboard/subscriptions/components/subscription-detail';
7 |
8 | export default function SubscriptionPage() {
9 | const { subscriptionId } = useParams<{ subscriptionId: string }>();
10 | return (
11 |
12 | }>
13 |
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/dashboard/subscriptions/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { validateUserSession } from '@/utils/supabase/server';
4 | import { Subscription } from '@paddle/paddle-node-sdk';
5 | import { revalidatePath } from 'next/cache';
6 | import { getPaddleInstance } from '@/utils/paddle/get-paddle-instance';
7 |
8 | const paddle = getPaddleInstance();
9 |
10 | interface Error {
11 | error: string;
12 | }
13 |
14 | export async function cancelSubscription(subscriptionId: string): Promise {
15 | try {
16 | await validateUserSession();
17 |
18 | const subscription = await paddle.subscriptions.cancel(subscriptionId, { effectiveFrom: 'next_billing_period' });
19 | if (subscription) {
20 | revalidatePath('/dashboard/subscriptions');
21 | }
22 | return JSON.parse(JSON.stringify(subscription));
23 | } catch (e) {
24 | console.log('Error canceling subscription', e);
25 | return { error: 'Something went wrong, please try again later' };
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/dashboard/subscriptions/page.tsx:
--------------------------------------------------------------------------------
1 | import { LoadingScreen } from '@/components/dashboard/layout/loading-screen';
2 | import { Suspense } from 'react';
3 | import { Subscriptions } from '@/components/dashboard/subscriptions/subscriptions';
4 |
5 | export default async function SubscriptionsListPage() {
6 | return (
7 |
8 | }>
9 |
10 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/app/error/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 |
3 | export const metadata: Metadata = {
4 | title: 'AeroEdit - Error',
5 | };
6 |
7 | export default function ErrorPage() {
8 | return (
9 |
10 |
11 | Something went wrong, please try again later
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PaddleHQ/paddle-nextjs-starter-kit/c3bfe45929fd5cc9af4f710e8b9089f9e457b2e9/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Inter } from 'next/font/google';
2 | import '../styles/globals.css';
3 | import '../styles/layout.css';
4 | import { ReactNode } from 'react';
5 | import type { Metadata } from 'next';
6 | import { Toaster } from '@/components/ui/toaster';
7 |
8 | const inter = Inter({ subsets: ['latin'] });
9 |
10 | export const metadata: Metadata = {
11 | metadataBase: new URL('https://paddle-billing.vercel.app'),
12 | title: 'AeroEdit',
13 | description:
14 | 'AeroEdit is a powerful team design collaboration app and image editor. With plans for businesses of all sizes, streamline your workflow with real-time collaboration, advanced editing tools, and seamless project management.',
15 | };
16 |
17 | export default function RootLayout({
18 | children,
19 | }: Readonly<{
20 | children: ReactNode;
21 | }>) {
22 | return (
23 |
24 |
25 | {children}
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/login/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { revalidatePath } from 'next/cache';
4 | import { redirect } from 'next/navigation';
5 | import { createClient } from '@/utils/supabase/server';
6 |
7 | interface FormData {
8 | email: string;
9 | password: string;
10 | }
11 | export async function login(data: FormData) {
12 | const supabase = await createClient();
13 | const { error } = await supabase.auth.signInWithPassword(data);
14 |
15 | if (error) {
16 | return { error: true };
17 | }
18 |
19 | revalidatePath('/', 'layout');
20 | redirect('/');
21 | }
22 |
23 | export async function signInWithGithub() {
24 | const supabase = await createClient();
25 | const { data } = await supabase.auth.signInWithOAuth({
26 | provider: 'github',
27 | options: {
28 | redirectTo: `https://paddle-billing.vercel.app/auth/callback`,
29 | },
30 | });
31 | if (data.url) {
32 | redirect(data.url);
33 | }
34 | }
35 |
36 | export async function loginAnonymously() {
37 | const supabase = await createClient();
38 | const { error: signInError } = await supabase.auth.signInAnonymously();
39 | const { error: updateUserError } = await supabase.auth.updateUser({
40 | email: `aeroedit+${Date.now().toString(36)}@paddle.com`,
41 | });
42 |
43 | if (signInError || updateUserError) {
44 | return { error: true };
45 | }
46 |
47 | revalidatePath('/', 'layout');
48 | redirect('/');
49 | }
50 |
--------------------------------------------------------------------------------
/src/app/login/page.tsx:
--------------------------------------------------------------------------------
1 | import { LoginGradient } from '@/components/gradients/login-gradient';
2 | import '../../styles/login.css';
3 | import { LoginCardGradient } from '@/components/gradients/login-card-gradient';
4 | import { LoginForm } from '@/components/authentication/login-form';
5 | import { GhLoginButton } from '@/components/authentication/gh-login-button';
6 |
7 | export default function LoginPage() {
8 | return (
9 |
10 |
11 |
12 |
17 |
18 |
19 |
20 |
21 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/app/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PaddleHQ/paddle-nextjs-starter-kit/c3bfe45929fd5cc9af4f710e8b9089f9e457b2e9/src/app/opengraph-image.png
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { HomePage } from '@/components/home/home-page';
2 |
3 | export default function Home() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/signup/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { revalidatePath } from 'next/cache';
4 | import { redirect } from 'next/navigation';
5 |
6 | import { createClient } from '@/utils/supabase/server';
7 |
8 | interface FormData {
9 | email: string;
10 | password: string;
11 | }
12 |
13 | export async function signup(data: FormData) {
14 | const supabase = await createClient();
15 | const { error } = await supabase.auth.signUp(data);
16 |
17 | if (error) {
18 | return { error: true };
19 | }
20 |
21 | revalidatePath('/', 'layout');
22 | redirect('/');
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/signup/page.tsx:
--------------------------------------------------------------------------------
1 | import { LoginGradient } from '@/components/gradients/login-gradient';
2 | import '../../styles/login.css';
3 | import { LoginCardGradient } from '@/components/gradients/login-card-gradient';
4 | import { GhLoginButton } from '@/components/authentication/gh-login-button';
5 | import { SignupForm } from '@/components/authentication/sign-up-form';
6 |
7 | export default function SignupPage() {
8 | return (
9 |
10 |
11 |
12 |
17 |
18 |
19 |
20 |
21 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/authentication/authentication-form.tsx:
--------------------------------------------------------------------------------
1 | import { Label } from '@/components/ui/label';
2 | import { Input } from '@/components/ui/input';
3 |
4 | interface Props {
5 | email: string;
6 | password: string;
7 | onEmailChange: (email: string) => void;
8 | onPasswordChange: (password: string) => void;
9 | }
10 |
11 | export function AuthenticationForm({ email, onEmailChange, onPasswordChange, password }: Props) {
12 | return (
13 | <>
14 |
15 |
18 | onEmailChange(e.target.value)}
25 | />
26 |
27 |
28 |
31 | onPasswordChange(e.target.value)}
38 | />
39 |
40 | >
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/src/components/authentication/gh-login-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Separator } from '@/components/ui/separator';
4 | import { Button } from '@/components/ui/button';
5 | import { signInWithGithub } from '@/app/login/actions';
6 | import Image from 'next/image';
7 |
8 | interface Props {
9 | label: string;
10 | }
11 | export function GhLoginButton({ label }: Props) {
12 | return (
13 |
18 |
23 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/authentication/login-form.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Image from 'next/image';
4 | import { Button } from '@/components/ui/button';
5 | import { login, loginAnonymously } from '@/app/login/actions';
6 | import { useState } from 'react';
7 | import { AuthenticationForm } from '@/components/authentication/authentication-form';
8 | import { Separator } from '@/components/ui/separator';
9 | import { useToast } from '@/components/ui/use-toast';
10 |
11 | export function LoginForm() {
12 | const { toast } = useToast();
13 | const [email, setEmail] = useState('');
14 | const [password, setPassword] = useState('');
15 |
16 | function handleLogin() {
17 | login({ email, password }).then((data) => {
18 | if (data?.error) {
19 | toast({ description: 'Invalid email or password', variant: 'destructive' });
20 | }
21 | });
22 | }
23 |
24 | function handleAnonymousLogin() {
25 | loginAnonymously().then((data) => {
26 | if (data?.error) {
27 | toast({ description: 'Something went wrong. Please try again', variant: 'destructive' });
28 | }
29 | });
30 | }
31 |
32 | return (
33 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/src/components/authentication/sign-up-form.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Image from 'next/image';
4 | import { Button } from '@/components/ui/button';
5 | import { useState } from 'react';
6 | import { AuthenticationForm } from '@/components/authentication/authentication-form';
7 | import { signup } from '@/app/signup/actions';
8 | import { useToast } from '@/components/ui/use-toast';
9 |
10 | export function SignupForm() {
11 | const { toast } = useToast();
12 | const [email, setEmail] = useState('');
13 | const [password, setPassword] = useState('');
14 |
15 | function handleSignup() {
16 | signup({ email, password }).then((data) => {
17 | if (data?.error) {
18 | toast({ description: 'Something went wrong. Please try again', variant: 'destructive' });
19 | }
20 | });
21 | }
22 |
23 | return (
24 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/checkout/checkout-contents.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { PriceSection } from '@/components/checkout/price-section';
4 | import { CheckoutFormGradients } from '@/components/gradients/checkout-form-gradients';
5 | import { type Environments, initializePaddle, type Paddle } from '@paddle/paddle-js';
6 | import type { CheckoutEventsData } from '@paddle/paddle-js/types/checkout/events';
7 | import throttle from 'lodash.throttle';
8 | import { useParams } from 'next/navigation';
9 | import { useCallback, useEffect, useState } from 'react';
10 |
11 | interface PathParams {
12 | priceId: string;
13 | [key: string]: string | string[];
14 | }
15 |
16 | interface Props {
17 | userEmail?: string;
18 | }
19 |
20 | export function CheckoutContents({ userEmail }: Props) {
21 | const { priceId } = useParams();
22 | const [quantity, setQuantity] = useState(1);
23 | const [paddle, setPaddle] = useState(null);
24 | const [checkoutData, setCheckoutData] = useState(null);
25 |
26 | const handleCheckoutEvents = (event: CheckoutEventsData) => {
27 | setCheckoutData(event);
28 | };
29 |
30 | const updateItems = useCallback(
31 | throttle((paddle: Paddle, priceId: string, quantity: number) => {
32 | paddle.Checkout.updateItems([{ priceId, quantity }]);
33 | }, 1000),
34 | [],
35 | );
36 |
37 | useEffect(() => {
38 | if (!paddle?.Initialized && process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN && process.env.NEXT_PUBLIC_PADDLE_ENV) {
39 | initializePaddle({
40 | token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN,
41 | environment: process.env.NEXT_PUBLIC_PADDLE_ENV as Environments,
42 | eventCallback: (event) => {
43 | if (event.data && event.name) {
44 | handleCheckoutEvents(event.data);
45 | }
46 | },
47 | checkout: {
48 | settings: {
49 | variant: 'one-page',
50 | displayMode: 'inline',
51 | theme: 'dark',
52 | allowLogout: !userEmail,
53 | frameTarget: 'paddle-checkout-frame',
54 | frameInitialHeight: 450,
55 | frameStyle: 'width: 100%; background-color: transparent; border: none',
56 | successUrl: '/checkout/success',
57 | },
58 | },
59 | }).then(async (paddle) => {
60 | if (paddle && priceId) {
61 | setPaddle(paddle);
62 | paddle.Checkout.open({
63 | ...(userEmail && { customer: { email: userEmail } }),
64 | items: [{ priceId: priceId, quantity: 1 }],
65 | });
66 | }
67 | });
68 | }
69 | }, [paddle?.Initialized, priceId, userEmail]);
70 |
71 | useEffect(() => {
72 | if (paddle && priceId && paddle.Initialized) {
73 | updateItems(paddle, priceId, quantity);
74 | }
75 | }, [paddle, priceId, quantity, updateItems]);
76 |
77 | return (
78 |
83 |
84 |
85 |
88 |
89 |
Payment details
90 |
91 |
92 |
93 |
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/src/components/checkout/checkout-header.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import { ChevronLeft } from 'lucide-react';
3 | import Link from 'next/link';
4 | import Image from 'next/image';
5 |
6 | export function CheckoutHeader() {
7 | return (
8 |
9 |
10 |
13 |
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/checkout/checkout-line-items.tsx:
--------------------------------------------------------------------------------
1 | import { QuantityField } from '@/components/checkout/quantity-field';
2 | import { Separator } from '@/components/ui/separator';
3 | import { CheckoutEventsData } from '@paddle/paddle-js/types/checkout/events';
4 | import { formatMoney } from '@/utils/paddle/parse-money';
5 | import { Skeleton } from '@/components/ui/skeleton';
6 |
7 | interface LoadingTextProps {
8 | value: number | undefined;
9 | currencyCode: string | undefined;
10 | }
11 |
12 | function LoadingText({ value, currencyCode }: LoadingTextProps) {
13 | if (value === undefined) {
14 | return ;
15 | } else {
16 | return formatMoney(value, currencyCode);
17 | }
18 | }
19 |
20 | interface Props {
21 | checkoutData: CheckoutEventsData | null;
22 | quantity: number;
23 | handleQuantityChange: (quantity: number) => void;
24 | }
25 |
26 | export function CheckoutLineItems({ handleQuantityChange, checkoutData, quantity }: Props) {
27 | return (
28 | <>
29 | {checkoutData?.items[0].price_name}
30 |
31 |
32 |
33 | Subtotal
34 |
35 |
36 |
37 |
38 |
39 | Tax
40 |
41 |
42 |
43 |
44 |
45 |
46 | Due today
47 |
48 |
49 |
50 |
51 | >
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/checkout/checkout-price-amount.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from '@/components/ui/skeleton';
2 | import { CheckoutEventsData } from '@paddle/paddle-js/types/checkout/events';
3 | import { formatMoney } from '@/utils/paddle/parse-money';
4 |
5 | interface Props {
6 | checkoutData: CheckoutEventsData | null;
7 | }
8 |
9 | export function CheckoutPriceAmount({ checkoutData }: Props) {
10 | const total = checkoutData?.totals.total;
11 | return (
12 | <>
13 | {total !== undefined ? (
14 |
15 | {formatMoney(total, checkoutData?.currency_code)}
16 | inc. tax
17 |
18 | ) : (
19 |
20 | )}
21 | >
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/checkout/checkout-price-container.tsx:
--------------------------------------------------------------------------------
1 | import { CheckoutPriceAmount } from '@/components/checkout/checkout-price-amount';
2 | import { CheckoutEventsData } from '@paddle/paddle-js/types/checkout/events';
3 | import { formatMoney } from '@/utils/paddle/parse-money';
4 | import { Skeleton } from '@/components/ui/skeleton';
5 | import { formatBillingCycle } from '@/utils/paddle/data-helpers';
6 |
7 | interface Props {
8 | checkoutData: CheckoutEventsData | null;
9 | }
10 |
11 | export function CheckoutPriceContainer({ checkoutData }: Props) {
12 | const recurringTotal = checkoutData?.recurring_totals?.total;
13 | const billingCycle = checkoutData?.items.find((item) => item.billing_cycle)?.billing_cycle;
14 | return (
15 | <>
16 | Order summary
17 |
18 | {recurringTotal !== undefined ? (
19 | billingCycle && (
20 |
21 | then {formatMoney(recurringTotal, checkoutData?.currency_code)} {formatBillingCycle(billingCycle)}
22 |
23 | )
24 | ) : (
25 |
26 | )}
27 | >
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/checkout/price-section.tsx:
--------------------------------------------------------------------------------
1 | import { CheckoutLineItems } from '@/components/checkout/checkout-line-items';
2 | import { CheckoutPriceContainer } from '@/components/checkout/checkout-price-container';
3 | import { CheckoutPriceAmount } from '@/components/checkout/checkout-price-amount';
4 | import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
5 | import { Separator } from '@/components/ui/separator';
6 | import { CheckoutEventsData } from '@paddle/paddle-js/types/checkout/events';
7 |
8 | interface Props {
9 | checkoutData: CheckoutEventsData | null;
10 | quantity: number;
11 | handleQuantityChange: (quantity: number) => void;
12 | }
13 |
14 | export function PriceSection({ checkoutData, handleQuantityChange, quantity }: Props) {
15 | return (
16 | <>
17 |
18 |
19 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | Order summary
31 |
32 |
37 |
38 |
39 |
40 |
41 | >
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/checkout/quantity-field.tsx:
--------------------------------------------------------------------------------
1 | import { Minus, Plus } from 'lucide-react';
2 | import { Button } from '@/components/ui/button';
3 |
4 | interface Props {
5 | quantity: number;
6 | handleQuantityChange: (quantity: number) => void;
7 | }
8 |
9 | export function QuantityField({ handleQuantityChange, quantity }: Props) {
10 | return (
11 |
12 |
22 |
23 | {quantity}
24 |
25 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/dashboard/landing/components/dashboard-subscription-card-group.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
2 | import { Button } from '@/components/ui/button';
3 | import Link from 'next/link';
4 | import { SubscriptionCards } from '@/components/dashboard/subscriptions/components/subscription-cards';
5 | import { getSubscriptions } from '@/utils/paddle/get-subscriptions';
6 | import { ErrorContent } from '@/components/dashboard/layout/error-content';
7 |
8 | export async function DashboardSubscriptionCardGroup() {
9 | const subscriptions = await getSubscriptions();
10 | return (
11 |
12 |
13 |
14 | Active subscriptions
15 |
18 |
19 |
20 |
21 | {subscriptions?.data ? (
22 |
26 | ) : (
27 |
28 | )}
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/dashboard/landing/components/dashboard-team-members-card.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
4 | import { Button } from '@/components/ui/button';
5 | import Link from 'next/link';
6 | import { Plus } from 'lucide-react';
7 |
8 | const teamMembers = [
9 | {
10 | name: 'Daniel Cromitch',
11 | email: 'dc@paddle.com',
12 | initials: 'DC',
13 | role: 'Owner',
14 | },
15 | {
16 | name: 'Melissa Lee',
17 | email: 'ml@paddle.com',
18 | initials: 'ML',
19 | role: 'Member',
20 | },
21 | {
22 | name: 'Jackson Khan',
23 | email: 'JK@paddle.com',
24 | initials: 'JK',
25 | role: 'Member',
26 | },
27 | {
28 | name: 'Isa Lopez',
29 | email: 'il@paddle.com',
30 | initials: 'IL',
31 | role: 'Guest',
32 | },
33 | ];
34 |
35 | export function DashboardTeamMembersCard() {
36 | return (
37 |
38 |
39 |
40 |
41 | Team members
42 | Invite your team members to collaborate
43 |
44 |
49 |
50 |
51 |
52 | {teamMembers.map((teamMember) => (
53 |
54 |
55 |
56 | {teamMember.initials}
57 |
58 |
59 | {teamMember.name}
60 | {teamMember.email}
61 |
62 |
63 |
64 | ))}
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/dashboard/landing/components/dashboard-tutorial-card.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
2 | import { Button } from '@/components/ui/button';
3 | import { ArrowUpRight } from 'lucide-react';
4 |
5 | export function DashboardTutorialCard() {
6 | return (
7 |
8 |
9 | Tutorials
10 |
11 |
12 |
13 | Learn how to get the most out of AeroEdit tools and discover your inner artist.
14 |
15 |
16 |
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/dashboard/landing/components/dashboard-usage-card-group.tsx:
--------------------------------------------------------------------------------
1 | import { Bolt, Image, Shapes, Timer } from 'lucide-react';
2 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
3 |
4 | const cards = [
5 | {
6 | title: 'Storage used',
7 | icon: ,
8 | value: '1.2 GB',
9 | change: '10 GB remaining',
10 | },
11 | {
12 | title: 'Active workspaces',
13 | icon: ,
14 | value: '4',
15 | change: '6 available workspaces',
16 | },
17 | {
18 | title: 'Assets exported',
19 | // eslint-disable-next-line jsx-a11y/alt-text
20 | icon: ,
21 | value: '286',
22 | change: '+16% from last month',
23 | },
24 | {
25 | title: 'Collaborators',
26 | icon: ,
27 | value: '10',
28 | change: '+27% from last month',
29 | },
30 | ];
31 | export function DashboardUsageCardGroup() {
32 | return (
33 |
34 | {cards.map((card) => (
35 |
36 |
37 |
38 | {card.title} {card.icon}
39 |
40 | {card.value}
41 |
42 |
43 | {card.change}
44 |
45 |
46 | ))}
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/dashboard/landing/dashboard-landing-page.tsx:
--------------------------------------------------------------------------------
1 | import { DashboardUsageCardGroup } from '@/components/dashboard/landing/components/dashboard-usage-card-group';
2 | import { DashboardSubscriptionCardGroup } from '@/components/dashboard/landing/components/dashboard-subscription-card-group';
3 | import { DashboardTutorialCard } from '@/components/dashboard/landing/components/dashboard-tutorial-card';
4 | import { DashboardTeamMembersCard } from '@/components/dashboard/landing/components/dashboard-team-members-card';
5 |
6 | export function DashboardLandingPage() {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/dashboard/layout/dashboard-layout.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import Image from 'next/image';
3 | import { ReactNode } from 'react';
4 | import { DashboardGradient } from '@/components/gradients/dashboard-gradient';
5 | import '../../../styles/dashboard.css';
6 | import { Sidebar } from '@/components/dashboard/layout/sidebar';
7 | import { SidebarUserInfo } from '@/components/dashboard/layout/sidebar-user-info';
8 |
9 | interface Props {
10 | children: ReactNode;
11 | }
12 |
13 | export function DashboardLayout({ children }: Props) {
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
{children}
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/dashboard/layout/dashboard-page-header.tsx:
--------------------------------------------------------------------------------
1 | import { Separator } from '@/components/ui/separator';
2 | import { MobileSidebar } from '@/components/dashboard/layout/mobile-sidebar';
3 |
4 | interface Props {
5 | pageTitle: string;
6 | }
7 |
8 | export function DashboardPageHeader({ pageTitle }: Props) {
9 | return (
10 |
11 |
12 |
13 |
{pageTitle}
14 |
15 |
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/dashboard/layout/error-content.tsx:
--------------------------------------------------------------------------------
1 | export function ErrorContent() {
2 | return Something went wrong, please try again later.
;
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/dashboard/layout/loading-screen.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderIcon } from 'lucide-react';
2 |
3 | export function LoadingScreen() {
4 | return (
5 |
6 |
7 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/dashboard/layout/mobile-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
2 | import { Button } from '@/components/ui/button';
3 | import { Menu } from 'lucide-react';
4 | import { Sidebar } from '@/components/dashboard/layout/sidebar';
5 | import { SidebarUserInfo } from '@/components/dashboard/layout/sidebar-user-info';
6 |
7 | export function MobileSidebar() {
8 | return (
9 |
10 |
11 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/dashboard/layout/sidebar-user-info.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Separator } from '@/components/ui/separator';
4 | import { LogOut } from 'lucide-react';
5 | import { createClient } from '@/utils/supabase/client';
6 | import { MouseEvent } from 'react';
7 | import { useUserInfo } from '@/hooks/useUserInfo';
8 |
9 | export function SidebarUserInfo() {
10 | const supabase = createClient();
11 | const { user } = useUserInfo(supabase);
12 |
13 | async function handleLogout(e: MouseEvent) {
14 | e.preventDefault();
15 | await supabase.auth.signOut();
16 | location.reload();
17 | }
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 | {user?.user_metadata?.full_name}
26 |
27 |
28 | {user?.email}
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/dashboard/layout/sidebar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Album, CreditCard, Home } from 'lucide-react';
4 | import Link from 'next/link';
5 | import { usePathname } from 'next/navigation';
6 | import { cn } from '@/lib/utils';
7 |
8 | const sidebarItems = [
9 | {
10 | title: 'Dashboard',
11 | icon: ,
12 | href: '/dashboard',
13 | },
14 | {
15 | title: 'Subscriptions',
16 | icon: ,
17 | href: '/dashboard/subscriptions',
18 | },
19 | {
20 | title: 'Payments',
21 | icon: ,
22 | href: '/dashboard/payments',
23 | },
24 | ];
25 |
26 | export function Sidebar() {
27 | const pathname = usePathname();
28 | return (
29 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/dashboard/payments/components/columns.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ColumnDef } from '@tanstack/react-table';
4 | import { Transaction } from '@paddle/paddle-node-sdk';
5 | import dayjs from 'dayjs';
6 | import { parseMoney } from '@/utils/paddle/parse-money';
7 | import { Status } from '@/components/shared/status/status';
8 | import { getPaymentReason } from '@/utils/paddle/data-helpers';
9 |
10 | // Column size is set as `auto` as React table column sizing is not working well.
11 | const columnSize = 'auto' as unknown as number;
12 |
13 | export const columns: ColumnDef[] = [
14 | {
15 | accessorKey: 'billedAt',
16 | header: 'Date',
17 | size: columnSize,
18 | cell: ({ row }) => {
19 | const billedDate = row.getValue('billedAt') as string;
20 | return billedDate ? dayjs(billedDate).format('MMM DD, YYYY [at] h:mma') : '-';
21 | },
22 | },
23 | {
24 | accessorKey: 'amount',
25 | header: () => Amount
,
26 | enableResizing: false,
27 | size: columnSize,
28 | cell: ({ row }) => {
29 | const formatted = parseMoney(row.original.details?.totals?.total, row.original.currencyCode);
30 | return {formatted}
;
31 | },
32 | },
33 | {
34 | accessorKey: 'status',
35 | header: 'Status',
36 | size: columnSize,
37 | cell: ({ row }) => {
38 | return ;
39 | },
40 | },
41 | {
42 | accessorKey: 'description',
43 | header: 'Description',
44 | size: columnSize,
45 | cell: ({ row }) => {
46 | return (
47 |
48 |
49 | {getPaymentReason(row.original.origin)}
50 | {row.original.details?.lineItems[0].product?.name}
51 | {row.original.details?.lineItems && row.original.details?.lineItems.length > 1 && (
52 | +{row.original.details?.lineItems.length - 1} more
53 | )}
54 |
55 |
56 | );
57 | },
58 | },
59 | ];
60 |
--------------------------------------------------------------------------------
/src/components/dashboard/payments/components/data-table.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReactTable } from '@tanstack/react-table';
4 |
5 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
6 | import { Button } from '@/components/ui/button';
7 | import { Transaction } from '@paddle/paddle-node-sdk';
8 |
9 | interface DataTableProps {
10 | columns: ColumnDef[];
11 | data: TData[];
12 | hasMore?: boolean;
13 | totalRecords?: number;
14 | goToNextPage: (cursor: string) => void;
15 | goToPrevPage: () => void;
16 | hasPrev: boolean;
17 | }
18 |
19 | export function DataTable({
20 | columns,
21 | data,
22 | totalRecords,
23 | hasMore,
24 | goToNextPage,
25 | goToPrevPage,
26 | hasPrev,
27 | }: DataTableProps) {
28 | const table = useReactTable({
29 | data,
30 | columns,
31 | getCoreRowModel: getCoreRowModel(),
32 | manualPagination: true,
33 | pageCount: totalRecords ? Math.ceil(totalRecords / data.length) : 1,
34 | rowCount: data.length,
35 | getPaginationRowModel: getPaginationRowModel(),
36 | });
37 |
38 | return (
39 |
40 |
41 |
42 | {table.getHeaderGroups().map((headerGroup) => (
43 |
44 | {headerGroup.headers.map((header) => {
45 | return (
46 |
53 | {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
54 |
55 | );
56 | })}
57 |
58 | ))}
59 |
60 |
61 | {table.getRowModel().rows?.length ? (
62 | table.getRowModel().rows.map((row) => (
63 |
64 | {row.getVisibleCells().map((cell) => (
65 |
72 | {flexRender(cell.column.columnDef.cell, cell.getContext())}
73 |
74 | ))}
75 |
76 | ))
77 | ) : (
78 |
79 |
80 | No results.
81 |
82 |
83 | )}
84 |
85 |
86 |
87 |
96 |
105 |
106 |
107 | );
108 | }
109 |
--------------------------------------------------------------------------------
/src/components/dashboard/payments/payments-content.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { getTransactions } from '@/utils/paddle/get-transactions';
4 | import { ErrorContent } from '@/components/dashboard/layout/error-content';
5 | import { DataTable } from '@/components/dashboard/payments/components/data-table';
6 | import { columns } from '@/components/dashboard/payments/components/columns';
7 | import { useEffect, useState } from 'react';
8 | import { LoadingScreen } from '@/components/dashboard/layout/loading-screen';
9 | import { usePagination } from '@/hooks/usePagination';
10 | import { TransactionResponse } from '@/lib/api.types';
11 |
12 | interface Props {
13 | subscriptionId: string;
14 | }
15 |
16 | export function PaymentsContent({ subscriptionId }: Props) {
17 | const { after, goToNextPage, goToPrevPage, hasPrev } = usePagination();
18 |
19 | const [transactionResponse, setTransactionResponse] = useState({
20 | data: [],
21 | hasMore: false,
22 | totalRecords: 0,
23 | error: undefined,
24 | });
25 | const [loading, setLoading] = useState(true);
26 |
27 | useEffect(() => {
28 | (async () => {
29 | setLoading(true);
30 | const response = await getTransactions(subscriptionId, after);
31 | if (response) {
32 | setTransactionResponse(response);
33 | }
34 | setLoading(false);
35 | })();
36 | }, [subscriptionId, after]);
37 |
38 | if (!transactionResponse || transactionResponse.error) {
39 | return ;
40 | } else if (loading) {
41 | return ;
42 | }
43 |
44 | const { data: transactionData, hasMore, totalRecords } = transactionResponse;
45 | return (
46 |
47 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/src/components/dashboard/subscriptions/components/payment-method-details.tsx:
--------------------------------------------------------------------------------
1 | import { PaymentMethodDetails as PaddlePaymentMethodDetails } from '@paddle/paddle-node-sdk';
2 | import { CreditCard } from 'lucide-react';
3 |
4 | const PaymentMethodLabels: Record = {
5 | card: 'Card',
6 | alipay: 'Alipay',
7 | wire_transfer: 'Wire Transfer',
8 | apple_pay: 'Apple Pay',
9 | google_pay: 'Google Pay',
10 | paypal: 'PayPal',
11 | ideal: 'iDEAL',
12 | bancontact: 'Bancontact',
13 | korea_local: 'Korean Local Payment',
14 | offline: 'Offline',
15 | unknown: 'Unknown',
16 | };
17 |
18 | interface Props {
19 | type: PaddlePaymentMethodDetails['type'];
20 | card?: PaddlePaymentMethodDetails['card'];
21 | }
22 |
23 | export function PaymentMethodDetails({ type, card }: Props) {
24 | if (type === 'card') {
25 | return (
26 | <>
27 |
28 | **** {card?.last4}
29 | >
30 | );
31 | } else {
32 | return type ? {PaymentMethodLabels[type]} : '-';
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/dashboard/subscriptions/components/payment-method-section.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import Link from 'next/link';
3 | import { PaymentMethodDetails } from '@/components/dashboard/subscriptions/components/payment-method-details';
4 | import { PaymentType, Transaction } from '@paddle/paddle-node-sdk';
5 |
6 | function findPaymentMethodDetails(transactions?: Transaction[]) {
7 | const transactionWithPaymentDetails = transactions?.find((transaction) => transaction.payments[0]?.methodDetails);
8 | const firstValidPaymentMethod = transactionWithPaymentDetails?.payments[0].methodDetails;
9 | return firstValidPaymentMethod ? firstValidPaymentMethod : { type: 'unknown' as PaymentType, card: null };
10 | }
11 |
12 | interface Props {
13 | updatePaymentMethodUrl?: string | null;
14 | transactions?: Transaction[];
15 | }
16 |
17 | export function PaymentMethodSection({ transactions, updatePaymentMethodUrl }: Props) {
18 | const { type, card } = findPaymentMethodDetails(transactions);
19 | if (type === 'unknown') {
20 | return null;
21 | }
22 | return (
23 |
24 |
25 |
Payment method
26 |
29 |
30 | {updatePaymentMethodUrl && (
31 |
32 |
37 |
38 | )}
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/dashboard/subscriptions/components/subscription-alerts.tsx:
--------------------------------------------------------------------------------
1 | import { Subscription } from '@paddle/paddle-node-sdk';
2 | import { Alert } from '@/components/ui/alert';
3 | import dayjs from 'dayjs';
4 |
5 | interface Props {
6 | subscription: Subscription;
7 | }
8 | export function SubscriptionAlerts({ subscription }: Props) {
9 | if (subscription.status === 'canceled') {
10 | return (
11 |
12 | This subscription was canceled on {dayjs(subscription.canceledAt).format('MMM DD, YYYY [at] h:mma')} and is no
13 | longer active.
14 |
15 | );
16 | } else if (subscription.scheduledChange && subscription.scheduledChange.action === 'cancel') {
17 | return (
18 |
19 | This subscription is scheduled to be canceled on{' '}
20 | {dayjs(subscription.scheduledChange.effectiveAt).format('MMM DD, YYYY [at] h:mma')}
21 |
22 | );
23 | }
24 | return null;
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/dashboard/subscriptions/components/subscription-cards.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
2 | import { ArrowRight } from 'lucide-react';
3 | import Link from 'next/link';
4 | import { Status } from '@/components/shared/status/status';
5 | import { Subscription } from '@paddle/paddle-node-sdk';
6 | import { cn } from '@/lib/utils';
7 | import Image from 'next/image';
8 | import { parseMoney } from '@/utils/paddle/parse-money';
9 |
10 | interface Props {
11 | subscriptions: Subscription[];
12 | className: string;
13 | }
14 |
15 | export function SubscriptionCards({ subscriptions, className }: Props) {
16 | if (subscriptions.length === 0) {
17 | return No active subscriptions;
18 | } else {
19 | return (
20 |
21 | {subscriptions.map((subscription) => {
22 | const subscriptionItem = subscription.items[0];
23 | const price = subscriptionItem.quantity * parseFloat(subscriptionItem.price.unitPrice.amount);
24 | const formattedPrice = parseMoney(price.toString(), subscription.currencyCode);
25 | const frequency =
26 | subscription.billingCycle.frequency === 1
27 | ? `/${subscription.billingCycle.interval}`
28 | : `every ${subscription.billingCycle.frequency} ${subscription.billingCycle.interval}s`;
29 | return (
30 |
31 |
32 |
33 |
39 | {subscriptionItem.product.imageUrl && (
40 |
46 | )}
47 |
48 |
49 |
50 |
51 | {subscriptionItem.product.name}
52 |
53 |
54 |
55 |
56 |
{subscriptionItem.product.description}
57 |
58 | {formattedPrice}
59 | {frequency}
60 |
61 |
62 |
63 |
64 |
65 | );
66 | })}
67 |
68 | );
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/components/dashboard/subscriptions/components/subscription-detail.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { getSubscription } from '@/utils/paddle/get-subscription';
4 | import { getTransactions } from '@/utils/paddle/get-transactions';
5 | import { SubscriptionPastPaymentsCard } from '@/components/dashboard/subscriptions/components/subscription-past-payments-card';
6 | import { SubscriptionNextPaymentCard } from '@/components/dashboard/subscriptions/components/subscription-next-payment-card';
7 | import { SubscriptionLineItems } from '@/components/dashboard/subscriptions/components/subscription-line-items';
8 | import { SubscriptionHeader } from '@/components/dashboard/subscriptions/components/subscription-header';
9 | import { Separator } from '@/components/ui/separator';
10 | import { ErrorContent } from '@/components/dashboard/layout/error-content';
11 | import { useEffect, useState } from 'react';
12 | import { LoadingScreen } from '@/components/dashboard/layout/loading-screen';
13 | import { SubscriptionDetailResponse, TransactionResponse } from '@/lib/api.types';
14 |
15 | interface Props {
16 | subscriptionId: string;
17 | }
18 |
19 | export function SubscriptionDetail({ subscriptionId }: Props) {
20 | const [loading, setLoading] = useState(true);
21 | const [subscription, setSubscription] = useState();
22 | const [transactions, setTransactions] = useState();
23 |
24 | useEffect(() => {
25 | (async () => {
26 | const [subscriptionResponse, transactionsResponse] = await Promise.all([
27 | getSubscription(subscriptionId),
28 | getTransactions(subscriptionId, ''),
29 | ]);
30 |
31 | if (subscriptionResponse) {
32 | setSubscription(subscriptionResponse);
33 | }
34 |
35 | if (transactionsResponse) {
36 | setTransactions(transactionsResponse);
37 | }
38 | setLoading(false);
39 | })();
40 | }, [subscriptionId]);
41 |
42 | if (loading) {
43 | return ;
44 | } else if (subscription?.data && transactions?.data) {
45 | return (
46 | <>
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | >
61 | );
62 | } else {
63 | return ;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/components/dashboard/subscriptions/components/subscription-header-action-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { cancelSubscription } from '@/app/dashboard/subscriptions/actions';
4 | import { Button } from '@/components/ui/button';
5 | import { useToast } from '@/components/ui/use-toast';
6 | import { CircleAlert, CircleCheck } from 'lucide-react';
7 | import { useState } from 'react';
8 | import { Confirmation } from '@/components/shared/confirmation/confirmation';
9 |
10 | interface Props {
11 | subscriptionId: string;
12 | }
13 |
14 | export function SubscriptionHeaderActionButton({ subscriptionId }: Props) {
15 | const { toast } = useToast();
16 | const [loading, setLoading] = useState(false);
17 | const [isModalOpen, setModalOpen] = useState(false);
18 |
19 | function handleCancelSubscription() {
20 | setModalOpen(false);
21 | setLoading(true);
22 | cancelSubscription(subscriptionId)
23 | .then(() => {
24 | toast({
25 | description: (
26 |
27 |
28 |
29 | Cancellation scheduled
30 |
31 | Subscription scheduled to cancel at the end of the billing period.
32 |
33 |
34 |
35 | ),
36 | });
37 | })
38 | .catch(() => {
39 | toast({
40 | description: (
41 |
42 |
43 |
44 |
Error
45 |
46 | Something went wrong, please try again later
47 |
48 |
49 |
50 | ),
51 | });
52 | })
53 | .finally(() => setLoading(false));
54 | }
55 |
56 | return (
57 | <>
58 |
67 | setModalOpen(false)}
71 | isOpen={isModalOpen}
72 | onConfirm={handleCancelSubscription}
73 | />
74 | >
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/src/components/dashboard/subscriptions/components/subscription-header.tsx:
--------------------------------------------------------------------------------
1 | import { Subscription } from '@paddle/paddle-node-sdk';
2 | import Image from 'next/image';
3 | import { Status } from '@/components/shared/status/status';
4 | import { parseMoney } from '@/utils/paddle/parse-money';
5 | import dayjs from 'dayjs';
6 | import { SubscriptionHeaderActionButton } from '@/components/dashboard/subscriptions/components/subscription-header-action-button';
7 | import { SubscriptionAlerts } from '@/components/dashboard/subscriptions/components/subscription-alerts';
8 | import { MobileSidebar } from '@/components/dashboard/layout/mobile-sidebar';
9 |
10 | interface Props {
11 | subscription: Subscription;
12 | }
13 |
14 | export function SubscriptionHeader({ subscription }: Props) {
15 | const subscriptionItem = subscription.items[0];
16 |
17 | const price = subscriptionItem.quantity * parseFloat(subscription?.recurringTransactionDetails?.totals.total ?? '0');
18 | const formattedPrice = parseMoney(price.toString(), subscription.currencyCode);
19 | const frequency =
20 | subscription.billingCycle.frequency === 1
21 | ? `/${subscription.billingCycle.interval}`
22 | : `every ${subscription.billingCycle.frequency} ${subscription.billingCycle.interval}s`;
23 |
24 | const formattedStartedDate = dayjs(subscription.startedAt).format('MMM DD, YYYY');
25 |
26 | return (
27 |
28 |
29 |
30 |
31 |
32 | {subscriptionItem.product.imageUrl && (
33 |
34 | )}
35 | {subscriptionItem.product.name}
36 |
37 |
38 |
39 | {formattedPrice}
40 | {frequency}
41 |
42 |
43 |
44 |
45 |
46 |
Started on: {formattedStartedDate}
47 |
48 |
49 | {!(subscription.scheduledChange || subscription.status === 'canceled') && (
50 |
51 | )}
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/dashboard/subscriptions/components/subscription-next-payment-card.tsx:
--------------------------------------------------------------------------------
1 | import { Card } from '@/components/ui/card';
2 | import { Subscription, Transaction } from '@paddle/paddle-node-sdk';
3 | import dayjs from 'dayjs';
4 | import { parseMoney } from '@/utils/paddle/parse-money';
5 | import { PaymentMethodSection } from '@/components/dashboard/subscriptions/components/payment-method-section';
6 |
7 | interface Props {
8 | transactions?: Transaction[];
9 | subscription?: Subscription;
10 | }
11 |
12 | export function SubscriptionNextPaymentCard({ subscription, transactions }: Props) {
13 | if (!subscription?.nextBilledAt) {
14 | return null;
15 | }
16 | return (
17 |
18 |
19 |
Next payment
20 |
21 |
22 | {parseMoney(subscription?.nextTransaction?.details.totals.total, subscription?.currencyCode)}
23 |
24 | due
25 |
26 | {dayjs(subscription?.nextBilledAt).format('MMM DD, YYYY')}
27 |
28 |
29 |
30 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/dashboard/subscriptions/components/subscription-past-payments-card.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardTitle } from '@/components/ui/card';
2 | import { Button } from '@/components/ui/button';
3 | import Link from 'next/link';
4 | import { Transaction } from '@paddle/paddle-node-sdk';
5 | import dayjs from 'dayjs';
6 | import { parseMoney } from '@/utils/paddle/parse-money';
7 | import { Status } from '@/components/shared/status/status';
8 | import { getPaymentReason } from '@/utils/paddle/data-helpers';
9 |
10 | interface Props {
11 | subscriptionId: string;
12 | transactions?: Transaction[];
13 | }
14 |
15 | export function SubscriptionPastPaymentsCard({ subscriptionId, transactions }: Props) {
16 | return (
17 |
18 |
19 | Payments
20 |
23 |
24 |
25 | {transactions?.slice(0, 3).map((transaction) => {
26 | const formattedPrice = parseMoney(transaction.details?.totals?.total, transaction.currencyCode);
27 | return (
28 |
29 |
30 | {dayjs(transaction.billedAt ?? transaction.createdAt).format('MMM DD, YYYY')}
31 |
32 |
33 | {getPaymentReason(transaction.origin)}
34 |
35 | {transaction.details?.lineItems[0].product?.name}
36 |
37 |
38 |
39 |
{formattedPrice}
40 |
41 |
42 |
43 | );
44 | })}
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/dashboard/subscriptions/subscriptions.tsx:
--------------------------------------------------------------------------------
1 | import { SubscriptionDetail } from '@/components/dashboard/subscriptions/components/subscription-detail';
2 | import { NoSubscriptionView } from '@/components/dashboard/subscriptions/views/no-subscription-view';
3 | import { MultipleSubscriptionsView } from '@/components/dashboard/subscriptions/views/multiple-subscriptions-view';
4 | import { SubscriptionErrorView } from '@/components/dashboard/subscriptions/views/subscription-error-view';
5 | import { getSubscriptions } from '@/utils/paddle/get-subscriptions';
6 |
7 | export async function Subscriptions() {
8 | const { data: subscriptions } = await getSubscriptions();
9 |
10 | if (subscriptions) {
11 | if (subscriptions.length === 0) {
12 | return ;
13 | } else if (subscriptions.length === 1) {
14 | return ;
15 | } else {
16 | return ;
17 | }
18 | } else {
19 | return ;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/dashboard/subscriptions/views/multiple-subscriptions-view.tsx:
--------------------------------------------------------------------------------
1 | import { DashboardPageHeader } from '@/components/dashboard/layout/dashboard-page-header';
2 | import { SubscriptionCards } from '@/components/dashboard/subscriptions/components/subscription-cards';
3 | import { Subscription } from '@paddle/paddle-node-sdk';
4 |
5 | interface Props {
6 | subscriptions: Subscription[];
7 | }
8 |
9 | export function MultipleSubscriptionsView({ subscriptions }: Props) {
10 | return (
11 | <>
12 |
13 |
14 | >
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/dashboard/subscriptions/views/no-subscription-view.tsx:
--------------------------------------------------------------------------------
1 | import { DashboardPageHeader } from '@/components/dashboard/layout/dashboard-page-header';
2 | import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
3 | import { Button } from '@/components/ui/button';
4 | import Link from 'next/link';
5 |
6 | export function NoSubscriptionView() {
7 | return (
8 | <>
9 |
10 |
11 |
14 |
15 |
16 | No active subscriptions
17 |
18 |
19 |
20 |
21 | Sign up for a subscription to see your subscriptions here.
22 |
23 |
24 |
25 |
28 |
29 |
30 |
31 | >
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/dashboard/subscriptions/views/subscription-error-view.tsx:
--------------------------------------------------------------------------------
1 | import { DashboardPageHeader } from '@/components/dashboard/layout/dashboard-page-header';
2 | import { ErrorContent } from '@/components/dashboard/layout/error-content';
3 |
4 | export function SubscriptionErrorView() {
5 | return (
6 | <>
7 |
8 |
9 | >
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/gradients/checkout-form-gradients.tsx:
--------------------------------------------------------------------------------
1 | export function CheckoutFormGradients() {
2 | return (
3 | <>
4 |
9 | >
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/gradients/checkout-gradients.tsx:
--------------------------------------------------------------------------------
1 | export function CheckoutGradients() {
2 | return (
3 | <>
4 |
10 |
15 | >
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/gradients/dashboard-gradient.tsx:
--------------------------------------------------------------------------------
1 | export function DashboardGradient() {
2 | return (
3 | <>
4 |
5 |
6 |
7 | >
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/gradients/featured-card-gradient.tsx:
--------------------------------------------------------------------------------
1 | export function FeaturedCardGradient() {
2 | return (
3 | <>
4 |
5 |
6 |
7 |
8 | >
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/src/components/gradients/home-page-background.tsx:
--------------------------------------------------------------------------------
1 | export function HomePageBackground() {
2 | return (
3 | <>
4 |
5 |
6 |
7 |
8 |
9 | >
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/gradients/login-card-gradient.tsx:
--------------------------------------------------------------------------------
1 | export function LoginCardGradient() {
2 | return (
3 | <>
4 |
5 |
6 |
11 |
16 | >
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/gradients/login-gradient.tsx:
--------------------------------------------------------------------------------
1 | export function LoginGradient() {
2 | return (
3 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/gradients/success-page-gradients.tsx:
--------------------------------------------------------------------------------
1 | export function SuccessPageGradients() {
2 | return (
3 | <>
4 |
5 |
6 |
7 | >
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/home/footer/built-using-tools.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 |
3 | export function BuiltUsingTools() {
4 | return (
5 |
6 |
Built with
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/home/footer/footer.tsx:
--------------------------------------------------------------------------------
1 | import { BuiltUsingTools } from '@/components/home/footer/built-using-tools';
2 | import { PoweredByPaddle } from '@/components/home/footer/powered-by-paddle';
3 |
4 | export function Footer() {
5 | return (
6 | <>
7 |
8 |
9 | >
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/home/footer/powered-by-paddle.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import Link from 'next/link';
3 | import { Separator } from '@/components/ui/separator';
4 | import { ArrowUpRight } from 'lucide-react';
5 |
6 | export function PoweredByPaddle() {
7 | return (
8 | <>
9 |
10 |
15 |
16 | A Next.js template by
17 |
18 |
19 |
20 |
21 |
22 | Explore Paddle
23 |
24 |
25 |
26 |
27 |
28 | Terms of use
29 |
30 |
31 |
32 |
33 |
34 | Privacy
35 |
36 |
37 |
38 |
39 |
40 | >
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/src/components/home/header/country-dropdown.tsx:
--------------------------------------------------------------------------------
1 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
2 |
3 | const regions = [
4 | { value: 'US', label: 'United States' },
5 | { value: 'GB', label: 'United Kingdom' },
6 | { value: 'DE', label: 'Germany' },
7 | { value: 'PT', label: 'Portugal' },
8 | { value: 'IN', label: 'India' },
9 | { value: 'BR', label: 'Brazil' },
10 | { value: 'OTHERS', label: 'Everywhere else' },
11 | ];
12 |
13 | interface Props {
14 | country: string;
15 | onCountryChange: (value: string) => void;
16 | }
17 |
18 | export function CountryDropdown({ country, onCountryChange }: Props) {
19 | const selected = regions.find((region) => region.value === country) || regions[0];
20 | return (
21 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/home/header/header.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import { User } from '@supabase/supabase-js';
3 | import Image from 'next/image';
4 | import { Button } from '@/components/ui/button';
5 |
6 | interface Props {
7 | user: User | null;
8 | }
9 |
10 | export default function Header({ user }: Props) {
11 | return (
12 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/home/header/localization-banner.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import Link from 'next/link';
3 | import { CountryDropdown } from '@/components/home/header/country-dropdown';
4 | import { ArrowUpRight, X } from 'lucide-react';
5 | import { useState } from 'react';
6 |
7 | interface Props {
8 | country: string;
9 | onCountryChange: (value: string) => void;
10 | }
11 | export function LocalizationBanner({ country, onCountryChange }: Props) {
12 | const [showBanner, setShowBanner] = useState(true);
13 | // TODO: This component is for demonstration purposes only. Please remove while integrating with the application.
14 | if (!showBanner) {
15 | return null;
16 | }
17 | return (
18 |
19 |
20 |
21 |
22 |
Preview localized prices
23 |
28 |
29 | Learn more
30 |
31 |
32 |
33 |
34 |
35 |
36 | setShowBanner(false)} />
37 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/home/hero-section/hero-section.tsx:
--------------------------------------------------------------------------------
1 | export function HeroSection() {
2 | return (
3 |
4 |
5 |
6 | Powerful design tools.
7 |
8 | Simple pricing.
9 |
10 |
11 | Plans for teams of every size — from start-up to enterprise.
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/home/home-page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { createClient } from '@/utils/supabase/client';
5 | import { useUserInfo } from '@/hooks/useUserInfo';
6 | import '../../styles/home-page.css';
7 | import { LocalizationBanner } from '@/components/home/header/localization-banner';
8 | import Header from '@/components/home/header/header';
9 | import { HeroSection } from '@/components/home/hero-section/hero-section';
10 | import { Pricing } from '@/components/home/pricing/pricing';
11 | import { HomePageBackground } from '@/components/gradients/home-page-background';
12 | import { Footer } from '@/components/home/footer/footer';
13 |
14 | export function HomePage() {
15 | const supabase = createClient();
16 | const { user } = useUserInfo(supabase);
17 | const [country, setCountry] = useState('US');
18 |
19 | return (
20 | <>
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | >
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/home/pricing/features-list.tsx:
--------------------------------------------------------------------------------
1 | import { Tier } from '@/constants/pricing-tier';
2 | import { CircleCheck } from 'lucide-react';
3 |
4 | interface Props {
5 | tier: Tier;
6 | }
7 |
8 | export function FeaturesList({ tier }: Props) {
9 | return (
10 |
11 | {tier.features.map((feature: string) => (
12 | -
13 |
14 | {feature}
15 |
16 | ))}
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/home/pricing/price-amount.tsx:
--------------------------------------------------------------------------------
1 | import { Tier } from '@/constants/pricing-tier';
2 | import { cn } from '@/lib/utils';
3 | import { Skeleton } from '@/components/ui/skeleton';
4 |
5 | interface Props {
6 | loading: boolean;
7 | tier: Tier;
8 | priceMap: Record;
9 | value: string;
10 | priceSuffix: string;
11 | }
12 |
13 | export function PriceAmount({ loading, priceMap, priceSuffix, tier, value }: Props) {
14 | return (
15 |
16 | {loading ? (
17 |
18 | ) : (
19 | <>
20 |
21 | {priceMap[tier.priceId[value]].replace(/\.00$/, '')}
22 |
23 |
{priceSuffix}
24 | >
25 | )}
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/home/pricing/price-cards.tsx:
--------------------------------------------------------------------------------
1 | import { PricingTier } from '@/constants/pricing-tier';
2 | import { IBillingFrequency } from '@/constants/billing-frequency';
3 | import { FeaturesList } from '@/components/home/pricing/features-list';
4 | import { PriceAmount } from '@/components/home/pricing/price-amount';
5 | import { cn } from '@/lib/utils';
6 | import { Button } from '@/components/ui/button';
7 | import { PriceTitle } from '@/components/home/pricing/price-title';
8 | import { Separator } from '@/components/ui/separator';
9 | import { FeaturedCardGradient } from '@/components/gradients/featured-card-gradient';
10 | import Link from 'next/link';
11 |
12 | interface Props {
13 | loading: boolean;
14 | frequency: IBillingFrequency;
15 | priceMap: Record;
16 | }
17 |
18 | export function PriceCards({ loading, frequency, priceMap }: Props) {
19 | return (
20 |
21 | {PricingTier.map((tier) => (
22 |
23 |
24 | {tier.featured &&
}
25 |
26 |
33 |
34 |
35 |
36 |
{tier.description}
37 |
38 |
39 |
42 |
43 |
44 |
45 | ))}
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/home/pricing/price-title.tsx:
--------------------------------------------------------------------------------
1 | import { Tier } from '@/constants/pricing-tier';
2 | import Image from 'next/image';
3 | import { cn } from '@/lib/utils';
4 |
5 | interface Props {
6 | tier: Tier;
7 | }
8 |
9 | export function PriceTitle({ tier }: Props) {
10 | const { name, featured, icon } = tier;
11 | return (
12 |
17 |
21 | {featured && (
22 |
27 | Most popular
28 |
29 | )}
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/home/pricing/pricing.tsx:
--------------------------------------------------------------------------------
1 | import { Toggle } from '@/components/shared/toggle/toggle';
2 | import { PriceCards } from '@/components/home/pricing/price-cards';
3 | import { useEffect, useState } from 'react';
4 | import { BillingFrequency, IBillingFrequency } from '@/constants/billing-frequency';
5 | import { Environments, initializePaddle, Paddle } from '@paddle/paddle-js';
6 | import { usePaddlePrices } from '@/hooks/usePaddlePrices';
7 |
8 | interface Props {
9 | country: string;
10 | }
11 |
12 | export function Pricing({ country }: Props) {
13 | const [frequency, setFrequency] = useState(BillingFrequency[0]);
14 | const [paddle, setPaddle] = useState(undefined);
15 |
16 | const { prices, loading } = usePaddlePrices(paddle, country);
17 |
18 | useEffect(() => {
19 | if (process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN && process.env.NEXT_PUBLIC_PADDLE_ENV) {
20 | initializePaddle({
21 | token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN,
22 | environment: process.env.NEXT_PUBLIC_PADDLE_ENV as Environments,
23 | }).then((paddle) => {
24 | if (paddle) {
25 | setPaddle(paddle);
26 | }
27 | });
28 | }
29 | }, []);
30 |
31 | return (
32 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/shared/confirmation/confirmation.tsx:
--------------------------------------------------------------------------------
1 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
2 | import { ReactNode } from 'react';
3 | import { Button } from '@/components/ui/button';
4 |
5 | interface Props {
6 | isOpen: boolean;
7 | title: ReactNode;
8 | description: ReactNode;
9 | onClose: (open: boolean) => void;
10 | onConfirm: () => void;
11 | }
12 |
13 | export function Confirmation({ isOpen, onClose, title, description, onConfirm }: Props) {
14 | return (
15 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/shared/select/select.tsx:
--------------------------------------------------------------------------------
1 | import { Select as ShadCnSelect, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
2 |
3 | interface Props {
4 | value: string;
5 | options: string[];
6 | onChange: (value: string) => void;
7 | }
8 | export function Select({ onChange, options, value }: Props) {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 | {options.map((option) => (
16 |
17 | {option}
18 |
19 | ))}
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/shared/status/status.tsx:
--------------------------------------------------------------------------------
1 | import { Check, CircleMinus, Clock4, Pause, SquarePen } from 'lucide-react';
2 | import { ReactNode } from 'react';
3 |
4 | interface Props {
5 | status: string;
6 | }
7 |
8 | interface StatusInfo {
9 | [key: string]: { color: string; icon: ReactNode; text: string };
10 | }
11 | // Ensure that any new colors are added to `safelist` in tailwind.config.js
12 | const StatusInfo: StatusInfo = {
13 | active: { color: '#25F497', icon: , text: 'Active' },
14 | paid: { color: '#25F497', icon: , text: 'Paid' },
15 | completed: { color: '#25F497', icon: , text: 'Completed' },
16 | trialing: { color: '#E0E0EB', icon: , text: 'Trialing' },
17 | draft: { color: '#797C7C', icon: , text: 'Draft' },
18 | ready: { color: '#797C7C', icon: , text: 'Ready' },
19 | canceled: { color: '#797C7C', icon: , text: 'Canceled' },
20 | inactive: { color: '#F42566', icon: , text: 'Inactive' },
21 | past_due: { color: '#F42566', icon: , text: 'Past due' },
22 | paused: { color: '#F79636', icon: , text: 'Paused' },
23 | billed: { color: '#F79636', icon: , text: 'Unpaid invoice' },
24 | };
25 |
26 | export function Status({ status }: Props) {
27 | const { color, icon, text } = StatusInfo[status] ?? { text: status };
28 | return (
29 |
32 | {icon}
33 | {text}
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/shared/toggle/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { BillingFrequency, IBillingFrequency } from '@/constants/billing-frequency';
2 | import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
3 |
4 | interface Props {
5 | frequency: IBillingFrequency;
6 | setFrequency: (frequency: IBillingFrequency) => void;
7 | }
8 |
9 | export function Toggle({ setFrequency, frequency }: Props) {
10 | return (
11 |
12 |
15 | setFrequency(BillingFrequency.find((billingFrequency) => value === billingFrequency.value)!)
16 | }
17 | >
18 |
19 | {BillingFrequency.map((billingFrequency) => (
20 |
21 | {billingFrequency.label}
22 |
23 | ))}
24 |
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as AccordionPrimitive from '@radix-ui/react-accordion';
5 | import { ChevronDown } from 'lucide-react';
6 |
7 | import { cn } from '@/lib/utils';
8 |
9 | const Accordion = AccordionPrimitive.Root;
10 |
11 | const AccordionItem = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
16 | ));
17 | AccordionItem.displayName = 'AccordionItem';
18 |
19 | const AccordionTrigger = React.forwardRef<
20 | React.ElementRef,
21 | React.ComponentPropsWithoutRef
22 | >(({ className, children, ...props }, ref) => (
23 |
24 | svg]:rotate-180',
28 | className,
29 | )}
30 | {...props}
31 | >
32 | {children}
33 |
34 |
35 |
36 | ));
37 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
38 |
39 | const AccordionContent = React.forwardRef<
40 | React.ElementRef,
41 | React.ComponentPropsWithoutRef
42 | >(({ className, children, ...props }, ref) => (
43 |
48 | {children}
49 |
50 | ));
51 |
52 | AccordionContent.displayName = AccordionPrimitive.Content.displayName;
53 |
54 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
55 |
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { cva, type VariantProps } from 'class-variance-authority';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const alertVariants = cva(
7 | 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
8 | {
9 | variants: {
10 | variant: {
11 | default: 'bg-background text-foreground',
12 | destructive: 'border-[#F42566] text-primary bg-[#1A040B]',
13 | },
14 | },
15 | defaultVariants: {
16 | variant: 'default',
17 | },
18 | },
19 | );
20 |
21 | const Alert = React.forwardRef<
22 | HTMLDivElement,
23 | React.HTMLAttributes & VariantProps
24 | >(({ className, variant, ...props }, ref) => (
25 |
26 | ));
27 | Alert.displayName = 'Alert';
28 |
29 | const AlertTitle = React.forwardRef>(
30 | ({ className, ...props }, ref) => (
31 |
32 | ),
33 | );
34 | AlertTitle.displayName = 'AlertTitle';
35 |
36 | const AlertDescription = React.forwardRef>(
37 | ({ className, ...props }, ref) => (
38 |
39 | ),
40 | );
41 | AlertDescription.displayName = 'AlertDescription';
42 |
43 | export { Alert, AlertTitle, AlertDescription };
44 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Slot } from '@radix-ui/react-slot';
3 | import { cva, type VariantProps } from 'class-variance-authority';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-md font-medium cursor-pointer ring-offset-background transition-colors focus:ring-ring focus:ring-2 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-primary text-primary-foreground hover:bg-primary/90',
13 | destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
14 | outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
15 | secondary: 'relative bg-[#fcfcfc33] text-white secondary-button-animation disabled:bg-[#191A1A]',
16 | ghost: 'hover:bg-accent hover:text-accent-foreground',
17 | link: 'text-primary underline-offset-4 hover:underline',
18 | },
19 | size: {
20 | default: 'h-11 px-5 py-[10px]',
21 | sm: 'h-9 text-sm leading-4 rounded-md px-3 py-2',
22 | lg: 'h-11 rounded-md px-8',
23 | icon: 'h-10 w-10',
24 | },
25 | },
26 | defaultVariants: {
27 | variant: 'default',
28 | size: 'default',
29 | },
30 | },
31 | );
32 |
33 | export interface ButtonProps
34 | extends React.ButtonHTMLAttributes,
35 | VariantProps {
36 | asChild?: boolean;
37 | }
38 |
39 | const Button = React.forwardRef(
40 | ({ className, variant, size, asChild = false, ...props }, ref) => {
41 | const Comp = asChild ? Slot : 'button';
42 | return ;
43 | },
44 | );
45 | Button.displayName = 'Button';
46 |
47 | export { Button, buttonVariants };
48 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | const Card = React.forwardRef>(({ className, ...props }, ref) => (
6 |
7 | ));
8 | Card.displayName = 'Card';
9 |
10 | const CardHeader = React.forwardRef>(
11 | ({ className, ...props }, ref) => (
12 |
13 | ),
14 | );
15 | CardHeader.displayName = 'CardHeader';
16 |
17 | const CardTitle = React.forwardRef>(
18 | ({ className, ...props }, ref) => (
19 |
20 | ),
21 | );
22 | CardTitle.displayName = 'CardTitle';
23 |
24 | const CardDescription = React.forwardRef>(
25 | ({ className, ...props }, ref) => (
26 |
27 | ),
28 | );
29 | CardDescription.displayName = 'CardDescription';
30 |
31 | const CardContent = React.forwardRef>(
32 | ({ className, ...props }, ref) => ,
33 | );
34 | CardContent.displayName = 'CardContent';
35 |
36 | const CardFooter = React.forwardRef>(
37 | ({ className, ...props }, ref) => (
38 |
39 | ),
40 | );
41 | CardFooter.displayName = 'CardFooter';
42 |
43 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
44 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as DialogPrimitive from '@radix-ui/react-dialog';
5 | import { X } from 'lucide-react';
6 |
7 | import { cn } from '@/lib/utils';
8 |
9 | const Dialog = DialogPrimitive.Root;
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger;
12 |
13 | const DialogPortal = DialogPrimitive.Portal;
14 |
15 | const DialogClose = DialogPrimitive.Close;
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ));
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ));
54 | DialogContent.displayName = DialogPrimitive.Content.displayName;
55 |
56 | const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
57 |
58 | );
59 | DialogHeader.displayName = 'DialogHeader';
60 |
61 | const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
62 |
63 | );
64 | DialogFooter.displayName = 'DialogFooter';
65 |
66 | const DialogTitle = React.forwardRef<
67 | React.ElementRef,
68 | React.ComponentPropsWithoutRef
69 | >(({ className, ...props }, ref) => (
70 |
75 | ));
76 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
77 |
78 | const DialogDescription = React.forwardRef<
79 | React.ElementRef,
80 | React.ComponentPropsWithoutRef
81 | >(({ className, ...props }, ref) => (
82 |
83 | ));
84 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
85 |
86 | export {
87 | Dialog,
88 | DialogPortal,
89 | DialogOverlay,
90 | DialogClose,
91 | DialogTrigger,
92 | DialogContent,
93 | DialogHeader,
94 | DialogFooter,
95 | DialogTitle,
96 | DialogDescription,
97 | };
98 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | const Input = ({ className, type, ...props }: React.ComponentProps<'input'>) => {
6 | return (
7 |
15 | );
16 | };
17 |
18 | export { Input };
19 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as LabelPrimitive from '@radix-ui/react-label';
5 | import { cva, type VariantProps } from 'class-variance-authority';
6 |
7 | import { cn } from '@/lib/utils';
8 |
9 | const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70');
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef & VariantProps
14 | >(({ className, ...props }, ref) => (
15 |
16 | ));
17 | Label.displayName = LabelPrimitive.Root.displayName;
18 |
19 | export { Label };
20 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as SeparatorPrimitive from '@radix-ui/react-separator';
5 |
6 | import { cn } from '@/lib/utils';
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
12 |
19 | ));
20 | Separator.displayName = SeparatorPrimitive.Root.displayName;
21 |
22 | export { Separator };
23 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils';
2 |
3 | function Skeleton({ className, ...props }: React.HTMLAttributes) {
4 | return ;
5 | }
6 |
7 | export { Skeleton };
8 |
--------------------------------------------------------------------------------
/src/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | const Table = React.forwardRef>(
6 | ({ className, ...props }, ref) => (
7 |
10 | ),
11 | );
12 | Table.displayName = 'Table';
13 |
14 | const TableHeader = React.forwardRef>(
15 | ({ className, ...props }, ref) => ,
16 | );
17 | TableHeader.displayName = 'TableHeader';
18 |
19 | const TableBody = React.forwardRef>(
20 | ({ className, ...props }, ref) => (
21 |
22 | ),
23 | );
24 | TableBody.displayName = 'TableBody';
25 |
26 | const TableFooter = React.forwardRef>(
27 | ({ className, ...props }, ref) => (
28 | tr]:border-b-0', className)} {...props} />
29 | ),
30 | );
31 | TableFooter.displayName = 'TableFooter';
32 |
33 | const TableRow = React.forwardRef>(
34 | ({ className, ...props }, ref) => (
35 |
40 | ),
41 | );
42 | TableRow.displayName = 'TableRow';
43 |
44 | const TableHead = React.forwardRef>(
45 | ({ className, ...props }, ref) => (
46 | |
54 | ),
55 | );
56 | TableHead.displayName = 'TableHead';
57 |
58 | const TableCell = React.forwardRef>(
59 | ({ className, ...props }, ref) => (
60 | |
61 | ),
62 | );
63 | TableCell.displayName = 'TableCell';
64 |
65 | const TableCaption = React.forwardRef>(
66 | ({ className, ...props }, ref) => (
67 |
68 | ),
69 | );
70 | TableCaption.displayName = 'TableCaption';
71 |
72 | export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
73 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as TabsPrimitive from '@radix-ui/react-tabs';
5 |
6 | import { cn } from '@/lib/utils';
7 |
8 | const Tabs = TabsPrimitive.Root;
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | TabsList.displayName = TabsPrimitive.List.displayName;
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ));
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ));
53 | TabsContent.displayName = TabsPrimitive.Content.displayName;
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent };
56 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from '@/components/ui/toast';
4 | import { useToast } from '@/components/ui/use-toast';
5 |
6 | export function Toaster() {
7 | const { toasts } = useToast();
8 |
9 | return (
10 |
11 | {toasts.map(function ({ id, title, description, action, ...props }) {
12 | return (
13 |
14 |
15 | {title && {title}}
16 | {description && {description}}
17 |
18 | {action}
19 |
20 |
21 | );
22 | })}
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/constants/billing-frequency.ts:
--------------------------------------------------------------------------------
1 | export interface IBillingFrequency {
2 | value: string;
3 | label: string;
4 | priceSuffix: string;
5 | }
6 |
7 | export const BillingFrequency: IBillingFrequency[] = [
8 | { value: 'month', label: 'Monthly', priceSuffix: 'per user/month' },
9 | { value: 'year', label: 'Annual', priceSuffix: 'per user/year' },
10 | ];
11 |
--------------------------------------------------------------------------------
/src/constants/pricing-tier.ts:
--------------------------------------------------------------------------------
1 | export interface Tier {
2 | name: string;
3 | id: 'starter' | 'pro' | 'advanced';
4 | icon: string;
5 | description: string;
6 | features: string[];
7 | featured: boolean;
8 | priceId: Record;
9 | }
10 |
11 | export const PricingTier: Tier[] = [
12 | {
13 | name: 'Starter',
14 | id: 'starter',
15 | icon: '/assets/icons/price-tiers/free-icon.svg',
16 | description: 'Ideal for individuals who want to get started with simple design tasks.',
17 | features: ['1 workspace', 'Limited collaboration', 'Export to PNG and SVG'],
18 | featured: false,
19 | priceId: { month: 'pri_01hsxyh9txq4rzbrhbyngkhy46', year: 'pri_01hsxyh9txq4rzbrhbyngkhy46' },
20 | },
21 | {
22 | name: 'Pro',
23 | id: 'pro',
24 | icon: '/assets/icons/price-tiers/basic-icon.svg',
25 | description: 'Enhanced design tools for scaling teams who need more flexibility.',
26 | features: ['Integrations', 'Unlimited workspaces', 'Advanced editing tools', 'Everything in Starter'],
27 | featured: true,
28 | priceId: { month: 'pri_01hsxycme6m95sejkz7sbz5e9g', year: 'pri_01hsxyeb2bmrg618bzwcwvdd6q' },
29 | },
30 | {
31 | name: 'Advanced',
32 | id: 'advanced',
33 | icon: '/assets/icons/price-tiers/pro-icon.svg',
34 | description: 'Powerful tools designed for extensive collaboration and customization.',
35 | features: [
36 | 'Single sign on (SSO)',
37 | 'Advanced version control',
38 | 'Assets library',
39 | 'Guest accounts',
40 | 'Everything in Pro',
41 | ],
42 | featured: false,
43 | priceId: { month: 'pri_01hsxyff091kyc9rjzx7zm6yqh', year: 'pri_01hsxyfysbzf90tkh2wqbfxwa5' },
44 | },
45 | ];
46 |
--------------------------------------------------------------------------------
/src/hooks/usePaddlePrices.ts:
--------------------------------------------------------------------------------
1 | import { Paddle, PricePreviewParams, PricePreviewResponse } from '@paddle/paddle-js';
2 | import { useEffect, useState } from 'react';
3 | import { PricingTier } from '@/constants/pricing-tier';
4 |
5 | export type PaddlePrices = Record;
6 |
7 | function getLineItems(): PricePreviewParams['items'] {
8 | const priceId = PricingTier.map((tier) => [tier.priceId.month, tier.priceId.year]);
9 | return priceId.flat().map((priceId) => ({ priceId, quantity: 1 }));
10 | }
11 |
12 | function getPriceAmounts(prices: PricePreviewResponse) {
13 | return prices.data.details.lineItems.reduce((acc, item) => {
14 | acc[item.price.id] = item.formattedTotals.total;
15 | return acc;
16 | }, {} as PaddlePrices);
17 | }
18 |
19 | export function usePaddlePrices(
20 | paddle: Paddle | undefined,
21 | country: string,
22 | ): { prices: PaddlePrices; loading: boolean } {
23 | const [prices, setPrices] = useState({});
24 | const [loading, setLoading] = useState(true);
25 |
26 | useEffect(() => {
27 | const paddlePricePreviewRequest: Partial = {
28 | items: getLineItems(),
29 | ...(country !== 'OTHERS' && { address: { countryCode: country } }),
30 | };
31 |
32 | setLoading(true);
33 |
34 | paddle?.PricePreview(paddlePricePreviewRequest as PricePreviewParams).then((prices) => {
35 | setPrices((prevState) => ({ ...prevState, ...getPriceAmounts(prices) }));
36 | setLoading(false);
37 | });
38 | }, [country, paddle]);
39 | return { prices, loading };
40 | }
41 |
--------------------------------------------------------------------------------
/src/hooks/usePagination.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | export function usePagination() {
4 | const [nextCursor, setNextCursor] = useState('');
5 | const [cursorHistory, setCursorHistory] = useState([]);
6 |
7 | function goToNextPage(cursor: string) {
8 | setCursorHistory([...cursorHistory, nextCursor]);
9 | setNextCursor(cursor);
10 | }
11 |
12 | function goToPrevPage() {
13 | const lastCursor = cursorHistory[cursorHistory.length - 1] ?? '';
14 | setCursorHistory(cursorHistory.slice(0, -1));
15 | setNextCursor(lastCursor);
16 | }
17 |
18 | return {
19 | after: nextCursor,
20 | hasPrev: cursorHistory.length > 0 || !!nextCursor,
21 | goToNextPage,
22 | goToPrevPage,
23 | };
24 | }
25 |
--------------------------------------------------------------------------------
/src/hooks/useUserInfo.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { SupabaseClient, User } from '@supabase/supabase-js';
3 |
4 | export function useUserInfo(supabase: SupabaseClient) {
5 | const [user, setUser] = useState(null);
6 |
7 | useEffect(() => {
8 | (async () => {
9 | const { data } = await supabase.auth.getUser();
10 | if (data?.user) {
11 | setUser(data.user);
12 | }
13 | })();
14 | }, [supabase.auth]);
15 |
16 | return { user };
17 | }
18 |
--------------------------------------------------------------------------------
/src/lib/api.types.ts:
--------------------------------------------------------------------------------
1 | import { Subscription, Transaction } from '@paddle/paddle-node-sdk';
2 |
3 | export interface SubscriptionResponse {
4 | data?: Subscription[];
5 | hasMore: boolean;
6 | totalRecords: number;
7 | error?: string;
8 | }
9 |
10 | export interface TransactionResponse {
11 | data?: Transaction[];
12 | hasMore: boolean;
13 | totalRecords: number;
14 | error?: string;
15 | }
16 |
17 | export interface SubscriptionDetailResponse {
18 | data?: Subscription;
19 | error?: string;
20 | }
21 |
--------------------------------------------------------------------------------
/src/lib/database.types.ts:
--------------------------------------------------------------------------------
1 | export interface Subscription {
2 | subscriptionId: string;
3 | subscriptionStatus: string;
4 | priceId: string;
5 | productId: string;
6 | scheduledChange: string;
7 | customerId: string;
8 | customerEmail: string;
9 | }
10 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { type NextRequest } from 'next/server';
2 | import { updateSession } from '@/utils/supabase/middleware';
3 |
4 | export async function middleware(request: NextRequest) {
5 | return await updateSession(request);
6 | }
7 |
8 | export const config = {
9 | matcher: [
10 | /*
11 | * Match all request paths except for the ones starting with:
12 | * - _next/static (static files)
13 | * - _next/image (image optimization files)
14 | * - favicon.ico (favicon file)
15 | * Feel free to modify this pattern to include more paths.
16 | */
17 | '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
18 | ],
19 | };
20 |
--------------------------------------------------------------------------------
/src/styles/checkout.css:
--------------------------------------------------------------------------------
1 | .checkout-background-base {
2 | width: 100%;
3 | position: absolute;
4 | z-index: -1;
5 | }
6 |
7 | .grain-background {
8 | background: url('/assets/background/grain-bg.svg') repeat;
9 | }
10 |
11 | .grid-bg {
12 | background: url('/assets/background/grid-bg.svg') no-repeat;
13 | }
14 |
15 | .top-left-gradient-background {
16 | background: url('/assets/background/checkout-top-gradient.svg') no-repeat top left;
17 | }
18 | .bottom-right-gradient-background {
19 | background: url('/assets/background/checkout-bottom-gradient.svg') no-repeat bottom right;
20 | }
21 |
22 | .checkout-yellow-highlight {
23 | position: absolute;
24 | left: 96px;
25 | top: 0;
26 | width: 248px;
27 | height: 1px;
28 | background: linear-gradient(
29 | 90deg,
30 | rgba(255, 255, 255, 0) 15%,
31 | rgba(255, 248, 0, 0.6) 50%,
32 | rgba(255, 255, 255, 0) 85%
33 | );
34 | }
35 |
36 | .checkout-hard-blur {
37 | width: 196px;
38 | height: 4px;
39 | position: absolute;
40 | left: 103px;
41 | top: -2px;
42 | background: #fff800;
43 | opacity: 0.1;
44 | filter: blur(12px);
45 | }
46 |
47 | .checkout-soft-blur {
48 | width: 296px;
49 | height: 16.576px;
50 | position: absolute;
51 | left: 52px;
52 | top: -9px;
53 | border-radius: 296px;
54 | opacity: 0.3;
55 | background: #fff800;
56 | filter: blur(32px);
57 | }
58 |
59 | .checkout-mobile-grainy-blur {
60 | width: 211px;
61 | height: 245px;
62 | background: linear-gradient(
63 | 0deg,
64 | rgba(255, 251, 229, 0) 0%,
65 | rgba(21, 227, 227, 0.06) 35.5%,
66 | rgba(255, 248, 0, 0.48) 80.5%
67 | );
68 | filter: blur(26px);
69 | }
70 | .checkout-mobile-grainy-blur::before {
71 | content: '';
72 | left: 55px;
73 | position: absolute;
74 | width: 101px;
75 | height: 167px;
76 | background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(168, 240, 248, 0.6) 100%);
77 | filter: blur(26px);
78 | }
79 |
80 | .checkout-mobile-top-gradient {
81 | position: absolute;
82 | left: 50%;
83 | margin-left: -105px;
84 | top: -140px;
85 | width: 211px;
86 | height: 280px;
87 | }
88 |
89 | .checkout-mobile-bottom-gradient {
90 | width: 211px;
91 | height: 280px;
92 | position: absolute;
93 | right: -140.023px;
94 | bottom: -109.977px;
95 | }
96 |
97 | .checkout-mobile-bottom-gradient.checkout-mobile-grainy-blur {
98 | transform: rotate(180deg);
99 | }
100 |
101 | .checkout-order-summary-mobile-yellow-highlight::before {
102 | content: '';
103 | position: absolute;
104 | left: 50%;
105 | margin-left: -124px;
106 | top: 0;
107 | width: 248px;
108 | height: 1px;
109 | background: linear-gradient(
110 | 90deg,
111 | rgba(255, 255, 255, 0) 15%,
112 | rgba(255, 248, 0, 0.6) 50%,
113 | rgba(255, 255, 255, 0) 85%
114 | );
115 | }
116 |
117 | .checkout-success-background {
118 | position: absolute;
119 | left: 50%;
120 | margin-left: -410px;
121 | top: -338.001px;
122 | width: 820px;
123 | height: 938px;
124 | border-radius: 820px;
125 | transform: rotate(180deg);
126 | background: linear-gradient(
127 | 180deg,
128 | rgba(255, 251, 229, 0) 0%,
129 | rgba(21, 227, 227, 0.06) 35.5%,
130 | rgba(255, 248, 0, 0.18) 80.5%
131 | );
132 | filter: blur(100px);
133 | }
134 |
135 | .checkout-success-background::before {
136 | content: '';
137 | position: absolute;
138 | width: 394px;
139 | top: 350px;
140 | height: 559px;
141 | left: 50%;
142 | margin-left: -197px;
143 | background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(168, 240, 248, 0.6) 100%);
144 | filter: blur(100px);
145 | }
146 |
147 | .footer-border {
148 | position: relative;
149 | background: linear-gradient(90deg, rgba(65, 75, 78, 0) 0%, #414b4e 49.5%, rgba(65, 75, 78, 0) 100%);
150 | }
151 | .footer-border::after {
152 | content: '';
153 | position: absolute;
154 | bottom: 0;
155 | left: calc(50% - 124px);
156 | width: 248px;
157 | height: 1px;
158 | background: linear-gradient(
159 | 90deg,
160 | rgba(255, 255, 255, 0) 15%,
161 | rgba(255, 248, 0, 0.6) 50%,
162 | rgba(255, 255, 255, 0) 85%
163 | );
164 | }
165 |
--------------------------------------------------------------------------------
/src/styles/dashboard.css:
--------------------------------------------------------------------------------
1 | .dashboard-background-base {
2 | width: 100%;
3 | position: absolute;
4 | z-index: -1;
5 | }
6 |
7 | .grain-background {
8 | background: url('/assets/background/grain-bg.svg') repeat;
9 | }
10 |
11 | .dashboard-shared-top-grainy-blur {
12 | position: absolute;
13 | left: -131.023px;
14 | top: -127.993px;
15 | width: 211px;
16 | height: 245px;
17 | z-index: -1;
18 | background: linear-gradient(
19 | 0deg,
20 | rgba(255, 251, 229, 0) 0%,
21 | rgba(21, 227, 227, 0.06) 35.5%,
22 | rgba(255, 248, 0, 0.48) 80.5%
23 | );
24 | filter: blur(26px);
25 | }
26 | .dashboard-shared-top-grainy-blur::before {
27 | content: '';
28 | left: 55px;
29 | position: absolute;
30 | width: 101px;
31 | height: 167px;
32 | flex-shrink: 0;
33 | background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(168, 240, 248, 0.6) 100%);
34 | filter: blur(26px);
35 | }
36 |
37 | .dashboard-shared-bottom-grainy-blur {
38 | position: absolute;
39 | transform: rotate(-90deg);
40 | right: -440.007px;
41 | bottom: -560px;
42 | width: 820px;
43 | height: 951px;
44 | flex-shrink: 0;
45 | border-radius: 951px;
46 | background: linear-gradient(
47 | 180deg,
48 | rgba(255, 251, 229, 0) 0%,
49 | rgba(21, 227, 227, 0.06) 35.5%,
50 | rgba(255, 248, 0, 0.48) 80.5%
51 | );
52 | filter: blur(100px);
53 | }
54 |
55 | .dashboard-shared-bottom-grainy-blur::before {
56 | content: '';
57 | position: absolute;
58 | width: 394px;
59 | height: 648px;
60 | flex-shrink: 0;
61 | background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(168, 240, 248, 0.6) 100%);
62 | filter: blur(100px);
63 | }
64 |
65 | .dashboard-sidebar-items {
66 | svg.lucide {
67 | color: #4b4f4f;
68 | }
69 | &.dashboard-sidebar-items-active {
70 | background: #161d1d;
71 | svg.lucide {
72 | color: hsl(var(--primary));
73 | }
74 | }
75 | }
76 |
77 | .dashboard-sidebar-items:hover {
78 | background: #161d1d;
79 | svg.lucide {
80 | color: hsl(var(--primary));
81 | }
82 | }
83 |
84 | .dashboard-sidebar-highlight:after {
85 | content: '';
86 | width: 248px;
87 | height: 1px;
88 | position: absolute;
89 | left: 50%;
90 | margin-left: -124px;
91 | top: 0;
92 | background: linear-gradient(
93 | 90deg,
94 | rgba(255, 255, 255, 0) 15%,
95 | rgba(255, 248, 0, 0.6) 50%,
96 | rgba(255, 255, 255, 0) 85%
97 | );
98 | }
99 |
100 | .dashboard-header-highlight::after {
101 | content: '';
102 | width: 248px;
103 | height: 1px;
104 | position: absolute;
105 | left: 8px;
106 | background: linear-gradient(
107 | 90deg,
108 | rgba(255, 255, 255, 0) 15%,
109 | rgba(255, 248, 0, 0.6) 50%,
110 | rgba(255, 255, 255, 0) 85%
111 | );
112 | }
113 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 |
3 | @plugin "tailwindcss-animate";
4 |
5 | @custom-variant dark (&:is(.dark *));
6 |
7 | :root {
8 | --background: hsl(0 0% 100%);
9 | --foreground: hsl(240 5% 96%);
10 | --card: hsl(0 0% 100%);
11 | --card-foreground: hsl(222.2 84% 4.9%);
12 | --popover: hsl(0 0% 100%);
13 | --popover-foreground: hsl(222.2 84% 4.9%);
14 | --primary: hsl(222.2 47.4% 11.2%);
15 | --primary-foreground: hsl(240 5% 96%);
16 | --secondary: hsl(210 40% 96.1%);
17 | --secondary-foreground: hsl(222.2 47.4% 11.2%);
18 | --muted: hsl(210 40% 96.1%);
19 | --muted-foreground: hsl(215.4 16.3% 46.9%);
20 | --accent: hsl(210 40% 96.1%);
21 | --accent-foreground: hsl(222.2 47.4% 11.2%);
22 | --destructive: hsl(0 84.2% 60.2%);
23 | --destructive-foreground: hsl(210 40% 98%);
24 | --border: hsl(194 9% 28%);
25 | --input: hsl(214.3 31.8% 91.4%);
26 | --ring: hsl(222.2 84% 4.9%);
27 | --radius: 16px;
28 | --chart-1: hsl(12 76% 61%);
29 | --chart-2: hsl(173 58% 39%);
30 | --chart-3: hsl(197 37% 24%);
31 | --chart-4: hsl(43 74% 66%);
32 | --chart-5: hsl(27 87% 67%);
33 | }
34 |
35 | .dark {
36 | --background: hsl(180 18% 7%);
37 | --foreground: hsl(240 5% 96%);
38 | --card: hsl(222.2 84% 4.9%);
39 | --card-foreground: hsl(210 40% 98%);
40 | --popover: hsl(222.2 84% 4.9%);
41 | --popover-foreground: hsl(210 40% 98%);
42 | --primary: hsl(210 40% 98%);
43 | --primary-foreground: hsl(222.2 47.4% 11.2%);
44 | --secondary: hsl(240, 5%, 80%);
45 | --secondary-foreground: hsl(0 0% 100%);
46 | --muted: hsl(217.2 32.6% 17.5%);
47 | --muted-foreground: hsl(180 1% 48%);
48 | --accent: hsl(217.2 32.6% 17.5%);
49 | --accent-foreground: hsl(210 40% 98%);
50 | --destructive: hsl(0 62.8% 30.6%);
51 | --destructive-foreground: hsl(210 40% 98%);
52 | --border: hsl(187 10% 17%);
53 | --input: hsl(217.2 32.6% 17.5%);
54 | --ring: hsl(58 100% 70%);
55 | --chart-1: hsl(220 70% 50%);
56 | --chart-2: hsl(160 60% 45%);
57 | --chart-3: hsl(30 80% 55%);
58 | --chart-4: hsl(280 65% 60%);
59 | --chart-5: hsl(340 75% 55%);
60 | }
61 |
62 | @utility container {
63 | margin-inline: auto;
64 | padding-inline: 2rem;
65 | }
66 |
67 | @theme inline {
68 | --color-background: var(--background);
69 | --color-foreground: var(--foreground);
70 | --color-card: var(--card);
71 | --color-card-foreground: var(--card-foreground);
72 | --color-popover: var(--popover);
73 | --color-popover-foreground: var(--popover-foreground);
74 | --color-primary: var(--primary);
75 | --color-primary-foreground: var(--primary-foreground);
76 | --color-secondary: var(--secondary);
77 | --color-secondary-foreground: var(--secondary-foreground);
78 | --color-muted: var(--muted);
79 | --color-muted-foreground: var(--muted-foreground);
80 | --color-accent: var(--accent);
81 | --color-accent-foreground: var(--accent-foreground);
82 | --color-destructive: var(--destructive);
83 | --color-destructive-foreground: var(--destructive-foreground);
84 | --color-border: var(--border);
85 | --color-input: var(--input);
86 | --color-ring: var(--ring);
87 | --radius-xs: calc(var(--radius) - 8px);
88 | --radius-sm: calc(var(--radius) - 4px);
89 | --radius-md: calc(var(--radius) - 2px);
90 | --radius-lg: var(--radius);
91 | --radius-xl: calc(var(--radius) + 4px);
92 | --animate-accordion-down: accordion-down 0.2s ease-out;
93 | --animate-accordion-up: accordion-up 0.2s ease-out;
94 |
95 | --container-2xs: 16rem;
96 | --container-4xs: 8rem;
97 | --container-16xs: 4rem;
98 |
99 | @keyframes accordion-down {
100 | from {
101 | height: 0;
102 | }
103 | to {
104 | height: var(--radix-accordion-content-height);
105 | }
106 | }
107 |
108 | @keyframes accordion-up {
109 | from {
110 | height: var(--radix-accordion-content-height);
111 | }
112 | to {
113 | height: 0;
114 | }
115 | }
116 | }
117 |
118 | @layer base {
119 | * {
120 | @apply border-border outline-ring/50;
121 | }
122 | body {
123 | @apply bg-background text-foreground;
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/src/styles/home-page.css:
--------------------------------------------------------------------------------
1 | .background-base {
2 | min-height: 1400px;
3 | width: 100%;
4 | position: absolute;
5 | z-index: -1;
6 | }
7 |
8 | .grid-bg {
9 | background: url('/assets/background/grid-bg.svg') no-repeat;
10 | }
11 |
12 | .grain-background {
13 | background: url('/assets/background/grain-bg.svg') repeat;
14 | }
15 |
16 | .grain-blur {
17 | top: -220px;
18 | background: url('/assets/background/grain-blur.svg') no-repeat 50%;
19 | }
20 |
21 | .large-blur {
22 | left: -30px;
23 | top: -864px;
24 | border-radius: 750px;
25 | opacity: 0.2;
26 | background: radial-gradient(
27 | 70.71% 70.71% at 50% 50%,
28 | rgba(117, 173, 255, 0.2) 0%,
29 | rgba(117, 173, 255, 0) 70%,
30 | rgba(117, 173, 255, 0) 100%
31 | );
32 | }
33 |
34 | .small-blur {
35 | background: url('/assets/background/small-blur.svg') no-repeat 50%;
36 | }
37 |
38 | .featured-card-badge {
39 | position: relative;
40 | background: linear-gradient(90deg, #fff800 0%, #fffecc 100%);
41 | background-clip: text;
42 | -webkit-background-clip: text;
43 | -webkit-text-fill-color: transparent;
44 | }
45 |
46 | .featured-card-badge::before {
47 | content: '';
48 | background: linear-gradient(
49 | 90deg,
50 | rgba(255, 255, 255, 0) 15%,
51 | rgba(255, 248, 0, 0.6) 50%,
52 | rgba(255, 255, 255, 0) 85%
53 | );
54 | position: absolute;
55 | left: 8px;
56 | top: -1px;
57 | width: 48px;
58 | height: 1px;
59 | }
60 | .pricing-card-border {
61 | position: relative;
62 | }
63 | .pricing-card-border::before {
64 | content: '';
65 | position: absolute;
66 | inset: 0;
67 | border-radius: 16px 16px 0 0;
68 | padding: 1px 1px 0;
69 | background: linear-gradient(180deg, #414b4e 49.5%, rgba(65, 75, 78, 0) 100%);
70 | -webkit-mask:
71 | linear-gradient(#fff 0 0) content-box,
72 | linear-gradient(#fff 0 0);
73 | -webkit-mask-composite: xor;
74 | mask-composite: exclude;
75 | }
76 |
77 | .footer-border {
78 | position: relative;
79 | background: linear-gradient(90deg, rgba(65, 75, 78, 0) 0%, #414b4e 49.5%, rgba(65, 75, 78, 0) 100%);
80 | }
81 | .footer-border::after {
82 | content: '';
83 | position: absolute;
84 | bottom: 0;
85 | left: calc(50% - 124px);
86 | width: 248px;
87 | height: 1px;
88 | background: linear-gradient(
89 | 90deg,
90 | rgba(255, 255, 255, 0) 15%,
91 | rgba(255, 248, 0, 0.6) 50%,
92 | rgba(255, 255, 255, 0) 85%
93 | );
94 | }
95 |
96 | .featured-price-title {
97 | position: relative;
98 | }
99 | .featured-price-title::before {
100 | content: '';
101 | position: absolute;
102 | left: 44px;
103 | top: -7px;
104 | height: 17px;
105 | width: 296px;
106 | border-radius: 296px;
107 | opacity: 0.2;
108 | background: #fddd35;
109 | filter: blur(32px);
110 | }
111 |
112 | .featured-price-title::after {
113 | content: '';
114 | width: 196px;
115 | height: 4px;
116 | position: absolute;
117 | left: 94px;
118 | top: -2px;
119 | border-radius: 196px;
120 | opacity: 0.5;
121 | background: #4d94ff;
122 | filter: blur(12px);
123 | }
124 |
125 | .featured-hard-blur-bg {
126 | width: 88px;
127 | height: 4px;
128 | position: absolute;
129 | left: 50%;
130 | margin-left: -44px;
131 | top: -2px;
132 | background: #fff800;
133 | opacity: 0.5;
134 | filter: blur(12px);
135 | }
136 |
137 | .featured-yellow-highlight-bg {
138 | content: '';
139 | position: absolute;
140 | left: 50%;
141 | margin-left: -124px;
142 | width: 248px;
143 | height: 1px;
144 | background: linear-gradient(
145 | 90deg,
146 | rgba(255, 255, 255, 0) 15%,
147 | rgba(255, 248, 0, 0.6) 50%,
148 | rgba(255, 255, 255, 0) 85%
149 | );
150 | }
151 | .featured-vertical-hard-blur-bg {
152 | position: absolute;
153 | top: -140px;
154 | left: 50%;
155 | margin-left: -64px;
156 | width: 128px;
157 | height: 280px;
158 | border-radius: 280px;
159 | opacity: 0.1;
160 | background: #fff800;
161 | filter: blur(48px);
162 | }
163 |
164 | .featured-soft-blur-bg {
165 | position: absolute;
166 | top: -19px;
167 | left: 50%;
168 | margin-left: -192px;
169 | width: 384px;
170 | height: 37px;
171 | border-radius: 384px;
172 | opacity: 0.3;
173 | background: #fff800;
174 | filter: blur(32px);
175 | }
176 |
--------------------------------------------------------------------------------
/src/styles/layout.css:
--------------------------------------------------------------------------------
1 | .secondary-button-animation {
2 | &::after {
3 | content: '';
4 | position: absolute;
5 | z-index: -1;
6 | top: 0;
7 | right: 0;
8 | bottom: 0;
9 | left: 0;
10 | display: block;
11 | background: linear-gradient(90deg, hsla(0, 0%, 99%, 0.16), hsla(0, 0%, 99%, 0) 50%, hsla(0, 0%, 99%, 0));
12 | background-size: 200%;
13 | background-position: 100%;
14 | transition: all 0.8s cubic-bezier(0.246, 0.75, 0.187, 1);
15 | border-radius: inherit;
16 | }
17 | &:hover::after {
18 | background-position: 0;
19 | border-radius: inherit;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/styles/login.css:
--------------------------------------------------------------------------------
1 | .login-background-base {
2 | width: 100%;
3 | position: absolute;
4 | z-index: -1;
5 | }
6 |
7 | .grid-bg {
8 | background: url('/assets/background/grid-bg.svg') no-repeat;
9 | }
10 |
11 | .grain-background {
12 | mix-blend-mode: overlay;
13 | background: url('/assets/background/grain-bg.svg') repeat;
14 | }
15 |
16 | .login-gradient-background {
17 | background: url('/assets/background/login-gradient.svg') no-repeat top;
18 | }
19 |
20 | .login-card-border,
21 | .login-card-hard-blur,
22 | .login-card-vertical-hard-blur,
23 | .login-card-soft-blur,
24 | .login-card-yellow-highlight {
25 | position: relative;
26 | }
27 |
28 | .login-card-border::before {
29 | content: '';
30 | z-index: -1;
31 | position: absolute;
32 | inset: 0;
33 | border-radius: 16px 16px 0 0;
34 | padding: 1px 1px 0;
35 | background: linear-gradient(180deg, #414b4e 49.5%, rgba(65, 75, 78, 0) 100%);
36 | -webkit-mask:
37 | linear-gradient(#fff 0 0) content-box,
38 | linear-gradient(#fff 0 0);
39 | -webkit-mask-composite: xor;
40 | mask-composite: exclude;
41 | }
42 |
43 | .login-card-hard-blur::before {
44 | content: '';
45 | width: 88px;
46 | height: 4px;
47 | position: absolute;
48 | left: 50%;
49 | margin-left: -44px;
50 | top: -2px;
51 | background: #fff800;
52 | opacity: 0.5;
53 | filter: blur(12px);
54 | }
55 |
56 | .login-card-vertical-hard-blur::before {
57 | content: '';
58 | width: 128px;
59 | height: 280px;
60 | position: absolute;
61 | left: 50%;
62 | margin-left: -64px;
63 | top: -140px;
64 | border-radius: 280px;
65 | opacity: 0.1;
66 | background: #fff800;
67 | filter: blur(48px);
68 | }
69 |
70 | .login-card-small-soft-blur::before {
71 | width: 250px;
72 | }
73 | .login-card-medium-soft-blur::before {
74 | width: 384px;
75 | }
76 | .login-card-soft-blur::before {
77 | content: '';
78 | height: 37px;
79 | position: absolute;
80 | left: 52px;
81 | top: -19px;
82 | border-radius: 384px;
83 | opacity: 0.2;
84 | background: #fff800;
85 | filter: blur(32px);
86 | }
87 |
88 | .login-card-small-yellow-highlight::before {
89 | width: 250px;
90 | margin-left: -125px;
91 | }
92 | .login-card-medium-yellow-highlight::before {
93 | width: 384px;
94 | margin-left: -192px;
95 | }
96 |
97 | .login-card-yellow-highlight::before {
98 | content: '';
99 | height: 1px;
100 | left: 50%;
101 |
102 | position: absolute;
103 | background: linear-gradient(
104 | 90deg,
105 | rgba(255, 255, 255, 0) 15%,
106 | rgba(255, 248, 0, 0.9) 50%,
107 | rgba(255, 255, 255, 0) 85%
108 | );
109 | }
110 |
--------------------------------------------------------------------------------
/src/utils/paddle/data-helpers.ts:
--------------------------------------------------------------------------------
1 | import { CheckoutEventsTimePeriod } from '@paddle/paddle-js';
2 |
3 | export function parseSDKResponse(response: T): T {
4 | return JSON.parse(JSON.stringify(response));
5 | }
6 |
7 | export const ErrorMessage = 'Something went wrong, please try again later';
8 |
9 | export function getErrorMessage() {
10 | return { error: ErrorMessage, data: [], hasMore: false, totalRecords: 0 };
11 | }
12 |
13 | export function getPaymentReason(origin: string) {
14 | if (origin === 'web' || origin === 'subscription_charge') {
15 | return 'New';
16 | } else {
17 | return 'Renewal of ';
18 | }
19 | }
20 |
21 | const BillingCycleMap = {
22 | day: 'daily',
23 | week: 'weekly',
24 | month: 'monthly',
25 | year: 'yearly',
26 | };
27 |
28 | const CustomBillingCycleMap = {
29 | day: 'days',
30 | week: 'weeks',
31 | month: 'months',
32 | year: 'years',
33 | };
34 |
35 | export function formatBillingCycle({ frequency, interval }: CheckoutEventsTimePeriod) {
36 | if (frequency === 1) {
37 | return BillingCycleMap[interval];
38 | } else {
39 | return `every ${frequency} ${CustomBillingCycleMap[interval]}`;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/utils/paddle/get-customer-id.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from '@/utils/supabase/server';
2 |
3 | export async function getCustomerId() {
4 | const supabase = await createClient();
5 | const user = await supabase.auth.getUser();
6 | if (user.data.user?.email) {
7 | const customersData = await supabase
8 | .from('customers')
9 | .select('customer_id,email')
10 | .eq('email', user.data.user?.email)
11 | .single();
12 | if (customersData?.data?.customer_id) {
13 | return customersData?.data?.customer_id as string;
14 | }
15 | }
16 | return '';
17 | }
18 |
--------------------------------------------------------------------------------
/src/utils/paddle/get-paddle-instance.ts:
--------------------------------------------------------------------------------
1 | import { Environment, LogLevel, Paddle, PaddleOptions } from '@paddle/paddle-node-sdk';
2 |
3 | export function getPaddleInstance() {
4 | const paddleOptions: PaddleOptions = {
5 | environment: (process.env.NEXT_PUBLIC_PADDLE_ENV as Environment) ?? Environment.sandbox,
6 | logLevel: LogLevel.error,
7 | };
8 |
9 | if (!process.env.PADDLE_API_KEY) {
10 | console.error('Paddle API key is missing');
11 | }
12 |
13 | return new Paddle(process.env.PADDLE_API_KEY!, paddleOptions);
14 | }
15 |
--------------------------------------------------------------------------------
/src/utils/paddle/get-subscription.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { getCustomerId } from '@/utils/paddle/get-customer-id';
4 | import { ErrorMessage, parseSDKResponse } from '@/utils/paddle/data-helpers';
5 | import { getPaddleInstance } from '@/utils/paddle/get-paddle-instance';
6 | import { SubscriptionDetailResponse } from '@/lib/api.types';
7 |
8 | export async function getSubscription(subscriptionId: string): Promise {
9 | try {
10 | const customerId = await getCustomerId();
11 | if (customerId) {
12 | const subscription = await getPaddleInstance().subscriptions.get(subscriptionId, {
13 | include: ['next_transaction', 'recurring_transaction_details'],
14 | });
15 |
16 | return { data: parseSDKResponse(subscription) };
17 | }
18 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
19 | } catch (e) {
20 | return { error: ErrorMessage };
21 | }
22 | return { error: ErrorMessage };
23 | }
24 |
--------------------------------------------------------------------------------
/src/utils/paddle/get-subscriptions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { getCustomerId } from '@/utils/paddle/get-customer-id';
4 | import { getPaddleInstance } from '@/utils/paddle/get-paddle-instance';
5 | import { SubscriptionResponse } from '@/lib/api.types';
6 | import { getErrorMessage } from '@/utils/paddle/data-helpers';
7 |
8 | export async function getSubscriptions(): Promise {
9 | try {
10 | const customerId = await getCustomerId();
11 | if (customerId) {
12 | const subscriptionCollection = getPaddleInstance().subscriptions.list({ customerId: [customerId], perPage: 20 });
13 | const subscriptions = await subscriptionCollection.next();
14 | return {
15 | data: subscriptions,
16 | hasMore: subscriptionCollection.hasMore,
17 | totalRecords: subscriptionCollection.estimatedTotal,
18 | };
19 | }
20 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
21 | } catch (e) {
22 | return getErrorMessage();
23 | }
24 | return getErrorMessage();
25 | }
26 |
--------------------------------------------------------------------------------
/src/utils/paddle/get-transactions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { getCustomerId } from '@/utils/paddle/get-customer-id';
4 | import { getErrorMessage, parseSDKResponse } from '@/utils/paddle/data-helpers';
5 | import { getPaddleInstance } from '@/utils/paddle/get-paddle-instance';
6 | import { TransactionResponse } from '@/lib/api.types';
7 |
8 | export async function getTransactions(subscriptionId: string, after: string): Promise {
9 | try {
10 | const customerId = await getCustomerId();
11 | if (customerId) {
12 | const transactionCollection = getPaddleInstance().transactions.list({
13 | customerId: [customerId],
14 | after: after,
15 | perPage: 10,
16 | status: ['billed', 'paid', 'past_due', 'completed', 'canceled'],
17 | subscriptionId: subscriptionId ? [subscriptionId] : undefined,
18 | });
19 | const transactionData = await transactionCollection.next();
20 | return {
21 | data: parseSDKResponse(transactionData ?? []),
22 | hasMore: transactionCollection.hasMore,
23 | totalRecords: transactionCollection.estimatedTotal,
24 | error: undefined,
25 | };
26 | } else {
27 | return { data: [], hasMore: false, totalRecords: 0 };
28 | }
29 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
30 | } catch (e) {
31 | return getErrorMessage();
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/utils/paddle/parse-money.ts:
--------------------------------------------------------------------------------
1 | export function convertAmountFromLowestUnit(amount: string, currency: string) {
2 | switch (currency) {
3 | case 'JPY':
4 | case 'KRW':
5 | return parseFloat(amount);
6 | default:
7 | return parseFloat(amount) / 100;
8 | }
9 | }
10 |
11 | export function parseMoney(amount: string = '0', currency: string = 'USD') {
12 | const parsedAmount = convertAmountFromLowestUnit(amount, currency);
13 | return formatMoney(parsedAmount, currency);
14 | }
15 |
16 | export function formatMoney(amount: number = 0, currency: string = 'USD') {
17 | const language = typeof navigator !== 'undefined' ? navigator.language : 'en-US';
18 | return new Intl.NumberFormat(language ?? 'en-US', {
19 | style: 'currency',
20 | currency: currency,
21 | }).format(amount);
22 | }
23 |
--------------------------------------------------------------------------------
/src/utils/paddle/process-webhook.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CustomerCreatedEvent,
3 | CustomerUpdatedEvent,
4 | EventEntity,
5 | EventName,
6 | SubscriptionCreatedEvent,
7 | SubscriptionUpdatedEvent,
8 | } from '@paddle/paddle-node-sdk';
9 | import { createClient } from '@/utils/supabase/server-internal';
10 |
11 | export class ProcessWebhook {
12 | async processEvent(eventData: EventEntity) {
13 | switch (eventData.eventType) {
14 | case EventName.SubscriptionCreated:
15 | case EventName.SubscriptionUpdated:
16 | await this.updateSubscriptionData(eventData);
17 | break;
18 | case EventName.CustomerCreated:
19 | case EventName.CustomerUpdated:
20 | await this.updateCustomerData(eventData);
21 | break;
22 | }
23 | }
24 |
25 | private async updateSubscriptionData(eventData: SubscriptionCreatedEvent | SubscriptionUpdatedEvent) {
26 | const supabase = await createClient();
27 | const { error } = await supabase
28 | .from('subscriptions')
29 | .upsert({
30 | subscription_id: eventData.data.id,
31 | subscription_status: eventData.data.status,
32 | price_id: eventData.data.items[0].price?.id ?? '',
33 | product_id: eventData.data.items[0].price?.productId ?? '',
34 | scheduled_change: eventData.data.scheduledChange?.effectiveAt,
35 | customer_id: eventData.data.customerId,
36 | })
37 | .select();
38 |
39 | if (error) throw error;
40 | }
41 |
42 | private async updateCustomerData(eventData: CustomerCreatedEvent | CustomerUpdatedEvent) {
43 | const supabase = await createClient();
44 | const { error } = await supabase
45 | .from('customers')
46 | .upsert({
47 | customer_id: eventData.data.id,
48 | email: eventData.data.email,
49 | })
50 | .select();
51 |
52 | if (error) throw error;
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/utils/supabase/client.ts:
--------------------------------------------------------------------------------
1 | import { createBrowserClient } from '@supabase/ssr';
2 |
3 | export function createClient() {
4 | return createBrowserClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);
5 | }
6 |
--------------------------------------------------------------------------------
/src/utils/supabase/middleware.ts:
--------------------------------------------------------------------------------
1 | import { createServerClient } from '@supabase/ssr';
2 | import { NextResponse, type NextRequest } from 'next/server';
3 |
4 | export async function updateSession(request: NextRequest) {
5 | let supabaseResponse = NextResponse.next({
6 | request,
7 | });
8 |
9 | const supabase = createServerClient(
10 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
11 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
12 | {
13 | cookies: {
14 | getAll() {
15 | return request.cookies.getAll();
16 | },
17 | setAll(cookiesToSet) {
18 | cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
19 | supabaseResponse = NextResponse.next({
20 | request,
21 | });
22 | cookiesToSet.forEach(({ name, value, options }) => supabaseResponse.cookies.set(name, value, options));
23 | },
24 | },
25 | },
26 | );
27 |
28 | // Do not run code between createServerClient and
29 | // supabase.auth.getUser(). A simple mistake could make it very hard to debug
30 | // issues with users being randomly logged out.
31 |
32 | // IMPORTANT: DO NOT REMOVE auth.getUser()
33 |
34 | await supabase.auth.getUser();
35 |
36 | // IMPORTANT: You *must* return the supabaseResponse object as it is.
37 | // If you're creating a new response object with NextResponse.next() make sure to:
38 | // 1. Pass the request in it, like so:
39 | // const myNewResponse = NextResponse.next({ request })
40 | // 2. Copy over the cookies, like so:
41 | // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())
42 | // 3. Change the myNewResponse object to fit your needs, but avoid changing
43 | // the cookies!
44 | // 4. Finally:
45 | // return myNewResponse
46 | // If this is not done, you may be causing the browser and server to go out
47 | // of sync and terminate the user's session prematurely!
48 |
49 | return supabaseResponse;
50 | }
51 |
--------------------------------------------------------------------------------
/src/utils/supabase/server-internal.ts:
--------------------------------------------------------------------------------
1 | import { createServerClient } from '@supabase/ssr';
2 | import { cookies } from 'next/headers';
3 |
4 | export async function createClient() {
5 | const cookieStore = await cookies();
6 |
7 | return createServerClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, {
8 | cookies: {
9 | getAll() {
10 | return cookieStore.getAll();
11 | },
12 | setAll(cookiesToSet) {
13 | try {
14 | cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
15 | } catch {
16 | // The `setAll` method was called from a Server Component.
17 | // This can be ignored if you have middleware refreshing
18 | // user sessions.
19 | }
20 | },
21 | },
22 | });
23 | }
24 |
--------------------------------------------------------------------------------
/src/utils/supabase/server.ts:
--------------------------------------------------------------------------------
1 | import { createServerClient } from '@supabase/ssr';
2 | import { cookies } from 'next/headers';
3 |
4 | export async function createClient() {
5 | const cookieStore = await cookies();
6 |
7 | return createServerClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, {
8 | cookies: {
9 | getAll() {
10 | return cookieStore.getAll();
11 | },
12 | setAll(cookiesToSet) {
13 | try {
14 | cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
15 | } catch {
16 | // The `setAll` method was called from a Server Component.
17 | // This can be ignored if you have middleware refreshing
18 | // user sessions.
19 | }
20 | },
21 | },
22 | });
23 | }
24 |
25 | export async function validateUserSession() {
26 | const supabase = await createClient();
27 | const {
28 | data: { session },
29 | } = await supabase.auth.getSession();
30 |
31 | if (!session) {
32 | throw new Error('You are not allowed to perform this action.');
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/supabase/.gitignore:
--------------------------------------------------------------------------------
1 | # Supabase
2 | .branches
3 | .temp
4 | .env
5 |
--------------------------------------------------------------------------------
/supabase/migrations/20240907140223_initialize.sql:
--------------------------------------------------------------------------------
1 | -- Create customers table to map Paddle customer_id to email
2 | create table
3 | public.customers (
4 | customer_id text not null,
5 | email text not null,
6 | created_at timestamp with time zone not null default now(),
7 | updated_at timestamp with time zone not null default now(),
8 | constraint customers_pkey primary key (customer_id)
9 | ) tablespace pg_default;
10 |
11 | -- Create subscription table to store webhook events sent by Paddle
12 | create table
13 | public.subscriptions (
14 | subscription_id text not null,
15 | subscription_status text not null,
16 | price_id text null,
17 | product_id text null,
18 | scheduled_change text null,
19 | customer_id text not null,
20 | created_at timestamp with time zone not null default now(),
21 | updated_at timestamp with time zone not null default now(),
22 | constraint subscriptions_pkey primary key (subscription_id),
23 | constraint public_subscriptions_customer_id_fkey foreign key (customer_id) references customers (customer_id)
24 | ) tablespace pg_default;
25 |
26 | -- Grant access to authenticated users to read the customers table to get the customer_id based on the email
27 | create policy "Enable read access for authenticated users to customers" on "public"."customers" as PERMISSIVE for SELECT to authenticated using ( true );
28 |
29 | -- Grant access to authenticated users to read the subscriptions table
30 | create policy "Enable read access for authenticated users to subscriptions" on "public"."subscriptions" as PERMISSIVE for SELECT to authenticated using ( true );
31 |
--------------------------------------------------------------------------------
/supabase/seed.sql:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PaddleHQ/paddle-nextjs-starter-kit/c3bfe45929fd5cc9af4f710e8b9089f9e457b2e9/supabase/seed.sql
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "target": "ES2015",
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------