Created
December 31, 2024 02:58
-
-
Save archasek/35a536335fe38835ef8aaa4e4a5ad3cb to your computer and use it in GitHub Desktop.
Paddle Next.js Starter Kit as text
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Directory structure: | |
└── PaddleHQ-paddle-nextjs-starter-kit/ | |
├── .prettierrc | |
├── .github/ | |
│ ├── workflows/ | |
│ │ └── test.yml | |
│ ├── CODEOWNERS | |
│ └── ISSUE_TEMPLATE/ | |
│ ├── config.yml | |
│ ├── feature_request.yaml | |
│ └── bug_report.yaml | |
├── next.config.mjs | |
├── .eslintrc.json | |
├── .env.local.example | |
├── public/ | |
│ └── assets/ | |
│ ├── background/ | |
│ └── icons/ | |
│ ├── product-icons/ | |
│ ├── price-tiers/ | |
│ └── logo/ | |
├── postcss.config.js | |
├── package.json | |
├── .nvmrc | |
├── SECURITY.md | |
├── components.json | |
├── tailwind.config.ts | |
├── LICENSE | |
├── tsconfig.json | |
├── README.md | |
├── pnpm-lock.yaml | |
├── supabase/ | |
│ ├── .gitignore | |
│ ├── config.toml | |
│ ├── migrations/ | |
│ │ └── 20240907140223_initialize.sql | |
│ └── seed.sql | |
├── CONTRIBUTING.md | |
└── src/ | |
├── middleware.ts | |
├── components/ | |
│ ├── checkout/ | |
│ │ ├── checkout-price-amount.tsx | |
│ │ ├── checkout-price-container.tsx | |
│ │ ├── checkout-contents.tsx | |
│ │ ├── checkout-header.tsx | |
│ │ ├── price-section.tsx | |
│ │ ├── quantity-field.tsx | |
│ │ └── checkout-line-items.tsx | |
│ ├── dashboard/ | |
│ │ ├── payments/ | |
│ │ │ ├── components/ | |
│ │ │ │ ├── columns.tsx | |
│ │ │ │ └── data-table.tsx | |
│ │ │ └── payments-content.tsx | |
│ │ ├── landing/ | |
│ │ │ ├── components/ | |
│ │ │ │ ├── dashboard-team-members-card.tsx | |
│ │ │ │ ├── dashboard-usage-card-group.tsx | |
│ │ │ │ ├── dashboard-subscription-card-group.tsx | |
│ │ │ │ └── dashboard-tutorial-card.tsx | |
│ │ │ └── dashboard-landing-page.tsx | |
│ │ ├── subscriptions/ | |
│ │ │ ├── components/ | |
│ │ │ │ ├── subscription-next-payment-card.tsx | |
│ │ │ │ ├── subscription-line-items.tsx | |
│ │ │ │ ├── subscription-header.tsx | |
│ │ │ │ ├── subscription-alerts.tsx | |
│ │ │ │ ├── subscription-detail.tsx | |
│ │ │ │ ├── subscription-past-payments-card.tsx | |
│ │ │ │ ├── payment-method-section.tsx | |
│ │ │ │ ├── subscription-header-action-button.tsx | |
│ │ │ │ ├── payment-method-details.tsx | |
│ │ │ │ └── subscription-cards.tsx | |
│ │ │ ├── subscriptions.tsx | |
│ │ │ └── views/ | |
│ │ │ ├── subscription-error-view.tsx | |
│ │ │ ├── multiple-subscriptions-view.tsx | |
│ │ │ └── no-subscription-view.tsx | |
│ │ └── layout/ | |
│ │ ├── dashboard-layout.tsx | |
│ │ ├── sidebar.tsx | |
│ │ ├── sidebar-user-info.tsx | |
│ │ ├── dashboard-page-header.tsx | |
│ │ ├── mobile-sidebar.tsx | |
│ │ ├── loading-screen.tsx | |
│ │ └── error-content.tsx | |
│ ├── shared/ | |
│ │ ├── confirmation/ | |
│ │ │ └── confirmation.tsx | |
│ │ ├── select/ | |
│ │ │ └── select.tsx | |
│ │ ├── toggle/ | |
│ │ │ └── toggle.tsx | |
│ │ └── status/ | |
│ │ └── status.tsx | |
│ ├── ui/ | |
│ │ ├── toaster.tsx | |
│ │ ├── alert.tsx | |
│ │ ├── input.tsx | |
│ │ ├── accordion.tsx | |
│ │ ├── table.tsx | |
│ │ ├── label.tsx | |
│ │ ├── dropdown-menu.tsx | |
│ │ ├── toast.tsx | |
│ │ ├── use-toast.ts | |
│ │ ├── skeleton.tsx | |
│ │ ├── select.tsx | |
│ │ ├── button.tsx | |
│ │ ├── separator.tsx | |
│ │ ├── sheet.tsx | |
│ │ ├── dialog.tsx | |
│ │ ├── card.tsx | |
│ │ └── tabs.tsx | |
│ ├── authentication/ | |
│ │ ├── sign-up-form.tsx | |
│ │ ├── authentication-form.tsx | |
│ │ ├── gh-login-button.tsx | |
│ │ └── login-form.tsx | |
│ ├── gradients/ | |
│ │ ├── featured-card-gradient.tsx | |
│ │ ├── login-gradient.tsx | |
│ │ ├── checkout-form-gradients.tsx | |
│ │ ├── login-card-gradient.tsx | |
│ │ ├── home-page-background.tsx | |
│ │ ├── checkout-gradients.tsx | |
│ │ ├── dashboard-gradient.tsx | |
│ │ └── success-page-gradients.tsx | |
│ └── home/ | |
│ ├── footer/ | |
│ │ ├── built-using-tools.tsx | |
│ │ ├── powered-by-paddle.tsx | |
│ │ └── footer.tsx | |
│ ├── header/ | |
│ │ ├── country-dropdown.tsx | |
│ │ ├── localization-banner.tsx | |
│ │ └── header.tsx | |
│ ├── home-page.tsx | |
│ ├── pricing/ | |
│ │ ├── price-title.tsx | |
│ │ ├── pricing.tsx | |
│ │ ├── features-list.tsx | |
│ │ ├── price-cards.tsx | |
│ │ └── price-amount.tsx | |
│ └── hero-section/ | |
│ └── hero-section.tsx | |
├── lib/ | |
│ ├── database.types.ts | |
│ ├── utils.ts | |
│ └── api.types.ts | |
├── styles/ | |
│ ├── checkout.css | |
│ ├── globals.css | |
│ ├── layout.css | |
│ ├── home-page.css | |
│ ├── dashboard.css | |
│ └── login.css | |
├── constants/ | |
│ ├── billing-frequency.ts | |
│ └── pricing-tier.ts | |
├── hooks/ | |
│ ├── useUserInfo.ts | |
│ ├── usePaddlePrices.ts | |
│ └── usePagination.ts | |
├── utils/ | |
│ ├── paddle/ | |
│ │ ├── data-helpers.ts | |
│ │ ├── get-customer-id.ts | |
│ │ ├── get-subscription.ts | |
│ │ ├── get-subscriptions.ts | |
│ │ ├── get-transactions.ts | |
│ │ ├── parse-money.ts | |
│ │ ├── get-paddle-instance.ts | |
│ │ └── process-webhook.ts | |
│ └── supabase/ | |
│ ├── middleware.ts | |
│ ├── server.ts | |
│ ├── client.ts | |
│ └── server-internal.ts | |
└── app/ | |
├── api/ | |
│ └── webhook/ | |
│ └── route.ts | |
├── checkout/ | |
│ ├── [priceId]/ | |
│ │ └── page.tsx | |
│ └── success/ | |
│ └── page.tsx | |
├── page.tsx | |
├── auth/ | |
│ └── callback/ | |
│ └── route.ts | |
├── error/ | |
│ └── page.tsx | |
├── dashboard/ | |
│ ├── page.tsx | |
│ ├── payments/ | |
│ │ ├── page.tsx | |
│ │ └── [subscriptionId]/ | |
│ │ └── page.tsx | |
│ ├── subscriptions/ | |
│ │ ├── page.tsx | |
│ │ ├── actions.ts | |
│ │ └── [subscriptionId]/ | |
│ │ └── page.tsx | |
│ └── layout.tsx | |
├── login/ | |
│ ├── page.tsx | |
│ └── actions.ts | |
├── layout.tsx | |
└── signup/ | |
├── page.tsx | |
└── actions.ts | |
================================================ | |
File: /README.md | |
================================================ | |
# Paddle Billing subscriptions Next.js starter kit | |
[Paddle Billing](https://www.paddle.com/billing?utm_source=dx&utm_medium=paddle-nextjs-starter-kit) is a complete digital product sales and subscription management platform, designed for modern software businesses. It helps you increase your revenue, retain customers, and scale your operations. | |
This is a complete starter kit that you can use to build and deploy a [Next.js](https://nextjs.org/) SaaS app powered by Paddle Billing. | |
> **Important:** This starter kit works with Paddle Billing. It does not support Paddle Classic. To work with Paddle Classic, see: [Paddle Classic API reference](https://developer.paddle.com/classic/api-reference/1384a288aca7a-api-reference?utm_source=dx&utm_medium=paddle-nextjs-starter-kit) | |
## Demo | |
See it in action: [https://paddle-billing.vercel.app/](https://paddle-billing.vercel.app/?utm_source=dx&utm_medium=paddle-nextjs-starter-kit) | |
 | |
## Features | |
- Three-tier pricing page that's fully localized for 200+ markets using [Paddle.js](https://developer.paddle.com/paddlejs/overview). | |
- An integrated checkout experience built with [Paddle Checkout](https://developer.paddle.com/concepts/sell/self-serve-checkout), with secure [optimized payments](https://developer.paddle.com/concepts/payment-methods/overview?utm_source=dx&utm_medium=paddle-nextjs-starter-kit) by card, Apple Pay, Google Pay, PayPal, and others. | |
- User management and auth handled by [Supabase](https://supabase.com/). | |
- Ready-made screens to let customers manage their payments and subscriptions. | |
- Automatic syncing of customer and subscription data between Paddle and your app using [webhooks](https://developer.paddle.com/webhooks/overview?utm_source=dx&utm_medium=paddle-nextjs-starter-kit). | |
## Stack | |
This starter kit is built with: | |
- **Framework:** [Next.js](https://nextjs.org/) | |
- **Auth and user management:** [Supabase](https://supabase.com/) | |
- **Component library:** [Ant Design](https://ant.design/) | |
- **CSS framework:** [Tailwind](https://tailwindcss.com/) | |
- **Billing solution**: [Paddle Billing](https://www.paddle.com/billing?utm_source=dx&utm_medium=paddle-nextjs-starter-kit) | |
## Prerequisites | |
### Local dev environment | |
- [Node.js](https://nodejs.org/en/download/package-manager/current) version > `20` | |
- [npm](https://www.npmjs.com/), [Yarn](https://yarnpkg.com/), or [pnpm](https://pnpm.io/) | |
### Accounts | |
- [Vercel account](https://vercel.com/) | |
- [Supabase account](https://supabase.com/) | |
- [Paddle Billing](https://sandbox-login.paddle.com/signup?utm_source=dx&utm_medium=paddle-nextjs-starter-kit) — sandbox recommended | |
## Step-by-step setup | |
> **Important:** If you're totally new to Next.js and Paddle, we have a more complete tutorial on our dev docs: [Build and deploy Next.js app with Vercel and Supabase](https://developer.paddle.com/build/nextjs-supabase-vercel-starter-kit?utm_source=dx&utm_medium=paddle-nextjs-starter-kit) | |
### 1. Deploy on Vercel | |
#### Start deploy | |
Click this button to clone this repo and create a new project in your Vercel account: | |
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FPaddleHQ%2Fpaddle-nextjs-starter-kit&env=PADDLE_API_KEY,PADDLE_NOTIFICATION_WEBHOOK_SECRET,NEXT_PUBLIC_PADDLE_ENV,NEXT_PUBLIC_PADDLE_CLIENT_TOKEN&integration-ids=oac_VqOgBHqhEoFTPzGkPd7L0iH6&external-id=https%3A%2F%2Fgithub.com%2FPaddleHQ%2Fpaddle-nextjs-starter-kit%2Ftree%2Fmain) | |
You can also [create a new application manually](https://vercel.com/new). | |
#### Configure project | |
Click **Add** to walk through integrating with Supabase. You'll be asked to authenticate with Supabase and confirm creating the database schemas. | |
Then, enter Paddle environment variables: | |
| Variable | Used for | How to get it | | |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | |
| `PADDLE_API_KEY` | An [API key](https://developer.paddle.com/api-reference/about/authentication?utm_source=dx&utm_medium=paddle-nextjs-starter-kit), used for interacting with Paddle data in the backend. For example, syncing customer and subscription data with Supabase. | Go to [**Paddle > Developer tools > Authentication**](https://sandbox-vendors.paddle.com/authentication-v2) and create a new API key. | | |
| `NEXT_PUBLIC_PADDLE_CLIENT_TOKEN` | A [client-side key](https://developer.paddle.com/api-reference/about/authentication?utm_source=dx&utm_medium=paddle-nextjs-starter-kit), used for interacting with Paddle in the frontend. For example, getting localized prices for pricing pages and opening a checkout. | Go to [**Paddle > Developer tools > Authentication**](https://sandbox-vendors.paddle.com/authentication-v2) and create a new client-side token. | | |
| `PADDLE_NOTIFICATION_WEBHOOK_SECRET` | A secret key used for verifying that [webhooks](https://developer.paddle.com/webhooks/notification-destinations?utm_source=dx&utm_medium=paddle-nextjs-starter-kit) came from Paddle and haven't been tampered with in transit. Important for security. | Go to [**Paddle > Developer tools > Notifications**](https://sandbox-vendors.paddle.com/notifications), create a new notification destination for your Vercel deploy URL + `/api/webhook`, then edit to copy the secret key. See below for more information. | | |
| `NEXT_PUBLIC_PADDLE_ENV` | Environment for our Paddle account. This should match the kind of Paddle account you signed up for. | Enter `sandbox` for sandbox accounts or `production` for live accounts. | | |
Use `https://<PROJECTNAME>.vercel.app/api/webhook` as the endpoint URL for your notification destination, where `<PROJECTNAME>` is the name of your project in Vercel. For example, `https://paddle-billing.vercel.app/api/webhook`. | |
If your project name isn't unique, your Vercel deploy URL may not match the URL you enter here. We can review and update this after deploy. | |
#### Deploy | |
Click **Deploy** when you're done. Wait for Vercel to build. | |
### 2. Set up product catalog | |
#### Clone locally | |
1. Clone the repository you created earlier. | |
```sh | |
git clone https://github.com/PATH_TO_YOUR_REPO | |
``` | |
2. Install dependencies using npm, Yarn, or pnpm. | |
Install using npm: | |
```sh | |
npm install | |
``` | |
Install using Yarn: | |
```sh | |
yarn install | |
``` | |
Install using pnpm: | |
```sh | |
pnpm install | |
``` | |
#### Add products and prices | |
[Create products and prices](https://developer.paddle.com/build/products/create-products-prices?utm_source=dx&utm_medium=paddle-nextjs-starter-kit) in Paddle for your subscription plans. We recommend creating three products for your plans, with two prices for each product to describe how you bill. For example, create a product called "Pro plan" with two prices for "monthly" and "annual." | |
Next, copy your price IDs and update the [`pricing-tier.ts`](src/constants/pricing-tier.ts) constants file with your new prices. | |
Commit and push your changes to `main`. | |
### 3. Add URL to Paddle and test | |
#### Add deploy URL to Paddle | |
You must add URLs to Paddle before you can launch a checkout from them. This protects you as a seller, making sure that only you're able to sell your products. | |
1. Go to [**Paddle > Checkout > Website approval**](https://sandbox-vendors.paddle.com/request-domain-approval), then enter your Vercel demo app link and click **Submit for approval**. | |
2. Go to [**Paddle > Checkout > Checkout settings**](https://sandbox-vendors.paddle.com/checkout-settings), then enter your Vercel demo app link as your default payment link and click **Save**. | |
3. Go to [**Paddle > Developer tools > Notifications**](https://sandbox-vendors.paddle.com/notifications), then check that the endpoint URL matches your Vercel demo app link domain. | |
> **Important:** Website approval is instant for sandbox accounts, but may take a little while for live accounts while the Paddle seller verification team check your website. | |
#### Test | |
Open your Vercel demo site. You should notice that Paddle returns the prices you entered for each of your plans on your pricing page. | |
Click **Get started** to launch a checkout for a plan, then take a test payment. | |
If you're using a sandbox account, you can take a test payment using [our test card details](https://developer.paddle.com/concepts/payment-methods/credit-debit-card?utm_source=dx&utm_medium=paddle-nextjs-starter-kit): | |
| Field | Value | | |
| -------------------------- | ------------------------------------- | | |
| **Email address** | An email address you own | | |
| **Country** | Any valid country supported by Paddle | | |
| **ZIP code** (if required) | Any valid ZIP or postal code | | |
| **Card number** | `4242 4242 4242 4242` | | |
| **Name on card** | Any name | | |
| **Expiration date** | Any valid date in the future. | | |
| **Security code** | `100` | | |
After checkout is completed, head back to the homepage and click **Sign in**. Have a look at the subscriptions and payments pages. They pull information from Paddle about a customer's subscriptions and transactions. | |
### 4. Next steps | |
You're done! 🎉 You can use this starter kit as a basis for building a SaaS app powered by Paddle Billing. | |
Once you've built your app, transition to a live account to start taking real payments: | |
1. [Sign up for a live account](https://login.paddle.com/signup?utm_source=dx&utm_medium=paddle-nextjs-starter-kit), then follow our [go-live checklist](https://developer.paddle.com/build/onboarding/go-live-checklist) to transition from sandbox to live. | |
2. Update your environment variables so they're for your live account. | |
3. Create new schemas in Supabase for your live data. | |
4. [Set up Paddle Retain](https://developer.paddle.com/build/retain/set-up-retain-profitwell) to handle payment recovery. | |
## Get help | |
For help, contact the Paddle DX team at `[email protected]`. | |
## Learn more | |
- [Build and deploy Next.js app with Vercel and Supabase](https://developer.paddle.com/build/nextjs-supabase-vercel-starter-kit?utm_source=dx&utm_medium=paddle-nextjs-starter-kit) | |
- [Paddle API reference](https://developer.paddle.com/api-reference/overview?utm_source=dx&utm_medium=paddle-nextjs-starter-kit) | |
- [Sign up for Paddle Billing](https://sandbox-login.paddle.com/signup?utm_source=dx&utm_medium=paddle-nextjs-starter-kit) | |
================================================ | |
File: /.prettierrc | |
================================================ | |
{ | |
"singleQuote": true, | |
"semi": true, | |
"tabWidth": 2, | |
"printWidth": 120, | |
"trailingComma": "all", | |
"bracketSpacing": true | |
} | |
================================================ | |
File: /.github/workflows/test.yml | |
================================================ | |
name: test | |
on: pull_request | |
jobs: | |
test: | |
runs-on: ubuntu-latest | |
steps: | |
- name: Cancel running workflows | |
uses: styfle/[email protected] | |
with: | |
access_token: ${{ github.token }} | |
- name: Checkout repo | |
uses: actions/checkout@v4 | |
- name: Set node version | |
uses: actions/setup-node@v3 | |
with: | |
node-version-file: '.nvmrc' | |
- name: Install pnpm | |
uses: pnpm/action-setup@v4 | |
with: | |
version: 9 | |
- name: Cache node_modules | |
id: node-modules-cache | |
uses: actions/cache@v3 | |
with: | |
path: '**/node_modules' | |
key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }} | |
- name: Install dependencies | |
if: steps.node-modules-cache.outputs.cache-hit != 'true' | |
run: pnpm install --frozen-lockfile | |
- name: Run tests | |
run: pnpm test | |
================================================ | |
File: /.github/CODEOWNERS | |
================================================ | |
* @PaddleHQ/developer-experience | |
================================================ | |
File: /.github/ISSUE_TEMPLATE/config.yml | |
================================================ | |
blank_issues_enabled: false | |
contact_links: | |
- name: Get help | |
url: https://developer.paddle.com/ | |
about: For help with the Paddle Node.js SDK or building your integration, contact our support team at [[email protected]](mailto:[email protected]). | |
- name: Report a vulnerability | |
url: https://vdp.paddle.com/p/Report-a-Vulnerability | |
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. | |
================================================ | |
File: /.github/ISSUE_TEMPLATE/feature_request.yaml | |
================================================ | |
name: Feature request | |
description: Suggest an idea. | |
title: '[Feature]: ' | |
labels: ['feature'] | |
body: | |
- type: markdown | |
attributes: | |
value: | | |
Use this form to send us suggestions for improvements to the Next.js starter kit for Paddle Billing. | |
For general feedback about the Paddle API or developer platform, contact our DX team directly | |
at [[email protected]](mailto:[email protected]). | |
Thanks for helping to make the Paddle platform better for everyone! | |
- type: textarea | |
id: request | |
attributes: | |
label: Tell us about your feature request | |
description: Describe what you'd like to see added or improved. | |
validations: | |
required: true | |
- type: textarea | |
id: problem | |
attributes: | |
label: What problem are you looking to solve? | |
description: Tell us how and why would implementing your suggestion would help. | |
validations: | |
required: true | |
- type: textarea | |
id: additional-information | |
attributes: | |
label: Additional context | |
description: Add any other context, screenshots, or illustrations about your suggestion here. | |
- type: dropdown | |
id: priority | |
attributes: | |
label: How important is this suggestion to you? | |
options: | |
- Nice to have | |
- Important | |
- Critical | |
default: 0 | |
validations: | |
required: true | |
================================================ | |
File: /.github/ISSUE_TEMPLATE/bug_report.yaml | |
================================================ | |
name: Bug report | |
description: Report a problem. | |
title: '[Bug]: ' | |
labels: ['bug'] | |
body: | |
- type: markdown | |
attributes: | |
value: | | |
Use this form to report a bug or problem with the Next.js starter kit for Paddle Billing. | |
Remember to remove sensitive information from screenshots, videos, or code samples before submitting. | |
**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). | |
Thanks for helping to make the Paddle platform better for everyone! | |
- type: textarea | |
id: description | |
attributes: | |
label: What happened? | |
description: Describe the bug in a sentence or two. Feel free to add screenshots or a video to better explain! | |
validations: | |
required: true | |
- type: textarea | |
id: reproduce | |
attributes: | |
label: Steps to reproduce | |
description: Explain how to reproduce this issue. We prefer a step-by-step walkthrough, where possible. | |
value: | | |
1. | |
2. | |
3. | |
... | |
validations: | |
required: true | |
- type: textarea | |
id: expected-behavior | |
attributes: | |
label: What did you expect to happen? | |
description: Tell us what should happen when you encounter this bug. | |
- type: textarea | |
id: logs | |
attributes: | |
label: Logs | |
description: Copy and paste any relevant logs. This is automatically formatted into code, so no need for backticks. | |
render: shell | |
================================================ | |
File: /next.config.mjs | |
================================================ | |
/** @type {import('next').NextConfig} */ | |
const nextConfig = { | |
images: { | |
domains: ['cdn.simpleicons.org', 'localhost', 'paddle-billing.vercel.app'], | |
}, | |
}; | |
export default nextConfig; | |
================================================ | |
File: /.eslintrc.json | |
================================================ | |
{ | |
"extends": ["next/core-web-vitals", "next"] | |
} | |
================================================ | |
File: /.env.local.example | |
================================================ | |
# Supabase | |
## Private | |
SUPABASE_SERVICE_ROLE_KEY= | |
## Public | |
NEXT_PUBLIC_SUPABASE_URL= | |
NEXT_PUBLIC_SUPABASE_ANON_KEY= | |
# Paddle | |
## Private | |
NEXT_PUBLIC_PADDLE_ENV=sandbox # or `production` | |
PADDLE_API_KEY= | |
PADDLE_NOTIFICATION_WEBHOOK_SECRET= | |
## Public | |
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN= | |
================================================ | |
File: /postcss.config.js | |
================================================ | |
module.exports = { | |
plugins: { | |
tailwindcss: {}, | |
autoprefixer: {}, | |
}, | |
}; | |
================================================ | |
File: /package.json | |
================================================ | |
{ | |
"name": "@paddle/nextjs-starter-kit", | |
"version": "0.1.0", | |
"private": true, | |
"engines": { | |
"node": ">=20" | |
}, | |
"scripts": { | |
"dev": "next dev", | |
"build": "next build", | |
"start": "next start", | |
"lint": "next lint", | |
"lint:fix": "next lint --fix", | |
"prettier": "prettier --write --ignore-unknown .", | |
"prettier:check": "prettier --check --ignore-unknown .", | |
"lint-staged": "lint-staged", | |
"test": "pnpm lint && pnpm prettier:check" | |
}, | |
"git": { | |
"pre-commit": "lint-staged" | |
}, | |
"lint-staged": { | |
"*": "prettier --write --ignore-unknown" | |
}, | |
"dependencies": { | |
"@paddle/paddle-js": "^1.3.1", | |
"@paddle/paddle-node-sdk": "^1.5.0", | |
"@radix-ui/react-accordion": "^1.2.0", | |
"@radix-ui/react-dialog": "^1.1.1", | |
"@radix-ui/react-dropdown-menu": "^2.1.1", | |
"@radix-ui/react-label": "^2.1.0", | |
"@radix-ui/react-select": "^2.1.1", | |
"@radix-ui/react-separator": "^1.1.0", | |
"@radix-ui/react-slot": "^1.1.0", | |
"@radix-ui/react-tabs": "^1.1.0", | |
"@radix-ui/react-toast": "^1.2.1", | |
"@supabase/auth-helpers-nextjs": "^0.10.0", | |
"@supabase/ssr": "^0.4.0", | |
"@supabase/supabase-js": "^2.45.0", | |
"@tailwindcss/container-queries": "^0.1.1", | |
"@tailwindcss/forms": "^0.5.7", | |
"@tanstack/react-table": "^8.20.1", | |
"class-variance-authority": "^0.7.0", | |
"clsx": "^2.1.1", | |
"dayjs": "^1.11.12", | |
"lucide-react": "^0.417.0", | |
"next": "^14.2.5", | |
"react": "^18.3.1", | |
"react-dom": "^18.3.1", | |
"tailwind-merge": "^2.4.0", | |
"tailwindcss-animate": "^1.0.7" | |
}, | |
"devDependencies": { | |
"@types/node": "^20.14.13", | |
"@types/react": "^18.3.3", | |
"@types/react-dom": "^18.3.0", | |
"@vercel/git-hooks": "^1.0.0", | |
"autoprefixer": "^10.4.19", | |
"eslint": "^8.57.0", | |
"eslint-config-next": "14.2.3", | |
"lint-staged": "^15.2.2", | |
"postcss": "^8.4.40", | |
"prettier": "^3.3.3", | |
"tailwindcss": "^3.4.7", | |
"typescript": "^5.5.4" | |
} | |
} | |
================================================ | |
File: /.nvmrc | |
================================================ | |
20 | |
================================================ | |
File: /SECURITY.md | |
================================================ | |
- [Security Policy](#security-policy) | |
- [Reporting a Vulnerability](#reporting-a-vulnerability) | |
# Security policy | |
## Reporting a vulnerability | |
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. | |
> [!WARNING] | |
> Do not create issues for potential security vulnerabilities. Issues are public and can be seen by potentially malicious actors. | |
Thanks for helping to make the Paddle platform safe for everyone. | |
================================================ | |
File: /components.json | |
================================================ | |
{ | |
"$schema": "https://ui.shadcn.com/schema.json", | |
"style": "default", | |
"rsc": true, | |
"tsx": true, | |
"tailwind": { | |
"config": "tailwind.config.ts", | |
"css": "src/app/globals.css", | |
"baseColor": "slate", | |
"cssVariables": true, | |
"prefix": "" | |
}, | |
"aliases": { | |
"components": "@/components", | |
"utils": "@/lib/utils" | |
} | |
} | |
================================================ | |
File: /tailwind.config.ts | |
================================================ | |
import type { Config } from 'tailwindcss'; | |
const config = { | |
darkMode: ['class'], | |
content: ['./pages/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './app/**/*.{ts,tsx}', './src/**/*.{ts,tsx}'], | |
prefix: '', | |
theme: { | |
container: { | |
center: true, | |
padding: '2rem', | |
screens: { | |
'2xl': '1400px', | |
}, | |
}, | |
extend: { | |
colors: { | |
border: 'hsl(var(--border))', | |
input: 'hsl(var(--input))', | |
ring: 'hsl(var(--ring))', | |
background: 'hsl(var(--background))', | |
foreground: 'hsl(var(--foreground))', | |
primary: { | |
DEFAULT: 'hsl(var(--primary))', | |
foreground: 'hsl(var(--primary-foreground))', | |
}, | |
secondary: { | |
DEFAULT: 'hsl(var(--secondary))', | |
foreground: 'hsl(var(--secondary-foreground))', | |
}, | |
destructive: { | |
DEFAULT: 'hsl(var(--destructive))', | |
foreground: 'hsl(var(--destructive-foreground))', | |
}, | |
muted: { | |
DEFAULT: 'hsl(var(--muted))', | |
foreground: 'hsl(var(--muted-foreground))', | |
}, | |
accent: { | |
DEFAULT: 'hsl(var(--accent))', | |
foreground: 'hsl(var(--accent-foreground))', | |
}, | |
popover: { | |
DEFAULT: 'hsl(var(--popover))', | |
foreground: 'hsl(var(--popover-foreground))', | |
}, | |
card: { | |
DEFAULT: 'hsl(var(--card))', | |
foreground: 'hsl(var(--card-foreground))', | |
}, | |
}, | |
borderRadius: { | |
lg: 'var(--radius)', | |
md: 'calc(var(--radius) - 2px)', | |
sm: 'calc(var(--radius) - 4px)', | |
xs: 'calc(var(--radius) - 8px)', | |
xxs: 'calc(var(--radius) - 10px)', | |
}, | |
keyframes: { | |
'accordion-down': { | |
from: { height: '0' }, | |
to: { height: 'var(--radix-accordion-content-height)' }, | |
}, | |
'accordion-up': { | |
from: { height: 'var(--radix-accordion-content-height)' }, | |
to: { height: '0' }, | |
}, | |
}, | |
animation: { | |
'accordion-down': 'accordion-down 0.2s ease-out', | |
'accordion-up': 'accordion-up 0.2s ease-out', | |
}, | |
containers: { | |
'2xs': '16rem', | |
'4xs': '8rem', | |
'16xs': '4rem', | |
}, | |
}, | |
}, | |
plugins: [require('tailwindcss-animate'), require('@tailwindcss/container-queries')], | |
safelist: ['text-[#25F497]', 'text-[#797C7C]', 'text-[#F42566]', 'text-[#F79636]', 'text-[#E0E0EB]'], | |
} satisfies Config; | |
export default config; | |
================================================ | |
File: /LICENSE | |
================================================ | |
Apache License | |
Version 2.0, January 2004 | |
http://www.apache.org/licenses/ | |
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION | |
1. Definitions. | |
"License" shall mean the terms and conditions for use, reproduction, | |
and distribution as defined by Sections 1 through 9 of this document. | |
"Licensor" shall mean the copyright owner or entity authorized by | |
the copyright owner that is granting the License. | |
"Legal Entity" shall mean the union of the acting entity and all | |
other entities that control, are controlled by, or are under common | |
control with that entity. For the purposes of this definition, | |
"control" means (i) the power, direct or indirect, to cause the | |
direction or management of such entity, whether by contract or | |
otherwise, or (ii) ownership of fifty percent (50%) or more of the | |
outstanding shares, or (iii) beneficial ownership of such entity. | |
"You" (or "Your") shall mean an individual or Legal Entity | |
exercising permissions granted by this License. | |
"Source" form shall mean the preferred form for making modifications, | |
including but not limited to software source code, documentation | |
source, and configuration files. | |
"Object" form shall mean any form resulting from mechanical | |
transformation or translation of a Source form, including but | |
not limited to compiled object code, generated documentation, | |
and conversions to other media types. | |
"Work" shall mean the work of authorship, whether in Source or | |
Object form, made available under the License, as indicated by a | |
copyright notice that is included in or attached to the work | |
(an example is provided in the Appendix below). | |
"Derivative Works" shall mean any work, whether in Source or Object | |
form, that is based on (or derived from) the Work and for which the | |
editorial revisions, annotations, elaborations, or other modifications | |
represent, as a whole, an original work of authorship. For the purposes | |
of this License, Derivative Works shall not include works that remain | |
separable from, or merely link (or bind by name) to the interfaces of, | |
the Work and Derivative Works thereof. | |
"Contribution" shall mean any work of authorship, including | |
the original version of the Work and any modifications or additions | |
to that Work or Derivative Works thereof, that is intentionally | |
submitted to Licensor for inclusion in the Work by the copyright owner | |
or by an individual or Legal Entity authorized to submit on behalf of | |
the copyright owner. For the purposes of this definition, "submitted" | |
means any form of electronic, verbal, or written communication sent | |
to the Licensor or its representatives, including but not limited to | |
communication on electronic mailing lists, source code control systems, | |
and issue tracking systems that are managed by, or on behalf of, the | |
Licensor for the purpose of discussing and improving the Work, but | |
excluding communication that is conspicuously marked or otherwise | |
designated in writing by the copyright owner as "Not a Contribution." | |
"Contributor" shall mean Licensor and any individual or Legal Entity | |
on behalf of whom a Contribution has been received by Licensor and | |
subsequently incorporated within the Work. | |
2. Grant of Copyright License. Subject to the terms and conditions of | |
this License, each Contributor hereby grants to You a perpetual, | |
worldwide, non-exclusive, no-charge, royalty-free, irrevocable | |
copyright license to reproduce, prepare Derivative Works of, | |
publicly display, publicly perform, sublicense, and distribute the | |
Work and such Derivative Works in Source or Object form. | |
3. Grant of Patent License. Subject to the terms and conditions of | |
this License, each Contributor hereby grants to You a perpetual, | |
worldwide, non-exclusive, no-charge, royalty-free, irrevocable | |
(except as stated in this section) patent license to make, have made, | |
use, offer to sell, sell, import, and otherwise transfer the Work, | |
where such license applies only to those patent claims licensable | |
by such Contributor that are necessarily infringed by their | |
Contribution(s) alone or by combination of their Contribution(s) | |
with the Work to which such Contribution(s) was submitted. If You | |
institute patent litigation against any entity (including a | |
cross-claim or counterclaim in a lawsuit) alleging that the Work | |
or a Contribution incorporated within the Work constitutes direct | |
or contributory patent infringement, then any patent licenses | |
granted to You under this License for that Work shall terminate | |
as of the date such litigation is filed. | |
4. Redistribution. You may reproduce and distribute copies of the | |
Work or Derivative Works thereof in any medium, with or without | |
modifications, and in Source or Object form, provided that You | |
meet the following conditions: | |
(a) You must give any other recipients of the Work or | |
Derivative Works a copy of this License; and | |
(b) You must cause any modified files to carry prominent notices | |
stating that You changed the files; and | |
(c) You must retain, in the Source form of any Derivative Works | |
that You distribute, all copyright, patent, trademark, and | |
attribution notices from the Source form of the Work, | |
excluding those notices that do not pertain to any part of | |
the Derivative Works; and | |
(d) If the Work includes a "NOTICE" text file as part of its | |
distribution, then any Derivative Works that You distribute must | |
include a readable copy of the attribution notices contained | |
within such NOTICE file, excluding those notices that do not | |
pertain to any part of the Derivative Works, in at least one | |
of the following places: within a NOTICE text file distributed | |
as part of the Derivative Works; within the Source form or | |
documentation, if provided along with the Derivative Works; or, | |
within a display generated by the Derivative Works, if and | |
wherever such third-party notices normally appear. The contents | |
of the NOTICE file are for informational purposes only and | |
do not modify the License. You may add Your own attribution | |
notices within Derivative Works that You distribute, alongside | |
or as an addendum to the NOTICE text from the Work, provided | |
that such additional attribution notices cannot be construed | |
as modifying the License. | |
You may add Your own copyright statement to Your modifications and | |
may provide additional or different license terms and conditions | |
for use, reproduction, or distribution of Your modifications, or | |
for any such Derivative Works as a whole, provided Your use, | |
reproduction, and distribution of the Work otherwise complies with | |
the conditions stated in this License. | |
5. Submission of Contributions. Unless You explicitly state otherwise, | |
any Contribution intentionally submitted for inclusion in the Work | |
by You to the Licensor shall be under the terms and conditions of | |
this License, without any additional terms or conditions. | |
Notwithstanding the above, nothing herein shall supersede or modify | |
the terms of any separate license agreement you may have executed | |
with Licensor regarding such Contributions. | |
6. Trademarks. This License does not grant permission to use the trade | |
names, trademarks, service marks, or product names of the Licensor, | |
except as required for reasonable and customary use in describing the | |
origin of the Work and reproducing the content of the NOTICE file. | |
7. Disclaimer of Warranty. Unless required by applicable law or | |
agreed to in writing, Licensor provides the Work (and each | |
Contributor provides its Contributions) on an "AS IS" BASIS, | |
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or | |
implied, including, without limitation, any warranties or conditions | |
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A | |
PARTICULAR PURPOSE. You are solely responsible for determining the | |
appropriateness of using or redistributing the Work and assume any | |
risks associated with Your exercise of permissions under this License. | |
8. Limitation of Liability. In no event and under no legal theory, | |
whether in tort (including negligence), contract, or otherwise, | |
unless required by applicable law (such as deliberate and grossly | |
negligent acts) or agreed to in writing, shall any Contributor be | |
liable to You for damages, including any direct, indirect, special, | |
incidental, or consequential damages of any character arising as a | |
result of this License or out of the use or inability to use the | |
Work (including but not limited to damages for loss of goodwill, | |
work stoppage, computer failure or malfunction, or any and all | |
other commercial damages or losses), even if such Contributor | |
has been advised of the possibility of such damages. | |
9. Accepting Warranty or Additional Liability. While redistributing | |
the Work or Derivative Works thereof, You may choose to offer, | |
and charge a fee for, acceptance of support, warranty, indemnity, | |
or other liability obligations and/or rights consistent with this | |
License. However, in accepting such obligations, You may act only | |
on Your own behalf and on Your sole responsibility, not on behalf | |
of any other Contributor, and only if You agree to indemnify, | |
defend, and hold each Contributor harmless for any liability | |
incurred by, or claims asserted against, such Contributor by reason | |
of your accepting any such warranty or additional liability. | |
END OF TERMS AND CONDITIONS | |
Copyright 2024 Paddle.com Market Limited | |
Licensed under the Apache License, Version 2.0 (the "License"); | |
you may not use this file except in compliance with the License. | |
You may obtain a copy of the License at | |
http://www.apache.org/licenses/LICENSE-2.0 | |
Unless required by applicable law or agreed to in writing, software | |
distributed under the License is distributed on an "AS IS" BASIS, | |
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
See the License for the specific language governing permissions and | |
limitations under the License. | |
================================================ | |
File: /tsconfig.json | |
================================================ | |
{ | |
"compilerOptions": { | |
"lib": ["dom", "dom.iterable", "esnext"], | |
"target": "ES2015", | |
"allowJs": true, | |
"skipLibCheck": true, | |
"strict": true, | |
"noEmit": true, | |
"esModuleInterop": true, | |
"module": "esnext", | |
"moduleResolution": "bundler", | |
"resolveJsonModule": true, | |
"isolatedModules": true, | |
"jsx": "preserve", | |
"incremental": true, | |
"plugins": [ | |
{ | |
"name": "next" | |
} | |
], | |
"paths": { | |
"@/*": ["./src/*"] | |
} | |
}, | |
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], | |
"exclude": ["node_modules"] | |
} | |
================================================ | |
File: /supabase/.gitignore | |
================================================ | |
# Supabase | |
.branches | |
.temp | |
.env | |
================================================ | |
File: /supabase/config.toml | |
================================================ | |
# A string used to distinguish different Supabase projects on the same host. Defaults to the | |
# working directory name when running `supabase init`. | |
project_id = "paddle-subscription" | |
[api] | |
enabled = true | |
# Port to use for the API URL. | |
port = 54321 | |
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API | |
# endpoints. `public` is always included. | |
schemas = ["public", "graphql_public"] | |
# Extra schemas to add to the search_path of every request. `public` is always included. | |
extra_search_path = ["public", "extensions"] | |
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size | |
# for accidental or malicious requests. | |
max_rows = 1000 | |
[api.tls] | |
enabled = false | |
[db] | |
# Port to use for the local database URL. | |
port = 54322 | |
# Port used by db diff command to initialize the shadow database. | |
shadow_port = 54320 | |
# The database major version to use. This has to be the same as your remote database's. Run `SHOW | |
# server_version;` on the remote database to check. | |
major_version = 15 | |
[db.pooler] | |
enabled = false | |
# Port to use for the local connection pooler. | |
port = 54329 | |
# Specifies when a server connection can be reused by other clients. | |
# Configure one of the supported pooler modes: `transaction`, `session`. | |
pool_mode = "transaction" | |
# How many server connections to allow per user/database pair. | |
default_pool_size = 20 | |
# Maximum number of client connections allowed. | |
max_client_conn = 100 | |
[realtime] | |
enabled = true | |
# Bind realtime via either IPv4 or IPv6. (default: IPv4) | |
# ip_version = "IPv6" | |
# The maximum length in bytes of HTTP request headers. (default: 4096) | |
# max_header_length = 4096 | |
[studio] | |
enabled = true | |
# Port to use for Supabase Studio. | |
port = 54323 | |
# External URL of the API server that frontend connects to. | |
api_url = "http://127.0.0.1" | |
# OpenAI API Key to use for Supabase AI in the Supabase Studio. | |
openai_api_key = "env(OPENAI_API_KEY)" | |
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they | |
# are monitored, and you can view the emails that would have been sent from the web interface. | |
[inbucket] | |
enabled = true | |
# Port to use for the email testing server web interface. | |
port = 54324 | |
# Uncomment to expose additional ports for testing user applications that send emails. | |
# smtp_port = 54325 | |
# pop3_port = 54326 | |
[storage] | |
enabled = true | |
# The maximum file size allowed (e.g. "5MB", "500KB"). | |
file_size_limit = "50MiB" | |
[storage.image_transformation] | |
enabled = true | |
# Uncomment to configure local storage buckets | |
# [storage.buckets.images] | |
# public = false | |
# file_size_limit = "50MiB" | |
# allowed_mime_types = ["image/png", "image/jpeg"] | |
# objects_path = "./images" | |
[auth] | |
enabled = true | |
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used | |
# in emails. | |
site_url = "http://127.0.0.1:3000" | |
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. | |
additional_redirect_urls = ["https://127.0.0.1:3000"] | |
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). | |
jwt_expiry = 3600 | |
# If disabled, the refresh token will never expire. | |
enable_refresh_token_rotation = true | |
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. | |
# Requires enable_refresh_token_rotation = true. | |
refresh_token_reuse_interval = 10 | |
# Allow/disallow new user signups to your project. | |
enable_signup = true | |
# Allow/disallow anonymous sign-ins to your project. | |
enable_anonymous_sign_ins = false | |
# Allow/disallow testing manual linking of accounts | |
enable_manual_linking = false | |
[auth.email] | |
# Allow/disallow new user signups via email to your project. | |
enable_signup = true | |
# If enabled, a user will be required to confirm any email change on both the old, and new email | |
# addresses. If disabled, only the new email is required to confirm. | |
double_confirm_changes = true | |
# If enabled, users need to confirm their email address before signing in. | |
enable_confirmations = false | |
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. | |
max_frequency = "1s" | |
# Use a production-ready SMTP server | |
# [auth.email.smtp] | |
# host = "smtp.sendgrid.net" | |
# port = 587 | |
# user = "apikey" | |
# pass = "env(SENDGRID_API_KEY)" | |
# admin_email = "[email protected]" | |
# sender_name = "Admin" | |
# Uncomment to customize email template | |
# [auth.email.template.invite] | |
# subject = "You have been invited" | |
# content_path = "./supabase/templates/invite.html" | |
[auth.sms] | |
# Allow/disallow new user signups via SMS to your project. | |
enable_signup = true | |
# If enabled, users need to confirm their phone number before signing in. | |
enable_confirmations = false | |
# Template for sending OTP to users | |
template = "Your code is {{ .Code }} ." | |
# Controls the minimum amount of time that must pass before sending another sms otp. | |
max_frequency = "5s" | |
# Use pre-defined map of phone number to OTP for testing. | |
# [auth.sms.test_otp] | |
# 4152127777 = "123456" | |
# Configure logged in session timeouts. | |
# [auth.sessions] | |
# Force log out after the specified duration. | |
# timebox = "24h" | |
# Force log out if the user has been inactive longer than the specified duration. | |
# inactivity_timeout = "8h" | |
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. | |
# [auth.hook.custom_access_token] | |
# enabled = true | |
# uri = "pg-functions://<database>/<schema>/<hook_name>" | |
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. | |
[auth.sms.twilio] | |
enabled = false | |
account_sid = "" | |
message_service_sid = "" | |
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: | |
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" | |
[auth.mfa] | |
# Control how many MFA factors can be enrolled at once per user. | |
max_enrolled_factors = 10 | |
# Control use of MFA via App Authenticator (TOTP) | |
[auth.mfa.totp] | |
enroll_enabled = true | |
verify_enabled = true | |
# Configure Multi-factor-authentication via Phone Messaging | |
# [auth.mfa.phone] | |
# enroll_enabled = true | |
# verify_enabled = true | |
# otp_length = 6 | |
# template = "Your code is {{ .Code }} ." | |
# max_frequency = "10s" | |
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, | |
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, | |
# `twitter`, `slack`, `spotify`, `workos`, `zoom`. | |
[auth.external.apple] | |
enabled = false | |
client_id = "" | |
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: | |
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" | |
# Overrides the default auth redirectUrl. | |
redirect_uri = "" | |
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, | |
# or any other third-party OIDC providers. | |
url = "" | |
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. | |
skip_nonce_check = false | |
# Use Firebase Auth as a third-party provider alongside Supabase Auth. | |
[auth.third_party.firebase] | |
enabled = false | |
# project_id = "my-firebase-project" | |
# Use Auth0 as a third-party provider alongside Supabase Auth. | |
[auth.third_party.auth0] | |
enabled = false | |
# tenant = "my-auth0-tenant" | |
# tenant_region = "us" | |
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. | |
[auth.third_party.aws_cognito] | |
enabled = false | |
# user_pool_id = "my-user-pool-id" | |
# user_pool_region = "us-east-1" | |
[edge_runtime] | |
enabled = true | |
# Configure one of the supported request policies: `oneshot`, `per_worker`. | |
# Use `oneshot` for hot reload, or `per_worker` for load testing. | |
policy = "oneshot" | |
inspector_port = 8083 | |
[analytics] | |
enabled = true | |
port = 54327 | |
# Configure one of the supported backends: `postgres`, `bigquery`. | |
backend = "postgres" | |
# Experimental features may be deprecated any time | |
[experimental] | |
# Configures Postgres storage engine to use OrioleDB (S3) | |
orioledb_version = "" | |
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com | |
s3_host = "env(S3_HOST)" | |
# Configures S3 bucket region, eg. us-east-1 | |
s3_region = "env(S3_REGION)" | |
# Configures AWS_ACCESS_KEY_ID for S3 bucket | |
s3_access_key = "env(S3_ACCESS_KEY)" | |
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket | |
s3_secret_key = "env(S3_SECRET_KEY)" | |
================================================ | |
File: /supabase/migrations/20240907140223_initialize.sql | |
================================================ | |
-- Create customers table to map Paddle customer_id to email | |
create table | |
public.customers ( | |
customer_id text not null, | |
email text not null, | |
created_at timestamp with time zone not null default now(), | |
updated_at timestamp with time zone not null default now(), | |
constraint customers_pkey primary key (customer_id) | |
) tablespace pg_default; | |
-- Create subscription table to store webhook events sent by Paddle | |
create table | |
public.subscriptions ( | |
subscription_id text not null, | |
subscription_status text not null, | |
price_id text null, | |
product_id text null, | |
scheduled_change text null, | |
customer_id text not null, | |
created_at timestamp with time zone not null default now(), | |
updated_at timestamp with time zone not null default now(), | |
constraint subscriptions_pkey primary key (subscription_id), | |
constraint public_subscriptions_customer_id_fkey foreign key (customer_id) references customers (customer_id) | |
) tablespace pg_default; | |
-- Grant access to authenticated users to read the customers table to get the customer_id based on the email | |
create policy "Enable read access for authenticated users to customers" on "public"."customers" as PERMISSIVE for SELECT to authenticated using ( true ); | |
-- Grant access to authenticated users to read the subscriptions table | |
create policy "Enable read access for authenticated users to subscriptions" on "public"."subscriptions" as PERMISSIVE for SELECT to authenticated using ( true ); | |
================================================ | |
File: /CONTRIBUTING.md | |
================================================ | |
## Contributing | |
If you've spotted a problem with this package or have a new feature request, please open an issue. | |
For help with the Paddle API or building your integration, contact our support team at [[email protected]](mailto:[email protected]). | |
================================================ | |
File: /src/middleware.ts | |
================================================ | |
import { type NextRequest } from 'next/server'; | |
import { updateSession } from '@/utils/supabase/middleware'; | |
export async function middleware(request: NextRequest) { | |
return await updateSession(request); | |
} | |
export const config = { | |
matcher: [ | |
/* | |
* Match all request paths except for the ones starting with: | |
* - _next/static (static files) | |
* - _next/image (image optimization files) | |
* - favicon.ico (favicon file) | |
* Feel free to modify this pattern to include more paths. | |
*/ | |
'/((?!_next/static|_next/image|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', | |
], | |
}; | |
================================================ | |
File: /src/components/checkout/checkout-price-amount.tsx | |
================================================ | |
import { Skeleton } from '@/components/ui/skeleton'; | |
import { CheckoutEventsData } from '@paddle/paddle-js/types/checkout/events'; | |
import { formatMoney } from '@/utils/paddle/parse-money'; | |
interface Props { | |
checkoutData: CheckoutEventsData | null; | |
} | |
export function CheckoutPriceAmount({ checkoutData }: Props) { | |
const total = checkoutData?.totals.total; | |
return ( | |
<> | |
{total !== undefined ? ( | |
<div className={'pt-8 flex gap-2 items-end'}> | |
<span className={'text-5xl'}>{formatMoney(total, checkoutData?.currency_code)}</span> | |
<span className={'text-base leading-[16px]'}>inc. tax</span> | |
</div> | |
) : ( | |
<Skeleton className="mt-8 h-[48px] w-full bg-border" /> | |
)} | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/checkout/checkout-price-container.tsx | |
================================================ | |
import { CheckoutPriceAmount } from '@/components/checkout/checkout-price-amount'; | |
import { CheckoutEventsData } from '@paddle/paddle-js/types/checkout/events'; | |
import { formatMoney } from '@/utils/paddle/parse-money'; | |
import { Skeleton } from '@/components/ui/skeleton'; | |
interface Props { | |
checkoutData: CheckoutEventsData | null; | |
} | |
export function CheckoutPriceContainer({ checkoutData }: Props) { | |
const recurringTotal = checkoutData?.recurring_totals?.total; | |
return ( | |
<> | |
<div className={'text-base leading-[20px] font-semibold'}>Order summary</div> | |
<CheckoutPriceAmount checkoutData={checkoutData} /> | |
{recurringTotal !== undefined ? ( | |
<div className={'pt-4 text-base leading-[20px] font-medium text-muted-foreground'}> | |
then {formatMoney(checkoutData?.recurring_totals?.total, checkoutData?.currency_code)} monthly | |
</div> | |
) : ( | |
<Skeleton className="mt-4 h-[20px] w-full bg-border" /> | |
)} | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/checkout/checkout-contents.tsx | |
================================================ | |
'use client'; | |
import { PriceSection } from '@/components/checkout/price-section'; | |
import { Environments, initializePaddle, Paddle } from '@paddle/paddle-js'; | |
import { useEffect, useState } from 'react'; | |
import { useParams } from 'next/navigation'; | |
import { CheckoutFormGradients } from '@/components/gradients/checkout-form-gradients'; | |
import { CheckoutEventsData } from '@paddle/paddle-js/types/checkout/events'; | |
interface PathParams { | |
priceId: string; | |
[key: string]: string | string[]; | |
} | |
interface Props { | |
userEmail?: string; | |
} | |
export function CheckoutContents({ userEmail }: Props) { | |
const { priceId } = useParams<PathParams>(); | |
const [quantity, setQuantity] = useState<number>(1); | |
const [paddle, setPaddle] = useState<Paddle | null>(null); | |
const [checkoutData, setCheckoutData] = useState<CheckoutEventsData | null>(null); | |
const handleCheckoutEvents = (event: CheckoutEventsData) => { | |
setCheckoutData(event); | |
}; | |
useEffect(() => { | |
if (!paddle?.Initialized && process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN && process.env.NEXT_PUBLIC_PADDLE_ENV) { | |
initializePaddle({ | |
token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN, | |
environment: process.env.NEXT_PUBLIC_PADDLE_ENV as Environments, | |
eventCallback: (event) => { | |
if (event.data && event.name) { | |
handleCheckoutEvents(event.data); | |
} | |
}, | |
checkout: { | |
settings: { | |
displayMode: 'inline', | |
theme: 'dark', | |
allowLogout: !userEmail, | |
frameTarget: 'paddle-checkout-frame', | |
frameInitialHeight: 450, | |
frameStyle: 'width: 100%; background-color: transparent; border: none', | |
successUrl: '/checkout/success', | |
}, | |
}, | |
}).then(async (paddle) => { | |
if (paddle && priceId) { | |
setPaddle(paddle); | |
paddle.Checkout.open({ | |
...(userEmail && { customer: { email: userEmail } }), | |
items: [{ priceId: priceId, quantity: 1 }], | |
}); | |
} | |
}); | |
} | |
}, [paddle?.Initialized, priceId, userEmail]); | |
useEffect(() => { | |
if (paddle && priceId && paddle.Initialized) { | |
paddle.Checkout.updateItems([{ priceId: priceId, quantity: quantity }]); | |
} | |
}, [paddle, priceId, quantity]); | |
return ( | |
<div | |
className={ | |
'rounded-lg md:bg-background/80 md:backdrop-blur-[24px] md:p-10 md:pl-16 md:pt-16 md:min-h-[400px] flex flex-col justify-between relative' | |
} | |
> | |
<CheckoutFormGradients /> | |
<div className={'flex flex-col md:flex-row gap-8 md:gap-16'}> | |
<div className={'w-full md:w-[400px]'}> | |
<PriceSection checkoutData={checkoutData} quantity={quantity} handleQuantityChange={setQuantity} /> | |
</div> | |
<div className={'min-w-[375px] lg:min-w-[535px]'}> | |
<div className={'text-base leading-[20px] font-semibold mb-8'}>Payment details</div> | |
<div className={'paddle-checkout-frame'} /> | |
</div> | |
</div> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/checkout/checkout-header.tsx | |
================================================ | |
import { Button } from '@/components/ui/button'; | |
import { ChevronLeft } from 'lucide-react'; | |
import Link from 'next/link'; | |
import Image from 'next/image'; | |
export function CheckoutHeader() { | |
return ( | |
<div className={'flex gap-4'}> | |
<Link href={'/'}> | |
<Button variant={'secondary'} className={'h-[32px] bg-[#182222] border-border w-[32px] p-0 rounded-[4px]'}> | |
<ChevronLeft /> | |
</Button> | |
</Link> | |
<Image src={'/logo.svg'} alt={'AeroEdit'} width={131} height={28} /> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/checkout/price-section.tsx | |
================================================ | |
import { CheckoutLineItems } from '@/components/checkout/checkout-line-items'; | |
import { CheckoutPriceContainer } from '@/components/checkout/checkout-price-container'; | |
import { CheckoutPriceAmount } from '@/components/checkout/checkout-price-amount'; | |
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; | |
import { Separator } from '@/components/ui/separator'; | |
import { CheckoutEventsData } from '@paddle/paddle-js/types/checkout/events'; | |
interface Props { | |
checkoutData: CheckoutEventsData | null; | |
quantity: number; | |
handleQuantityChange: (quantity: number) => void; | |
} | |
export function PriceSection({ checkoutData, handleQuantityChange, quantity }: Props) { | |
return ( | |
<> | |
<div className={'hidden md:block'}> | |
<CheckoutPriceContainer checkoutData={checkoutData} /> | |
<CheckoutLineItems | |
handleQuantityChange={handleQuantityChange} | |
checkoutData={checkoutData} | |
quantity={quantity} | |
/> | |
</div> | |
<div className={'block md:hidden'}> | |
<CheckoutPriceAmount checkoutData={checkoutData} /> | |
<Separator className={'relative bg-border/50 mt-6 checkout-order-summary-mobile-yellow-highlight'} /> | |
<Accordion type="single" collapsible> | |
<AccordionItem className={'border-none'} value="item-1"> | |
<AccordionTrigger className={'text-muted-foreground !no-underline'}>Order summary</AccordionTrigger> | |
<AccordionContent className={'pb-0'}> | |
<CheckoutLineItems | |
handleQuantityChange={handleQuantityChange} | |
checkoutData={checkoutData} | |
quantity={quantity} | |
/> | |
</AccordionContent> | |
</AccordionItem> | |
</Accordion> | |
</div> | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/checkout/quantity-field.tsx | |
================================================ | |
import { Minus, Plus } from 'lucide-react'; | |
import { Button } from '@/components/ui/button'; | |
interface Props { | |
quantity: number; | |
handleQuantityChange: (quantity: number) => void; | |
} | |
export function QuantityField({ handleQuantityChange, quantity }: Props) { | |
return ( | |
<div className={'mt-3 bg-background gap-1 w-fit flex items-center rounded-sm border border-border p-[6px]'}> | |
<Button | |
disabled={quantity === 1} | |
variant={'secondary'} | |
className={ | |
'h-[32px] bg-[#182222] disabled:bg-transparent text-muted-foreground border-border w-[32px] p-0 rounded-[4px]' | |
} | |
onClick={() => handleQuantityChange(quantity - 1)} | |
> | |
<Minus /> | |
</Button> | |
<span className={'text-center leading-[24px] bg-[#182222] rounded-[4px] w-[56px] px-2 py-1 text-xs'}> | |
{quantity} | |
</span> | |
<Button | |
variant={'secondary'} | |
className={'h-[32px] bg-[#182222] text-muted-foreground border-border w-[32px] p-0 rounded-[4px]'} | |
onClick={() => handleQuantityChange(quantity + 1)} | |
> | |
<Plus /> | |
</Button> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/checkout/checkout-line-items.tsx | |
================================================ | |
import { QuantityField } from '@/components/checkout/quantity-field'; | |
import { Separator } from '@/components/ui/separator'; | |
import { CheckoutEventsData } from '@paddle/paddle-js/types/checkout/events'; | |
import { formatMoney } from '@/utils/paddle/parse-money'; | |
import { Skeleton } from '@/components/ui/skeleton'; | |
interface LoadingTextProps { | |
value: number | undefined; | |
currencyCode: string | undefined; | |
} | |
function LoadingText({ value, currencyCode }: LoadingTextProps) { | |
if (value === undefined) { | |
return <Skeleton className="h-[20px] w-[75px] bg-border" />; | |
} else { | |
return formatMoney(value, currencyCode); | |
} | |
} | |
interface Props { | |
checkoutData: CheckoutEventsData | null; | |
quantity: number; | |
handleQuantityChange: (quantity: number) => void; | |
} | |
export function CheckoutLineItems({ handleQuantityChange, checkoutData, quantity }: Props) { | |
return ( | |
<> | |
<div className={'md:pt-12 text-base leading-[20px] font-medium'}>{checkoutData?.items[0].price_name}</div> | |
<QuantityField quantity={quantity} handleQuantityChange={handleQuantityChange} /> | |
<Separator className={'bg-border/50 mt-6'} /> | |
<div className={'pt-6 flex justify-between'}> | |
<span className={'text-base leading-[20px] font-medium text-muted-foreground'}>Subtotal</span> | |
<span className={'text-base leading-[20px] font-semibold'}> | |
<LoadingText currencyCode={checkoutData?.currency_code} value={checkoutData?.totals.subtotal} /> | |
</span> | |
</div> | |
<div className={'pt-6 flex justify-between'}> | |
<span className={'text-base leading-[20px] font-medium text-muted-foreground'}>Tax</span> | |
<span className={'text-base leading-[20px] font-semibold'}> | |
<LoadingText currencyCode={checkoutData?.currency_code} value={checkoutData?.totals.tax} /> | |
</span> | |
</div> | |
<Separator className={'bg-border/50 mt-6'} /> | |
<div className={'pt-6 flex justify-between'}> | |
<span className={'text-base leading-[20px] font-medium text-muted-foreground'}>Due today</span> | |
<span className={'text-base leading-[20px] font-semibold'}> | |
<LoadingText currencyCode={checkoutData?.currency_code} value={checkoutData?.totals.total} /> | |
</span> | |
</div> | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/payments/components/columns.tsx | |
================================================ | |
'use client'; | |
import { ColumnDef } from '@tanstack/react-table'; | |
import { Transaction } from '@paddle/paddle-node-sdk'; | |
import dayjs from 'dayjs'; | |
import { parseMoney } from '@/utils/paddle/parse-money'; | |
import { Status } from '@/components/shared/status/status'; | |
import { getPaymentReason } from '@/utils/paddle/data-helpers'; | |
// Column size is set as `auto` as React table column sizing is not working well. | |
const columnSize = 'auto' as unknown as number; | |
export const columns: ColumnDef<Transaction>[] = [ | |
{ | |
accessorKey: 'billedAt', | |
header: 'Date', | |
size: columnSize, | |
cell: ({ row }) => { | |
const billedDate = row.getValue('billedAt') as string; | |
return billedDate ? dayjs(billedDate).format('MMM DD, YYYY [at] h:mma') : '-'; | |
}, | |
}, | |
{ | |
accessorKey: 'amount', | |
header: () => <div className="text-right font-medium">Amount</div>, | |
enableResizing: false, | |
size: columnSize, | |
cell: ({ row }) => { | |
const formatted = parseMoney(row.original.details?.totals?.total, row.original.currencyCode); | |
return <div className="text-right font-medium">{formatted}</div>; | |
}, | |
}, | |
{ | |
accessorKey: 'status', | |
header: 'Status', | |
size: columnSize, | |
cell: ({ row }) => { | |
return <Status status={row.original.status} />; | |
}, | |
}, | |
{ | |
accessorKey: 'description', | |
header: 'Description', | |
size: columnSize, | |
cell: ({ row }) => { | |
return ( | |
<div className={'max-w-[250px]'}> | |
<div className={'whitespace-nowrap flex gap-1 truncate'}> | |
<span className={'font-semibold'}>{getPaymentReason(row.original.origin)}</span> | |
<span className={'font-medium truncate'}>{row.original.details?.lineItems[0].product?.name}</span> | |
{row.original.details?.lineItems && row.original.details?.lineItems.length > 1 && ( | |
<span className={'font-medium'}>+{row.original.details?.lineItems.length - 1} more</span> | |
)} | |
</div> | |
</div> | |
); | |
}, | |
}, | |
]; | |
================================================ | |
File: /src/components/dashboard/payments/components/data-table.tsx | |
================================================ | |
'use client'; | |
import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReactTable } from '@tanstack/react-table'; | |
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; | |
import { Button } from '@/components/ui/button'; | |
import { Transaction } from '@paddle/paddle-node-sdk'; | |
interface DataTableProps<TData, TValue> { | |
columns: ColumnDef<TData, TValue>[]; | |
data: TData[]; | |
hasMore?: boolean; | |
totalRecords?: number; | |
goToNextPage: (cursor: string) => void; | |
goToPrevPage: () => void; | |
hasPrev: boolean; | |
} | |
export function DataTable<TData, TValue>({ | |
columns, | |
data, | |
totalRecords, | |
hasMore, | |
goToNextPage, | |
goToPrevPage, | |
hasPrev, | |
}: DataTableProps<TData, TValue>) { | |
const table = useReactTable({ | |
data, | |
columns, | |
getCoreRowModel: getCoreRowModel(), | |
manualPagination: true, | |
pageCount: totalRecords ? Math.ceil(totalRecords / data.length) : 1, | |
rowCount: data.length, | |
getPaginationRowModel: getPaginationRowModel(), | |
}); | |
return ( | |
<div className="rounded-md border bg-background relative"> | |
<Table> | |
<TableHeader> | |
{table.getHeaderGroups().map((headerGroup) => ( | |
<TableRow key={headerGroup.id}> | |
{headerGroup.headers.map((header) => { | |
return ( | |
<TableHead | |
key={header.id} | |
style={{ | |
minWidth: header.column.columnDef.size, | |
maxWidth: header.column.columnDef.size, | |
}} | |
> | |
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} | |
</TableHead> | |
); | |
})} | |
</TableRow> | |
))} | |
</TableHeader> | |
<TableBody> | |
{table.getRowModel().rows?.length ? ( | |
table.getRowModel().rows.map((row) => ( | |
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}> | |
{row.getVisibleCells().map((cell) => ( | |
<TableCell | |
key={cell.id} | |
style={{ | |
minWidth: cell.column.columnDef.size, | |
maxWidth: cell.column.columnDef.size, | |
}} | |
> | |
{flexRender(cell.column.columnDef.cell, cell.getContext())} | |
</TableCell> | |
))} | |
</TableRow> | |
)) | |
) : ( | |
<TableRow> | |
<TableCell colSpan={columns.length} className="h-24 text-center"> | |
No results. | |
</TableCell> | |
</TableRow> | |
)} | |
</TableBody> | |
</Table> | |
<div className="flex items-center justify-end space-x-2 px-6 py-4"> | |
<Button | |
size={'sm'} | |
variant={'outline'} | |
className={'flex gap-2 text-sm rounded-sm border-border'} | |
onClick={() => goToPrevPage()} | |
disabled={!hasPrev} | |
> | |
Previous | |
</Button> | |
<Button | |
size={'sm'} | |
variant={'outline'} | |
className={'flex gap-2 text-sm rounded-sm border-border'} | |
onClick={() => goToNextPage((data[data.length - 1] as Transaction).id)} | |
disabled={!hasMore} | |
> | |
Next | |
</Button> | |
</div> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/payments/payments-content.tsx | |
================================================ | |
'use client'; | |
import { getTransactions } from '@/utils/paddle/get-transactions'; | |
import { ErrorContent } from '@/components/dashboard/layout/error-content'; | |
import { DataTable } from '@/components/dashboard/payments/components/data-table'; | |
import { columns } from '@/components/dashboard/payments/components/columns'; | |
import { useEffect, useState } from 'react'; | |
import { LoadingScreen } from '@/components/dashboard/layout/loading-screen'; | |
import { usePagination } from '@/hooks/usePagination'; | |
import { TransactionResponse } from '@/lib/api.types'; | |
interface Props { | |
subscriptionId: string; | |
} | |
export function PaymentsContent({ subscriptionId }: Props) { | |
const { after, goToNextPage, goToPrevPage, hasPrev } = usePagination(); | |
const [transactionResponse, setTransactionResponse] = useState<TransactionResponse>({ | |
data: [], | |
hasMore: false, | |
totalRecords: 0, | |
error: undefined, | |
}); | |
const [loading, setLoading] = useState(true); | |
useEffect(() => { | |
(async () => { | |
setLoading(true); | |
const response = await getTransactions(subscriptionId, after); | |
if (response) { | |
setTransactionResponse(response); | |
} | |
setLoading(false); | |
})(); | |
}, [subscriptionId, after]); | |
if (!transactionResponse || transactionResponse.error) { | |
return <ErrorContent />; | |
} else if (loading) { | |
return <LoadingScreen />; | |
} | |
const { data: transactionData, hasMore, totalRecords } = transactionResponse; | |
return ( | |
<div> | |
<DataTable | |
columns={columns} | |
hasMore={hasMore} | |
totalRecords={totalRecords} | |
goToNextPage={goToNextPage} | |
goToPrevPage={goToPrevPage} | |
hasPrev={hasPrev} | |
data={transactionData ?? []} | |
/> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/landing/components/dashboard-team-members-card.tsx | |
================================================ | |
'use client'; | |
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |
import { Button } from '@/components/ui/button'; | |
import Link from 'next/link'; | |
import { Plus } from 'lucide-react'; | |
const teamMembers = [ | |
{ | |
name: 'Daniel Cromitch', | |
email: '[email protected]', | |
initials: 'DC', | |
role: 'Owner', | |
}, | |
{ | |
name: 'Melissa Lee', | |
email: '[email protected]', | |
initials: 'ML', | |
role: 'Member', | |
}, | |
{ | |
name: 'Jackson Khan', | |
email: '[email protected]', | |
initials: 'JK', | |
role: 'Member', | |
}, | |
{ | |
name: 'Isa Lopez', | |
email: '[email protected]', | |
initials: 'IL', | |
role: 'Guest', | |
}, | |
]; | |
const roles = ['Owner', 'Member', 'Guest']; | |
export function DashboardTeamMembersCard() { | |
return ( | |
<Card className={'bg-background/50 backdrop-blur-[24px] border-border p-6'}> | |
<CardHeader className="p-0 space-y-0"> | |
<CardTitle className="flex justify-between gap-2 items-center pb-6 border-border border-b"> | |
<div className={'flex flex-col gap-2'}> | |
<span className={'text-xl font-medium'}>Team members</span> | |
<span className={'text-base leading-4 text-secondary'}>Invite your team members to collaborate</span> | |
</div> | |
<Button asChild={true} size={'sm'} variant={'outline'} className={'text-sm rounded-sm border-border'}> | |
<Link href={'/dashboard/subscriptions'}> | |
<Plus size={16} className={'text-muted-foreground'} /> | |
</Link> | |
</Button> | |
</CardTitle> | |
</CardHeader> | |
<CardContent className={'p-0 pt-6 flex gap-6 flex-col'}> | |
{teamMembers.map((teamMember) => ( | |
<div key={teamMember.email} className={'flex justify-between items-center gap-2'}> | |
<div className={'flex gap-4'}> | |
<div className={'flex items-center justify-center px-3 py-4'}> | |
<span className={'text-white text-base w-5'}>{teamMember.initials}</span> | |
</div> | |
<div className={'flex flex-col gap-2'}> | |
<span className={'text-base leading-4 font-medium'}>{teamMember.name}</span> | |
<span className={'text-base leading-4 text-secondary'}>{teamMember.email}</span> | |
</div> | |
</div> | |
</div> | |
))} | |
</CardContent> | |
</Card> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/landing/components/dashboard-usage-card-group.tsx | |
================================================ | |
import { Bolt, Image, Shapes, Timer } from 'lucide-react'; | |
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; | |
const cards = [ | |
{ | |
title: 'Storage used', | |
icon: <Bolt className={'text-[#4B4F4F]'} size={18} />, | |
value: '1.2 GB', | |
change: '10 GB remaining', | |
}, | |
{ | |
title: 'Active workspaces', | |
icon: <Shapes className={'text-[#4B4F4F]'} size={18} />, | |
value: '4', | |
change: '6 available workspaces', | |
}, | |
{ | |
title: 'Assets exported', | |
// eslint-disable-next-line jsx-a11y/alt-text | |
icon: <Image className={'text-[#4B4F4F]'} size={18} />, | |
value: '286', | |
change: '+16% from last month', | |
}, | |
{ | |
title: 'Collaborators', | |
icon: <Timer className={'text-[#4B4F4F]'} size={18} />, | |
value: '10', | |
change: '+27% from last month', | |
}, | |
]; | |
export function DashboardUsageCardGroup() { | |
return ( | |
<div className={'grid gap-6 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-2'}> | |
{cards.map((card) => ( | |
<Card key={card.title} className={'bg-background/50 backdrop-blur-[24px] border-border p-6'}> | |
<CardHeader className="p-0 space-y-0"> | |
<CardTitle className="flex justify-between items-center mb-6"> | |
<span className={'text-base leading-4'}>{card.title}</span> {card.icon} | |
</CardTitle> | |
<CardDescription className={'text-[32px] leading-[32px] text-primary'}>{card.value}</CardDescription> | |
</CardHeader> | |
<CardContent className={'p-0'}> | |
<div className="text-sm leading-[14px] pt-2 text-secondary">{card.change}</div> | |
</CardContent> | |
</Card> | |
))} | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/landing/components/dashboard-subscription-card-group.tsx | |
================================================ | |
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |
import { Button } from '@/components/ui/button'; | |
import Link from 'next/link'; | |
import { SubscriptionCards } from '@/components/dashboard/subscriptions/components/subscription-cards'; | |
import { getSubscriptions } from '@/utils/paddle/get-subscriptions'; | |
import { ErrorContent } from '@/components/dashboard/layout/error-content'; | |
export async function DashboardSubscriptionCardGroup() { | |
const subscriptions = await getSubscriptions(); | |
return ( | |
<Card className={'bg-background/50 backdrop-blur-[24px] border-border p-6'}> | |
<CardHeader className="p-0 space-y-0"> | |
<CardTitle className="flex justify-between items-center pb-6 border-border border-b"> | |
<span className={'text-xl font-medium'}>Active subscriptions</span> | |
<Button asChild={true} size={'sm'} variant={'outline'} className={'text-sm rounded-sm border-border'}> | |
<Link href={'/dashboard/subscriptions'}>View all</Link> | |
</Button> | |
</CardTitle> | |
</CardHeader> | |
<CardContent className={'p-0 pt-6 @container'}> | |
{subscriptions?.data ? ( | |
<SubscriptionCards | |
className={'grid-cols-1 gap-6 @[600px]:grid-cols-2'} | |
subscriptions={subscriptions.data.slice(0, 2) ?? []} | |
/> | |
) : ( | |
<ErrorContent /> | |
)} | |
</CardContent> | |
</Card> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/landing/components/dashboard-tutorial-card.tsx | |
================================================ | |
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |
import { Button } from '@/components/ui/button'; | |
import { ArrowUpRight } from 'lucide-react'; | |
export function DashboardTutorialCard() { | |
return ( | |
<Card className={'bg-background/50 backdrop-blur-[24px] border-border p-6'}> | |
<CardHeader className="p-0 space-y-0"> | |
<CardTitle className="flex justify-between items-center text-xl mb-2 font-medium">Tutorials</CardTitle> | |
</CardHeader> | |
<CardContent className={'p-0 flex flex-col gap-6'}> | |
<div className="text-base leading-6 text-secondary"> | |
Learn how to get the most out of AeroEdit tools and discover your inner artist. | |
</div> | |
<div> | |
<Button size={'sm'} variant={'outline'} className={'flex gap-2 text-sm rounded-sm border-border'}> | |
Tutorials | |
<ArrowUpRight size={16} className={'text-[#797C7C]'} /> | |
</Button> | |
</div> | |
</CardContent> | |
</Card> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/landing/dashboard-landing-page.tsx | |
================================================ | |
import { DashboardUsageCardGroup } from '@/components/dashboard/landing/components/dashboard-usage-card-group'; | |
import { DashboardSubscriptionCardGroup } from '@/components/dashboard/landing/components/dashboard-subscription-card-group'; | |
import { DashboardTutorialCard } from '@/components/dashboard/landing/components/dashboard-tutorial-card'; | |
import { DashboardTeamMembersCard } from '@/components/dashboard/landing/components/dashboard-team-members-card'; | |
export function DashboardLandingPage() { | |
return ( | |
<div className={'grid flex-1 items-start gap-6 p-0 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3'}> | |
<div className={'grid auto-rows-max items-start gap-6 lg:col-span-2'}> | |
<DashboardUsageCardGroup /> | |
<DashboardSubscriptionCardGroup /> | |
</div> | |
<div className={'grid auto-rows-max items-start gap-6'}> | |
<DashboardTeamMembersCard /> | |
<DashboardTutorialCard /> | |
</div> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/subscriptions/components/subscription-next-payment-card.tsx | |
================================================ | |
import { Card } from '@/components/ui/card'; | |
import { Subscription, Transaction } from '@paddle/paddle-node-sdk'; | |
import dayjs from 'dayjs'; | |
import { parseMoney } from '@/utils/paddle/parse-money'; | |
import { PaymentMethodSection } from '@/components/dashboard/subscriptions/components/payment-method-section'; | |
interface Props { | |
transactions?: Transaction[]; | |
subscription?: Subscription; | |
} | |
export function SubscriptionNextPaymentCard({ subscription, transactions }: Props) { | |
if (!subscription?.nextBilledAt) { | |
return null; | |
} | |
return ( | |
<Card className={'bg-background/50 backdrop-blur-[24px] border-border p-6 @container'}> | |
<div className={'flex gap-6 flex-col border-border border-b pb-6'}> | |
<div className={'text-xl font-medium'}>Next payment</div> | |
<div className={'flex gap-1 items-end @16xs:flex-wrap'}> | |
<span className={'text-xl leading-5 font-medium text-primary'}> | |
{parseMoney(subscription?.nextTransaction?.details.totals.total, subscription?.currencyCode)} | |
</span> | |
<span className={'text-base text-secondary leading-4'}>due</span> | |
<span className={'ext-base leading-4 font-semibold text-primary'}> | |
{dayjs(subscription?.nextBilledAt).format('MMM DD, YYYY')} | |
</span> | |
</div> | |
</div> | |
<PaymentMethodSection | |
transactions={transactions} | |
updatePaymentMethodUrl={subscription?.managementUrls?.updatePaymentMethod} | |
/> | |
</Card> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/subscriptions/components/subscription-line-items.tsx | |
================================================ | |
import { Card, CardContent, CardTitle } from '@/components/ui/card'; | |
import { Subscription } from '@paddle/paddle-node-sdk'; | |
import { Fragment } from 'react'; | |
import { parseMoney } from '@/utils/paddle/parse-money'; | |
import Image from 'next/image'; | |
interface Props { | |
subscription?: Subscription; | |
} | |
export function SubscriptionLineItems({ subscription }: Props) { | |
return ( | |
<Card className={'bg-background/50 backdrop-blur-[24px] border-border p-6'}> | |
<CardTitle className="flex justify-between items-center pb-6 border-border border-b"> | |
<span className={'text-xl font-medium'}>Recurring products in this subscription</span> | |
</CardTitle> | |
<CardContent className={'p-0 pt-10'}> | |
<div className={'grid grid-cols-12'}> | |
<div className={'col-span-6'}></div> | |
<div className={'flex gap-6 w-full col-span-6'}> | |
<div className={'col-span-2 w-full text-base leading-4 font-semibold'}>Qty</div> | |
<div className={'col-span-2 w-full text-base leading-4 font-semibold'}>Tax</div> | |
<div className={'col-span-2 w-full text-base leading-4 font-semibold text-right'}> | |
<span>Amount</span> | |
<span className={'text-secondary text-sm leading-[14px] font-normal'}>(exc. tax)</span> | |
</div> | |
</div> | |
{subscription?.recurringTransactionDetails?.lineItems.map((lineItem) => { | |
return ( | |
<Fragment key={lineItem.priceId}> | |
<div className={'col-span-6 border-border border-b py-6'}> | |
<div className={'flex gap-4 items-center'}> | |
<div> | |
{lineItem.product.imageUrl && ( | |
<Image src={lineItem.product.imageUrl} width={48} height={48} alt={lineItem.product.name} /> | |
)} | |
</div> | |
<div className={'flex flex-col gap-3 px-4'}> | |
<div className={'text-base leading-6 font-semibold'}>{lineItem.product.name}</div> | |
<div className={'text-base leading-6 text-secondary'}>{lineItem.product.description}</div> | |
</div> | |
</div> | |
</div> | |
<div className={'flex gap-6 w-full col-span-6 items-center border-border border-b py-6'}> | |
<div className={'col-span-2 w-full text-base leading-4 font-semibold text-secondary'}> | |
{lineItem.quantity} | |
</div> | |
<div className={'col-span-2 w-full text-base leading-4 font-semibold text-secondary'}> | |
{parseFloat(lineItem.taxRate) * 100}% | |
</div> | |
<div className={'col-span-2 text-right w-full text-base leading-4 font-semibold text-secondary'}> | |
{parseMoney(lineItem.totals.subtotal, subscription?.currencyCode)} | |
</div> | |
</div> | |
</Fragment> | |
); | |
})} | |
<div className={'col-span-6'}></div> | |
<div className={'flex flex-col w-full col-span-6 pt-6'}> | |
<div className={'flex justify-between py-4 pt-0 border-border border-b'}> | |
<div className={'col-span-3 w-full text-base leading-4 text-secondary'}>Amount</div> | |
<div className={'col-span-3 w-full text-base leading-4 text-right text-secondary'}> | |
{parseMoney(subscription?.recurringTransactionDetails?.totals.subtotal, subscription?.currencyCode)} | |
</div> | |
</div> | |
<div className={'flex justify-between py-4 border-border border-b'}> | |
<div className={'col-span-3 w-full text-base leading-4 text-secondary'}>Tax</div> | |
<div className={'col-span-3 w-full text-base leading-4 text-right text-secondary'}> | |
{parseMoney(subscription?.recurringTransactionDetails?.totals.tax, subscription?.currencyCode)} | |
</div> | |
</div> | |
<div className={'flex justify-between py-4 border-border border-b'}> | |
<div className={'col-span-3 w-full text-base leading-4 text-secondary'}>Total (Inc. tax)</div> | |
<div className={'col-span-3 w-full text-base leading-4 font-semibold text-right'}> | |
{parseMoney(subscription?.recurringTransactionDetails?.totals.total, subscription?.currencyCode)} | |
</div> | |
</div> | |
</div> | |
</div> | |
</CardContent> | |
</Card> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/subscriptions/components/subscription-header.tsx | |
================================================ | |
import { Subscription } from '@paddle/paddle-node-sdk'; | |
import Image from 'next/image'; | |
import { Status } from '@/components/shared/status/status'; | |
import { parseMoney } from '@/utils/paddle/parse-money'; | |
import dayjs from 'dayjs'; | |
import { SubscriptionHeaderActionButton } from '@/components/dashboard/subscriptions/components/subscription-header-action-button'; | |
import { SubscriptionAlerts } from '@/components/dashboard/subscriptions/components/subscription-alerts'; | |
import { MobileSidebar } from '@/components/dashboard/layout/mobile-sidebar'; | |
interface Props { | |
subscription: Subscription; | |
} | |
export function SubscriptionHeader({ subscription }: Props) { | |
const subscriptionItem = subscription.items[0]; | |
const price = subscriptionItem.quantity * parseFloat(subscription?.recurringTransactionDetails?.totals.total ?? '0'); | |
const formattedPrice = parseMoney(price.toString(), subscription.currencyCode); | |
const frequency = | |
subscription.billingCycle.frequency === 1 | |
? `/${subscription.billingCycle.interval}` | |
: `every ${subscription.billingCycle.frequency} ${subscription.billingCycle.interval}s`; | |
const formattedStartedDate = dayjs(subscription.startedAt).format('MMM DD, YYYY'); | |
return ( | |
<div className={'flex justify-between items-start sm:items-center flex-col sm:flex-row mb-6 sm:mb-0'}> | |
<div className={'flex flex-col w-full'}> | |
<SubscriptionAlerts subscription={subscription} /> | |
<div className={'flex items-center gap-5'}> | |
<MobileSidebar /> | |
{subscriptionItem.product.imageUrl && ( | |
<Image src={subscriptionItem.product.imageUrl} alt={subscriptionItem.product.name} width={48} height={48} /> | |
)} | |
<span className={'text-4xl leading-9 font-medium'}>{subscriptionItem.product.name}</span> | |
</div> | |
<div className={'flex items-center gap-6 py-8 pb-6 flex-wrap md:flex-wrap'}> | |
<div className={'flex gap-1 items-end'}> | |
<span className={'text-4xl leading-9 font-medium'}>{formattedPrice}</span> | |
<span className={'text-secondary text-sm leading-[14px] font-medium'}>{frequency}</span> | |
</div> | |
<div> | |
<Status status={subscription.status} /> | |
</div> | |
</div> | |
<div className={'text-secondary text-base leading-5 pb-8'}>Started on: {formattedStartedDate}</div> | |
</div> | |
<div> | |
{!(subscription.scheduledChange || subscription.status === 'canceled') && ( | |
<SubscriptionHeaderActionButton subscriptionId={subscription.id} /> | |
)} | |
</div> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/subscriptions/components/subscription-alerts.tsx | |
================================================ | |
import { Subscription } from '@paddle/paddle-node-sdk'; | |
import { Alert } from '@/components/ui/alert'; | |
import dayjs from 'dayjs'; | |
interface Props { | |
subscription: Subscription; | |
} | |
export function SubscriptionAlerts({ subscription }: Props) { | |
if (subscription.status === 'canceled') { | |
return ( | |
<Alert variant={'destructive'} className={'mb-10'}> | |
This subscription was canceled on {dayjs(subscription.canceledAt).format('MMM DD, YYYY [at] h:mma')} and is no | |
longer active. | |
</Alert> | |
); | |
} else if (subscription.scheduledChange && subscription.scheduledChange.action === 'cancel') { | |
return ( | |
<Alert className={'mb-10'}> | |
This subscription is scheduled to be canceled on{' '} | |
{dayjs(subscription.scheduledChange.effectiveAt).format('MMM DD, YYYY [at] h:mma')} | |
</Alert> | |
); | |
} | |
return null; | |
} | |
================================================ | |
File: /src/components/dashboard/subscriptions/components/subscription-detail.tsx | |
================================================ | |
'use client'; | |
import { getSubscription } from '@/utils/paddle/get-subscription'; | |
import { getTransactions } from '@/utils/paddle/get-transactions'; | |
import { SubscriptionPastPaymentsCard } from '@/components/dashboard/subscriptions/components/subscription-past-payments-card'; | |
import { SubscriptionNextPaymentCard } from '@/components/dashboard/subscriptions/components/subscription-next-payment-card'; | |
import { SubscriptionLineItems } from '@/components/dashboard/subscriptions/components/subscription-line-items'; | |
import { SubscriptionHeader } from '@/components/dashboard/subscriptions/components/subscription-header'; | |
import { Separator } from '@/components/ui/separator'; | |
import { ErrorContent } from '@/components/dashboard/layout/error-content'; | |
import { useEffect, useState } from 'react'; | |
import { LoadingScreen } from '@/components/dashboard/layout/loading-screen'; | |
import { SubscriptionDetailResponse, TransactionResponse } from '@/lib/api.types'; | |
interface Props { | |
subscriptionId: string; | |
} | |
export function SubscriptionDetail({ subscriptionId }: Props) { | |
const [loading, setLoading] = useState(true); | |
const [subscription, setSubscription] = useState<SubscriptionDetailResponse>(); | |
const [transactions, setTransactions] = useState<TransactionResponse>(); | |
useEffect(() => { | |
(async () => { | |
const [subscriptionResponse, transactionsResponse] = await Promise.all([ | |
getSubscription(subscriptionId), | |
getTransactions(subscriptionId, ''), | |
]); | |
if (subscriptionResponse) { | |
setSubscription(subscriptionResponse); | |
} | |
if (transactionsResponse) { | |
setTransactions(transactionsResponse); | |
} | |
setLoading(false); | |
})(); | |
}, [subscriptionId]); | |
if (loading) { | |
return <LoadingScreen />; | |
} else if (subscription?.data && transactions?.data) { | |
return ( | |
<> | |
<div> | |
<SubscriptionHeader subscription={subscription.data} /> | |
<Separator className={'relative bg-border mb-8 dashboard-header-highlight'} /> | |
</div> | |
<div className={'grid gap-6 grid-cols-1 xl:grid-cols-6'}> | |
<div className={'grid auto-rows-max gap-6 grid-cols-1 xl:col-span-2'}> | |
<SubscriptionNextPaymentCard transactions={transactions.data} subscription={subscription.data} /> | |
<SubscriptionPastPaymentsCard transactions={transactions.data} subscriptionId={subscriptionId} /> | |
</div> | |
<div className={'grid auto-rows-max gap-6 grid-cols-1 xl:col-span-4'}> | |
<SubscriptionLineItems subscription={subscription.data} /> | |
</div> | |
</div> | |
</> | |
); | |
} else { | |
return <ErrorContent />; | |
} | |
} | |
================================================ | |
File: /src/components/dashboard/subscriptions/components/subscription-past-payments-card.tsx | |
================================================ | |
import { Card, CardContent, CardTitle } from '@/components/ui/card'; | |
import { Button } from '@/components/ui/button'; | |
import Link from 'next/link'; | |
import { Transaction } from '@paddle/paddle-node-sdk'; | |
import dayjs from 'dayjs'; | |
import { parseMoney } from '@/utils/paddle/parse-money'; | |
import { Status } from '@/components/shared/status/status'; | |
import { getPaymentReason } from '@/utils/paddle/data-helpers'; | |
interface Props { | |
subscriptionId: string; | |
transactions?: Transaction[]; | |
} | |
export function SubscriptionPastPaymentsCard({ subscriptionId, transactions }: Props) { | |
return ( | |
<Card className={'bg-background/50 backdrop-blur-[24px] border-border p-6 @container'}> | |
<CardTitle className="flex justify-between items-center pb-6 border-border border-b flex-wrap"> | |
<span className={'text-xl font-medium'}>Payments</span> | |
<Button asChild={true} size={'sm'} variant={'outline'} className={'text-sm rounded-sm border-border'}> | |
<Link href={`/dashboard/payments/${subscriptionId}`}>View all</Link> | |
</Button> | |
</CardTitle> | |
<CardContent className={'p-0'}> | |
{transactions?.slice(0, 3).map((transaction) => { | |
const formattedPrice = parseMoney(transaction.details?.totals?.total, transaction.currencyCode); | |
return ( | |
<div key={transaction.id} className={'flex flex-col gap-4 border-border border-b py-6'}> | |
<div className={'text-secondary text-base leading-4'}> | |
{dayjs(transaction.billedAt ?? transaction.createdAt).format('MMM DD, YYYY')} | |
</div> | |
<div className={'flex-wrap flex items-center gap-5'}> | |
<span className={'font-semibold text-base leading-4'}>{getPaymentReason(transaction.origin)}</span> | |
<span className={'text-base leading-6 text-secondary'}> | |
{transaction.details?.lineItems[0].product?.name} | |
</span> | |
</div> | |
<div className={'flex gap-5 items-center flex-wrap'}> | |
<div className={'text-base leading-4 font-semibold'}>{formattedPrice}</div> | |
<Status status={transaction.status} /> | |
</div> | |
</div> | |
); | |
})} | |
</CardContent> | |
</Card> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/subscriptions/components/payment-method-section.tsx | |
================================================ | |
import { Button } from '@/components/ui/button'; | |
import Link from 'next/link'; | |
import { PaymentMethodDetails } from '@/components/dashboard/subscriptions/components/payment-method-details'; | |
import { PaymentType, Transaction } from '@paddle/paddle-node-sdk'; | |
function findPaymentMethodDetails(transactions?: Transaction[]) { | |
const transactionWithPaymentDetails = transactions?.find((transaction) => transaction.payments[0]?.methodDetails); | |
const firstValidPaymentMethod = transactionWithPaymentDetails?.payments[0].methodDetails; | |
return firstValidPaymentMethod ? firstValidPaymentMethod : { type: 'unknown' as PaymentType, card: null }; | |
} | |
interface Props { | |
updatePaymentMethodUrl?: string | null; | |
transactions?: Transaction[]; | |
} | |
export function PaymentMethodSection({ transactions, updatePaymentMethodUrl }: Props) { | |
const { type, card } = findPaymentMethodDetails(transactions); | |
if (type === 'unknown') { | |
return null; | |
} | |
return ( | |
<div className={'flex gap-6 pt-6 items-end justify-between @16xs:flex-wrap'}> | |
<div className={'flex flex-col gap-4'}> | |
<div className={'text-base text-secondary leading-4 whitespace-nowrap'}>Payment method</div> | |
<div className={'flex gap-1 items-end'}> | |
<PaymentMethodDetails type={type} card={card} /> | |
</div> | |
</div> | |
{updatePaymentMethodUrl && ( | |
<div> | |
<Button asChild={true} size={'sm'} className={'text-sm rounded-sm border-border'} variant={'outline'}> | |
<Link target={'_blank'} href={updatePaymentMethodUrl}> | |
Update | |
</Link> | |
</Button> | |
</div> | |
)} | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/subscriptions/components/subscription-header-action-button.tsx | |
================================================ | |
'use client'; | |
import { cancelSubscription } from '@/app/dashboard/subscriptions/actions'; | |
import { Button } from '@/components/ui/button'; | |
import { useToast } from '@/components/ui/use-toast'; | |
import { CircleAlert, CircleCheck } from 'lucide-react'; | |
import { useState } from 'react'; | |
import { Confirmation } from '@/components/shared/confirmation/confirmation'; | |
interface Props { | |
subscriptionId: string; | |
} | |
export function SubscriptionHeaderActionButton({ subscriptionId }: Props) { | |
const { toast } = useToast(); | |
const [loading, setLoading] = useState(false); | |
const [isModalOpen, setModalOpen] = useState(false); | |
function handleCancelSubscription() { | |
setModalOpen(false); | |
setLoading(true); | |
cancelSubscription(subscriptionId) | |
.then(() => { | |
toast({ | |
description: ( | |
<div className={'flex items-center gap-3'}> | |
<CircleCheck size={20} color={'#25F497'} /> | |
<div className={'flex flex-col gap-1'}> | |
<span className={'text-primary font-medium test-sm leading-5'}>Cancellation scheduled</span> | |
<span className={'text-muted-foreground test-sm leading-5'}> | |
Subscription scheduled to cancel at the end of the billing period. | |
</span> | |
</div> | |
</div> | |
), | |
}); | |
}) | |
.catch((error) => { | |
toast({ | |
description: ( | |
<div className={'flex items-start gap-3'}> | |
<CircleAlert size={20} color={'#F42566'} /> | |
<div className={'flex flex-col gap-1'}> | |
<div className={'text-primary font-medium test-sm leading-5'}>Error</div> | |
<div className={'text-muted-foreground test-sm leading-5'}> | |
Something went wrong, please try again later | |
</div> | |
</div> | |
</div> | |
), | |
}); | |
}) | |
.finally(() => setLoading(false)); | |
} | |
return ( | |
<> | |
<Button | |
disabled={loading} | |
onClick={() => setModalOpen(true)} | |
size={'sm'} | |
variant={'outline'} | |
className={'flex gap-2 text-sm rounded-sm border-border'} | |
> | |
Cancel subscription | |
</Button> | |
<Confirmation | |
description={'This subscription will be scheduled to cancel at the end of the billing period.'} | |
title={'Cancel subscription?'} | |
onClose={() => setModalOpen(false)} | |
isOpen={isModalOpen} | |
onConfirm={handleCancelSubscription} | |
/> | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/subscriptions/components/payment-method-details.tsx | |
================================================ | |
import { PaymentMethodDetails as PaddlePaymentMethodDetails } from '@paddle/paddle-node-sdk'; | |
import { CreditCard } from 'lucide-react'; | |
const PaymentMethodLabels: Record<PaddlePaymentMethodDetails['type'], string> = { | |
card: 'Card', | |
alipay: 'Alipay', | |
wire_transfer: 'Wire Transfer', | |
apple_pay: 'Apple Pay', | |
google_pay: 'Google Pay', | |
paypal: 'PayPal', | |
ideal: 'iDEAL', | |
bancontact: 'Bancontact', | |
offline: 'Offline', | |
unknown: 'Unknown', | |
}; | |
interface Props { | |
type: PaddlePaymentMethodDetails['type']; | |
card?: PaddlePaymentMethodDetails['card']; | |
} | |
export function PaymentMethodDetails({ type, card }: Props) { | |
if (type === 'card') { | |
return ( | |
<> | |
<CreditCard size={18} /> | |
<span className={'text-base text-secondary leading-4'}>**** {card?.last4}</span> | |
</> | |
); | |
} else { | |
return type ? <span className={'text-base text-secondary leading-4'}>{PaymentMethodLabels[type]}</span> : '-'; | |
} | |
} | |
================================================ | |
File: /src/components/dashboard/subscriptions/components/subscription-cards.tsx | |
================================================ | |
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |
import { ArrowRight } from 'lucide-react'; | |
import Link from 'next/link'; | |
import { Status } from '@/components/shared/status/status'; | |
import { Subscription } from '@paddle/paddle-node-sdk'; | |
import { cn } from '@/lib/utils'; | |
import Image from 'next/image'; | |
import { parseMoney } from '@/utils/paddle/parse-money'; | |
interface Props { | |
subscriptions: Subscription[]; | |
className: string; | |
} | |
export function SubscriptionCards({ subscriptions, className }: Props) { | |
if (subscriptions.length === 0) { | |
return <span className={'text-base font-medium'}>No active subscriptions</span>; | |
} else { | |
return ( | |
<div className={cn('grid flex-1 items-start', className)}> | |
{subscriptions.map((subscription) => { | |
const subscriptionItem = subscription.items[0]; | |
const price = subscriptionItem.quantity * parseFloat(subscriptionItem.price.unitPrice.amount); | |
const formattedPrice = parseMoney(price.toString(), subscription.currencyCode); | |
const frequency = | |
subscription.billingCycle.frequency === 1 | |
? `/${subscription.billingCycle.interval}` | |
: `every ${subscription.billingCycle.frequency} ${subscription.billingCycle.interval}s`; | |
return ( | |
<Card key={subscription.id} className={'bg-background/50 backdrop-blur-[24px] border-border p-6'}> | |
<CardHeader className="p-0 space-y-0"> | |
<CardTitle className="flex flex-col justify-between items-start mb-6"> | |
<div | |
className={cn('flex mb-4 w-full', { | |
'justify-between': subscriptionItem.product.imageUrl, | |
'justify-end': !subscriptionItem.product.imageUrl, | |
})} | |
> | |
{subscriptionItem.product.imageUrl && ( | |
<Image | |
src={subscriptionItem.product.imageUrl} | |
alt={subscriptionItem.product.name} | |
width={48} | |
height={48} | |
/> | |
)} | |
<Link href={`/dashboard/subscriptions/${subscription.id}`}> | |
<ArrowRight size={20} /> | |
</Link> | |
</div> | |
<span className={'text-xl leading-7 font-medium'}>{subscriptionItem.product.name}</span> | |
</CardTitle> | |
</CardHeader> | |
<CardContent className={'p-0 flex justify-between gap-3 flex-wrap xl:flex-nowrap'}> | |
<div className={'flex flex-col gap-3'}> | |
<div className="text-base leading-6 text-secondary">{subscriptionItem.product.description}</div> | |
<div className="text-base leading-[16px] text-primary"> | |
{formattedPrice} | |
{frequency} | |
</div> | |
</div> | |
<Status status={subscription.status} /> | |
</CardContent> | |
</Card> | |
); | |
})} | |
</div> | |
); | |
} | |
} | |
================================================ | |
File: /src/components/dashboard/subscriptions/subscriptions.tsx | |
================================================ | |
import { SubscriptionDetail } from '@/components/dashboard/subscriptions/components/subscription-detail'; | |
import { NoSubscriptionView } from '@/components/dashboard/subscriptions/views/no-subscription-view'; | |
import { MultipleSubscriptionsView } from '@/components/dashboard/subscriptions/views/multiple-subscriptions-view'; | |
import { SubscriptionErrorView } from '@/components/dashboard/subscriptions/views/subscription-error-view'; | |
import { getSubscriptions } from '@/utils/paddle/get-subscriptions'; | |
export async function Subscriptions() { | |
const { data: subscriptions } = await getSubscriptions(); | |
if (subscriptions) { | |
if (subscriptions.length === 0) { | |
return <NoSubscriptionView />; | |
} else if (subscriptions.length === 1) { | |
return <SubscriptionDetail subscriptionId={subscriptions[0].id} />; | |
} else { | |
return <MultipleSubscriptionsView subscriptions={subscriptions} />; | |
} | |
} else { | |
return <SubscriptionErrorView />; | |
} | |
} | |
================================================ | |
File: /src/components/dashboard/subscriptions/views/subscription-error-view.tsx | |
================================================ | |
import { DashboardPageHeader } from '@/components/dashboard/layout/dashboard-page-header'; | |
import { ErrorContent } from '@/components/dashboard/layout/error-content'; | |
export function SubscriptionErrorView() { | |
return ( | |
<> | |
<DashboardPageHeader pageTitle={'Subscriptions'} /> | |
<ErrorContent /> | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/subscriptions/views/multiple-subscriptions-view.tsx | |
================================================ | |
import { DashboardPageHeader } from '@/components/dashboard/layout/dashboard-page-header'; | |
import { SubscriptionCards } from '@/components/dashboard/subscriptions/components/subscription-cards'; | |
import { Subscription } from '@paddle/paddle-node-sdk'; | |
interface Props { | |
subscriptions: Subscription[]; | |
} | |
export function MultipleSubscriptionsView({ subscriptions }: Props) { | |
return ( | |
<> | |
<DashboardPageHeader pageTitle={'Subscriptions'} /> | |
<SubscriptionCards className={'grid-cols-1 lg:grid-cols-3 gap-6'} subscriptions={subscriptions} /> | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/subscriptions/views/no-subscription-view.tsx | |
================================================ | |
import { DashboardPageHeader } from '@/components/dashboard/layout/dashboard-page-header'; | |
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; | |
import { Button } from '@/components/ui/button'; | |
import Link from 'next/link'; | |
export function NoSubscriptionView() { | |
return ( | |
<> | |
<DashboardPageHeader pageTitle={'Subscriptions'} /> | |
<div className={'grid grid-cols-12'}> | |
<Card | |
className={'bg-background/50 backdrop-blur-[24px] border-border p-6 col-span-12 md:col-span-6 lg:col-span-4'} | |
> | |
<CardHeader className="p-0 space-y-0"> | |
<CardTitle className="flex justify-between items-center pb-2"> | |
<span className={'text-xl font-medium'}>No active subscriptions</span> | |
</CardTitle> | |
</CardHeader> | |
<CardContent className={'p-0'}> | |
<div className="text-base leading-6 text-secondary"> | |
Sign up for a subscription to see your subscriptions here. | |
</div> | |
</CardContent> | |
<CardFooter className={'p-0 pt-6'}> | |
<Button asChild={true} size={'sm'} variant={'outline'} className={'text-sm rounded-sm border-border'}> | |
<Link href={'/'}>View all</Link> | |
</Button> | |
</CardFooter> | |
</Card> | |
</div> | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/layout/dashboard-layout.tsx | |
================================================ | |
import Link from 'next/link'; | |
import Image from 'next/image'; | |
import { ReactNode } from 'react'; | |
import { DashboardGradient } from '@/components/gradients/dashboard-gradient'; | |
import '../../../styles/dashboard.css'; | |
import { Sidebar } from '@/components/dashboard/layout/sidebar'; | |
import { SidebarUserInfo } from '@/components/dashboard/layout/sidebar-user-info'; | |
interface Props { | |
children: ReactNode; | |
} | |
export function DashboardLayout({ children }: Props) { | |
return ( | |
<div className="grid min-h-screen w-full md:grid-cols-[220px_1fr] lg:grid-cols-[280px_1fr] relative overflow-hidden"> | |
<DashboardGradient /> | |
<div className="hidden border-r md:block relative"> | |
<div className="flex h-full flex-col gap-2"> | |
<div className="flex items-center pt-8 pl-6 pb-10"> | |
<Link href="/" className="flex items-center gap-2 font-semibold"> | |
<Image src={'/assets/icons/logo/aeroedit-logo-icon.svg'} alt={'AeroEdit'} width={41} height={41} /> | |
</Link> | |
</div> | |
<div className="flex flex-col flex-grow"> | |
<Sidebar /> | |
<SidebarUserInfo /> | |
</div> | |
</div> | |
</div> | |
<div className="flex flex-col">{children}</div> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/layout/sidebar.tsx | |
================================================ | |
'use client'; | |
import { Album, CreditCard, Home } from 'lucide-react'; | |
import Link from 'next/link'; | |
import { usePathname } from 'next/navigation'; | |
import { cn } from '@/lib/utils'; | |
const sidebarItems = [ | |
{ | |
title: 'Dashboard', | |
icon: <Home className="h-6 w-6" />, | |
href: '/dashboard', | |
}, | |
{ | |
title: 'Subscriptions', | |
icon: <Album className="h-6 w-6" />, | |
href: '/dashboard/subscriptions', | |
}, | |
{ | |
title: 'Payments', | |
icon: <CreditCard className="h-6 w-6" />, | |
href: '/dashboard/payments', | |
}, | |
]; | |
export function Sidebar() { | |
const pathname = usePathname(); | |
return ( | |
<nav className="flex flex-col flex-grow justify-between items-start px-2 text-sm font-medium lg:px-4"> | |
<div className={'w-full'}> | |
{sidebarItems.map((item) => ( | |
<Link | |
key={item.title} | |
href={item.href} | |
className={cn('flex items-center text-base gap-3 px-4 py-3 rounded-xxs dashboard-sidebar-items', { | |
'dashboard-sidebar-items-active': | |
item.href === '/dashboard' ? pathname === item.href : pathname.includes(item.href), | |
})} | |
> | |
{item.icon} | |
{item.title} | |
</Link> | |
))} | |
</div> | |
</nav> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/layout/sidebar-user-info.tsx | |
================================================ | |
'use client'; | |
import { Separator } from '@/components/ui/separator'; | |
import { LogOut } from 'lucide-react'; | |
import { createClient } from '@/utils/supabase/client'; | |
import { MouseEvent } from 'react'; | |
import { useUserInfo } from '@/hooks/useUserInfo'; | |
export function SidebarUserInfo() { | |
const supabase = createClient(); | |
const { user } = useUserInfo(supabase); | |
async function handleLogout(e: MouseEvent) { | |
e.preventDefault(); | |
await supabase.auth.signOut(); | |
location.reload(); | |
} | |
return ( | |
<div className={'flex flex-col items-start pb-8 px-2 text-sm font-medium lg:px-4'}> | |
<Separator className={'relative mt-6 dashboard-sidebar-highlight bg-[#283031]'} /> | |
<div className={'flex w-full flex-row mt-6 items-center justify-between'}> | |
<div className={'flex flex-col items-start justify-center overflow-hidden text-ellipsis'}> | |
<div className={'text-sm leading-5 font-semibold w-full overflow-hidden text-ellipsis'}> | |
{user?.user_metadata?.full_name} | |
</div> | |
<div className={'text-sm leading-5 text-muted-foreground w-full overflow-hidden text-ellipsis'}> | |
{user?.email} | |
</div> | |
</div> | |
<div> | |
<LogOut onClick={handleLogout} className={'h-6 w-6 text-muted-foreground cursor-pointer'} /> | |
</div> | |
</div> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/layout/dashboard-page-header.tsx | |
================================================ | |
import { Separator } from '@/components/ui/separator'; | |
import { MobileSidebar } from '@/components/dashboard/layout/mobile-sidebar'; | |
interface Props { | |
pageTitle: string; | |
} | |
export function DashboardPageHeader({ pageTitle }: Props) { | |
return ( | |
<div> | |
<div className={'flex items-center gap-6'}> | |
<MobileSidebar /> | |
<h1 className="text-lg font-semibold md:text-4xl">{pageTitle}</h1> | |
</div> | |
<Separator className={'relative bg-border my-8 dashboard-header-highlight'} /> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/layout/mobile-sidebar.tsx | |
================================================ | |
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; | |
import { Button } from '@/components/ui/button'; | |
import { Menu } from 'lucide-react'; | |
import { Sidebar } from '@/components/dashboard/layout/sidebar'; | |
import { SidebarUserInfo } from '@/components/dashboard/layout/sidebar-user-info'; | |
export function MobileSidebar() { | |
return ( | |
<Sheet> | |
<SheetTrigger asChild> | |
<Button variant="ghost" size="icon" className="shrink-0 md:hidden"> | |
<Menu className="h-5 w-5" /> | |
<span className="sr-only">Toggle navigation menu</span> | |
</Button> | |
</SheetTrigger> | |
<SheetContent side="left" className="flex flex-col"> | |
<Sidebar /> | |
<SidebarUserInfo /> | |
</SheetContent> | |
</Sheet> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/layout/loading-screen.tsx | |
================================================ | |
import { LoaderIcon } from 'lucide-react'; | |
export function LoadingScreen() { | |
return ( | |
<div className="flex items-center flex-col h-screen w-full mt-[100px]"> | |
<LoaderIcon className="animate-spin" /> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/dashboard/layout/error-content.tsx | |
================================================ | |
export function ErrorContent() { | |
return <div className={'text-center'}>Something went wrong, please try again later.</div>; | |
} | |
================================================ | |
File: /src/components/shared/confirmation/confirmation.tsx | |
================================================ | |
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; | |
import { ReactNode } from 'react'; | |
import { DialogBody } from 'next/dist/client/components/react-dev-overlay/internal/components/Dialog'; | |
import { Button } from '@/components/ui/button'; | |
interface Props { | |
isOpen: boolean; | |
title: ReactNode; | |
description: ReactNode; | |
onClose: (open: boolean) => void; | |
onConfirm: () => void; | |
} | |
export function Confirmation({ isOpen, onClose, title, description, onConfirm }: Props) { | |
return ( | |
<Dialog open={isOpen} onOpenChange={onClose}> | |
<DialogContent> | |
<DialogHeader> | |
<DialogTitle>{title}</DialogTitle> | |
</DialogHeader> | |
<DialogBody> | |
<div className={'flex flex-col gap-6'}> | |
<DialogDescription>{description}</DialogDescription> | |
<div className={'flex gap-4 items-center justify-end w-full'}> | |
<Button onClick={() => onClose(false)} variant={'outline'}> | |
Close | |
</Button> | |
<Button onClick={() => onConfirm()} variant={'destructive'}> | |
Cancel subscription | |
</Button> | |
</div> | |
</div> | |
</DialogBody> | |
</DialogContent> | |
</Dialog> | |
); | |
} | |
================================================ | |
File: /src/components/shared/select/select.tsx | |
================================================ | |
import { Select as ShadCnSelect, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; | |
interface Props { | |
value: string; | |
options: string[]; | |
onChange: (value: string) => void; | |
} | |
export function Select({ onChange, options, value }: Props) { | |
return ( | |
<ShadCnSelect onValueChange={onChange} value={value}> | |
<SelectTrigger className="w-full"> | |
<SelectValue defaultValue={value} /> | |
</SelectTrigger> | |
<SelectContent> | |
{options.map((option) => ( | |
<SelectItem key={option} value={option}> | |
{option} | |
</SelectItem> | |
))} | |
</SelectContent> | |
</ShadCnSelect> | |
); | |
} | |
================================================ | |
File: /src/components/shared/toggle/toggle.tsx | |
================================================ | |
import { BillingFrequency, IBillingFrequency } from '@/constants/billing-frequency'; | |
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; | |
interface Props { | |
frequency: IBillingFrequency; | |
setFrequency: (frequency: IBillingFrequency) => void; | |
} | |
export function Toggle({ setFrequency, frequency }: Props) { | |
return ( | |
<div className="flex justify-center mb-8"> | |
<Tabs | |
value={frequency.value} | |
onValueChange={(value) => | |
setFrequency(BillingFrequency.find((billingFrequency) => value === billingFrequency.value)!) | |
} | |
> | |
<TabsList> | |
{BillingFrequency.map((billingFrequency) => ( | |
<TabsTrigger key={billingFrequency.value} value={billingFrequency.value}> | |
{billingFrequency.label} | |
</TabsTrigger> | |
))} | |
</TabsList> | |
</Tabs> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/shared/status/status.tsx | |
================================================ | |
import { Check, CircleMinus, Clock4, Pause, SquarePen } from 'lucide-react'; | |
import { ReactNode } from 'react'; | |
interface Props { | |
status: string; | |
} | |
interface StatusInfo { | |
[key: string]: { color: string; icon: ReactNode; text: string }; | |
} | |
// Ensure that any new colors are added to `safelist` in tailwind.config.js | |
const StatusInfo: StatusInfo = { | |
active: { color: '#25F497', icon: <Check size={16} />, text: 'Active' }, | |
paid: { color: '#25F497', icon: <Check size={16} />, text: 'Paid' }, | |
completed: { color: '#25F497', icon: <Check size={16} />, text: 'Completed' }, | |
trialing: { color: '#E0E0EB', icon: <Clock4 size={16} />, text: 'Trialing' }, | |
draft: { color: '#797C7C', icon: <SquarePen size={16} />, text: 'Draft' }, | |
ready: { color: '#797C7C', icon: <SquarePen size={16} />, text: 'Ready' }, | |
canceled: { color: '#797C7C', icon: <CircleMinus size={16} />, text: 'Canceled' }, | |
inactive: { color: '#F42566', icon: <CircleMinus size={16} />, text: 'Inactive' }, | |
past_due: { color: '#F42566', icon: <Clock4 size={16} />, text: 'Past due' }, | |
paused: { color: '#F79636', icon: <Pause size={16} />, text: 'Paused' }, | |
billed: { color: '#F79636', icon: <Clock4 size={16} />, text: 'Unpaid invoice' }, | |
}; | |
export function Status({ status }: Props) { | |
const { color, icon, text } = StatusInfo[status] ?? { text: status }; | |
return ( | |
<div | |
className={`self-end flex items-center gap-2 border rounded-xxs border-border py-1 px-2 text-[${color}] w-fit @4xs:text-nowrap text-wrap`} | |
> | |
{icon} | |
{text} | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/ui/toaster.tsx | |
================================================ | |
'use client'; | |
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from '@/components/ui/toast'; | |
import { useToast } from '@/components/ui/use-toast'; | |
export function Toaster() { | |
const { toasts } = useToast(); | |
return ( | |
<ToastProvider> | |
{toasts.map(function ({ id, title, description, action, ...props }) { | |
return ( | |
<Toast key={id} {...props}> | |
<div className="grid gap-1"> | |
{title && <ToastTitle>{title}</ToastTitle>} | |
{description && <ToastDescription>{description}</ToastDescription>} | |
</div> | |
{action} | |
<ToastClose /> | |
</Toast> | |
); | |
})} | |
<ToastViewport /> | |
</ToastProvider> | |
); | |
} | |
================================================ | |
File: /src/components/ui/alert.tsx | |
================================================ | |
import * as React from 'react'; | |
import { cva, type VariantProps } from 'class-variance-authority'; | |
import { cn } from '@/lib/utils'; | |
const alertVariants = cva( | |
'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', | |
{ | |
variants: { | |
variant: { | |
default: 'bg-background text-foreground', | |
destructive: 'border-[#F42566] text-primary bg-[#1A040B]', | |
}, | |
}, | |
defaultVariants: { | |
variant: 'default', | |
}, | |
}, | |
); | |
const Alert = React.forwardRef< | |
HTMLDivElement, | |
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> | |
>(({ className, variant, ...props }, ref) => ( | |
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} /> | |
)); | |
Alert.displayName = 'Alert'; | |
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( | |
({ className, ...props }, ref) => ( | |
<h5 ref={ref} className={cn('mb-1 font-medium leading-none tracking-tight', className)} {...props} /> | |
), | |
); | |
AlertTitle.displayName = 'AlertTitle'; | |
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>( | |
({ className, ...props }, ref) => ( | |
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} /> | |
), | |
); | |
AlertDescription.displayName = 'AlertDescription'; | |
export { Alert, AlertTitle, AlertDescription }; | |
================================================ | |
File: /src/components/ui/input.tsx | |
================================================ | |
import * as React from 'react'; | |
import { cn } from '@/lib/utils'; | |
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {} | |
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => { | |
return ( | |
<input | |
type={type} | |
className={cn( | |
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', | |
className, | |
)} | |
ref={ref} | |
{...props} | |
/> | |
); | |
}); | |
Input.displayName = 'Input'; | |
export { Input }; | |
================================================ | |
File: /src/components/ui/accordion.tsx | |
================================================ | |
'use client'; | |
import * as React from 'react'; | |
import * as AccordionPrimitive from '@radix-ui/react-accordion'; | |
import { ChevronDown } from 'lucide-react'; | |
import { cn } from '@/lib/utils'; | |
const Accordion = AccordionPrimitive.Root; | |
const AccordionItem = React.forwardRef< | |
React.ElementRef<typeof AccordionPrimitive.Item>, | |
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> | |
>(({ className, ...props }, ref) => ( | |
<AccordionPrimitive.Item ref={ref} className={cn('border-b', className)} {...props} /> | |
)); | |
AccordionItem.displayName = 'AccordionItem'; | |
const AccordionTrigger = React.forwardRef< | |
React.ElementRef<typeof AccordionPrimitive.Trigger>, | |
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> | |
>(({ className, children, ...props }, ref) => ( | |
<AccordionPrimitive.Header className="flex"> | |
<AccordionPrimitive.Trigger | |
ref={ref} | |
className={cn( | |
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180', | |
className, | |
)} | |
{...props} | |
> | |
{children} | |
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" /> | |
</AccordionPrimitive.Trigger> | |
</AccordionPrimitive.Header> | |
)); | |
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; | |
const AccordionContent = React.forwardRef< | |
React.ElementRef<typeof AccordionPrimitive.Content>, | |
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> | |
>(({ className, children, ...props }, ref) => ( | |
<AccordionPrimitive.Content | |
ref={ref} | |
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" | |
{...props} | |
> | |
<div className={cn('pb-4 pt-0', className)}>{children}</div> | |
</AccordionPrimitive.Content> | |
)); | |
AccordionContent.displayName = AccordionPrimitive.Content.displayName; | |
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; | |
================================================ | |
File: /src/components/ui/table.tsx | |
================================================ | |
import * as React from 'react'; | |
import { cn } from '@/lib/utils'; | |
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>( | |
({ className, ...props }, ref) => ( | |
<div className="relative w-full overflow-auto"> | |
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} /> | |
</div> | |
), | |
); | |
Table.displayName = 'Table'; | |
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>( | |
({ className, ...props }, ref) => <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />, | |
); | |
TableHeader.displayName = 'TableHeader'; | |
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>( | |
({ className, ...props }, ref) => ( | |
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} /> | |
), | |
); | |
TableBody.displayName = 'TableBody'; | |
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>( | |
({ className, ...props }, ref) => ( | |
<tfoot ref={ref} className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)} {...props} /> | |
), | |
); | |
TableFooter.displayName = 'TableFooter'; | |
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>( | |
({ className, ...props }, ref) => ( | |
<tr | |
ref={ref} | |
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)} | |
{...props} | |
/> | |
), | |
); | |
TableRow.displayName = 'TableRow'; | |
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>( | |
({ className, ...props }, ref) => ( | |
<th | |
ref={ref} | |
className={cn( | |
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0', | |
className, | |
)} | |
{...props} | |
/> | |
), | |
); | |
TableHead.displayName = 'TableHead'; | |
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>( | |
({ className, ...props }, ref) => ( | |
<td ref={ref} className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} /> | |
), | |
); | |
TableCell.displayName = 'TableCell'; | |
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>( | |
({ className, ...props }, ref) => ( | |
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} /> | |
), | |
); | |
TableCaption.displayName = 'TableCaption'; | |
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; | |
================================================ | |
File: /src/components/ui/label.tsx | |
================================================ | |
'use client'; | |
import * as React from 'react'; | |
import * as LabelPrimitive from '@radix-ui/react-label'; | |
import { cva, type VariantProps } from 'class-variance-authority'; | |
import { cn } from '@/lib/utils'; | |
const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'); | |
const Label = React.forwardRef< | |
React.ElementRef<typeof LabelPrimitive.Root>, | |
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants> | |
>(({ className, ...props }, ref) => ( | |
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} /> | |
)); | |
Label.displayName = LabelPrimitive.Root.displayName; | |
export { Label }; | |
================================================ | |
File: /src/components/ui/dropdown-menu.tsx | |
================================================ | |
'use client'; | |
import * as React from 'react'; | |
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; | |
import { Check, ChevronRight, Circle } from 'lucide-react'; | |
import { cn } from '@/lib/utils'; | |
const DropdownMenu = DropdownMenuPrimitive.Root; | |
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; | |
const DropdownMenuGroup = DropdownMenuPrimitive.Group; | |
const DropdownMenuPortal = DropdownMenuPrimitive.Portal; | |
const DropdownMenuSub = DropdownMenuPrimitive.Sub; | |
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; | |
const DropdownMenuSubTrigger = React.forwardRef< | |
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, | |
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { | |
inset?: boolean; | |
} | |
>(({ className, inset, children, ...props }, ref) => ( | |
<DropdownMenuPrimitive.SubTrigger | |
ref={ref} | |
className={cn( | |
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent', | |
inset && 'pl-8', | |
className, | |
)} | |
{...props} | |
> | |
{children} | |
<ChevronRight className="ml-auto h-4 w-4" /> | |
</DropdownMenuPrimitive.SubTrigger> | |
)); | |
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; | |
const DropdownMenuSubContent = React.forwardRef< | |
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, | |
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> | |
>(({ className, ...props }, ref) => ( | |
<DropdownMenuPrimitive.SubContent | |
ref={ref} | |
className={cn( | |
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', | |
className, | |
)} | |
{...props} | |
/> | |
)); | |
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; | |
const DropdownMenuContent = React.forwardRef< | |
React.ElementRef<typeof DropdownMenuPrimitive.Content>, | |
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> | |
>(({ className, sideOffset = 4, ...props }, ref) => ( | |
<DropdownMenuPrimitive.Portal> | |
<DropdownMenuPrimitive.Content | |
ref={ref} | |
sideOffset={sideOffset} | |
className={cn( | |
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', | |
className, | |
)} | |
{...props} | |
/> | |
</DropdownMenuPrimitive.Portal> | |
)); | |
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; | |
const DropdownMenuItem = React.forwardRef< | |
React.ElementRef<typeof DropdownMenuPrimitive.Item>, | |
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { | |
inset?: boolean; | |
} | |
>(({ className, inset, ...props }, ref) => ( | |
<DropdownMenuPrimitive.Item | |
ref={ref} | |
className={cn( | |
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', | |
inset && 'pl-8', | |
className, | |
)} | |
{...props} | |
/> | |
)); | |
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; | |
const DropdownMenuCheckboxItem = React.forwardRef< | |
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, | |
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> | |
>(({ className, children, checked, ...props }, ref) => ( | |
<DropdownMenuPrimitive.CheckboxItem | |
ref={ref} | |
className={cn( | |
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', | |
className, | |
)} | |
checked={checked} | |
{...props} | |
> | |
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> | |
<DropdownMenuPrimitive.ItemIndicator> | |
<Check className="h-4 w-4" /> | |
</DropdownMenuPrimitive.ItemIndicator> | |
</span> | |
{children} | |
</DropdownMenuPrimitive.CheckboxItem> | |
)); | |
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; | |
const DropdownMenuRadioItem = React.forwardRef< | |
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, | |
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> | |
>(({ className, children, ...props }, ref) => ( | |
<DropdownMenuPrimitive.RadioItem | |
ref={ref} | |
className={cn( | |
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', | |
className, | |
)} | |
{...props} | |
> | |
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> | |
<DropdownMenuPrimitive.ItemIndicator> | |
<Circle className="h-2 w-2 fill-current" /> | |
</DropdownMenuPrimitive.ItemIndicator> | |
</span> | |
{children} | |
</DropdownMenuPrimitive.RadioItem> | |
)); | |
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; | |
const DropdownMenuLabel = React.forwardRef< | |
React.ElementRef<typeof DropdownMenuPrimitive.Label>, | |
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { | |
inset?: boolean; | |
} | |
>(({ className, inset, ...props }, ref) => ( | |
<DropdownMenuPrimitive.Label | |
ref={ref} | |
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)} | |
{...props} | |
/> | |
)); | |
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; | |
const DropdownMenuSeparator = React.forwardRef< | |
React.ElementRef<typeof DropdownMenuPrimitive.Separator>, | |
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> | |
>(({ className, ...props }, ref) => ( | |
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} /> | |
)); | |
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; | |
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => { | |
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />; | |
}; | |
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; | |
export { | |
DropdownMenu, | |
DropdownMenuTrigger, | |
DropdownMenuContent, | |
DropdownMenuItem, | |
DropdownMenuCheckboxItem, | |
DropdownMenuRadioItem, | |
DropdownMenuLabel, | |
DropdownMenuSeparator, | |
DropdownMenuShortcut, | |
DropdownMenuGroup, | |
DropdownMenuPortal, | |
DropdownMenuSub, | |
DropdownMenuSubContent, | |
DropdownMenuSubTrigger, | |
DropdownMenuRadioGroup, | |
}; | |
================================================ | |
File: /src/components/ui/toast.tsx | |
================================================ | |
'use client'; | |
import * as React from 'react'; | |
import * as ToastPrimitives from '@radix-ui/react-toast'; | |
import { cva, type VariantProps } from 'class-variance-authority'; | |
import { X } from 'lucide-react'; | |
import { cn } from '@/lib/utils'; | |
const ToastProvider = ToastPrimitives.Provider; | |
const ToastViewport = React.forwardRef< | |
React.ElementRef<typeof ToastPrimitives.Viewport>, | |
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> | |
>(({ className, ...props }, ref) => ( | |
<ToastPrimitives.Viewport | |
ref={ref} | |
className={cn( | |
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]', | |
className, | |
)} | |
{...props} | |
/> | |
)); | |
ToastViewport.displayName = ToastPrimitives.Viewport.displayName; | |
const toastVariants = cva( | |
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', | |
{ | |
variants: { | |
variant: { | |
default: 'border bg-background text-foreground backdrop-blur-[24px]', | |
destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground', | |
}, | |
}, | |
defaultVariants: { | |
variant: 'default', | |
}, | |
}, | |
); | |
const Toast = React.forwardRef< | |
React.ElementRef<typeof ToastPrimitives.Root>, | |
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants> | |
>(({ className, variant, ...props }, ref) => { | |
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />; | |
}); | |
Toast.displayName = ToastPrimitives.Root.displayName; | |
const ToastAction = React.forwardRef< | |
React.ElementRef<typeof ToastPrimitives.Action>, | |
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> | |
>(({ className, ...props }, ref) => ( | |
<ToastPrimitives.Action | |
ref={ref} | |
className={cn( | |
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive', | |
className, | |
)} | |
{...props} | |
/> | |
)); | |
ToastAction.displayName = ToastPrimitives.Action.displayName; | |
const ToastClose = React.forwardRef< | |
React.ElementRef<typeof ToastPrimitives.Close>, | |
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> | |
>(({ className, ...props }, ref) => ( | |
<ToastPrimitives.Close | |
ref={ref} | |
className={cn( | |
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600', | |
className, | |
)} | |
toast-close="" | |
{...props} | |
> | |
<X className="h-4 w-4" /> | |
</ToastPrimitives.Close> | |
)); | |
ToastClose.displayName = ToastPrimitives.Close.displayName; | |
const ToastTitle = React.forwardRef< | |
React.ElementRef<typeof ToastPrimitives.Title>, | |
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> | |
>(({ className, ...props }, ref) => ( | |
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} /> | |
)); | |
ToastTitle.displayName = ToastPrimitives.Title.displayName; | |
const ToastDescription = React.forwardRef< | |
React.ElementRef<typeof ToastPrimitives.Description>, | |
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> | |
>(({ className, ...props }, ref) => ( | |
<ToastPrimitives.Description ref={ref} className={cn('text-sm opacity-90', className)} {...props} /> | |
)); | |
ToastDescription.displayName = ToastPrimitives.Description.displayName; | |
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>; | |
type ToastActionElement = React.ReactElement<typeof ToastAction>; | |
export { | |
type ToastProps, | |
type ToastActionElement, | |
ToastProvider, | |
ToastViewport, | |
Toast, | |
ToastTitle, | |
ToastDescription, | |
ToastClose, | |
ToastAction, | |
}; | |
================================================ | |
File: /src/components/ui/use-toast.ts | |
================================================ | |
'use client'; | |
// Inspired by react-hot-toast library | |
import * as React from 'react'; | |
import type { ToastActionElement, ToastProps } from '@/components/ui/toast'; | |
const TOAST_LIMIT = 1; | |
const TOAST_REMOVE_DELAY = 1000000; | |
type ToasterToast = ToastProps & { | |
id: string; | |
title?: React.ReactNode; | |
description?: React.ReactNode; | |
action?: ToastActionElement; | |
}; | |
const actionTypes = { | |
ADD_TOAST: 'ADD_TOAST', | |
UPDATE_TOAST: 'UPDATE_TOAST', | |
DISMISS_TOAST: 'DISMISS_TOAST', | |
REMOVE_TOAST: 'REMOVE_TOAST', | |
} as const; | |
let count = 0; | |
function genId() { | |
count = (count + 1) % Number.MAX_SAFE_INTEGER; | |
return count.toString(); | |
} | |
type ActionType = typeof actionTypes; | |
type Action = | |
| { | |
type: ActionType['ADD_TOAST']; | |
toast: ToasterToast; | |
} | |
| { | |
type: ActionType['UPDATE_TOAST']; | |
toast: Partial<ToasterToast>; | |
} | |
| { | |
type: ActionType['DISMISS_TOAST']; | |
toastId?: ToasterToast['id']; | |
} | |
| { | |
type: ActionType['REMOVE_TOAST']; | |
toastId?: ToasterToast['id']; | |
}; | |
interface State { | |
toasts: ToasterToast[]; | |
} | |
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>(); | |
const addToRemoveQueue = (toastId: string) => { | |
if (toastTimeouts.has(toastId)) { | |
return; | |
} | |
const timeout = setTimeout(() => { | |
toastTimeouts.delete(toastId); | |
dispatch({ | |
type: 'REMOVE_TOAST', | |
toastId: toastId, | |
}); | |
}, TOAST_REMOVE_DELAY); | |
toastTimeouts.set(toastId, timeout); | |
}; | |
export const reducer = (state: State, action: Action): State => { | |
switch (action.type) { | |
case 'ADD_TOAST': | |
return { | |
...state, | |
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), | |
}; | |
case 'UPDATE_TOAST': | |
return { | |
...state, | |
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)), | |
}; | |
case 'DISMISS_TOAST': { | |
const { toastId } = action; | |
// ! Side effects ! - This could be extracted into a dismissToast() action, | |
// but I'll keep it here for simplicity | |
if (toastId) { | |
addToRemoveQueue(toastId); | |
} else { | |
state.toasts.forEach((toast) => { | |
addToRemoveQueue(toast.id); | |
}); | |
} | |
return { | |
...state, | |
toasts: state.toasts.map((t) => | |
t.id === toastId || toastId === undefined | |
? { | |
...t, | |
open: false, | |
} | |
: t, | |
), | |
}; | |
} | |
case 'REMOVE_TOAST': | |
if (action.toastId === undefined) { | |
return { | |
...state, | |
toasts: [], | |
}; | |
} | |
return { | |
...state, | |
toasts: state.toasts.filter((t) => t.id !== action.toastId), | |
}; | |
} | |
}; | |
const listeners: Array<(state: State) => void> = []; | |
let memoryState: State = { toasts: [] }; | |
function dispatch(action: Action) { | |
memoryState = reducer(memoryState, action); | |
listeners.forEach((listener) => { | |
listener(memoryState); | |
}); | |
} | |
type Toast = Omit<ToasterToast, 'id'>; | |
function toast({ ...props }: Toast) { | |
const id = genId(); | |
const update = (props: ToasterToast) => | |
dispatch({ | |
type: 'UPDATE_TOAST', | |
toast: { ...props, id }, | |
}); | |
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id }); | |
dispatch({ | |
type: 'ADD_TOAST', | |
toast: { | |
...props, | |
id, | |
open: true, | |
onOpenChange: (open) => { | |
if (!open) dismiss(); | |
}, | |
}, | |
}); | |
return { | |
id: id, | |
dismiss, | |
update, | |
}; | |
} | |
function useToast() { | |
const [state, setState] = React.useState<State>(memoryState); | |
React.useEffect(() => { | |
listeners.push(setState); | |
return () => { | |
const index = listeners.indexOf(setState); | |
if (index > -1) { | |
listeners.splice(index, 1); | |
} | |
}; | |
}, [state]); | |
return { | |
...state, | |
toast, | |
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }), | |
}; | |
} | |
export { useToast, toast }; | |
================================================ | |
File: /src/components/ui/skeleton.tsx | |
================================================ | |
import { cn } from '@/lib/utils'; | |
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { | |
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />; | |
} | |
export { Skeleton }; | |
================================================ | |
File: /src/components/ui/select.tsx | |
================================================ | |
'use client'; | |
import * as React from 'react'; | |
import * as SelectPrimitive from '@radix-ui/react-select'; | |
import { Check, ChevronDown, ChevronUp } from 'lucide-react'; | |
import { cn } from '@/lib/utils'; | |
const Select = SelectPrimitive.Root; | |
const SelectGroup = SelectPrimitive.Group; | |
const SelectValue = SelectPrimitive.Value; | |
const SelectTrigger = React.forwardRef< | |
React.ElementRef<typeof SelectPrimitive.Trigger>, | |
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> | |
>(({ className, children, ...props }, ref) => ( | |
<SelectPrimitive.Trigger | |
ref={ref} | |
className={cn( | |
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1', | |
className, | |
)} | |
{...props} | |
> | |
{children} | |
<SelectPrimitive.Icon asChild> | |
<ChevronDown className="h-4 w-4 opacity-50" /> | |
</SelectPrimitive.Icon> | |
</SelectPrimitive.Trigger> | |
)); | |
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; | |
const SelectScrollUpButton = React.forwardRef< | |
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, | |
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> | |
>(({ className, ...props }, ref) => ( | |
<SelectPrimitive.ScrollUpButton | |
ref={ref} | |
className={cn('flex cursor-default items-center justify-center py-1', className)} | |
{...props} | |
> | |
<ChevronUp className="h-4 w-4" /> | |
</SelectPrimitive.ScrollUpButton> | |
)); | |
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; | |
const SelectScrollDownButton = React.forwardRef< | |
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, | |
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> | |
>(({ className, ...props }, ref) => ( | |
<SelectPrimitive.ScrollDownButton | |
ref={ref} | |
className={cn('flex cursor-default items-center justify-center py-1', className)} | |
{...props} | |
> | |
<ChevronDown className="h-4 w-4" /> | |
</SelectPrimitive.ScrollDownButton> | |
)); | |
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; | |
const SelectContent = React.forwardRef< | |
React.ElementRef<typeof SelectPrimitive.Content>, | |
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> | |
>(({ className, children, position = 'popper', ...props }, ref) => ( | |
<SelectPrimitive.Portal> | |
<SelectPrimitive.Content | |
ref={ref} | |
className={cn( | |
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', | |
position === 'popper' && | |
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', | |
className, | |
)} | |
position={position} | |
{...props} | |
> | |
<SelectScrollUpButton /> | |
<SelectPrimitive.Viewport | |
className={cn( | |
'p-1', | |
position === 'popper' && | |
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]', | |
)} | |
> | |
{children} | |
</SelectPrimitive.Viewport> | |
<SelectScrollDownButton /> | |
</SelectPrimitive.Content> | |
</SelectPrimitive.Portal> | |
)); | |
SelectContent.displayName = SelectPrimitive.Content.displayName; | |
const SelectLabel = React.forwardRef< | |
React.ElementRef<typeof SelectPrimitive.Label>, | |
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> | |
>(({ className, ...props }, ref) => ( | |
<SelectPrimitive.Label ref={ref} className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)} {...props} /> | |
)); | |
SelectLabel.displayName = SelectPrimitive.Label.displayName; | |
const SelectItem = React.forwardRef< | |
React.ElementRef<typeof SelectPrimitive.Item>, | |
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> | |
>(({ className, children, ...props }, ref) => ( | |
<SelectPrimitive.Item | |
ref={ref} | |
className={cn( | |
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', | |
className, | |
)} | |
{...props} | |
> | |
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> | |
<SelectPrimitive.ItemIndicator> | |
<Check className="h-4 w-4" /> | |
</SelectPrimitive.ItemIndicator> | |
</span> | |
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> | |
</SelectPrimitive.Item> | |
)); | |
SelectItem.displayName = SelectPrimitive.Item.displayName; | |
const SelectSeparator = React.forwardRef< | |
React.ElementRef<typeof SelectPrimitive.Separator>, | |
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> | |
>(({ className, ...props }, ref) => ( | |
<SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} /> | |
)); | |
SelectSeparator.displayName = SelectPrimitive.Separator.displayName; | |
export { | |
Select, | |
SelectGroup, | |
SelectValue, | |
SelectTrigger, | |
SelectContent, | |
SelectLabel, | |
SelectItem, | |
SelectSeparator, | |
SelectScrollUpButton, | |
SelectScrollDownButton, | |
}; | |
================================================ | |
File: /src/components/ui/button.tsx | |
================================================ | |
import * as React from 'react'; | |
import { Slot } from '@radix-ui/react-slot'; | |
import { cva, type VariantProps } from 'class-variance-authority'; | |
import { cn } from '@/lib/utils'; | |
const buttonVariants = cva( | |
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-md font-medium ring-offset-background transition-colors focus:ring-ring focus:ring-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', | |
{ | |
variants: { | |
variant: { | |
default: 'bg-primary text-primary-foreground hover:bg-primary/90', | |
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', | |
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', | |
secondary: 'relative bg-[#fcfcfc33] text-white secondary-button-animation disabled:bg-[#191A1A]', | |
ghost: 'hover:bg-accent hover:text-accent-foreground', | |
link: 'text-primary underline-offset-4 hover:underline', | |
}, | |
size: { | |
default: 'h-11 px-5 py-[10px]', | |
sm: 'h-9 text-sm leading-4 rounded-md px-3 py-2', | |
lg: 'h-11 rounded-md px-8', | |
icon: 'h-10 w-10', | |
}, | |
}, | |
defaultVariants: { | |
variant: 'default', | |
size: 'default', | |
}, | |
}, | |
); | |
export interface ButtonProps | |
extends React.ButtonHTMLAttributes<HTMLButtonElement>, | |
VariantProps<typeof buttonVariants> { | |
asChild?: boolean; | |
} | |
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( | |
({ className, variant, size, asChild = false, ...props }, ref) => { | |
const Comp = asChild ? Slot : 'button'; | |
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />; | |
}, | |
); | |
Button.displayName = 'Button'; | |
export { Button, buttonVariants }; | |
================================================ | |
File: /src/components/ui/separator.tsx | |
================================================ | |
'use client'; | |
import * as React from 'react'; | |
import * as SeparatorPrimitive from '@radix-ui/react-separator'; | |
import { cn } from '@/lib/utils'; | |
const Separator = React.forwardRef< | |
React.ElementRef<typeof SeparatorPrimitive.Root>, | |
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> | |
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => ( | |
<SeparatorPrimitive.Root | |
ref={ref} | |
decorative={decorative} | |
orientation={orientation} | |
className={cn('shrink-0 bg-border', orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]', className)} | |
{...props} | |
/> | |
)); | |
Separator.displayName = SeparatorPrimitive.Root.displayName; | |
export { Separator }; | |
================================================ | |
File: /src/components/ui/sheet.tsx | |
================================================ | |
'use client'; | |
import * as React from 'react'; | |
import * as SheetPrimitive from '@radix-ui/react-dialog'; | |
import { cva, type VariantProps } from 'class-variance-authority'; | |
import { X } from 'lucide-react'; | |
import { cn } from '@/lib/utils'; | |
const Sheet = SheetPrimitive.Root; | |
const SheetTrigger = SheetPrimitive.Trigger; | |
const SheetClose = SheetPrimitive.Close; | |
const SheetPortal = SheetPrimitive.Portal; | |
const SheetOverlay = React.forwardRef< | |
React.ElementRef<typeof SheetPrimitive.Overlay>, | |
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay> | |
>(({ className, ...props }, ref) => ( | |
<SheetPrimitive.Overlay | |
className={cn( | |
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', | |
className, | |
)} | |
{...props} | |
ref={ref} | |
/> | |
)); | |
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; | |
const sheetVariants = cva( | |
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', | |
{ | |
variants: { | |
side: { | |
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', | |
bottom: | |
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', | |
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', | |
right: | |
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', | |
}, | |
}, | |
defaultVariants: { | |
side: 'right', | |
}, | |
}, | |
); | |
interface SheetContentProps | |
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, | |
VariantProps<typeof sheetVariants> {} | |
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>( | |
({ side = 'right', className, children, ...props }, ref) => ( | |
<SheetPortal> | |
<SheetOverlay /> | |
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}> | |
{children} | |
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> | |
<X className="h-4 w-4" /> | |
<span className="sr-only">Close</span> | |
</SheetPrimitive.Close> | |
</SheetPrimitive.Content> | |
</SheetPortal> | |
), | |
); | |
SheetContent.displayName = SheetPrimitive.Content.displayName; | |
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( | |
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} /> | |
); | |
SheetHeader.displayName = 'SheetHeader'; | |
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( | |
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} /> | |
); | |
SheetFooter.displayName = 'SheetFooter'; | |
const SheetTitle = React.forwardRef< | |
React.ElementRef<typeof SheetPrimitive.Title>, | |
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> | |
>(({ className, ...props }, ref) => ( | |
<SheetPrimitive.Title ref={ref} className={cn('text-lg font-semibold text-foreground', className)} {...props} /> | |
)); | |
SheetTitle.displayName = SheetPrimitive.Title.displayName; | |
const SheetDescription = React.forwardRef< | |
React.ElementRef<typeof SheetPrimitive.Description>, | |
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> | |
>(({ className, ...props }, ref) => ( | |
<SheetPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} /> | |
)); | |
SheetDescription.displayName = SheetPrimitive.Description.displayName; | |
export { | |
Sheet, | |
SheetPortal, | |
SheetOverlay, | |
SheetTrigger, | |
SheetClose, | |
SheetContent, | |
SheetHeader, | |
SheetFooter, | |
SheetTitle, | |
SheetDescription, | |
}; | |
================================================ | |
File: /src/components/ui/dialog.tsx | |
================================================ | |
'use client'; | |
import * as React from 'react'; | |
import * as DialogPrimitive from '@radix-ui/react-dialog'; | |
import { X } from 'lucide-react'; | |
import { cn } from '@/lib/utils'; | |
const Dialog = DialogPrimitive.Root; | |
const DialogTrigger = DialogPrimitive.Trigger; | |
const DialogPortal = DialogPrimitive.Portal; | |
const DialogClose = DialogPrimitive.Close; | |
const DialogOverlay = React.forwardRef< | |
React.ElementRef<typeof DialogPrimitive.Overlay>, | |
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> | |
>(({ className, ...props }, ref) => ( | |
<DialogPrimitive.Overlay | |
ref={ref} | |
className={cn( | |
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', | |
className, | |
)} | |
{...props} | |
/> | |
)); | |
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; | |
const DialogContent = React.forwardRef< | |
React.ElementRef<typeof DialogPrimitive.Content>, | |
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> | |
>(({ className, children, ...props }, ref) => ( | |
<DialogPortal> | |
<DialogOverlay /> | |
<DialogPrimitive.Content | |
ref={ref} | |
className={cn( | |
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', | |
className, | |
)} | |
{...props} | |
> | |
{children} | |
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> | |
<X className="h-4 w-4" /> | |
<span className="sr-only">Close</span> | |
</DialogPrimitive.Close> | |
</DialogPrimitive.Content> | |
</DialogPortal> | |
)); | |
DialogContent.displayName = DialogPrimitive.Content.displayName; | |
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( | |
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} /> | |
); | |
DialogHeader.displayName = 'DialogHeader'; | |
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( | |
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} /> | |
); | |
DialogFooter.displayName = 'DialogFooter'; | |
const DialogTitle = React.forwardRef< | |
React.ElementRef<typeof DialogPrimitive.Title>, | |
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> | |
>(({ className, ...props }, ref) => ( | |
<DialogPrimitive.Title | |
ref={ref} | |
className={cn('text-lg font-semibold leading-none tracking-tight', className)} | |
{...props} | |
/> | |
)); | |
DialogTitle.displayName = DialogPrimitive.Title.displayName; | |
const DialogDescription = React.forwardRef< | |
React.ElementRef<typeof DialogPrimitive.Description>, | |
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> | |
>(({ className, ...props }, ref) => ( | |
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} /> | |
)); | |
DialogDescription.displayName = DialogPrimitive.Description.displayName; | |
export { | |
Dialog, | |
DialogPortal, | |
DialogOverlay, | |
DialogClose, | |
DialogTrigger, | |
DialogContent, | |
DialogHeader, | |
DialogFooter, | |
DialogTitle, | |
DialogDescription, | |
}; | |
================================================ | |
File: /src/components/ui/card.tsx | |
================================================ | |
import * as React from 'react'; | |
import { cn } from '@/lib/utils'; | |
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => ( | |
<div ref={ref} className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)} {...props} /> | |
)); | |
Card.displayName = 'Card'; | |
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( | |
({ className, ...props }, ref) => ( | |
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} /> | |
), | |
); | |
CardHeader.displayName = 'CardHeader'; | |
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( | |
({ className, ...props }, ref) => ( | |
<h3 ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} /> | |
), | |
); | |
CardTitle.displayName = 'CardTitle'; | |
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>( | |
({ className, ...props }, ref) => ( | |
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} /> | |
), | |
); | |
CardDescription.displayName = 'CardDescription'; | |
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( | |
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />, | |
); | |
CardContent.displayName = 'CardContent'; | |
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( | |
({ className, ...props }, ref) => ( | |
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} /> | |
), | |
); | |
CardFooter.displayName = 'CardFooter'; | |
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; | |
================================================ | |
File: /src/components/ui/tabs.tsx | |
================================================ | |
'use client'; | |
import * as React from 'react'; | |
import * as TabsPrimitive from '@radix-ui/react-tabs'; | |
import { cn } from '@/lib/utils'; | |
const Tabs = TabsPrimitive.Root; | |
const TabsList = React.forwardRef< | |
React.ElementRef<typeof TabsPrimitive.List>, | |
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> | |
>(({ className, ...props }, ref) => ( | |
<TabsPrimitive.List | |
ref={ref} | |
className={cn( | |
'inline-flex h-14 items-center gap-[8px] justify-center rounded-sm bg-background px-[6px] py-[6px] text-muted-foreground border-border border', | |
className, | |
)} | |
{...props} | |
/> | |
)); | |
TabsList.displayName = TabsPrimitive.List.displayName; | |
const TabsTrigger = React.forwardRef< | |
React.ElementRef<typeof TabsPrimitive.Trigger>, | |
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> | |
>(({ className, ...props }, ref) => ( | |
<TabsPrimitive.Trigger | |
ref={ref} | |
className={cn( | |
'inline-flex items-center justify-center whitespace-nowrap rounded-xs h-11 px-5 py-[10px] text-md font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-[#182222] data-[state=active]:text-foreground data-[state=active]:shadow-sm', | |
className, | |
)} | |
{...props} | |
/> | |
)); | |
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; | |
const TabsContent = React.forwardRef< | |
React.ElementRef<typeof TabsPrimitive.Content>, | |
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> | |
>(({ className, ...props }, ref) => ( | |
<TabsPrimitive.Content | |
ref={ref} | |
className={cn( | |
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', | |
className, | |
)} | |
{...props} | |
/> | |
)); | |
TabsContent.displayName = TabsPrimitive.Content.displayName; | |
export { Tabs, TabsList, TabsTrigger, TabsContent }; | |
================================================ | |
File: /src/components/authentication/sign-up-form.tsx | |
================================================ | |
'use client'; | |
import Image from 'next/image'; | |
import { Button } from '@/components/ui/button'; | |
import { useState } from 'react'; | |
import { AuthenticationForm } from '@/components/authentication/authentication-form'; | |
import { signup } from '@/app/signup/actions'; | |
import { useToast } from '@/components/ui/use-toast'; | |
export function SignupForm() { | |
const { toast } = useToast(); | |
const [email, setEmail] = useState(''); | |
const [password, setPassword] = useState(''); | |
function handleSignup() { | |
signup({ email, password }).then((data) => { | |
if (data?.error) { | |
toast({ description: 'Something went wrong. Please try again', variant: 'destructive' }); | |
} | |
}); | |
} | |
return ( | |
<form action={'#'} className={'px-6 md:px-16 pb-6 py-8 gap-6 flex flex-col items-center justify-center'}> | |
<Image src={'/assets/icons/logo/aeroedit-icon.svg'} alt={'AeroEdit'} width={80} height={80} /> | |
<div className={'text-[30px] leading-[36px] font-medium tracking-[-0.6px] text-center'}>Create an account</div> | |
<AuthenticationForm | |
email={email} | |
onEmailChange={(email) => setEmail(email)} | |
password={password} | |
onPasswordChange={(password) => setPassword(password)} | |
/> | |
<Button formAction={() => handleSignup()} type={'submit'} variant={'secondary'} className={'w-full'}> | |
Sign up | |
</Button> | |
</form> | |
); | |
} | |
================================================ | |
File: /src/components/authentication/authentication-form.tsx | |
================================================ | |
import { Label } from '@/components/ui/label'; | |
import { Input } from '@/components/ui/input'; | |
interface Props { | |
email: string; | |
password: string; | |
onEmailChange: (email: string) => void; | |
onPasswordChange: (password: string) => void; | |
} | |
export function AuthenticationForm({ email, onEmailChange, onPasswordChange, password }: Props) { | |
return ( | |
<> | |
<div className="grid w-full max-w-sm items-center gap-1.5 mt-2"> | |
<Label className={'text-muted-foreground leading-2'} htmlFor="email"> | |
Email address | |
</Label> | |
<Input | |
className={'border-border rounded-xs'} | |
type="email" | |
id="email" | |
autoComplete={'username'} | |
value={email} | |
onChange={(e) => onEmailChange(e.target.value)} | |
/> | |
</div> | |
<div className="grid w-full max-w-sm items-center gap-1.5"> | |
<Label className={'text-muted-foreground leading-2'} htmlFor="password"> | |
Password | |
</Label> | |
<Input | |
className={'border-border rounded-xs'} | |
type="password" | |
id="password" | |
autoComplete="current-password" | |
value={password} | |
onChange={(e) => onPasswordChange(e.target.value)} | |
/> | |
</div> | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/authentication/gh-login-button.tsx | |
================================================ | |
'use client'; | |
import { Separator } from '@/components/ui/separator'; | |
import { Button } from '@/components/ui/button'; | |
import { signInWithGithub } from '@/app/login/actions'; | |
import Image from 'next/image'; | |
interface Props { | |
label: string; | |
} | |
export function GhLoginButton({ label }: Props) { | |
return ( | |
<div | |
className={ | |
'mx-auto w-[343px] md:w-[488px] bg-background/80 backdrop-blur-[6px] px-6 md:px-16 pt-0 py-8 gap-6 flex flex-col items-center justify-center rounded-b-lg' | |
} | |
> | |
<div className={'flex w-full items-center justify-center'}> | |
<Separator className={'w-5/12 bg-border'} /> | |
<div className={'text-border text-xs font-medium px-4'}>or</div> | |
<Separator className={'w-5/12 bg-border'} /> | |
</div> | |
<Button onClick={() => signInWithGithub()} variant={'secondary'} className={'w-full'}> | |
<Image | |
height="24" | |
className={'mr-3'} | |
width="24" | |
src="https://cdn.simpleicons.org/github/878989" | |
unoptimized={true} | |
alt={'GitHub logo'} | |
/> | |
{label} | |
</Button> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/authentication/login-form.tsx | |
================================================ | |
'use client'; | |
import Image from 'next/image'; | |
import { Button } from '@/components/ui/button'; | |
import { login, loginAnonymously } from '@/app/login/actions'; | |
import { useState } from 'react'; | |
import { AuthenticationForm } from '@/components/authentication/authentication-form'; | |
import { Separator } from '@/components/ui/separator'; | |
import { useToast } from '@/components/ui/use-toast'; | |
export function LoginForm() { | |
const { toast } = useToast(); | |
const [email, setEmail] = useState(''); | |
const [password, setPassword] = useState(''); | |
function handleLogin() { | |
login({ email, password }).then((data) => { | |
if (data?.error) { | |
toast({ description: 'Invalid email or password', variant: 'destructive' }); | |
} | |
}); | |
} | |
function handleAnonymousLogin() { | |
loginAnonymously().then((data) => { | |
if (data?.error) { | |
toast({ description: 'Something went wrong. Please try again', variant: 'destructive' }); | |
} | |
}); | |
} | |
return ( | |
<form action={'#'} className={'px-6 md:px-16 pb-6 py-8 gap-6 flex flex-col items-center justify-center'}> | |
<Image src={'/assets/icons/logo/aeroedit-icon.svg'} alt={'AeroEdit'} width={80} height={80} /> | |
<div className={'text-[30px] leading-[36px] font-medium tracking-[-0.6px] text-center'}> | |
Log in to your account | |
</div> | |
<Button onClick={() => handleAnonymousLogin()} type={'button'} variant={'secondary'} className={'w-full mt-6'}> | |
Log in as Guest | |
</Button> | |
<div className={'flex w-full items-center justify-center'}> | |
<Separator className={'w-5/12 bg-border'} /> | |
<div className={'text-border text-xs font-medium px-4'}>or</div> | |
<Separator className={'w-5/12 bg-border'} /> | |
</div> | |
<AuthenticationForm | |
email={email} | |
onEmailChange={(email) => setEmail(email)} | |
password={password} | |
onPasswordChange={(password) => setPassword(password)} | |
/> | |
<Button formAction={() => handleLogin()} type={'submit'} variant={'secondary'} className={'w-full'}> | |
Log in | |
</Button> | |
</form> | |
); | |
} | |
================================================ | |
File: /src/components/gradients/featured-card-gradient.tsx | |
================================================ | |
export function FeaturedCardGradient() { | |
return ( | |
<> | |
<div className={'featured-yellow-highlight-bg'} /> | |
<div className={'featured-hard-blur-bg'} /> | |
<div className={'featured-vertical-hard-blur-bg'} /> | |
<div className={'featured-soft-blur-bg'} /> | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/gradients/login-gradient.tsx | |
================================================ | |
export function LoginGradient() { | |
return ( | |
<div> | |
<div className={'login-background-base login-gradient-background min-h-screen md:min-h-[919px]'}></div> | |
<div className={'login-background-base grain-background min-h-screen md:min-h-[919px]'}></div> | |
<div className={'login-background-base grid-bg min-h-screen md:min-h-[919px]'}></div> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/gradients/checkout-form-gradients.tsx | |
================================================ | |
export function CheckoutFormGradients() { | |
return ( | |
<> | |
<div className={'hidden md:block'}> | |
<div className={'checkout-yellow-highlight'} /> | |
<div className={'checkout-hard-blur'} /> | |
<div className={'checkout-soft-blur'} /> | |
</div> | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/gradients/login-card-gradient.tsx | |
================================================ | |
export function LoginCardGradient() { | |
return ( | |
<> | |
<div className={'login-background-base login-card-hard-blur'} /> | |
<div className={'login-background-base login-card-vertical-hard-blur'} /> | |
<div | |
className={ | |
'login-background-base login-card-small-soft-blur md:login-card-medium-soft-blur login-card-soft-blur' | |
} | |
/> | |
<div | |
className={ | |
'login-background-base login-card-yellow-highlight login-card-small-yellow-highlight md:login-card-medium-yellow-highlight' | |
} | |
/> | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/gradients/home-page-background.tsx | |
================================================ | |
export function HomePageBackground() { | |
return ( | |
<> | |
<div className={'grain-blur background-base'} /> | |
<div className={'grain-background background-base'} /> | |
<div className={'grid-bg background-base'} /> | |
<div className={'large-blur background-base'} /> | |
<div className={'small-blur background-base'} /> | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/gradients/checkout-gradients.tsx | |
================================================ | |
export function CheckoutGradients() { | |
return ( | |
<> | |
<div className={'hidden md:block'}> | |
<div className={'top-left-gradient-background checkout-background-base min-h-[1280px]'}></div> | |
<div className={'bottom-right-gradient-background checkout-background-base min-h-[1280px]'}></div> | |
<div className={'grain-background checkout-background-base min-h-[1280px]'}></div> | |
<div className={'grid-bg checkout-background-base min-h-[1280px]'}></div> | |
</div> | |
<div className={'block md:hidden'}> | |
<div className={'checkout-mobile-grainy-blur checkout-mobile-top-gradient checkout-background-base'}></div> | |
<div className={'checkout-mobile-grainy-blur checkout-mobile-bottom-gradient checkout-background-base'}></div> | |
<div className={'grain-background checkout-background-base h-full min-h-screen'}></div> | |
</div> | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/gradients/dashboard-gradient.tsx | |
================================================ | |
export function DashboardGradient() { | |
return ( | |
<> | |
<div className={'dashboard-shared-top-grainy-blur'} /> | |
<div className={'dashboard-shared-bottom-grainy-blur'} /> | |
<div className={'grain-background dashboard-background-base h-full'}></div> | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/gradients/success-page-gradients.tsx | |
================================================ | |
export function SuccessPageGradients() { | |
return ( | |
<> | |
<div className={'grid-bg checkout-background-base min-h-screen'}></div> | |
<div className={'checkout-success-background checkout-background-base'}></div> | |
<div className={'grain-background checkout-background-base min-h-screen'}></div> | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/home/footer/built-using-tools.tsx | |
================================================ | |
import Image from 'next/image'; | |
export function BuiltUsingTools() { | |
return ( | |
<div className={'mx-auto max-w-7xl text-center px-8 mt-24 mb-24'}> | |
<span className={'text-base'}>Built with</span> | |
<div className={'flex flex-row flex-wrap gap-6 justify-center md:justify-between items-center mt-8 md:gap-1'}> | |
<Image src={'/assets/icons/logo/paddle-logo.svg'} alt={'TailwindCSS logo'} width={120} height={32} /> | |
<Image src={'/assets/icons/logo/tailwind-logo.svg'} alt={'TailwindCSS logo'} width={194} height={24} /> | |
<Image src={'/assets/icons/logo/supabase-logo.svg'} alt={'Supabase logo'} width={150} height={32} /> | |
<Image src={'/assets/icons/logo/nextjs-logo.svg'} alt={'Next.js logo'} width={120} height={24} /> | |
<Image src={'/assets/icons/logo/shadcn-logo.svg'} alt={'Shadcn logo'} width={137} height={32} /> | |
</div> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/home/footer/powered-by-paddle.tsx | |
================================================ | |
import Image from 'next/image'; | |
import Link from 'next/link'; | |
import { Separator } from '@/components/ui/separator'; | |
import { ArrowUpRight } from 'lucide-react'; | |
export function PoweredByPaddle() { | |
return ( | |
<> | |
<Separator className={'footer-border'} /> | |
<div | |
className={ | |
'flex flex-col justify-center items-center gap-2 text-muted-foreground text-sm leading-[14px] py-[24px]' | |
} | |
> | |
<div className={'flex justify-center items-center gap-2'}> | |
<span className={'text-sm leading-[14px]'}>A Next.js template by</span> | |
<Image src={'/assets/icons/logo/paddle-white-logo.svg'} alt={'Paddle logo'} width={54} height={14} /> | |
</div> | |
<div className={'flex justify-center items-center gap-2 flex-wrap md:flex-nowrap'}> | |
<Link className={'text-sm leading-[14px]'} href={'https://paddle.com'} target={'_blank'}> | |
<span className={'flex items-center gap-1'}> | |
Explore Paddle | |
<ArrowUpRight className={'h-4 w-4'} /> | |
</span> | |
</Link> | |
<Link className={'text-sm leading-[14px]'} href={'https://www.paddle.com/legal/terms'} target={'_blank'}> | |
<span className={'flex items-center gap-1'}> | |
Terms of use | |
<ArrowUpRight className={'h-4 w-4'} /> | |
</span> | |
</Link> | |
<Link className={'text-sm leading-[14px]'} href={'https://www.paddle.com/legal/privacy'} target={'_blank'}> | |
<span className={'flex items-center gap-1'}> | |
Privacy | |
<ArrowUpRight className={'h-4 w-4'} /> | |
</span> | |
</Link> | |
</div> | |
</div> | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/home/footer/footer.tsx | |
================================================ | |
import { BuiltUsingTools } from '@/components/home/footer/built-using-tools'; | |
import { PoweredByPaddle } from '@/components/home/footer/powered-by-paddle'; | |
export function Footer() { | |
return ( | |
<> | |
<BuiltUsingTools /> | |
<PoweredByPaddle /> | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/home/header/country-dropdown.tsx | |
================================================ | |
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; | |
const regions = [ | |
{ value: 'US', label: 'United States' }, | |
{ value: 'GB', label: 'United Kingdom' }, | |
{ value: 'DE', label: 'Germany' }, | |
{ value: 'PT', label: 'Portugal' }, | |
{ value: 'IN', label: 'India' }, | |
{ value: 'BR', label: 'Brazil' }, | |
{ value: 'OTHERS', label: 'Everywhere else' }, | |
]; | |
interface Props { | |
country: string; | |
onCountryChange: (value: string) => void; | |
} | |
export function CountryDropdown({ country, onCountryChange }: Props) { | |
const selected = regions.find((region) => region.value === country) || regions[0]; | |
return ( | |
<Select defaultValue={'US'} onValueChange={(value) => onCountryChange(value)} value={selected.value}> | |
<SelectTrigger className="w-[220px]"> | |
<SelectValue /> | |
</SelectTrigger> | |
<SelectContent> | |
{regions.map((region) => ( | |
<SelectItem key={region.value} value={region.value}> | |
{region.label} | |
</SelectItem> | |
))} | |
</SelectContent> | |
</Select> | |
); | |
} | |
================================================ | |
File: /src/components/home/header/localization-banner.tsx | |
================================================ | |
import Image from 'next/image'; | |
import Link from 'next/link'; | |
import { CountryDropdown } from '@/components/home/header/country-dropdown'; | |
import { ArrowUpRight, X } from 'lucide-react'; | |
import { useState } from 'react'; | |
interface Props { | |
country: string; | |
onCountryChange: (value: string) => void; | |
} | |
export function LocalizationBanner({ country, onCountryChange }: Props) { | |
const [showBanner, setShowBanner] = useState(true); | |
// TODO: This component is for demonstration purposes only. Please remove while integrating with the application. | |
if (!showBanner) { | |
return null; | |
} | |
return ( | |
<div className={'hidden md:flex border-border/50 border-b-2 bg-background'}> | |
<div className={'flex flex-1 justify-center items-center p-2 gap-8'}> | |
<div className={'flex items-center gap-4'}> | |
<Image src={'/assets/icons/localization-icon.svg'} alt={'Localization Icon'} width={36} height={36} /> | |
<p className={'text-[16px] font-medium text-center'}>Preview localized prices</p> | |
<Link | |
className={'text-[16px] text-muted-foreground'} | |
href={'https://developer.paddle.com/build/products/offer-localized-pricing'} | |
target={'_blank'} | |
> | |
<span className={'flex items-center gap-1'}> | |
Learn more | |
<ArrowUpRight className={'h-4 w-4'} /> | |
</span> | |
</Link> | |
</div> | |
<div className={'flex items-center gap-4'}> | |
<CountryDropdown country={country} onCountryChange={onCountryChange} /> | |
<X size={'16'} className={'cursor-pointer'} onClick={() => setShowBanner(false)} /> | |
</div> | |
</div> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/home/header/header.tsx | |
================================================ | |
import Link from 'next/link'; | |
import { User } from '@supabase/supabase-js'; | |
import Image from 'next/image'; | |
import { Button } from '@/components/ui/button'; | |
interface Props { | |
user: User | null; | |
} | |
export default function Header({ user }: Props) { | |
return ( | |
<nav> | |
<div className="mx-auto max-w-7xl relative px-[32px] py-[18px] flex items-center justify-between"> | |
<div className="flex flex-1 items-center justify-start"> | |
<Link className="flex items-center" href={'/'}> | |
<Image className="w-auto block" src="/logo.svg" width={131} height={28} alt="AeroEdit" /> | |
</Link> | |
</div> | |
<div className="flex flex-1 items-center justify-end"> | |
<div className="flex space-x-4"> | |
{user?.id ? ( | |
<Button variant={'secondary'} asChild={true}> | |
<Link href={'/dashboard'}>Dashboard</Link> | |
</Button> | |
) : ( | |
<Button asChild={true} variant={'secondary'}> | |
<Link href={'/login'}>Sign in</Link> | |
</Button> | |
)} | |
</div> | |
</div> | |
</div> | |
</nav> | |
); | |
} | |
================================================ | |
File: /src/components/home/home-page.tsx | |
================================================ | |
'use client'; | |
import { useState } from 'react'; | |
import { createClient } from '@/utils/supabase/client'; | |
import { useUserInfo } from '@/hooks/useUserInfo'; | |
import '../../styles/home-page.css'; | |
import { LocalizationBanner } from '@/components/home/header/localization-banner'; | |
import Header from '@/components/home/header/header'; | |
import { HeroSection } from '@/components/home/hero-section/hero-section'; | |
import { Pricing } from '@/components/home/pricing/pricing'; | |
import { HomePageBackground } from '@/components/gradients/home-page-background'; | |
import { Footer } from '@/components/home/footer/footer'; | |
export function HomePage() { | |
const supabase = createClient(); | |
const { user } = useUserInfo(supabase); | |
const [country, setCountry] = useState('US'); | |
return ( | |
<> | |
<LocalizationBanner country={country} onCountryChange={setCountry} /> | |
<div> | |
<HomePageBackground /> | |
<Header user={user} /> | |
<HeroSection /> | |
<Pricing country={country} /> | |
<Footer /> | |
</div> | |
</> | |
); | |
} | |
================================================ | |
File: /src/components/home/pricing/price-title.tsx | |
================================================ | |
import { Tier } from '@/constants/pricing-tier'; | |
import Image from 'next/image'; | |
import { cn } from '@/lib/utils'; | |
interface Props { | |
tier: Tier; | |
} | |
export function PriceTitle({ tier }: Props) { | |
const { name, featured, icon } = tier; | |
return ( | |
<div | |
className={cn('flex justify-between items-center px-8 pt-8', { | |
'featured-price-title': featured, | |
})} | |
> | |
<div className={'flex items-center gap-[10px]'}> | |
<Image src={icon} height={40} width={40} alt={name} /> | |
<p className={'text-[20px] leading-[30px] font-semibold'}>{name}</p> | |
</div> | |
{featured && ( | |
<div | |
className={ | |
'flex items-center px-3 py-1 rounded-xs border border-secondary-foreground/10 text-[14px] h-[29px] leading-[21px] featured-card-badge' | |
} | |
> | |
Most popular | |
</div> | |
)} | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/home/pricing/pricing.tsx | |
================================================ | |
import { Toggle } from '@/components/shared/toggle/toggle'; | |
import { PriceCards } from '@/components/home/pricing/price-cards'; | |
import { useEffect, useState } from 'react'; | |
import { BillingFrequency, IBillingFrequency } from '@/constants/billing-frequency'; | |
import { Environments, initializePaddle, Paddle } from '@paddle/paddle-js'; | |
import { usePaddlePrices } from '@/hooks/usePaddlePrices'; | |
interface Props { | |
country: string; | |
} | |
export function Pricing({ country }: Props) { | |
const [frequency, setFrequency] = useState<IBillingFrequency>(BillingFrequency[0]); | |
const [paddle, setPaddle] = useState<Paddle | undefined>(undefined); | |
const { prices, loading } = usePaddlePrices(paddle, country); | |
useEffect(() => { | |
if (process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN && process.env.NEXT_PUBLIC_PADDLE_ENV) { | |
initializePaddle({ | |
token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN, | |
environment: process.env.NEXT_PUBLIC_PADDLE_ENV as Environments, | |
}).then((paddle) => { | |
if (paddle) { | |
setPaddle(paddle); | |
} | |
}); | |
} | |
}, []); | |
return ( | |
<div className="mx-auto max-w-7xl relative px-[32px] flex flex-col items-center justify-between"> | |
<Toggle frequency={frequency} setFrequency={setFrequency} /> | |
<PriceCards frequency={frequency} loading={loading} priceMap={prices} /> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/home/pricing/features-list.tsx | |
================================================ | |
import { Tier } from '@/constants/pricing-tier'; | |
import { CircleCheck } from 'lucide-react'; | |
interface Props { | |
tier: Tier; | |
} | |
export function FeaturesList({ tier }: Props) { | |
return ( | |
<ul className={'p-8 flex flex-col gap-4'}> | |
{tier.features.map((feature: string) => ( | |
<li key={feature} className="flex gap-x-3"> | |
<CircleCheck className={'h-6 w-6 text-muted-foreground'} /> | |
<span className={'text-base'}>{feature}</span> | |
</li> | |
))} | |
</ul> | |
); | |
} | |
================================================ | |
File: /src/components/home/pricing/price-cards.tsx | |
================================================ | |
import { PricingTier } from '@/constants/pricing-tier'; | |
import { IBillingFrequency } from '@/constants/billing-frequency'; | |
import { FeaturesList } from '@/components/home/pricing/features-list'; | |
import { PriceAmount } from '@/components/home/pricing/price-amount'; | |
import { cn } from '@/lib/utils'; | |
import { Button } from '@/components/ui/button'; | |
import { PriceTitle } from '@/components/home/pricing/price-title'; | |
import { Separator } from '@/components/ui/separator'; | |
import { FeaturedCardGradient } from '@/components/gradients/featured-card-gradient'; | |
import Link from 'next/link'; | |
interface Props { | |
loading: boolean; | |
frequency: IBillingFrequency; | |
priceMap: Record<string, string>; | |
} | |
export function PriceCards({ loading, frequency, priceMap }: Props) { | |
return ( | |
<div className="isolate mx-auto grid grid-cols-1 gap-8 lg:mx-0 lg:max-w-none lg:grid-cols-3"> | |
{PricingTier.map((tier) => ( | |
<div key={tier.id} className={cn('rounded-lg bg-background/70 backdrop-blur-[6px] overflow-hidden')}> | |
<div className={cn('flex gap-5 flex-col rounded-lg rounded-b-none pricing-card-border')}> | |
{tier.featured && <FeaturedCardGradient />} | |
<PriceTitle tier={tier} /> | |
<PriceAmount | |
loading={loading} | |
tier={tier} | |
priceMap={priceMap} | |
value={frequency.value} | |
priceSuffix={frequency.priceSuffix} | |
/> | |
<div className={'px-8'}> | |
<Separator className={'bg-border'} /> | |
</div> | |
<div className={'px-8 text-[16px] leading-[24px]'}>{tier.description}</div> | |
</div> | |
<div className={'px-8 mt-8'}> | |
<Button className={'w-full'} variant={'secondary'} asChild={true}> | |
<Link href={`/checkout/${tier.priceId[frequency.value]}`}>Get started</Link> | |
</Button> | |
</div> | |
<FeaturesList tier={tier} /> | |
</div> | |
))} | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/home/pricing/price-amount.tsx | |
================================================ | |
import { Tier } from '@/constants/pricing-tier'; | |
import { cn } from '@/lib/utils'; | |
import { Skeleton } from '@/components/ui/skeleton'; | |
interface Props { | |
loading: boolean; | |
tier: Tier; | |
priceMap: Record<string, string>; | |
value: string; | |
priceSuffix: string; | |
} | |
export function PriceAmount({ loading, priceMap, priceSuffix, tier, value }: Props) { | |
return ( | |
<div className="mt-6 flex flex-col px-8"> | |
{loading ? ( | |
<Skeleton className="h-[96px] w-full bg-border" /> | |
) : ( | |
<> | |
<div className={cn('text-[80px] leading-[96px] tracking-[-1.6px] font-medium')}> | |
{priceMap[tier.priceId[value]].replace(/\.00$/, '')} | |
</div> | |
<div className={cn('font-medium leading-[12px] text-[12px]')}>{priceSuffix}</div> | |
</> | |
)} | |
</div> | |
); | |
} | |
================================================ | |
File: /src/components/home/hero-section/hero-section.tsx | |
================================================ | |
export function HeroSection() { | |
return ( | |
<section className={'mx-auto max-w-7xl px-[32px] relative flex items-center justify-between mt-16 mb-12'}> | |
<div className={'text-center w-full '}> | |
<h1 className={'text-[48px] leading-[48px] md:text-[80px] md:leading-[80px] tracking-[-1.6px] font-medium'}> | |
Powerful design tools. | |
<br /> | |
Simple pricing. | |
</h1> | |
<p className={'mt-6 text-[18px] leading-[27px] md:text-[20px] md:leading-[30px]'}> | |
Plans for teams of every size — from start-up to enterprise. | |
</p> | |
</div> | |
</section> | |
); | |
} | |
================================================ | |
File: /src/lib/database.types.ts | |
================================================ | |
export interface Subscription { | |
subscriptionId: string; | |
subscriptionStatus: string; | |
priceId: string; | |
productId: string; | |
scheduledChange: string; | |
customerId: string; | |
customerEmail: string; | |
} | |
================================================ | |
File: /src/lib/utils.ts | |
================================================ | |
import { type ClassValue, clsx } from 'clsx'; | |
import { twMerge } from 'tailwind-merge'; | |
export function cn(...inputs: ClassValue[]) { | |
return twMerge(clsx(inputs)); | |
} | |
================================================ | |
File: /src/lib/api.types.ts | |
================================================ | |
import { Subscription, Transaction } from '@paddle/paddle-node-sdk'; | |
export interface SubscriptionResponse { | |
data?: Subscription[]; | |
hasMore: boolean; | |
totalRecords: number; | |
error?: string; | |
} | |
export interface TransactionResponse { | |
data?: Transaction[]; | |
hasMore: boolean; | |
totalRecords: number; | |
error?: string; | |
} | |
export interface SubscriptionDetailResponse { | |
data?: Subscription; | |
error?: string; | |
} | |
================================================ | |
File: /src/styles/checkout.css | |
================================================ | |
.checkout-background-base { | |
width: 100%; | |
position: absolute; | |
z-index: -1; | |
} | |
.grain-background { | |
background: url('/assets/background/grain-bg.svg') repeat; | |
} | |
.grid-bg { | |
background: url('/assets/background/grid-bg.svg') no-repeat; | |
} | |
.top-left-gradient-background { | |
background: url('/assets/background/checkout-top-gradient.svg') no-repeat top left; | |
} | |
.bottom-right-gradient-background { | |
background: url('/assets/background/checkout-bottom-gradient.svg') no-repeat bottom right; | |
} | |
.checkout-yellow-highlight { | |
position: absolute; | |
left: 96px; | |
top: 0; | |
width: 248px; | |
height: 1px; | |
background: linear-gradient( | |
90deg, | |
rgba(255, 255, 255, 0) 15%, | |
rgba(255, 248, 0, 0.6) 50%, | |
rgba(255, 255, 255, 0) 85% | |
); | |
} | |
.checkout-hard-blur { | |
width: 196px; | |
height: 4px; | |
position: absolute; | |
left: 103px; | |
top: -2px; | |
background: #fff800; | |
opacity: 0.1; | |
filter: blur(12px); | |
} | |
.checkout-soft-blur { | |
width: 296px; | |
height: 16.576px; | |
position: absolute; | |
left: 52px; | |
top: -9px; | |
border-radius: 296px; | |
opacity: 0.3; | |
background: #fff800; | |
filter: blur(32px); | |
} | |
.checkout-mobile-grainy-blur { | |
width: 211px; | |
height: 245px; | |
background: linear-gradient( | |
0deg, | |
rgba(255, 251, 229, 0) 0%, | |
rgba(21, 227, 227, 0.06) 35.5%, | |
rgba(255, 248, 0, 0.48) 80.5% | |
); | |
filter: blur(26px); | |
} | |
.checkout-mobile-grainy-blur::before { | |
content: ''; | |
left: 55px; | |
position: absolute; | |
width: 101px; | |
height: 167px; | |
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(168, 240, 248, 0.6) 100%); | |
filter: blur(26px); | |
} | |
.checkout-mobile-top-gradient { | |
position: absolute; | |
left: 50%; | |
margin-left: -105px; | |
top: -140px; | |
width: 211px; | |
height: 280px; | |
} | |
.checkout-mobile-bottom-gradient { | |
width: 211px; | |
height: 280px; | |
position: absolute; | |
right: -140.023px; | |
bottom: -109.977px; | |
} | |
.checkout-mobile-bottom-gradient.checkout-mobile-grainy-blur { | |
transform: rotate(180deg); | |
} | |
.checkout-order-summary-mobile-yellow-highlight::before { | |
content: ''; | |
position: absolute; | |
left: 50%; | |
margin-left: -124px; | |
top: 0; | |
width: 248px; | |
height: 1px; | |
background: linear-gradient( | |
90deg, | |
rgba(255, 255, 255, 0) 15%, | |
rgba(255, 248, 0, 0.6) 50%, | |
rgba(255, 255, 255, 0) 85% | |
); | |
} | |
.checkout-success-background { | |
position: absolute; | |
left: 50%; | |
margin-left: -410px; | |
top: -338.001px; | |
width: 820px; | |
height: 938px; | |
border-radius: 820px; | |
transform: rotate(180deg); | |
background: linear-gradient( | |
180deg, | |
rgba(255, 251, 229, 0) 0%, | |
rgba(21, 227, 227, 0.06) 35.5%, | |
rgba(255, 248, 0, 0.18) 80.5% | |
); | |
filter: blur(100px); | |
} | |
.checkout-success-background::before { | |
content: ''; | |
position: absolute; | |
width: 394px; | |
top: 350px; | |
height: 559px; | |
left: 50%; | |
margin-left: -197px; | |
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(168, 240, 248, 0.6) 100%); | |
filter: blur(100px); | |
} | |
.footer-border { | |
position: relative; | |
background: linear-gradient(90deg, rgba(65, 75, 78, 0) 0%, #414b4e 49.5%, rgba(65, 75, 78, 0) 100%); | |
} | |
.footer-border::after { | |
content: ''; | |
position: absolute; | |
bottom: 0; | |
left: calc(50% - 124px); | |
width: 248px; | |
height: 1px; | |
background: linear-gradient( | |
90deg, | |
rgba(255, 255, 255, 0) 15%, | |
rgba(255, 248, 0, 0.6) 50%, | |
rgba(255, 255, 255, 0) 85% | |
); | |
} | |
================================================ | |
File: /src/styles/globals.css | |
================================================ | |
@tailwind base; | |
@tailwind components; | |
@tailwind utilities; | |
@layer base { | |
:root { | |
--background: 0 0% 100%; | |
--foreground: 240 5% 96%; | |
--card: 0 0% 100%; | |
--card-foreground: 222.2 84% 4.9%; | |
--popover: 0 0% 100%; | |
--popover-foreground: 222.2 84% 4.9%; | |
--primary: 222.2 47.4% 11.2%; | |
--primary-foreground: 240 5% 96%; | |
--secondary: 210 40% 96.1%; | |
--secondary-foreground: 222.2 47.4% 11.2%; | |
--muted: 210 40% 96.1%; | |
--muted-foreground: 215.4 16.3% 46.9%; | |
--accent: 210 40% 96.1%; | |
--accent-foreground: 222.2 47.4% 11.2%; | |
--destructive: 0 84.2% 60.2%; | |
--destructive-foreground: 210 40% 98%; | |
--border: 194 9% 28%; | |
--input: 214.3 31.8% 91.4%; | |
--ring: 222.2 84% 4.9%; | |
--radius: 16px; | |
--chart-1: 12 76% 61%; | |
--chart-2: 173 58% 39%; | |
--chart-3: 197 37% 24%; | |
--chart-4: 43 74% 66%; | |
--chart-5: 27 87% 67%; | |
} | |
.dark { | |
--background: 180 18% 7%; | |
--foreground: 240 5% 96%; | |
--card: 222.2 84% 4.9%; | |
--card-foreground: 210 40% 98%; | |
--popover: 222.2 84% 4.9%; | |
--popover-foreground: 210 40% 98%; | |
--primary: 210 40% 98%; | |
--primary-foreground: 222.2 47.4% 11.2%; | |
--secondary: 240, 5%, 80%; | |
--secondary-foreground: 0 0% 100%; | |
--muted: 217.2 32.6% 17.5%; | |
--muted-foreground: 180 1% 48%; | |
--accent: 217.2 32.6% 17.5%; | |
--accent-foreground: 210 40% 98%; | |
--destructive: 0 62.8% 30.6%; | |
--destructive-foreground: 210 40% 98%; | |
--border: 187 10% 17%; | |
--input: 217.2 32.6% 17.5%; | |
--ring: 58 100% 70%; | |
--chart-1: 220 70% 50%; | |
--chart-2: 160 60% 45%; | |
--chart-3: 30 80% 55%; | |
--chart-4: 280 65% 60%; | |
--chart-5: 340 75% 55%; | |
} | |
} | |
@layer base { | |
* { | |
@apply border-border; | |
} | |
body { | |
@apply bg-background text-foreground; | |
} | |
} | |
================================================ | |
File: /src/styles/layout.css | |
================================================ | |
.secondary-button-animation { | |
&::after { | |
content: ''; | |
position: absolute; | |
z-index: -1; | |
top: 0; | |
right: 0; | |
bottom: 0; | |
left: 0; | |
display: block; | |
background: linear-gradient(90deg, hsla(0, 0%, 99%, 0.16), hsla(0, 0%, 99%, 0) 50%, hsla(0, 0%, 99%, 0)); | |
background-size: 200%; | |
background-position: 100%; | |
transition: all 0.8s cubic-bezier(0.246, 0.75, 0.187, 1); | |
border-radius: inherit; | |
} | |
&:hover::after { | |
background-position: 0; | |
border-radius: inherit; | |
} | |
} | |
================================================ | |
File: /src/styles/home-page.css | |
================================================ | |
.background-base { | |
min-height: 1400px; | |
width: 100%; | |
position: absolute; | |
z-index: -1; | |
} | |
.grid-bg { | |
background: url('/assets/background/grid-bg.svg') no-repeat; | |
} | |
.grain-background { | |
background: url('/assets/background/grain-bg.svg') repeat; | |
} | |
.grain-blur { | |
top: -220px; | |
background: url('/assets/background/grain-blur.svg') no-repeat 50%; | |
} | |
.large-blur { | |
left: -30px; | |
top: -864px; | |
border-radius: 750px; | |
opacity: 0.2; | |
background: radial-gradient( | |
70.71% 70.71% at 50% 50%, | |
rgba(117, 173, 255, 0.2) 0%, | |
rgba(117, 173, 255, 0) 70%, | |
rgba(117, 173, 255, 0) 100% | |
); | |
} | |
.small-blur { | |
background: url('/assets/background/small-blur.svg') no-repeat 50%; | |
} | |
.featured-card-badge { | |
position: relative; | |
background: linear-gradient(90deg, #fff800 0%, #fffecc 100%); | |
background-clip: text; | |
-webkit-background-clip: text; | |
-webkit-text-fill-color: transparent; | |
} | |
.featured-card-badge::before { | |
content: ''; | |
background: linear-gradient( | |
90deg, | |
rgba(255, 255, 255, 0) 15%, | |
rgba(255, 248, 0, 0.6) 50%, | |
rgba(255, 255, 255, 0) 85% | |
); | |
position: absolute; | |
left: 8px; | |
top: -1px; | |
width: 48px; | |
height: 1px; | |
} | |
.pricing-card-border { | |
position: relative; | |
} | |
.pricing-card-border::before { | |
content: ''; | |
position: absolute; | |
inset: 0; | |
border-radius: 16px 16px 0 0; | |
padding: 1px 1px 0; | |
background: linear-gradient(180deg, #414b4e 49.5%, rgba(65, 75, 78, 0) 100%); | |
-webkit-mask: | |
linear-gradient(#fff 0 0) content-box, | |
linear-gradient(#fff 0 0); | |
-webkit-mask-composite: xor; | |
mask-composite: exclude; | |
} | |
.footer-border { | |
position: relative; | |
background: linear-gradient(90deg, rgba(65, 75, 78, 0) 0%, #414b4e 49.5%, rgba(65, 75, 78, 0) 100%); | |
} | |
.footer-border::after { | |
content: ''; | |
position: absolute; | |
bottom: 0; | |
left: calc(50% - 124px); | |
width: 248px; | |
height: 1px; | |
background: linear-gradient( | |
90deg, | |
rgba(255, 255, 255, 0) 15%, | |
rgba(255, 248, 0, 0.6) 50%, | |
rgba(255, 255, 255, 0) 85% | |
); | |
} | |
.featured-price-title { | |
position: relative; | |
} | |
.featured-price-title::before { | |
content: ''; | |
position: absolute; | |
left: 44px; | |
top: -7px; | |
height: 17px; | |
width: 296px; | |
border-radius: 296px; | |
opacity: 0.2; | |
background: #fddd35; | |
filter: blur(32px); | |
} | |
.featured-price-title::after { | |
content: ''; | |
width: 196px; | |
height: 4px; | |
position: absolute; | |
left: 94px; | |
top: -2px; | |
border-radius: 196px; | |
opacity: 0.5; | |
background: #4d94ff; | |
filter: blur(12px); | |
} | |
.featured-hard-blur-bg { | |
width: 88px; | |
height: 4px; | |
position: absolute; | |
left: 50%; | |
margin-left: -44px; | |
top: -2px; | |
background: #fff800; | |
opacity: 0.5; | |
filter: blur(12px); | |
} | |
.featured-yellow-highlight-bg { | |
content: ''; | |
position: absolute; | |
left: 50%; | |
margin-left: -124px; | |
width: 248px; | |
height: 1px; | |
background: linear-gradient( | |
90deg, | |
rgba(255, 255, 255, 0) 15%, | |
rgba(255, 248, 0, 0.6) 50%, | |
rgba(255, 255, 255, 0) 85% | |
); | |
} | |
.featured-vertical-hard-blur-bg { | |
position: absolute; | |
top: -140px; | |
left: 50%; | |
margin-left: -64px; | |
width: 128px; | |
height: 280px; | |
border-radius: 280px; | |
opacity: 0.1; | |
background: #fff800; | |
filter: blur(48px); | |
} | |
.featured-soft-blur-bg { | |
position: absolute; | |
top: -19px; | |
left: 50%; | |
margin-left: -192px; | |
width: 384px; | |
height: 37px; | |
border-radius: 384px; | |
opacity: 0.3; | |
background: #fff800; | |
filter: blur(32px); | |
} | |
================================================ | |
File: /src/styles/dashboard.css | |
================================================ | |
.dashboard-background-base { | |
width: 100%; | |
position: absolute; | |
z-index: -1; | |
} | |
.grain-background { | |
background: url('/assets/background/grain-bg.svg') repeat; | |
} | |
.dashboard-shared-top-grainy-blur { | |
position: absolute; | |
left: -131.023px; | |
top: -127.993px; | |
width: 211px; | |
height: 245px; | |
z-index: -1; | |
background: linear-gradient( | |
0deg, | |
rgba(255, 251, 229, 0) 0%, | |
rgba(21, 227, 227, 0.06) 35.5%, | |
rgba(255, 248, 0, 0.48) 80.5% | |
); | |
filter: blur(26px); | |
} | |
.dashboard-shared-top-grainy-blur::before { | |
content: ''; | |
left: 55px; | |
position: absolute; | |
width: 101px; | |
height: 167px; | |
flex-shrink: 0; | |
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(168, 240, 248, 0.6) 100%); | |
filter: blur(26px); | |
} | |
.dashboard-shared-bottom-grainy-blur { | |
position: absolute; | |
transform: rotate(-90deg); | |
right: -440.007px; | |
bottom: -560px; | |
width: 820px; | |
height: 951px; | |
flex-shrink: 0; | |
border-radius: 951px; | |
background: linear-gradient( | |
180deg, | |
rgba(255, 251, 229, 0) 0%, | |
rgba(21, 227, 227, 0.06) 35.5%, | |
rgba(255, 248, 0, 0.48) 80.5% | |
); | |
filter: blur(100px); | |
} | |
.dashboard-shared-bottom-grainy-blur::before { | |
content: ''; | |
position: absolute; | |
width: 394px; | |
height: 648px; | |
flex-shrink: 0; | |
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(168, 240, 248, 0.6) 100%); | |
filter: blur(100px); | |
} | |
.dashboard-sidebar-items { | |
svg.lucide { | |
color: #4b4f4f; | |
} | |
&.dashboard-sidebar-items-active { | |
background: #161d1d; | |
svg.lucide { | |
color: hsl(var(--primary)); | |
} | |
} | |
} | |
.dashboard-sidebar-items:hover { | |
background: #161d1d; | |
svg.lucide { | |
color: hsl(var(--primary)); | |
} | |
} | |
.dashboard-sidebar-highlight:after { | |
content: ''; | |
width: 248px; | |
height: 1px; | |
position: absolute; | |
left: 50%; | |
margin-left: -124px; | |
top: 0; | |
background: linear-gradient( | |
90deg, | |
rgba(255, 255, 255, 0) 15%, | |
rgba(255, 248, 0, 0.6) 50%, | |
rgba(255, 255, 255, 0) 85% | |
); | |
} | |
.dashboard-header-highlight::after { | |
content: ''; | |
width: 248px; | |
height: 1px; | |
position: absolute; | |
left: 8px; | |
background: linear-gradient( | |
90deg, | |
rgba(255, 255, 255, 0) 15%, | |
rgba(255, 248, 0, 0.6) 50%, | |
rgba(255, 255, 255, 0) 85% | |
); | |
} | |
================================================ | |
File: /src/styles/login.css | |
================================================ | |
.login-background-base { | |
width: 100%; | |
position: absolute; | |
z-index: -1; | |
} | |
.grid-bg { | |
background: url('/assets/background/grid-bg.svg') no-repeat; | |
} | |
.grain-background { | |
mix-blend-mode: overlay; | |
background: url('/assets/background/grain-bg.svg') repeat; | |
} | |
.login-gradient-background { | |
background: url('/assets/background/login-gradient.svg') no-repeat top; | |
} | |
.login-card-border, | |
.login-card-hard-blur, | |
.login-card-vertical-hard-blur, | |
.login-card-soft-blur, | |
.login-card-yellow-highlight { | |
position: relative; | |
} | |
.login-card-border::before { | |
content: ''; | |
z-index: -1; | |
position: absolute; | |
inset: 0; | |
border-radius: 16px 16px 0 0; | |
padding: 1px 1px 0; | |
background: linear-gradient(180deg, #414b4e 49.5%, rgba(65, 75, 78, 0) 100%); | |
-webkit-mask: | |
linear-gradient(#fff 0 0) content-box, | |
linear-gradient(#fff 0 0); | |
-webkit-mask-composite: xor; | |
mask-composite: exclude; | |
} | |
.login-card-hard-blur::before { | |
content: ''; | |
width: 88px; | |
height: 4px; | |
position: absolute; | |
left: 50%; | |
margin-left: -44px; | |
top: -2px; | |
background: #fff800; | |
opacity: 0.5; | |
filter: blur(12px); | |
} | |
.login-card-vertical-hard-blur::before { | |
content: ''; | |
width: 128px; | |
height: 280px; | |
position: absolute; | |
left: 50%; | |
margin-left: -64px; | |
top: -140px; | |
border-radius: 280px; | |
opacity: 0.1; | |
background: #fff800; | |
filter: blur(48px); | |
} | |
.login-card-small-soft-blur::before { | |
width: 250px; | |
} | |
.login-card-medium-soft-blur::before { | |
width: 384px; | |
} | |
.login-card-soft-blur::before { | |
content: ''; | |
height: 37px; | |
position: absolute; | |
left: 52px; | |
top: -19px; | |
border-radius: 384px; | |
opacity: 0.2; | |
background: #fff800; | |
filter: blur(32px); | |
} | |
.login-card-small-yellow-highlight::before { | |
width: 250px; | |
margin-left: -125px; | |
} | |
.login-card-medium-yellow-highlight::before { | |
width: 384px; | |
margin-left: -192px; | |
} | |
.login-card-yellow-highlight::before { | |
content: ''; | |
height: 1px; | |
left: 50%; | |
position: absolute; | |
background: linear-gradient( | |
90deg, | |
rgba(255, 255, 255, 0) 15%, | |
rgba(255, 248, 0, 0.9) 50%, | |
rgba(255, 255, 255, 0) 85% | |
); | |
} | |
================================================ | |
File: /src/constants/billing-frequency.ts | |
================================================ | |
export interface IBillingFrequency { | |
value: string; | |
label: string; | |
priceSuffix: string; | |
} | |
export const BillingFrequency: IBillingFrequency[] = [ | |
{ value: 'month', label: 'Monthly', priceSuffix: 'per user/month' }, | |
{ value: 'year', label: 'Annual', priceSuffix: 'per user/year' }, | |
]; | |
================================================ | |
File: /src/constants/pricing-tier.ts | |
================================================ | |
export interface Tier { | |
name: string; | |
id: 'starter' | 'pro' | 'advanced'; | |
icon: string; | |
description: string; | |
features: string[]; | |
featured: boolean; | |
priceId: Record<string, string>; | |
} | |
export const PricingTier: Tier[] = [ | |
{ | |
name: 'Starter', | |
id: 'starter', | |
icon: '/assets/icons/price-tiers/free-icon.svg', | |
description: 'Ideal for individuals who want to get started with simple design tasks.', | |
features: ['1 workspace', 'Limited collaboration', 'Export to PNG and SVG'], | |
featured: false, | |
priceId: { month: 'pri_01hsxyh9txq4rzbrhbyngkhy46', year: 'pri_01hsxyh9txq4rzbrhbyngkhy46' }, | |
}, | |
{ | |
name: 'Pro', | |
id: 'pro', | |
icon: '/assets/icons/price-tiers/basic-icon.svg', | |
description: 'Enhanced design tools for scaling teams who need more flexibility.', | |
features: ['Integrations', 'Unlimited workspaces', 'Advanced editing tools', 'Everything in Starter'], | |
featured: true, | |
priceId: { month: 'pri_01hsxycme6m95sejkz7sbz5e9g', year: 'pri_01hsxyeb2bmrg618bzwcwvdd6q' }, | |
}, | |
{ | |
name: 'Advanced', | |
id: 'advanced', | |
icon: '/assets/icons/price-tiers/pro-icon.svg', | |
description: 'Powerful tools designed for extensive collaboration and customization.', | |
features: [ | |
'Single sign on (SSO)', | |
'Advanced version control', | |
'Assets library', | |
'Guest accounts', | |
'Everything in Pro', | |
], | |
featured: false, | |
priceId: { month: 'pri_01hsxyff091kyc9rjzx7zm6yqh', year: 'pri_01hsxyfysbzf90tkh2wqbfxwa5' }, | |
}, | |
]; | |
================================================ | |
File: /src/hooks/useUserInfo.ts | |
================================================ | |
import { useEffect, useState } from 'react'; | |
import { SupabaseClient, User } from '@supabase/supabase-js'; | |
export function useUserInfo(supabase: SupabaseClient) { | |
const [user, setUser] = useState<User | null>(null); | |
useEffect(() => { | |
(async () => { | |
const { data } = await supabase.auth.getUser(); | |
if (data?.user) { | |
setUser(data.user); | |
} | |
})(); | |
}, [supabase.auth]); | |
return { user }; | |
} | |
================================================ | |
File: /src/hooks/usePaddlePrices.ts | |
================================================ | |
import { Paddle, PricePreviewParams, PricePreviewResponse } from '@paddle/paddle-js'; | |
import { useEffect, useState } from 'react'; | |
import { PricingTier } from '@/constants/pricing-tier'; | |
export type PaddlePrices = Record<string, string>; | |
function getLineItems(): PricePreviewParams['items'] { | |
const priceId = PricingTier.map((tier) => [tier.priceId.month, tier.priceId.year]); | |
return priceId.flat().map((priceId) => ({ priceId, quantity: 1 })); | |
} | |
function getPriceAmounts(prices: PricePreviewResponse) { | |
return prices.data.details.lineItems.reduce((acc, item) => { | |
acc[item.price.id] = item.formattedTotals.total; | |
return acc; | |
}, {} as PaddlePrices); | |
} | |
export function usePaddlePrices( | |
paddle: Paddle | undefined, | |
country: string, | |
): { prices: PaddlePrices; loading: boolean } { | |
const [prices, setPrices] = useState<PaddlePrices>({}); | |
const [loading, setLoading] = useState<boolean>(true); | |
useEffect(() => { | |
const paddlePricePreviewRequest: Partial<PricePreviewParams> = { | |
items: getLineItems(), | |
...(country !== 'OTHERS' && { address: { countryCode: country } }), | |
}; | |
setLoading(true); | |
paddle?.PricePreview(paddlePricePreviewRequest as PricePreviewParams).then((prices) => { | |
setPrices((prevState) => ({ ...prevState, ...getPriceAmounts(prices) })); | |
setLoading(false); | |
}); | |
}, [country, paddle]); | |
return { prices, loading }; | |
} | |
================================================ | |
File: /src/hooks/usePagination.ts | |
================================================ | |
import { useState } from 'react'; | |
export function usePagination() { | |
const [nextCursor, setNextCursor] = useState<string>(''); | |
const [cursorHistory, setCursorHistory] = useState<string[]>([]); | |
function goToNextPage(cursor: string) { | |
setCursorHistory([...cursorHistory, nextCursor]); | |
setNextCursor(cursor); | |
} | |
function goToPrevPage() { | |
const lastCursor = cursorHistory[cursorHistory.length - 1] ?? ''; | |
setCursorHistory(cursorHistory.slice(0, -1)); | |
setNextCursor(lastCursor); | |
} | |
return { | |
after: nextCursor, | |
hasPrev: cursorHistory.length > 0 || !!nextCursor, | |
goToNextPage, | |
goToPrevPage, | |
}; | |
} | |
================================================ | |
File: /src/utils/paddle/data-helpers.ts | |
================================================ | |
export function parseSDKResponse<T>(response: T): T { | |
return JSON.parse(JSON.stringify(response)); | |
} | |
export const ErrorMessage = 'Something went wrong, please try again later'; | |
export function getErrorMessage() { | |
return { error: ErrorMessage, data: [], hasMore: false, totalRecords: 0 }; | |
} | |
export function getPaymentReason(origin: string) { | |
if (origin === 'web' || origin === 'subscription_charge') { | |
return 'New'; | |
} else { | |
return 'Renewal of '; | |
} | |
} | |
================================================ | |
File: /src/utils/paddle/get-customer-id.ts | |
================================================ | |
import { createClient } from '@/utils/supabase/server'; | |
export async function getCustomerId() { | |
const supabase = createClient(); | |
const user = await supabase.auth.getUser(); | |
if (user.data.user?.email) { | |
const customersData = await supabase | |
.from('customers') | |
.select('customer_id,email') | |
.eq('email', user.data.user?.email) | |
.single(); | |
if (customersData?.data?.customer_id) { | |
return customersData?.data?.customer_id as string; | |
} | |
} | |
return ''; | |
} | |
================================================ | |
File: /src/utils/paddle/get-subscription.ts | |
================================================ | |
'use server'; | |
import { getCustomerId } from '@/utils/paddle/get-customer-id'; | |
import { ErrorMessage, parseSDKResponse } from '@/utils/paddle/data-helpers'; | |
import { getPaddleInstance } from '@/utils/paddle/get-paddle-instance'; | |
import { SubscriptionDetailResponse } from '@/lib/api.types'; | |
export async function getSubscription(subscriptionId: string): Promise<SubscriptionDetailResponse> { | |
try { | |
const customerId = await getCustomerId(); | |
if (customerId) { | |
const subscription = await getPaddleInstance().subscriptions.get(subscriptionId, { | |
include: ['next_transaction', 'recurring_transaction_details'], | |
}); | |
return { data: parseSDKResponse(subscription) }; | |
} | |
} catch (e) { | |
return { error: ErrorMessage }; | |
} | |
return { error: ErrorMessage }; | |
} | |
================================================ | |
File: /src/utils/paddle/get-subscriptions.ts | |
================================================ | |
'use server'; | |
import { getCustomerId } from '@/utils/paddle/get-customer-id'; | |
import { getPaddleInstance } from '@/utils/paddle/get-paddle-instance'; | |
import { SubscriptionResponse } from '@/lib/api.types'; | |
import { getErrorMessage } from '@/utils/paddle/data-helpers'; | |
export async function getSubscriptions(): Promise<SubscriptionResponse> { | |
try { | |
const customerId = await getCustomerId(); | |
if (customerId) { | |
const subscriptionCollection = getPaddleInstance().subscriptions.list({ customerId: [customerId], perPage: 20 }); | |
const subscriptions = await subscriptionCollection.next(); | |
return { | |
data: subscriptions, | |
hasMore: subscriptionCollection.hasMore, | |
totalRecords: subscriptionCollection.estimatedTotal, | |
}; | |
} | |
} catch (e) { | |
return getErrorMessage(); | |
} | |
return getErrorMessage(); | |
} | |
================================================ | |
File: /src/utils/paddle/get-transactions.ts | |
================================================ | |
'use server'; | |
import { getCustomerId } from '@/utils/paddle/get-customer-id'; | |
import { getErrorMessage, parseSDKResponse } from '@/utils/paddle/data-helpers'; | |
import { getPaddleInstance } from '@/utils/paddle/get-paddle-instance'; | |
import { TransactionResponse } from '@/lib/api.types'; | |
export async function getTransactions(subscriptionId: string, after: string): Promise<TransactionResponse> { | |
try { | |
const customerId = await getCustomerId(); | |
if (customerId) { | |
const transactionCollection = getPaddleInstance().transactions.list({ | |
customerId: [customerId], | |
after: after, | |
perPage: 10, | |
status: ['billed', 'paid', 'past_due', 'completed', 'canceled'], | |
subscriptionId: subscriptionId ? [subscriptionId] : undefined, | |
}); | |
const transactionData = await transactionCollection.next(); | |
return { | |
data: parseSDKResponse(transactionData ?? []), | |
hasMore: transactionCollection.hasMore, | |
totalRecords: transactionCollection.estimatedTotal, | |
error: undefined, | |
}; | |
} else { | |
return { data: [], hasMore: false, totalRecords: 0 }; | |
} | |
} catch (e) { | |
return getErrorMessage(); | |
} | |
} | |
================================================ | |
File: /src/utils/paddle/parse-money.ts | |
================================================ | |
export function convertAmountFromLowestUnit(amount: string, currency: string) { | |
switch (currency) { | |
case 'JPY': | |
case 'KRW': | |
return parseFloat(amount); | |
default: | |
return parseFloat(amount) / 100; | |
} | |
} | |
export function parseMoney(amount: string = '0', currency: string = 'USD') { | |
const parsedAmount = convertAmountFromLowestUnit(amount, currency); | |
return formatMoney(parsedAmount, currency); | |
} | |
export function formatMoney(amount: number = 0, currency: string = 'USD') { | |
const language = typeof navigator !== 'undefined' ? navigator.language : 'en-US'; | |
return new Intl.NumberFormat(language ?? 'en-US', { | |
style: 'currency', | |
currency: currency, | |
}).format(amount); | |
} | |
================================================ | |
File: /src/utils/paddle/get-paddle-instance.ts | |
================================================ | |
import { Environment, LogLevel, Paddle, PaddleOptions } from '@paddle/paddle-node-sdk'; | |
export function getPaddleInstance() { | |
const paddleOptions: PaddleOptions = { | |
environment: (process.env.NEXT_PUBLIC_PADDLE_ENV as Environment) ?? Environment.sandbox, | |
logLevel: LogLevel.error, | |
}; | |
if (!process.env.PADDLE_API_KEY) { | |
console.error('Paddle API key is missing'); | |
} | |
return new Paddle(process.env.PADDLE_API_KEY!, paddleOptions); | |
} | |
================================================ | |
File: /src/utils/paddle/process-webhook.ts | |
================================================ | |
import { | |
CustomerCreatedEvent, | |
CustomerUpdatedEvent, | |
EventEntity, | |
EventName, | |
SubscriptionCreatedEvent, | |
SubscriptionUpdatedEvent, | |
} from '@paddle/paddle-node-sdk'; | |
import { createClient } from '@/utils/supabase/server-internal'; | |
export class ProcessWebhook { | |
async processEvent(eventData: EventEntity) { | |
switch (eventData.eventType) { | |
case EventName.SubscriptionCreated: | |
case EventName.SubscriptionUpdated: | |
await this.updateSubscriptionData(eventData); | |
break; | |
case EventName.CustomerCreated: | |
case EventName.CustomerUpdated: | |
await this.updateCustomerData(eventData); | |
break; | |
} | |
} | |
private async updateSubscriptionData(eventData: SubscriptionCreatedEvent | SubscriptionUpdatedEvent) { | |
try { | |
const response = await createClient() | |
.from('subscriptions') | |
.upsert({ | |
subscription_id: eventData.data.id, | |
subscription_status: eventData.data.status, | |
price_id: eventData.data.items[0].price?.id ?? '', | |
product_id: eventData.data.items[0].price?.productId ?? '', | |
scheduled_change: eventData.data.scheduledChange?.effectiveAt, | |
customer_id: eventData.data.customerId, | |
}) | |
.select(); | |
console.log(response); | |
} catch (e) { | |
console.error(e); | |
} | |
} | |
private async updateCustomerData(eventData: CustomerCreatedEvent | CustomerUpdatedEvent) { | |
try { | |
const response = await createClient() | |
.from('customers') | |
.upsert({ | |
customer_id: eventData.data.id, | |
email: eventData.data.email, | |
}) | |
.select(); | |
console.log(response); | |
} catch (e) { | |
console.error(e); | |
} | |
} | |
} | |
================================================ | |
File: /src/utils/supabase/middleware.ts | |
================================================ | |
import { type CookieOptions, createServerClient } from '@supabase/ssr'; | |
import { type NextRequest, NextResponse } from 'next/server'; | |
export async function updateSession(request: NextRequest) { | |
let response = NextResponse.next({ | |
request: { | |
headers: request.headers, | |
}, | |
}); | |
const supabase = createServerClient( | |
process.env.NEXT_PUBLIC_SUPABASE_URL!, | |
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, | |
{ | |
cookies: { | |
get(name: string) { | |
return request.cookies.get(name)?.value; | |
}, | |
set(name: string, value: string, options: CookieOptions) { | |
request.cookies.set({ | |
name, | |
value, | |
...options, | |
}); | |
response = NextResponse.next({ | |
request: { | |
headers: request.headers, | |
}, | |
}); | |
response.cookies.set({ | |
name, | |
value, | |
...options, | |
}); | |
}, | |
remove(name: string, options: CookieOptions) { | |
request.cookies.set({ | |
name, | |
value: '', | |
...options, | |
}); | |
response = NextResponse.next({ | |
request: { | |
headers: request.headers, | |
}, | |
}); | |
response.cookies.set({ | |
name, | |
value: '', | |
...options, | |
}); | |
}, | |
}, | |
}, | |
); | |
await supabase.auth.getUser(); | |
return response; | |
} | |
================================================ | |
File: /src/utils/supabase/server.ts | |
================================================ | |
import { type CookieOptions, createServerClient } from '@supabase/ssr'; | |
import { cookies } from 'next/headers'; | |
export function createClient() { | |
const cookieStore = cookies(); | |
return createServerClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { | |
cookies: { | |
get(name: string) { | |
return cookieStore.get(name)?.value; | |
}, | |
set(name: string, value: string, options: CookieOptions) { | |
try { | |
cookieStore.set({ name, value, ...options }); | |
} catch (error) { | |
// The `set` method was called from a Server Component. | |
// This can be ignored if you have middleware refreshing | |
// user sessions. | |
} | |
}, | |
remove(name: string, options: CookieOptions) { | |
try { | |
cookieStore.set({ name, value: '', ...options }); | |
} catch (error) { | |
// The `delete` method was called from a Server Component. | |
// This can be ignored if you have middleware refreshing | |
// user sessions. | |
} | |
}, | |
}, | |
}); | |
} | |
export async function validateUserSession() { | |
const supabase = createClient(); | |
const { | |
data: { session }, | |
} = await supabase.auth.getSession(); | |
if (!session) { | |
throw new Error('You are not allowed to perform this action.'); | |
} | |
} | |
================================================ | |
File: /src/utils/supabase/client.ts | |
================================================ | |
import { createBrowserClient } from '@supabase/ssr'; | |
export function createClient() { | |
return createBrowserClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!); | |
} | |
================================================ | |
File: /src/utils/supabase/server-internal.ts | |
================================================ | |
import { type CookieOptions, createServerClient } from '@supabase/ssr'; | |
import { cookies } from 'next/headers'; | |
export function createClient() { | |
const cookieStore = cookies(); | |
return createServerClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, { | |
cookies: { | |
get(name: string) { | |
return cookieStore.get(name)?.value; | |
}, | |
set(name: string, value: string, options: CookieOptions) { | |
try { | |
cookieStore.set({ name, value, ...options }); | |
} catch (error) { | |
// The `set` method was called from a Server Component. | |
// This can be ignored if you have middleware refreshing | |
// user sessions. | |
} | |
}, | |
remove(name: string, options: CookieOptions) { | |
try { | |
cookieStore.set({ name, value: '', ...options }); | |
} catch (error) { | |
// The `delete` method was called from a Server Component. | |
// This can be ignored if you have middleware refreshing | |
// user sessions. | |
} | |
}, | |
}, | |
}); | |
} | |
================================================ | |
File: /src/app/api/webhook/route.ts | |
================================================ | |
import { NextRequest } from 'next/server'; | |
import { ProcessWebhook } from '@/utils/paddle/process-webhook'; | |
import { getPaddleInstance } from '@/utils/paddle/get-paddle-instance'; | |
const webhookProcessor = new ProcessWebhook(); | |
export async function POST(request: NextRequest) { | |
const signature = request.headers.get('paddle-signature') || ''; | |
const rawRequestBody = await request.text(); | |
const privateKey = process.env['PADDLE_NOTIFICATION_WEBHOOK_SECRET'] || ''; | |
let status, eventName; | |
try { | |
if (signature && rawRequestBody) { | |
const paddle = getPaddleInstance(); | |
const eventData = paddle.webhooks.unmarshal(rawRequestBody, privateKey, signature); | |
status = 200; | |
eventName = eventData?.eventType ?? 'Unknown event'; | |
if (eventData) { | |
await webhookProcessor.processEvent(eventData); | |
} | |
} else { | |
status = 400; | |
console.log('Missing signature from header'); | |
} | |
} catch (e) { | |
// Handle error | |
status = 500; | |
console.log(e); | |
} | |
return Response.json({ status, eventName }); | |
} | |
================================================ | |
File: /src/app/checkout/[priceId]/page.tsx | |
================================================ | |
import { CheckoutGradients } from '@/components/gradients/checkout-gradients'; | |
import '../../../styles/checkout.css'; | |
import { CheckoutHeader } from '@/components/checkout/checkout-header'; | |
import { CheckoutContents } from '@/components/checkout/checkout-contents'; | |
import { createClient } from '@/utils/supabase/server'; | |
export default async function CheckoutPage() { | |
const supabase = createClient(); | |
const { data } = await supabase.auth.getUser(); | |
return ( | |
<div className={'w-full min-h-screen relative overflow-hidden'}> | |
<CheckoutGradients /> | |
<div | |
className={'mx-auto max-w-6xl relative px-[16px] md:px-[32px] py-[24px] flex flex-col gap-6 justify-between'} | |
> | |
<CheckoutHeader /> | |
<CheckoutContents userEmail={data.user?.email} /> | |
</div> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/app/checkout/success/page.tsx | |
================================================ | |
import { SuccessPageGradients } from '@/components/gradients/success-page-gradients'; | |
import Image from 'next/image'; | |
import { Button } from '@/components/ui/button'; | |
import Link from 'next/link'; | |
import { PoweredByPaddle } from '@/components/home/footer/powered-by-paddle'; | |
import '../../../styles/checkout.css'; | |
import { createClient } from '@/utils/supabase/server'; | |
export default async function SuccessPage() { | |
const supabase = createClient(); | |
const { data } = await supabase.auth.getUser(); | |
return ( | |
<main> | |
<div className={'relative h-screen overflow-hidden'}> | |
<SuccessPageGradients /> | |
<div className={'absolute inset-0 px-6 flex items-center justify-center'}> | |
<div className={'flex flex-col items-center text-white text-center'}> | |
<Image | |
className={'pb-12'} | |
src={'/assets/icons/logo/aeroedit-success-icon.svg'} | |
alt={'Success icon'} | |
height={96} | |
width={96} | |
/> | |
<h1 className={'text-4xl md:text-[80px] leading-9 md:leading-[80px] font-medium pb-6'}> | |
Payment successful | |
</h1> | |
<p className={'text-lg pb-16'}>Success! Your payment is complete, and you’re all set.</p> | |
<Button variant={'secondary'} asChild={true}> | |
{data.user ? <Link href={'/dashboard'}>Go to Dashboard</Link> : <Link href={'/'}>Go to Home</Link>} | |
</Button> | |
</div> | |
</div> | |
<div className={'absolute bottom-0 w-full'}> | |
<PoweredByPaddle /> | |
</div> | |
</div> | |
</main> | |
); | |
} | |
================================================ | |
File: /src/app/page.tsx | |
================================================ | |
import { HomePage } from '@/components/home/home-page'; | |
export default function Home() { | |
return <HomePage />; | |
} | |
================================================ | |
File: /src/app/auth/callback/route.ts | |
================================================ | |
import { NextResponse } from 'next/server'; | |
import { createClient } from '@/utils/supabase/server'; | |
export async function GET(request: Request) { | |
const { searchParams, origin } = new URL(request.url); | |
const code = searchParams.get('code'); | |
// if "next" is in param, use it as the redirect URL | |
const next = searchParams.get('next') ?? '/'; | |
if (code) { | |
const supabase = createClient(); | |
const { error } = await supabase.auth.exchangeCodeForSession(code); | |
if (!error) { | |
return NextResponse.redirect(`${origin}${next}`); | |
} | |
} | |
// return the user to an error page with instructions | |
return NextResponse.redirect(`${origin}/auth/auth-code-error`); | |
} | |
================================================ | |
File: /src/app/error/page.tsx | |
================================================ | |
import type { Metadata } from 'next'; | |
export const metadata: Metadata = { | |
title: 'AeroEdit - Error', | |
}; | |
export default function ErrorPage() { | |
return ( | |
<div className="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8"> | |
<p className="mt-10 text-center text-xl font-bold leading-9 tracking-tight text-primary"> | |
Something went wrong, please try again later | |
</p> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/app/dashboard/page.tsx | |
================================================ | |
import { DashboardPageHeader } from '@/components/dashboard/layout/dashboard-page-header'; | |
import { DashboardLandingPage } from '@/components/dashboard/landing/dashboard-landing-page'; | |
export default function LandingPage() { | |
return ( | |
<main className="flex flex-1 flex-col gap-4 p-4 lg:gap-6 lg:p-8"> | |
<DashboardPageHeader pageTitle={'Dashboard'} /> | |
<DashboardLandingPage /> | |
</main> | |
); | |
} | |
================================================ | |
File: /src/app/dashboard/payments/page.tsx | |
================================================ | |
import { DashboardPageHeader } from '@/components/dashboard/layout/dashboard-page-header'; | |
import { PaymentsContent } from '@/components/dashboard/payments/payments-content'; | |
import { LoadingScreen } from '@/components/dashboard/layout/loading-screen'; | |
import { Suspense } from 'react'; | |
export default async function PaymentsPage() { | |
return ( | |
<main className="flex flex-1 flex-col gap-4 p-4 lg:gap-6 lg:p-8"> | |
<DashboardPageHeader pageTitle={'Payments'} /> | |
<Suspense fallback={<LoadingScreen />}> | |
<PaymentsContent subscriptionId={''} /> | |
</Suspense> | |
</main> | |
); | |
} | |
================================================ | |
File: /src/app/dashboard/payments/[subscriptionId]/page.tsx | |
================================================ | |
'use client'; | |
import { DashboardPageHeader } from '@/components/dashboard/layout/dashboard-page-header'; | |
import { PaymentsContent } from '@/components/dashboard/payments/payments-content'; | |
import { LoadingScreen } from '@/components/dashboard/layout/loading-screen'; | |
import { Suspense } from 'react'; | |
import { useParams } from 'next/navigation'; | |
export default function SubscriptionsPaymentPage() { | |
const { subscriptionId } = useParams<{ subscriptionId: string }>(); | |
return ( | |
<main className="flex flex-1 flex-col gap-4 p-4 lg:gap-6 lg:p-8"> | |
<DashboardPageHeader pageTitle={'Payments'} /> | |
<Suspense fallback={<LoadingScreen />}> | |
<PaymentsContent subscriptionId={subscriptionId} /> | |
</Suspense> | |
</main> | |
); | |
} | |
================================================ | |
File: /src/app/dashboard/subscriptions/page.tsx | |
================================================ | |
import { LoadingScreen } from '@/components/dashboard/layout/loading-screen'; | |
import { Suspense } from 'react'; | |
import { Subscriptions } from '@/components/dashboard/subscriptions/subscriptions'; | |
export default async function SubscriptionsListPage() { | |
return ( | |
<main className="p-4 lg:gap-6 lg:p-8"> | |
<Suspense fallback={<LoadingScreen />}> | |
<Subscriptions /> | |
</Suspense> | |
</main> | |
); | |
} | |
================================================ | |
File: /src/app/dashboard/subscriptions/actions.ts | |
================================================ | |
'use server'; | |
import { validateUserSession } from '@/utils/supabase/server'; | |
import { Subscription } from '@paddle/paddle-node-sdk'; | |
import { revalidatePath } from 'next/cache'; | |
import { getPaddleInstance } from '@/utils/paddle/get-paddle-instance'; | |
const paddle = getPaddleInstance(); | |
interface Error { | |
error: string; | |
} | |
export async function cancelSubscription(subscriptionId: string): Promise<Subscription | Error> { | |
try { | |
await validateUserSession(); | |
const subscription = await paddle.subscriptions.cancel(subscriptionId, { effectiveFrom: 'next_billing_period' }); | |
if (subscription) { | |
revalidatePath('/dashboard/subscriptions'); | |
} | |
return JSON.parse(JSON.stringify(subscription)); | |
} catch (e) { | |
console.log('Error canceling subscription', e); | |
return { error: 'Something went wrong, please try again later' }; | |
} | |
} | |
================================================ | |
File: /src/app/dashboard/subscriptions/[subscriptionId]/page.tsx | |
================================================ | |
'use client'; | |
import { LoadingScreen } from '@/components/dashboard/layout/loading-screen'; | |
import { Suspense } from 'react'; | |
import { useParams } from 'next/navigation'; | |
import { SubscriptionDetail } from '@/components/dashboard/subscriptions/components/subscription-detail'; | |
export default function SubscriptionPage() { | |
const { subscriptionId } = useParams<{ subscriptionId: string }>(); | |
return ( | |
<main className="p-4 lg:gap-6 lg:p-8"> | |
<Suspense fallback={<LoadingScreen />}> | |
<SubscriptionDetail subscriptionId={subscriptionId} /> | |
</Suspense> | |
</main> | |
); | |
} | |
================================================ | |
File: /src/app/dashboard/layout.tsx | |
================================================ | |
import { ReactNode } from 'react'; | |
import { DashboardLayout } from '@/components/dashboard/layout/dashboard-layout'; | |
import { createClient } from '@/utils/supabase/server'; | |
import { redirect } from 'next/navigation'; | |
interface Props { | |
children: ReactNode; | |
} | |
export default async function Layout({ children }: Props) { | |
const supabase = createClient(); | |
const { data } = await supabase.auth.getUser(); | |
if (!data.user) { | |
redirect('/login'); | |
} | |
return <DashboardLayout>{children}</DashboardLayout>; | |
} | |
================================================ | |
File: /src/app/login/page.tsx | |
================================================ | |
import { LoginGradient } from '@/components/gradients/login-gradient'; | |
import '../../styles/login.css'; | |
import { LoginCardGradient } from '@/components/gradients/login-card-gradient'; | |
import { LoginForm } from '@/components/authentication/login-form'; | |
import { GhLoginButton } from '@/components/authentication/gh-login-button'; | |
export default function LoginPage() { | |
return ( | |
<div> | |
<LoginGradient /> | |
<div className={'flex flex-col'}> | |
<div | |
className={ | |
'mx-auto mt-[112px] bg-background/80 w-[343px] md:w-[488px] gap-5 flex-col rounded-lg rounded-b-none login-card-border backdrop-blur-[6px]' | |
} | |
> | |
<LoginCardGradient /> | |
<LoginForm /> | |
</div> | |
<GhLoginButton label={'Log in with GitHub'} /> | |
<div | |
className={ | |
'mx-auto w-[343px] md:w-[488px] bg-background/80 backdrop-blur-[6px] px-6 md:px-16 pt-0 py-8 gap-6 flex flex-col items-center justify-center rounded-b-lg' | |
} | |
> | |
<div className={'text-center text-muted-foreground text-sm mt-4 font-medium'}> | |
Don’t have an account?{' '} | |
<a href={'/signup'} className={'text-white'}> | |
Sign up | |
</a> | |
</div> | |
</div> | |
</div> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/app/login/actions.ts | |
================================================ | |
'use server'; | |
import { revalidatePath } from 'next/cache'; | |
import { redirect } from 'next/navigation'; | |
import { createClient } from '@/utils/supabase/server'; | |
interface FormData { | |
email: string; | |
password: string; | |
} | |
export async function login(data: FormData) { | |
const supabase = createClient(); | |
const { error } = await supabase.auth.signInWithPassword(data); | |
if (error) { | |
return { error: true }; | |
} | |
revalidatePath('/', 'layout'); | |
redirect('/'); | |
} | |
export async function signInWithGithub() { | |
const supabase = createClient(); | |
const { data } = await supabase.auth.signInWithOAuth({ | |
provider: 'github', | |
options: { | |
redirectTo: `https://paddle-billing.vercel.app/auth/callback`, | |
}, | |
}); | |
if (data.url) { | |
redirect(data.url); | |
} | |
} | |
export async function loginAnonymously() { | |
const supabase = createClient(); | |
const { error: signInError } = await supabase.auth.signInAnonymously(); | |
const { error: updateUserError } = await supabase.auth.updateUser({ | |
email: `anonymous+${Date.now().toString(36)}@example.com`, | |
}); | |
if (signInError || updateUserError) { | |
return { error: true }; | |
} | |
revalidatePath('/', 'layout'); | |
redirect('/'); | |
} | |
================================================ | |
File: /src/app/layout.tsx | |
================================================ | |
import { Inter } from 'next/font/google'; | |
import '../styles/globals.css'; | |
import '../styles/layout.css'; | |
import { ReactNode } from 'react'; | |
import type { Metadata } from 'next'; | |
import { Toaster } from '@/components/ui/toaster'; | |
const inter = Inter({ subsets: ['latin'] }); | |
export const metadata: Metadata = { | |
metadataBase: new URL('https://paddle-billing.vercel.app'), | |
title: 'AeroEdit', | |
description: | |
'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.', | |
}; | |
export default function RootLayout({ | |
children, | |
}: Readonly<{ | |
children: ReactNode; | |
}>) { | |
return ( | |
<html lang="en" className={'min-h-full dark'}> | |
<body className={inter.className}> | |
{children} | |
<Toaster /> | |
</body> | |
</html> | |
); | |
} | |
================================================ | |
File: /src/app/signup/page.tsx | |
================================================ | |
import { LoginGradient } from '@/components/gradients/login-gradient'; | |
import '../../styles/login.css'; | |
import { LoginCardGradient } from '@/components/gradients/login-card-gradient'; | |
import { GhLoginButton } from '@/components/authentication/gh-login-button'; | |
import { SignupForm } from '@/components/authentication/sign-up-form'; | |
export default function SignupPage() { | |
return ( | |
<div> | |
<LoginGradient /> | |
<div className={'flex flex-col'}> | |
<div | |
className={ | |
'mx-auto mt-[112px] bg-background/80 w-[343px] md:w-[488px] gap-5 flex-col rounded-lg rounded-b-none login-card-border backdrop-blur-[6px]' | |
} | |
> | |
<LoginCardGradient /> | |
<SignupForm /> | |
</div> | |
<GhLoginButton label={'Sign up with GitHub'} /> | |
<div | |
className={ | |
'mx-auto w-[343px] md:w-[488px] bg-background/80 backdrop-blur-[6px] px-6 md:px-16 pt-0 py-8 gap-6 flex flex-col items-center justify-center rounded-b-lg' | |
} | |
> | |
<div className={'text-center text-muted-foreground text-sm mt-4 font-medium'}> | |
Already have an account?{' '} | |
<a href={'/login'} className={'text-white'}> | |
Log in | |
</a> | |
</div> | |
</div> | |
</div> | |
</div> | |
); | |
} | |
================================================ | |
File: /src/app/signup/actions.ts | |
================================================ | |
'use server'; | |
import { revalidatePath } from 'next/cache'; | |
import { redirect } from 'next/navigation'; | |
import { createClient } from '@/utils/supabase/server'; | |
interface FormData { | |
email: string; | |
password: string; | |
} | |
export async function signup(data: FormData) { | |
const supabase = createClient(); | |
const { error } = await supabase.auth.signUp(data); | |
if (error) { | |
return { error: true }; | |
} | |
revalidatePath('/', 'layout'); | |
redirect('/'); | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment