├── .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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /public/assets/background/checkout-top-gradient.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /public/assets/background/grain-blur.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /public/assets/background/login-gradient.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /public/assets/background/small-blur.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/assets/icons/localization-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/assets/icons/logo/nextjs-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /public/assets/icons/logo/paddle-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/assets/icons/logo/paddle-white-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/assets/icons/logo/tailwind-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/assets/icons/price-tiers/basic-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /public/assets/icons/price-tiers/free-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/assets/icons/price-tiers/pro-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 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 | {'Success 26 |

27 | Payment successful 28 |

29 |

Success! Your payment is complete, and you’re all set.

30 | 33 |
34 |
35 |
36 | 37 |
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 |
26 |
27 | Don’t have an account?{' '} 28 | 29 | Sign up 30 | 31 |
32 |
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 |
26 |
27 | Already have an account?{' '} 28 | 29 | Log in 30 | 31 |
32 |
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 |
19 | 20 |
or
21 | 22 |
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 |
34 | {'AeroEdit'} 35 |
36 | Log in to your account 37 |
38 | 41 |
42 | 43 |
or
44 | 45 |
46 | setEmail(email)} 49 | password={password} 50 | onPasswordChange={(password) => setPassword(password)} 51 | /> 52 | 55 | 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 |
25 | {'AeroEdit'} 26 |
Create an account
27 | setEmail(email)} 30 | password={password} 31 | onPasswordChange={(password) => setPassword(password)} 32 | /> 33 | 36 | 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 |
86 | 87 |
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 | {'AeroEdit'} 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 | {'AeroEdit'} 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 |
27 | 28 |
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 | {subscriptionItem.product.name} 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 | {subscriptionItem.product.name} 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 |
5 |
6 |
7 |
8 |
9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/components/gradients/checkout-gradients.tsx: -------------------------------------------------------------------------------- 1 | export function CheckoutGradients() { 2 | return ( 3 | <> 4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
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 |
4 |
5 |
6 |
7 |
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 | {'TailwindCSS 9 | {'TailwindCSS 10 | {'Supabase 11 | {'Next.js 12 | {'Shadcn 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 | {'Paddle 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 | {'Localization 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 |
18 | {name} 19 |

{name}

20 |
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 |
33 | 34 | 35 |
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 | 16 | 17 | 18 | {title} 19 | 20 |
21 | {description} 22 |
23 | 26 | 29 |
30 |
31 |
32 |
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 |
8 | 9 | 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 | --------------------------------------------------------------------------------