├── .env.local.example
├── .eslintrc.json
├── .gitignore
├── LICENSE
├── README.md
├── bun.lockb
├── components.json
├── delete-me
├── deplyoment-env.png
└── github-banner.png
├── next.config.js
├── package.json
├── postcss.config.js
├── prettier.config.js
├── public
├── example1.png
├── example2.png
├── example3.png
├── example4.png
├── example5.png
├── example6.png
├── example7.png
├── example8.png
├── example9.png
├── hero-shape.png
├── logo.png
└── section-bg.png
├── src
├── app
│ ├── (account)
│ │ ├── account
│ │ │ └── page.tsx
│ │ └── manage-subscription
│ │ │ └── route.ts
│ ├── (auth)
│ │ ├── auth-actions.ts
│ │ ├── auth-ui.tsx
│ │ ├── auth
│ │ │ └── callback
│ │ │ │ └── route.ts
│ │ ├── login
│ │ │ └── page.tsx
│ │ └── signup
│ │ │ └── page.tsx
│ ├── api
│ │ └── webhooks
│ │ │ └── route.ts
│ ├── favicon.ico
│ ├── layout.tsx
│ ├── navigation.tsx
│ ├── page.tsx
│ └── pricing
│ │ └── page.tsx
├── components
│ ├── account-menu.tsx
│ ├── container.tsx
│ ├── logo.tsx
│ ├── sexy-boarder.tsx
│ └── ui
│ │ ├── button.tsx
│ │ ├── collapsible.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── input.tsx
│ │ ├── sheet.tsx
│ │ ├── tabs.tsx
│ │ ├── toast.tsx
│ │ ├── toaster.tsx
│ │ └── use-toast.ts
├── features
│ ├── account
│ │ └── controllers
│ │ │ ├── get-customer-id.ts
│ │ │ ├── get-or-create-customer.ts
│ │ │ ├── get-session.ts
│ │ │ ├── get-subscription.ts
│ │ │ ├── get-user.ts
│ │ │ └── upsert-user-subscription.ts
│ ├── emails
│ │ ├── tailwind.config.ts
│ │ └── welcome.tsx
│ └── pricing
│ │ ├── actions
│ │ └── create-checkout-action.ts
│ │ ├── components
│ │ ├── price-card.tsx
│ │ └── pricing-section.tsx
│ │ ├── controllers
│ │ ├── get-products.ts
│ │ ├── upsert-price.ts
│ │ └── upsert-product.ts
│ │ ├── models
│ │ └── product-metadata.ts
│ │ └── types.ts
├── libs
│ ├── resend
│ │ └── resend-client.ts
│ ├── stripe
│ │ └── stripe-admin.ts
│ └── supabase
│ │ ├── supabase-admin.ts
│ │ ├── supabase-middleware-client.ts
│ │ ├── supabase-server-client.ts
│ │ └── types.ts
├── middleware.ts
├── styles
│ └── globals.css
├── types
│ └── action-response.ts
└── utils
│ ├── cn.ts
│ ├── get-env-var.ts
│ ├── get-url.ts
│ └── to-date-time.ts
├── stripe-fixtures.json
├── supabase
├── migrations
│ └── 20240115041359_init.sql
└── seed.sql
├── tailwind.config.ts
└── tsconfig.json
/.env.local.example:
--------------------------------------------------------------------------------
1 | # Update these with your Supabase details from your project settings > API
2 | NEXT_PUBLIC_SUPABASE_URL=UPDATE_THIS_wITH_YOUR_SUPABASE_URL
3 | NEXT_PUBLIC_SUPABASE_ANON_KEY=UPDATE_THIS_wITH_YOUR_SUPABASE_ANON_KEY
4 | SUPABASE_SERVICE_ROLE_KEY=UPDATE_THIS_wITH_YOUR_SUPABASE_SUPABASE_SERVICE_ROLE_KEY
5 | SUPABASE_DB_PASSWORD=UPDATE_THIS_wITH_YOUR_SUPABASE_SUPABASE_DB_PASSWORD
6 |
7 | # Update these with your Stripe credentials from https://dashboard.stripe.com/apikeys
8 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=UPDATE_THIS_wITH_YOUR_STRIPE_PK
9 | STRIPE_SECRET_KEY=UPDATE_THIS_wITH_YOUR_STRIPE_SK
10 | STRIPE_WEBHOOK_SECRET=UPDATE_THIS_wITH_YOUR_STRIPE_WHSEC
11 |
12 | # Update this with your resend.com credentials from https://resend.com/api-keys
13 | RESEND_API_KEY=re_test_1234
14 |
15 | # Env
16 | NEXT_PUBLIC_SITE_URL=http://localhost:3000
17 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals"],
3 | "plugins": ["simple-import-sort"],
4 | "rules": {
5 | "simple-import-sort/imports": "error",
6 | "simple-import-sort/exports": "error"
7 | },
8 | "overrides": [
9 | {
10 | "files": ["**/*.js", "**/*.ts", "**/*.tsx"],
11 | "rules": {
12 | "simple-import-sort/imports": [
13 | "error",
14 | {
15 | "groups": [
16 | ["^react$", "^next", "^[a-z]"],
17 | ["^~", "^@"],
18 | ["^\\.\\.(?!/?$)", "^\\.\\./?$"],
19 | ["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"],
20 | ["^.+\\.s?css$"],
21 | ["^\\u0000"]
22 | ]
23 | }
24 | ]
25 | }
26 | }
27 | ]
28 | }
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | # supabase
39 | /supabase/.temp/
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Kolby Sisk
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
next-supabase-stripe-starter
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | See the demo
15 |
16 |
17 | ## Introduction
18 |
19 | Bootstrap your SaaS with a modern tech stack built to move quick. Follow the guide to get started.
20 |
21 | ### What's included
22 |
23 | - Next.js 15
24 | - [Supabase](https://supabase.com) - Postgres database & user authentication
25 | - [Stripe](https://stripe.com) - [Checkout](https://stripe.com/docs/payments/checkout), [subscriptions](https://stripe.com/docs/billing/subscriptions/overview), and [customer portal](https://stripe.com/docs/customer-management)
26 | - [React Email](https://react.email/) - Easily build emails and send them with [Resend](https://resend.com)
27 | - [Tailwindcss](https://tailwindcss.com/) - CSS framework
28 | - [shadcn/ui](https://ui.shadcn.com) - Prebuilt accessible components
29 | - Webhooks to automatically synchronize Stripe with Supabase
30 | - Stripe fixture to bootstrap product data
31 | - Supabase migrations to bootstrap and manage your db schema
32 | - Responsive, performant, and accessible prebuilt pages
33 | - Animated button borders! Now you can look cool without nerds saying you shipped too late
34 |
35 | ## Getting started
36 |
37 | ### 1. Setup Supabase
38 |
39 | 1. Go to [supabase.com](https://supabase.com) and create a project
40 | 1. Go to Project Settings → Database → Database password and click reset database password then click generate a new password. (I know you already made one, but this fixes a [bug with their CLI where it doesn't like special characters in the password](https://github.com/supabase/supabase/issues/15184))
41 | 1. Save this password somewhere, you can't see it after closing the box
42 |
43 | ### 2. Setup Stripe
44 |
45 | 1. Go to [stripe.com](https://stripe.com) and create a project
46 | 1. Go to [Customer Portal Settings](https://dashboard.stripe.com/test/settings/billing/portal) and click the `Active test link` button
47 |
48 | ### 3. Setup Resend
49 |
50 | 1. Go to [resend.com](https://resend.com) and create an account
51 | 1. Go to the [API Keys page](https://resend.com/api-keys) and create an API Key
52 | 1. Add the [Supabase Resend integration](https://supabase.com/partners/integrations/resend)
53 |
54 | ### 4. Deploy
55 |
56 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FKolbySisk%2Fnext-supabase-stripe-starter&env=NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SUPABASE_DB_PASSWORD,NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,RESEND_API_KEY&demo-title=AI%20Twitter%20Banner%20Demo&demo-url=https%3A%2F%2Fai-twitter-banner.vercel.app&integration-ids=oac_VqOgBHqhEoFTPzGkPd7L0iH6)
57 |
58 | 1. Next click the deploy button ⬆️
59 | 1. On the form create a new repo and add the Supabase integration
60 | 1. Add the environment variables that you have available. For the stripe webhook secret just put any value - we will come back to update this after configuring the webhook
61 | 1. Click Deploy
62 | 1. While you wait, clone your new repo and open it in your code editor. Then create a file named `.env.local`. Copy and pase the contents of `.env.local.example` into this file and add the correct values. They should be the same values you added in above.
63 |
64 | 
65 |
66 | ### 5. Stripe Webhook
67 |
68 | 1. After deploying go to your Vercel dashboard and find your Vercel URL
69 | 1. Next go to your Stripe dashboard, click `Developers` in the top nav, and then the `Webhooks` tab
70 | 1. Add an endpoint. Enter your Vercel URL followed by `/api/webhooks`
71 | 1. Click `Select events`
72 | 1. Check `Select all events`
73 | 1. Scroll to the bottom of the page and click `Add endpoint`
74 | 1. Click to `Reveal` signing secret and copy it
75 | 1. Go to your `Vercel project settings` → `Environment Variables`
76 | 1. Update the value of the `STRIPE_WEBHOOK_SECRET` env with your newly acquired webhook secret. Press `Save`
77 |
78 | ### 6. Run Supabase Migration
79 |
80 | Now we're going to run the initial [Supabase Migration](https://supabase.com/docs/reference/cli/supabase-migration-new) to create your database tables.
81 |
82 | 1. Run `bunx supabase login`
83 | 1. Run `bunx supabase init`
84 | 1. Open your `package.json` and update both `UPDATE_THIS_WITH_YOUR_SUPABASE_PROJECT_ID` strings with your supabase project id
85 | 1. Run `bun run supabase:link`
86 | 1. Run `bun run migration:up`
87 |
88 | ### 7. Run Stripe Fixture
89 |
90 | [Stripe fixtures](https://stripe.com/docs/cli/fixtures) are an easy way to configure your product offering without messing around in the Stripe UI.
91 |
92 | 1. Install the [Stripe CLI](https://stripe.com/docs/stripe-cli#install). For Macs run: `brew install stripe/stripe-cli/stripe`
93 | 1. Run (make sure to update the command with your Stripe sk) `stripe fixtures ./stripe-fixtures.json --api-key UPDATE_THIS_WITH_YOUR_STRIPE_SK`
94 |
95 | ### 8. Last steps
96 |
97 | 1. Do a `Search All` in your code editor for `UPDATE_THIS` and update all instances with the relevant value (**except for .env.local.example!**)
98 | 1. Delete the `delete-me` dir
99 |
100 | ### 9. Check it out!
101 |
102 | You did it! You should be able to look in your Stripe dashboard and see your products, and you should also see the same data has been populated in your Supabase database. Now let's test everything.
103 |
104 | 1. Run `bun i`
105 | 1. Run `bun run dev`.
106 | 1. Go to the app and click `Get started for free` - this will take you to the login page
107 | 1. We haven't configured auth providers, so for now click `Continue with Email` and submit your email address
108 | 1. Click the link sent to your email and you should be redirected back to your app - authenticated
109 | 1. Click `Get Started` on one of the plans. This will take you to a Stripe checkout page (In test mode)
110 | 1. Enter `4242424242424242` as your credit card number. Fill out the rest of the form with any valid data and click Subscribe
111 | 1. You should be redirect to the Account page where you can see your active subscription
112 | 1. Click the `Manage your subscription` button
113 |
114 | **That's the end of the setup. The following are guides to help you code in your new codebase.**
115 |
116 | ---
117 |
118 | ## Guides
119 |
120 | ### Managing products
121 |
122 | Your products and prices are managed via the `stripe-fixtures.json` file. You can delete your test data in Stripe on the [Developers page](https://dashboard.stripe.com/test/developers), make the changes you'd like, and then run the fixture command from above. When changes are made in Stripe the webhook hits the api route at `src/app/api/webhooks`. The handler will synchronize the data sent from Stripe to your Supabase database.
123 |
124 | The `metadata` field in your fixture is where we can store info about the product that can be used in your app. For example, say you have a basic product, and one of the features of the product includes a max number of team invites. You can add a field to the metadata like `team_invites`. Then update the Zod schema in `src/features/pricing/models/product-metadata.ts`
125 |
126 | Then you can make use of it like this:
127 |
128 | ```ts
129 | const products = await getProducts();
130 | const productMetadata = productMetadataSchema.parse(products[0].metadata); // Now it's typesafe 🙌!
131 | productMetadata.teamInvites; // The value you set in the fixture
132 | ```
133 |
134 | ### Managing your database schema
135 |
136 | [Migrations](https://supabase.com/docs/reference/cli/supabase-migration-new) are a powerful concept for managing your database schema. Any changes you make to your database schema should be done through migrations.
137 |
138 | Say you want to add a table named `invites`.
139 |
140 | First run `npm run migration:new add-invites-table`
141 | Then edit your file to include:
142 |
143 | ```sql
144 | create table invites (
145 | id uuid not null primary key default gen_random_uuid(),
146 | email text not null,
147 | );
148 | alter table invites enable row level security;
149 | ```
150 |
151 | Then run `npm run migration:up` and your table will be added.
152 |
153 | ### Configuring auth providers
154 |
155 | There are many auth providers you can choose from. [See the Supabase docs](https://supabase.com/docs/guides/auth#providers) for the full the list and their respective guides to configure them.
156 |
157 | ### Styling
158 |
159 | - [Learn more about shadcn/ui components](https://ui.shadcn.com/docs)
160 | - [Learn more about theming with shadcn/ui](https://ui.shadcn.com/docs/theming)
161 | - [Learn more about the Tailwindcss theme config](https://tailwindcss.com/docs/theme)
162 |
163 | ### Emails
164 |
165 | Your emails live in the `src/features/emails` dir. Emails are finicky and difficult to style correctly, so make sure to reference the [React Email docs](https://react.email/docs/introduction). After creating your email component, sending an email is as simple as:
166 |
167 | ```ts
168 | import WelcomeEmail from '@/features/emails/welcome';
169 | import { resendClient } from '@/libs/resend/resend-client';
170 |
171 | resendClient.emails.send({
172 | from: 'no-reply@your-domain.com',
173 | to: userEmail,
174 | subject: 'Welcome!',
175 | react: ,
176 | });
177 | ```
178 |
179 | ### File structure
180 |
181 | The file structure uses the group by `feature` concept. This is where you will colocate code related to a specific feature, with the exception of UI code. Typically you want to keep your UI code in the `app` dir, with the exception of reusable components. Most of the time reusable components will be agnostic to a feature and should live in the `components` dir. The `components/ui` dir is where `shadcn/ui` components are generated to.
182 |
183 | ### Going live
184 |
185 | Follow these steps when you're ready to go live:
186 |
187 | 1. Activate your Stripe account and set the dashboard to live mode
188 | 1. Repeat the steps above to create a Stripe webhook in live mode, this time using your live url
189 | 1. Update Vercel env variables with your live Stripe pk, sk, and whsec
190 | 1. After Vercel has redeployed with your new env variables, run the fixture command using your Stripe sk
191 |
192 | ---
193 |
194 | ## Support
195 |
196 | If you need help with the setup, or developing in the codebase, feel free to reach out to me on Twitter [@kolbysisk](https://twitter.com/KolbySisk) - I'm always happy to help.
197 |
198 | ## Contribute
199 |
200 | PRs are always welcome.
201 |
202 | ---
203 |
204 | This project was inspired by Vercel's [nextjs-subscription-payments](https://github.com/vercel/nextjs-subscription-payments).
205 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KolbySisk/next-supabase-stripe-starter/d193a27501e94ebccdf017c8b60b05a09c2f0834/bun.lockb
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/styles/global.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/utils/cn"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/delete-me/deplyoment-env.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KolbySisk/next-supabase-stripe-starter/d193a27501e94ebccdf017c8b60b05a09c2f0834/delete-me/deplyoment-env.png
--------------------------------------------------------------------------------
/delete-me/github-banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KolbySisk/next-supabase-stripe-starter/d193a27501e94ebccdf017c8b60b05a09c2f0834/delete-me/github-banner.png
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | module.exports = nextConfig;
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "UPDATE_THIS_WITH_YOUR_APP_NAME",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "email:build": "email build",
11 | "email:dev": "email dev --dir ./src/features/emails --port 3001",
12 | "email:export": "email export",
13 | "stripe:listen": "stripe listen --forward-to=localhost:3000/api/webhooks --project-name=UPDATE_THIS_WITH_YOUR_STRIPE_PROJECT_NAME",
14 | "generate-types": "npx supabase gen types typescript --project-id UPDATE_THIS_WITH_YOUR_SUPABASE_PROJECT_ID --schema public > src/libs/supabase/types.ts",
15 | "supabase:link": "env-cmd -f ./.env.local supabase link --project-ref UPDATE_THIS_WITH_YOUR_SUPABASE_PROJECT_ID",
16 | "migration:new": "supabase migration new",
17 | "migration:up": "supabase migration up --linked --debug && npm run generate-types",
18 | "migration:squash": "supabase migration squash --linked"
19 | },
20 | "dependencies": {
21 | "@radix-ui/react-collapsible": "^1.1.2",
22 | "@radix-ui/react-dialog": "^1.1.4",
23 | "@radix-ui/react-dropdown-menu": "^2.1.4",
24 | "@radix-ui/react-icons": "^1.3.2",
25 | "@radix-ui/react-slot": "^1.1.1",
26 | "@radix-ui/react-tabs": "^1.1.2",
27 | "@radix-ui/react-toast": "^1.2.4",
28 | "@react-email/components": "^0.0.32",
29 | "@stripe/stripe-js": "^2.4.0",
30 | "@supabase/ssr": "^0.5.2",
31 | "@supabase/supabase-js": "^2.47.12",
32 | "@vercel/analytics": "^1.4.1",
33 | "class-variance-authority": "^0.7.1",
34 | "classnames": "^2.5.1",
35 | "clsx": "^2.1.1",
36 | "lucide-react": "^0.474.0",
37 | "next": "^15.1.4",
38 | "next-route-handler-pipe": "^1.0.5",
39 | "react": "19.0.0",
40 | "react-dom": "19.0.0",
41 | "react-email": "^2.1.6",
42 | "react-icons": "^5.4.0",
43 | "resend": "^4.1.1",
44 | "stripe": "^14.25.0",
45 | "tailwind-merge": "^2.6.0",
46 | "tailwindcss": "^3.4.17",
47 | "tailwindcss-animate": "^1.0.7",
48 | "zod": "^3.24.1"
49 | },
50 | "devDependencies": {
51 | "@types/react": "19.0.4",
52 | "@types/react-dom": "19.0.2",
53 | "autoprefixer": "^10.4.20",
54 | "env-cmd": "^10.1.0",
55 | "eslint": "^8.57.1",
56 | "eslint-config-next": "^15.1.4",
57 | "eslint-config-prettier": "^8.10.0",
58 | "eslint-plugin-react": "^7.37.3",
59 | "eslint-plugin-simple-import-sort": "^10.0.0",
60 | "eslint-plugin-tailwindcss": "^3.17.5",
61 | "postcss": "^8.4.49",
62 | "prettier": "^2.8.8",
63 | "prettier-plugin-tailwindcss": "^0.3.0",
64 | "supabase": "^1.226.4",
65 | "typescript": "^5.7.3"
66 | },
67 | "overrides": {
68 | "@types/react": "19.0.4",
69 | "@types/react-dom": "19.0.2"
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import("prettier").Config} */
2 | const config = {
3 | plugins: ['prettier-plugin-tailwindcss'],
4 | singleQuote: true,
5 | jsxSingleQuote: true,
6 | semi: true,
7 | tabWidth: 2,
8 | bracketSpacing: true,
9 | jsxBracketSameLine: false,
10 | arrowParens: 'always',
11 | printWidth: 120,
12 | };
13 |
14 | module.exports = config;
15 |
--------------------------------------------------------------------------------
/public/example1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KolbySisk/next-supabase-stripe-starter/d193a27501e94ebccdf017c8b60b05a09c2f0834/public/example1.png
--------------------------------------------------------------------------------
/public/example2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KolbySisk/next-supabase-stripe-starter/d193a27501e94ebccdf017c8b60b05a09c2f0834/public/example2.png
--------------------------------------------------------------------------------
/public/example3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KolbySisk/next-supabase-stripe-starter/d193a27501e94ebccdf017c8b60b05a09c2f0834/public/example3.png
--------------------------------------------------------------------------------
/public/example4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KolbySisk/next-supabase-stripe-starter/d193a27501e94ebccdf017c8b60b05a09c2f0834/public/example4.png
--------------------------------------------------------------------------------
/public/example5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KolbySisk/next-supabase-stripe-starter/d193a27501e94ebccdf017c8b60b05a09c2f0834/public/example5.png
--------------------------------------------------------------------------------
/public/example6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KolbySisk/next-supabase-stripe-starter/d193a27501e94ebccdf017c8b60b05a09c2f0834/public/example6.png
--------------------------------------------------------------------------------
/public/example7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KolbySisk/next-supabase-stripe-starter/d193a27501e94ebccdf017c8b60b05a09c2f0834/public/example7.png
--------------------------------------------------------------------------------
/public/example8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KolbySisk/next-supabase-stripe-starter/d193a27501e94ebccdf017c8b60b05a09c2f0834/public/example8.png
--------------------------------------------------------------------------------
/public/example9.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KolbySisk/next-supabase-stripe-starter/d193a27501e94ebccdf017c8b60b05a09c2f0834/public/example9.png
--------------------------------------------------------------------------------
/public/hero-shape.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KolbySisk/next-supabase-stripe-starter/d193a27501e94ebccdf017c8b60b05a09c2f0834/public/hero-shape.png
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KolbySisk/next-supabase-stripe-starter/d193a27501e94ebccdf017c8b60b05a09c2f0834/public/logo.png
--------------------------------------------------------------------------------
/public/section-bg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KolbySisk/next-supabase-stripe-starter/d193a27501e94ebccdf017c8b60b05a09c2f0834/public/section-bg.png
--------------------------------------------------------------------------------
/src/app/(account)/account/page.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren, ReactNode } from 'react';
2 | import Link from 'next/link';
3 | import { redirect } from 'next/navigation';
4 |
5 | import { Button } from '@/components/ui/button';
6 | import { getSession } from '@/features/account/controllers/get-session';
7 | import { getSubscription } from '@/features/account/controllers/get-subscription';
8 | import { PricingCard } from '@/features/pricing/components/price-card';
9 | import { getProducts } from '@/features/pricing/controllers/get-products';
10 | import { Price, ProductWithPrices } from '@/features/pricing/types';
11 |
12 | export default async function AccountPage() {
13 | const [session, subscription, products] = await Promise.all([getSession(), getSubscription(), getProducts()]);
14 |
15 | if (!session) {
16 | redirect('/login');
17 | }
18 |
19 | let userProduct: ProductWithPrices | undefined;
20 | let userPrice: Price | undefined;
21 |
22 | if (subscription) {
23 | for (const product of products) {
24 | for (const price of product.prices) {
25 | if (price.id === subscription.price_id) {
26 | userProduct = product;
27 | userPrice = price;
28 | }
29 | }
30 | }
31 | }
32 |
33 | return (
34 |
35 | Account
36 |
37 |
38 |
43 | Manage your subscription
44 |
45 | ) : (
46 |
47 | Start a subscription
48 |
49 | )
50 | }
51 | >
52 | {userProduct && userPrice ? (
53 |
54 | ) : (
55 | You don't have an active subscription
56 | )}
57 |
58 |
59 |
60 | );
61 | }
62 |
63 | function Card({
64 | title,
65 | footer,
66 | children,
67 | }: PropsWithChildren<{
68 | title: string;
69 | footer?: ReactNode;
70 | }>) {
71 | return (
72 |
73 |
74 |
{title}
75 |
{children}
76 |
77 |
{footer}
78 |
79 | );
80 | }
81 |
--------------------------------------------------------------------------------
/src/app/(account)/manage-subscription/route.ts:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 |
3 | import { getCustomerId } from '@/features/account/controllers/get-customer-id';
4 | import { getSession } from '@/features/account/controllers/get-session';
5 | import { stripeAdmin } from '@/libs/stripe/stripe-admin';
6 | import { getURL } from '@/utils/get-url';
7 |
8 | export const dynamic = 'force-dynamic';
9 |
10 | export async function GET() {
11 | // 1. Get the user from session
12 | const session = await getSession();
13 |
14 | if (!session || !session.user.id) {
15 | throw Error('Could not get userId');
16 | }
17 |
18 | // 2. Retrieve or create the customer in Stripe
19 | const customer = await getCustomerId({
20 | userId: session.user.id,
21 | });
22 |
23 | if (!customer) {
24 | throw Error('Could not get customer');
25 | }
26 |
27 | // 3. Create portal link and redirect user
28 | const { url } = await stripeAdmin.billingPortal.sessions.create({
29 | customer,
30 | return_url: `${getURL()}/account`,
31 | });
32 |
33 | redirect(url);
34 | }
35 |
--------------------------------------------------------------------------------
/src/app/(auth)/auth-actions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { redirect } from 'next/navigation';
4 |
5 | import { createSupabaseServerClient } from '@/libs/supabase/supabase-server-client';
6 | import { ActionResponse } from '@/types/action-response';
7 | import { getURL } from '@/utils/get-url';
8 |
9 | export async function signInWithOAuth(provider: 'github' | 'google'): Promise {
10 | const supabase = await createSupabaseServerClient();
11 |
12 | const { data, error } = await supabase.auth.signInWithOAuth({
13 | provider,
14 | options: {
15 | redirectTo: getURL('/auth/callback'),
16 | },
17 | });
18 |
19 | if (error) {
20 | console.error(error);
21 | return { data: null, error: error };
22 | }
23 |
24 | return redirect(data.url);
25 | }
26 |
27 | export async function signInWithEmail(email: string): Promise {
28 | const supabase = await createSupabaseServerClient();
29 |
30 | const { error } = await supabase.auth.signInWithOtp({
31 | email,
32 | options: {
33 | emailRedirectTo: getURL('/auth/callback'),
34 | },
35 | });
36 |
37 | if (error) {
38 | console.error(error);
39 | return { data: null, error: error };
40 | }
41 |
42 | return { data: null, error: null };
43 | }
44 |
45 | export async function signOut(): Promise {
46 | const supabase = await createSupabaseServerClient();
47 | const { error } = await supabase.auth.signOut();
48 |
49 | if (error) {
50 | console.error(error);
51 | return { data: null, error: error };
52 | }
53 |
54 | return { data: null, error: null };
55 | }
56 |
--------------------------------------------------------------------------------
/src/app/(auth)/auth-ui.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { FormEvent, useState } from 'react';
4 | import Image from 'next/image';
5 | import Link from 'next/link';
6 | import { IoLogoGithub, IoLogoGoogle } from 'react-icons/io5';
7 |
8 | import { Button } from '@/components/ui/button';
9 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
10 | import { Input } from '@/components/ui/input';
11 | import { toast } from '@/components/ui/use-toast';
12 | import { ActionResponse } from '@/types/action-response';
13 |
14 | const titleMap = {
15 | login: 'Login to UPDATE_THIS_WITH_YOUR_APP_DISPLAY_NAME',
16 | signup: 'Join UPDATE_THIS_WITH_YOUR_APP_DISPLAY_NAME and start generating banners for free',
17 | } as const;
18 |
19 | export function AuthUI({
20 | mode,
21 | signInWithOAuth,
22 | signInWithEmail,
23 | }: {
24 | mode: 'login' | 'signup';
25 | signInWithOAuth: (provider: 'github' | 'google') => Promise;
26 | signInWithEmail: (email: string) => Promise;
27 | }) {
28 | const [pending, setPending] = useState(false);
29 | const [emailFormOpen, setEmailFormOpen] = useState(false);
30 |
31 | async function handleEmailSubmit(event: FormEvent) {
32 | event.preventDefault();
33 | setPending(true);
34 | const form = event.target as HTMLFormElement;
35 | const email = form['email'].value;
36 | const response = await signInWithEmail(email);
37 |
38 | if (response?.error) {
39 | toast({
40 | variant: 'destructive',
41 | description: 'An error occurred while authenticating. Please try again.',
42 | });
43 | } else {
44 | toast({
45 | description: `To continue, click the link in the email sent to: ${email}`,
46 | });
47 | }
48 |
49 | form.reset();
50 | setPending(false);
51 | }
52 |
53 | async function handleOAuthClick(provider: 'google' | 'github') {
54 | setPending(true);
55 | const response = await signInWithOAuth(provider);
56 |
57 | if (response?.error) {
58 | toast({
59 | variant: 'destructive',
60 | description: 'An error occurred while authenticating. Please try again.',
61 | });
62 | setPending(false);
63 | }
64 | }
65 |
66 | return (
67 |
68 |
69 |
70 |
{titleMap[mode]}
71 |
72 |
73 |
handleOAuthClick('google')}
76 | disabled={pending}
77 | >
78 |
79 | Continue with Google
80 |
81 |
handleOAuthClick('github')}
84 | disabled={pending}
85 | >
86 |
87 | Continue with GitHub
88 |
89 |
90 |
91 |
92 |
96 | Continue with Email
97 |
98 |
99 |
100 |
119 |
120 |
121 |
122 | {mode === 'signup' && (
123 |
124 | By clicking continue, you agree to our{' '}
125 |
126 | Terms of Service
127 | {' '}
128 | and{' '}
129 |
130 | Privacy Policy
131 |
132 | .
133 |
134 | )}
135 |
136 | );
137 | }
138 |
--------------------------------------------------------------------------------
/src/app/(auth)/auth/callback/route.ts:
--------------------------------------------------------------------------------
1 | // ref: https://github.com/vercel/next.js/blob/canary/examples/with-supabase/app/auth/callback/route.ts
2 |
3 | import type { NextRequest } from 'next/server';
4 | import { NextResponse } from 'next/server';
5 |
6 | import { createSupabaseServerClient } from '@/libs/supabase/supabase-server-client';
7 | import { getURL } from '@/utils/get-url';
8 |
9 | const siteUrl = getURL();
10 |
11 | export async function GET(request: NextRequest) {
12 | const requestUrl = new URL(request.url);
13 | const code = requestUrl.searchParams.get('code');
14 |
15 | if (code) {
16 | const supabase = await createSupabaseServerClient();
17 | await supabase.auth.exchangeCodeForSession(code);
18 |
19 | const {
20 | data: { user },
21 | } = await supabase.auth.getUser();
22 |
23 | if (!user?.id) {
24 | return NextResponse.redirect(`${siteUrl}/login`);
25 | }
26 |
27 | // Check if user is subscribed, if not redirect to pricing page
28 | const { data: userSubscription } = await supabase
29 | .from('subscriptions')
30 | .select('*, prices(*, products(*))')
31 | .in('status', ['trialing', 'active'])
32 | .maybeSingle();
33 |
34 | if (!userSubscription) {
35 | return NextResponse.redirect(`${siteUrl}/pricing`);
36 | } else {
37 | return NextResponse.redirect(`${siteUrl}`);
38 | }
39 | }
40 |
41 | return NextResponse.redirect(siteUrl);
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/(auth)/login/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 |
3 | import { getSession } from '@/features/account/controllers/get-session';
4 | import { getSubscription } from '@/features/account/controllers/get-subscription';
5 |
6 | import { signInWithEmail, signInWithOAuth } from '../auth-actions';
7 | import { AuthUI } from '../auth-ui';
8 |
9 | export default async function LoginPage() {
10 | const session = await getSession();
11 | const subscription = await getSubscription();
12 |
13 | if (session && subscription) {
14 | redirect('/account');
15 | }
16 |
17 | if (session && !subscription) {
18 | redirect('/pricing');
19 | }
20 |
21 | return (
22 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/(auth)/signup/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 |
3 | import { getSession } from '@/features/account/controllers/get-session';
4 | import { getSubscription } from '@/features/account/controllers/get-subscription';
5 |
6 | import { signInWithEmail, signInWithOAuth } from '../auth-actions';
7 | import { AuthUI } from '../auth-ui';
8 |
9 | export default async function SignUp() {
10 | const session = await getSession();
11 | const subscription = await getSubscription();
12 |
13 | if (session && subscription) {
14 | redirect('/account');
15 | }
16 |
17 | if (session && !subscription) {
18 | redirect('/pricing');
19 | }
20 |
21 | return (
22 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/api/webhooks/route.ts:
--------------------------------------------------------------------------------
1 | import Stripe from 'stripe';
2 |
3 | import { upsertUserSubscription } from '@/features/account/controllers/upsert-user-subscription';
4 | import { upsertPrice } from '@/features/pricing/controllers/upsert-price';
5 | import { upsertProduct } from '@/features/pricing/controllers/upsert-product';
6 | import { stripeAdmin } from '@/libs/stripe/stripe-admin';
7 | import { getEnvVar } from '@/utils/get-env-var';
8 |
9 | const relevantEvents = new Set([
10 | 'product.created',
11 | 'product.updated',
12 | 'price.created',
13 | 'price.updated',
14 | 'checkout.session.completed',
15 | 'customer.subscription.created',
16 | 'customer.subscription.updated',
17 | 'customer.subscription.deleted',
18 | ]);
19 |
20 | export async function POST(req: Request) {
21 | const body = await req.text();
22 | const sig = req.headers.get('stripe-signature') as string;
23 | const webhookSecret = getEnvVar(process.env.STRIPE_WEBHOOK_SECRET, 'STRIPE_WEBHOOK_SECRET');
24 | let event: Stripe.Event;
25 |
26 | try {
27 | if (!sig || !webhookSecret) return;
28 | event = stripeAdmin.webhooks.constructEvent(body, sig, webhookSecret);
29 | } catch (error) {
30 | return Response.json(`Webhook Error: ${(error as any).message}`, { status: 400 });
31 | }
32 |
33 | if (relevantEvents.has(event.type)) {
34 | try {
35 | switch (event.type) {
36 | case 'product.created':
37 | case 'product.updated':
38 | await upsertProduct(event.data.object as Stripe.Product);
39 | break;
40 | case 'price.created':
41 | case 'price.updated':
42 | await upsertPrice(event.data.object as Stripe.Price);
43 | break;
44 | case 'customer.subscription.created':
45 | case 'customer.subscription.updated':
46 | case 'customer.subscription.deleted':
47 | const subscription = event.data.object as Stripe.Subscription;
48 | await upsertUserSubscription({
49 | subscriptionId: subscription.id,
50 | customerId: subscription.customer as string,
51 | isCreateAction: false,
52 | });
53 | break;
54 | case 'checkout.session.completed':
55 | const checkoutSession = event.data.object as Stripe.Checkout.Session;
56 |
57 | if (checkoutSession.mode === 'subscription') {
58 | const subscriptionId = checkoutSession.subscription;
59 | await upsertUserSubscription({
60 | subscriptionId: subscriptionId as string,
61 | customerId: checkoutSession.customer as string,
62 | isCreateAction: true,
63 | });
64 | }
65 | break;
66 | default:
67 | throw new Error('Unhandled relevant event!');
68 | }
69 | } catch (error) {
70 | console.error(error);
71 | return Response.json('Webhook handler failed. View your nextjs function logs.', {
72 | status: 400,
73 | });
74 | }
75 | }
76 | return Response.json({ received: true });
77 | }
78 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KolbySisk/next-supabase-stripe-starter/d193a27501e94ebccdf017c8b60b05a09c2f0834/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren } from 'react';
2 | import type { Metadata } from 'next';
3 | import { Montserrat, Montserrat_Alternates } from 'next/font/google';
4 | import Link from 'next/link';
5 | import { IoLogoFacebook, IoLogoInstagram, IoLogoTwitter } from 'react-icons/io5';
6 |
7 | import { Logo } from '@/components/logo';
8 | import { Toaster } from '@/components/ui/toaster';
9 | import { cn } from '@/utils/cn';
10 | import { Analytics } from '@vercel/analytics/react';
11 |
12 | import { Navigation } from './navigation';
13 |
14 | import '@/styles/globals.css';
15 |
16 | export const dynamic = 'force-dynamic';
17 |
18 | const montserrat = Montserrat({
19 | variable: '--font-montserrat',
20 | subsets: ['latin'],
21 | });
22 |
23 | const montserratAlternates = Montserrat_Alternates({
24 | variable: '--font-montserrat-alternates',
25 | weight: ['500', '600', '700'],
26 | subsets: ['latin'],
27 | });
28 |
29 | export const metadata: Metadata = {
30 | title: 'Create Next App',
31 | description: 'Generated by create next app',
32 | };
33 |
34 | export default function RootLayout({ children }: PropsWithChildren) {
35 | return (
36 |
37 |
38 |
39 |
40 |
41 | {children}
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | );
50 | }
51 |
52 | async function AppBar() {
53 | return (
54 |
58 | );
59 | }
60 |
61 | function Footer() {
62 | return (
63 |
116 | );
117 | }
118 |
--------------------------------------------------------------------------------
/src/app/navigation.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import { IoMenu } from 'react-icons/io5';
3 |
4 | import { AccountMenu } from '@/components/account-menu';
5 | import { Logo } from '@/components/logo';
6 | import { Button } from '@/components/ui/button';
7 | import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTrigger } from '@/components/ui/sheet';
8 | import { getSession } from '@/features/account/controllers/get-session';
9 |
10 | import { signOut } from './(auth)/auth-actions';
11 |
12 | export async function Navigation() {
13 | const session = await getSession();
14 |
15 | return (
16 |
17 | {session ? (
18 |
19 | ) : (
20 | <>
21 |
22 | Get started for free
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | Get started for free
34 |
35 |
36 |
37 |
38 |
39 | >
40 | )}
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import Link from 'next/link';
3 |
4 | import { Container } from '@/components/container';
5 | import { Button } from '@/components/ui/button';
6 | import { PricingSection } from '@/features/pricing/components/pricing-section';
7 |
8 | export default async function HomePage() {
9 | return (
10 |
15 | );
16 | }
17 |
18 | function HeroSection() {
19 | return (
20 |
21 |
22 |
23 |
24 |
25 | Generate banners with DALL·E
26 |
27 |
28 |
Instantly craft stunning Twitter banners.
29 |
30 | Get started for free
31 |
32 |
33 |
34 |
43 |
44 | );
45 | }
46 |
47 | function ExamplesSection() {
48 | return (
49 |
50 |
51 |
59 |
67 |
75 |
76 |
77 |
85 |
93 |
101 |
102 |
103 |
111 |
119 |
127 |
128 |
129 | );
130 | }
131 |
--------------------------------------------------------------------------------
/src/app/pricing/page.tsx:
--------------------------------------------------------------------------------
1 | import { PricingSection } from '@/features/pricing/components/pricing-section';
2 |
3 | export default async function PricingPage() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/account-menu.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Link from 'next/link';
4 | import { useRouter } from 'next/navigation';
5 | import { IoPersonCircleOutline } from 'react-icons/io5';
6 |
7 | import {
8 | DropdownMenu,
9 | DropdownMenuArrow,
10 | DropdownMenuContent,
11 | DropdownMenuItem,
12 | DropdownMenuTrigger,
13 | } from '@/components/ui/dropdown-menu';
14 | import { ActionResponse } from '@/types/action-response';
15 |
16 | import { useToast } from './ui/use-toast';
17 |
18 | export function AccountMenu({ signOut }: { signOut: () => Promise }) {
19 | const router = useRouter();
20 | const { toast } = useToast();
21 |
22 | async function handleLogoutClick() {
23 | const response = await signOut();
24 |
25 | if (response?.error) {
26 | toast({
27 | variant: 'destructive',
28 | description: 'An error occurred while logging out. Please try again or contact support.',
29 | });
30 | } else {
31 | router.refresh();
32 |
33 | toast({
34 | description: 'You have been logged out.',
35 | });
36 | }
37 | }
38 |
39 | return (
40 |
41 |
42 |
43 |
44 |
45 |
46 | Account
47 |
48 | Log Out
49 |
50 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/container.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { cn } from '@/utils/cn';
4 |
5 | export const Container = React.forwardRef>(
6 | ({ className, ...props }, ref) =>
7 | );
8 |
9 | Container.displayName = 'Container';
10 |
--------------------------------------------------------------------------------
/src/components/logo.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import Link from 'next/link';
3 |
4 | export function Logo() {
5 | return (
6 |
7 |
15 | UPDATE_THIS_WITH_YOUR_APP_DISPLAY_NAME
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/sexy-boarder.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/utils/cn';
2 |
3 | export function SexyBoarder({
4 | children,
5 | className,
6 | offset = 10,
7 | }: React.ButtonHTMLAttributes & { offset?: number }) {
8 | return (
9 |
10 |
{children}
11 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { cva, type VariantProps } from 'class-variance-authority';
3 |
4 | import { cn } from '@/utils/cn';
5 | import { Slot } from '@radix-ui/react-slot';
6 |
7 | import { SexyBoarder } from '../sexy-boarder';
8 |
9 | const buttonVariants = cva(
10 | 'w-fit inline-flex items-center justify-center whitespace-nowrap text-sm rounded-md font-alt font-medium transition-colors disabled:pointer-events-none disabled:opacity-50',
11 | {
12 | variants: {
13 | variant: {
14 | default: 'bg-zinc-900 text-zinc-300 hover:bg-zinc-800',
15 | destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
16 | outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
17 | secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
18 | ghost: 'hover:bg-accent hover:text-accent-foreground',
19 | link: 'text-primary underline-offset-4 hover:underline',
20 | orange: 'bg-orange-500 hover:bg-orange-400',
21 | sexy: 'transition-all bg-black hover:bg-opacity-0',
22 | },
23 | size: {
24 | default: 'h-10 px-4',
25 | sm: 'h-8 rounded-md px-3 text-xs',
26 | lg: 'h-10 rounded-md px-8',
27 | icon: 'h-9 w-9',
28 | },
29 | },
30 | defaultVariants: {
31 | variant: 'default',
32 | size: 'default',
33 | },
34 | }
35 | );
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean;
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : 'button';
46 | return (
47 |
48 |
49 |
50 | );
51 | }
52 | );
53 | Button.displayName = 'Button';
54 |
55 | export function WithSexyBorder({
56 | variant,
57 | className,
58 | children,
59 | }: {
60 | variant: string | null | undefined;
61 | className?: string;
62 | children: React.ReactNode;
63 | }) {
64 | if (variant === 'sexy') {
65 | return {children} ;
66 | } else {
67 | return <>{children}>;
68 | }
69 | }
70 |
71 | export { Button, buttonVariants };
72 |
--------------------------------------------------------------------------------
/src/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
4 |
5 | const Collapsible = CollapsiblePrimitive.Root;
6 |
7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
8 |
9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
10 |
11 | export { Collapsible, CollapsibleContent, CollapsibleTrigger };
12 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 |
5 | import { cn } from '@/utils/cn';
6 | import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
7 | import { CheckIcon, ChevronRightIcon, DotFilledIcon } from '@radix-ui/react-icons';
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root;
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group;
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub;
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
20 |
21 | const DropdownMenuArrow = DropdownMenuPrimitive.Arrow;
22 |
23 | const DropdownMenuSubTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef & {
26 | inset?: boolean;
27 | }
28 | >(({ className, inset, children, ...props }, ref) => (
29 |
38 | {children}
39 |
40 |
41 | ));
42 | DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
43 |
44 | const DropdownMenuSubContent = React.forwardRef<
45 | React.ElementRef,
46 | React.ComponentPropsWithoutRef
47 | >(({ className, ...props }, ref) => (
48 |
56 | ));
57 | DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
74 |
75 | ));
76 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
77 |
78 | const DropdownMenuItem = React.forwardRef<
79 | React.ElementRef,
80 | React.ComponentPropsWithoutRef & {
81 | inset?: boolean;
82 | }
83 | >(({ className, inset, ...props }, ref) => (
84 |
93 | ));
94 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
95 |
96 | const DropdownMenuCheckboxItem = React.forwardRef<
97 | React.ElementRef,
98 | React.ComponentPropsWithoutRef
99 | >(({ className, children, checked, ...props }, ref) => (
100 |
109 |
110 |
111 |
112 |
113 |
114 | {children}
115 |
116 | ));
117 | DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
118 |
119 | const DropdownMenuRadioItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ));
139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
140 |
141 | const DropdownMenuLabel = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef & {
144 | inset?: boolean;
145 | }
146 | >(({ className, inset, ...props }, ref) => (
147 |
152 | ));
153 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
154 |
155 | const DropdownMenuSeparator = React.forwardRef<
156 | React.ElementRef,
157 | React.ComponentPropsWithoutRef
158 | >(({ className, ...props }, ref) => (
159 |
160 | ));
161 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
162 |
163 | const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => {
164 | return ;
165 | };
166 | DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
167 |
168 | export {
169 | DropdownMenu,
170 | DropdownMenuArrow,
171 | DropdownMenuCheckboxItem,
172 | DropdownMenuContent,
173 | DropdownMenuGroup,
174 | DropdownMenuItem,
175 | DropdownMenuLabel,
176 | DropdownMenuPortal,
177 | DropdownMenuRadioGroup,
178 | DropdownMenuRadioItem,
179 | DropdownMenuSeparator,
180 | DropdownMenuShortcut,
181 | DropdownMenuSub,
182 | DropdownMenuSubContent,
183 | DropdownMenuSubTrigger,
184 | DropdownMenuTrigger,
185 | };
186 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/utils/cn';
4 |
5 | export interface InputProps extends React.InputHTMLAttributes {}
6 |
7 | const Input = React.forwardRef(({ className, type, ...props }, ref) => {
8 | return (
9 |
18 | );
19 | });
20 | Input.displayName = 'Input';
21 |
22 | export { Input };
23 |
--------------------------------------------------------------------------------
/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import { cva, type VariantProps } from 'class-variance-authority';
5 |
6 | import { cn } from '@/utils/cn';
7 | import * as SheetPrimitive from '@radix-ui/react-dialog';
8 | import { Cross2Icon } from '@radix-ui/react-icons';
9 |
10 | const Sheet = SheetPrimitive.Root;
11 |
12 | const SheetTrigger = SheetPrimitive.Trigger;
13 |
14 | const SheetClose = SheetPrimitive.Close;
15 |
16 | const SheetPortal = SheetPrimitive.Portal;
17 |
18 | const SheetOverlay = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
30 | ));
31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
32 |
33 | const sheetVariants = cva(
34 | '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',
35 | {
36 | variants: {
37 | side: {
38 | top: 'inset-x-0 top-0 data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
39 | bottom: 'inset-x-0 bottom-0 data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
40 | left: 'inset-y-0 left-0 h-full w-3/4 data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
41 | right:
42 | 'inset-y-0 right-0 h-full w-3/4 data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
43 | },
44 | },
45 | defaultVariants: {
46 | side: 'right',
47 | },
48 | }
49 | );
50 |
51 | interface SheetContentProps
52 | extends React.ComponentPropsWithoutRef,
53 | VariantProps {}
54 |
55 | const SheetContent = React.forwardRef, SheetContentProps>(
56 | ({ side = 'right', className, children, ...props }, ref) => (
57 |
58 |
59 |
60 | {children}
61 |
62 |
63 | Close
64 |
65 |
66 |
67 | )
68 | );
69 | SheetContent.displayName = SheetPrimitive.Content.displayName;
70 |
71 | const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => (
72 |
73 | );
74 | SheetHeader.displayName = 'SheetHeader';
75 |
76 | const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => (
77 |
78 | );
79 | SheetFooter.displayName = 'SheetFooter';
80 |
81 | const SheetTitle = React.forwardRef<
82 | React.ElementRef,
83 | React.ComponentPropsWithoutRef
84 | >(({ className, ...props }, ref) => (
85 |
86 | ));
87 | SheetTitle.displayName = SheetPrimitive.Title.displayName;
88 |
89 | const SheetDescription = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => );
93 | SheetDescription.displayName = SheetPrimitive.Description.displayName;
94 |
95 | export {
96 | Sheet,
97 | SheetClose,
98 | SheetContent,
99 | SheetDescription,
100 | SheetFooter,
101 | SheetHeader,
102 | SheetOverlay,
103 | SheetPortal,
104 | SheetTitle,
105 | SheetTrigger,
106 | };
107 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 |
5 | import { cn } from '@/utils/cn';
6 | import * as TabsPrimitive from '@radix-ui/react-tabs';
7 |
8 | const Tabs = TabsPrimitive.Root;
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | TabsList.displayName = TabsPrimitive.List.displayName;
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ));
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ));
53 | TabsContent.displayName = TabsPrimitive.Content.displayName;
54 |
55 | export { Tabs, TabsContent, TabsList, TabsTrigger };
56 |
--------------------------------------------------------------------------------
/src/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { cva, type VariantProps } from 'class-variance-authority';
3 |
4 | import { cn } from '@/utils/cn';
5 | import { Cross2Icon } from '@radix-ui/react-icons';
6 | import * as ToastPrimitives from '@radix-ui/react-toast';
7 |
8 | const ToastProvider = ToastPrimitives.Provider;
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
24 |
25 | const toastVariants = cva(
26 | 'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 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',
27 | {
28 | variants: {
29 | variant: {
30 | default: 'border bg-background text-foreground',
31 | destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground',
32 | },
33 | },
34 | defaultVariants: {
35 | variant: 'default',
36 | },
37 | }
38 | );
39 |
40 | const Toast = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef & VariantProps
43 | >(({ className, variant, ...props }, ref) => {
44 | return ;
45 | });
46 | Toast.displayName = ToastPrimitives.Root.displayName;
47 |
48 | const ToastAction = React.forwardRef<
49 | React.ElementRef,
50 | React.ComponentPropsWithoutRef
51 | >(({ className, ...props }, ref) => (
52 |
60 | ));
61 | ToastAction.displayName = ToastPrimitives.Action.displayName;
62 |
63 | const ToastClose = React.forwardRef<
64 | React.ElementRef,
65 | React.ComponentPropsWithoutRef
66 | >(({ className, ...props }, ref) => (
67 |
76 |
77 |
78 | ));
79 | ToastClose.displayName = ToastPrimitives.Close.displayName;
80 |
81 | const ToastTitle = React.forwardRef<
82 | React.ElementRef,
83 | React.ComponentPropsWithoutRef
84 | >(({ className, ...props }, ref) => (
85 |
86 | ));
87 | ToastTitle.displayName = ToastPrimitives.Title.displayName;
88 |
89 | const ToastDescription = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => (
93 |
94 | ));
95 | ToastDescription.displayName = ToastPrimitives.Description.displayName;
96 |
97 | type ToastProps = React.ComponentPropsWithoutRef;
98 |
99 | type ToastActionElement = React.ReactElement;
100 |
101 | export {
102 | Toast,
103 | ToastAction,
104 | type ToastActionElement,
105 | ToastClose,
106 | ToastDescription,
107 | type ToastProps,
108 | ToastProvider,
109 | ToastTitle,
110 | ToastViewport,
111 | };
112 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from '@/components/ui/toast';
4 | import { useToast } from '@/components/ui/use-toast';
5 |
6 | export function Toaster() {
7 | const { toasts } = useToast();
8 |
9 | return (
10 |
11 | {toasts.map(function ({ id, title, description, action, ...props }) {
12 | return (
13 |
14 |
15 | {title && {title} }
16 | {description && {description} }
17 |
18 | {action}
19 |
20 |
21 | );
22 | })}
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from 'react';
3 |
4 | import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
5 |
6 | const TOAST_LIMIT = 1;
7 | const TOAST_REMOVE_DELAY = 1000000;
8 |
9 | type ToasterToast = ToastProps & {
10 | id: string;
11 | title?: React.ReactNode;
12 | description?: React.ReactNode;
13 | action?: ToastActionElement;
14 | };
15 |
16 | const actionTypes = {
17 | ADD_TOAST: 'ADD_TOAST',
18 | UPDATE_TOAST: 'UPDATE_TOAST',
19 | DISMISS_TOAST: 'DISMISS_TOAST',
20 | REMOVE_TOAST: 'REMOVE_TOAST',
21 | } as const;
22 |
23 | let count = 0;
24 |
25 | function genId() {
26 | count = (count + 1) % Number.MAX_SAFE_INTEGER;
27 | return count.toString();
28 | }
29 |
30 | type ActionType = typeof actionTypes;
31 |
32 | type Action =
33 | | {
34 | type: ActionType['ADD_TOAST'];
35 | toast: ToasterToast;
36 | }
37 | | {
38 | type: ActionType['UPDATE_TOAST'];
39 | toast: Partial;
40 | }
41 | | {
42 | type: ActionType['DISMISS_TOAST'];
43 | toastId?: ToasterToast['id'];
44 | }
45 | | {
46 | type: ActionType['REMOVE_TOAST'];
47 | toastId?: ToasterToast['id'];
48 | };
49 |
50 | interface State {
51 | toasts: ToasterToast[];
52 | }
53 |
54 | const toastTimeouts = new Map>();
55 |
56 | const addToRemoveQueue = (toastId: string) => {
57 | if (toastTimeouts.has(toastId)) {
58 | return;
59 | }
60 |
61 | const timeout = setTimeout(() => {
62 | toastTimeouts.delete(toastId);
63 | dispatch({
64 | type: 'REMOVE_TOAST',
65 | toastId: toastId,
66 | });
67 | }, TOAST_REMOVE_DELAY);
68 |
69 | toastTimeouts.set(toastId, timeout);
70 | };
71 |
72 | export const reducer = (state: State, action: Action): State => {
73 | switch (action.type) {
74 | case 'ADD_TOAST':
75 | return {
76 | ...state,
77 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
78 | };
79 |
80 | case 'UPDATE_TOAST':
81 | return {
82 | ...state,
83 | toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
84 | };
85 |
86 | case 'DISMISS_TOAST': {
87 | const { toastId } = action;
88 |
89 | // ! Side effects ! - This could be extracted into a dismissToast() action,
90 | // but I'll keep it here for simplicity
91 | if (toastId) {
92 | addToRemoveQueue(toastId);
93 | } else {
94 | state.toasts.forEach((toast) => {
95 | addToRemoveQueue(toast.id);
96 | });
97 | }
98 |
99 | return {
100 | ...state,
101 | toasts: state.toasts.map((t) =>
102 | t.id === toastId || toastId === undefined
103 | ? {
104 | ...t,
105 | open: false,
106 | }
107 | : t
108 | ),
109 | };
110 | }
111 | case 'REMOVE_TOAST':
112 | if (action.toastId === undefined) {
113 | return {
114 | ...state,
115 | toasts: [],
116 | };
117 | }
118 | return {
119 | ...state,
120 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
121 | };
122 | }
123 | };
124 |
125 | const listeners: Array<(state: State) => void> = [];
126 |
127 | let memoryState: State = { toasts: [] };
128 |
129 | function dispatch(action: Action) {
130 | memoryState = reducer(memoryState, action);
131 | listeners.forEach((listener) => {
132 | listener(memoryState);
133 | });
134 | }
135 |
136 | type Toast = Omit;
137 |
138 | function toast({ ...props }: Toast) {
139 | const id = genId();
140 |
141 | const update = (props: ToasterToast) =>
142 | dispatch({
143 | type: 'UPDATE_TOAST',
144 | toast: { ...props, id },
145 | });
146 | const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
147 |
148 | dispatch({
149 | type: 'ADD_TOAST',
150 | toast: {
151 | ...props,
152 | id,
153 | open: true,
154 | onOpenChange: (open) => {
155 | if (!open) dismiss();
156 | },
157 | },
158 | });
159 |
160 | return {
161 | id: id,
162 | dismiss,
163 | update,
164 | };
165 | }
166 |
167 | function useToast() {
168 | const [state, setState] = React.useState(memoryState);
169 |
170 | React.useEffect(() => {
171 | listeners.push(setState);
172 | return () => {
173 | const index = listeners.indexOf(setState);
174 | if (index > -1) {
175 | listeners.splice(index, 1);
176 | }
177 | };
178 | }, [state]);
179 |
180 | return {
181 | ...state,
182 | toast,
183 | dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
184 | };
185 | }
186 |
187 | export { toast, useToast };
188 |
--------------------------------------------------------------------------------
/src/features/account/controllers/get-customer-id.ts:
--------------------------------------------------------------------------------
1 | import { supabaseAdminClient } from '@/libs/supabase/supabase-admin';
2 |
3 | export async function getCustomerId({ userId }: { userId: string }) {
4 | const { data, error } = await supabaseAdminClient
5 | .from('customers')
6 | .select('stripe_customer_id')
7 | .eq('id', userId)
8 | .single();
9 |
10 | if (error) {
11 | throw new Error('Error fetching stripe_customer_id');
12 | }
13 |
14 | return data.stripe_customer_id;
15 | }
16 |
--------------------------------------------------------------------------------
/src/features/account/controllers/get-or-create-customer.ts:
--------------------------------------------------------------------------------
1 | import { stripeAdmin } from '@/libs/stripe/stripe-admin';
2 | import { supabaseAdminClient } from '@/libs/supabase/supabase-admin';
3 |
4 | export async function getOrCreateCustomer({ userId, email }: { userId: string; email: string }) {
5 | const { data, error } = await supabaseAdminClient
6 | .from('customers')
7 | .select('stripe_customer_id')
8 | .eq('id', userId)
9 | .single();
10 |
11 | if (error || !data?.stripe_customer_id) {
12 | // No customer record found, let's create one.
13 | const customerData = {
14 | email,
15 | metadata: {
16 | userId,
17 | },
18 | } as const;
19 |
20 | const customer = await stripeAdmin.customers.create(customerData);
21 |
22 | // Insert the customer ID into our Supabase mapping table.
23 | const { error: supabaseError } = await supabaseAdminClient
24 | .from('customers')
25 | .insert([{ id: userId, stripe_customer_id: customer.id }]);
26 |
27 | if (supabaseError) {
28 | throw supabaseError;
29 | }
30 |
31 | return customer.id;
32 | }
33 |
34 | return data.stripe_customer_id;
35 | }
36 |
--------------------------------------------------------------------------------
/src/features/account/controllers/get-session.ts:
--------------------------------------------------------------------------------
1 | import { createSupabaseServerClient } from '@/libs/supabase/supabase-server-client';
2 |
3 | export async function getSession() {
4 | const supabase = await createSupabaseServerClient();
5 |
6 | const { data, error } = await supabase.auth.getSession();
7 |
8 | if (error) {
9 | console.error(error);
10 | }
11 |
12 | return data.session;
13 | }
14 |
--------------------------------------------------------------------------------
/src/features/account/controllers/get-subscription.ts:
--------------------------------------------------------------------------------
1 | import { createSupabaseServerClient } from '@/libs/supabase/supabase-server-client';
2 |
3 | export async function getSubscription() {
4 | const supabase = await createSupabaseServerClient();
5 |
6 | const { data, error } = await supabase
7 | .from('subscriptions')
8 | .select('*, prices(*, products(*))')
9 | .in('status', ['trialing', 'active'])
10 | .maybeSingle();
11 |
12 | if (error) {
13 | console.error(error);
14 | }
15 |
16 | return data;
17 | }
18 |
--------------------------------------------------------------------------------
/src/features/account/controllers/get-user.ts:
--------------------------------------------------------------------------------
1 | import { createSupabaseServerClient } from '@/libs/supabase/supabase-server-client';
2 |
3 | export async function getUser() {
4 | const supabase = await createSupabaseServerClient();
5 |
6 | const { data, error } = await supabase.from('users').select('*').single();
7 |
8 | if (error) {
9 | console.error(error);
10 | }
11 |
12 | return data;
13 | }
14 |
--------------------------------------------------------------------------------
/src/features/account/controllers/upsert-user-subscription.ts:
--------------------------------------------------------------------------------
1 | import Stripe from 'stripe';
2 |
3 | import { stripeAdmin } from '@/libs/stripe/stripe-admin';
4 | import { supabaseAdminClient } from '@/libs/supabase/supabase-admin';
5 | import type { Database } from '@/libs/supabase/types';
6 | import { toDateTime } from '@/utils/to-date-time';
7 | import { AddressParam } from '@stripe/stripe-js';
8 |
9 | export async function upsertUserSubscription({
10 | subscriptionId,
11 | customerId,
12 | isCreateAction,
13 | }: {
14 | subscriptionId: string;
15 | customerId: string;
16 | isCreateAction?: boolean;
17 | }) {
18 | // Get customer's userId from mapping table.
19 | const { data: customerData, error: noCustomerError } = await supabaseAdminClient
20 | .from('customers')
21 | .select('id')
22 | .eq('stripe_customer_id', customerId)
23 | .single();
24 | if (noCustomerError) throw noCustomerError;
25 |
26 | const { id: userId } = customerData!;
27 |
28 | const subscription = await stripeAdmin.subscriptions.retrieve(subscriptionId, {
29 | expand: ['default_payment_method'],
30 | });
31 |
32 | // Upsert the latest status of the subscription object.
33 | const subscriptionData: Database['public']['Tables']['subscriptions']['Insert'] = {
34 | id: subscription.id,
35 | user_id: userId,
36 | metadata: subscription.metadata,
37 | status: subscription.status,
38 | price_id: subscription.items.data[0].price.id,
39 | cancel_at_period_end: subscription.cancel_at_period_end,
40 | cancel_at: subscription.cancel_at ? toDateTime(subscription.cancel_at).toISOString() : null,
41 | canceled_at: subscription.canceled_at ? toDateTime(subscription.canceled_at).toISOString() : null,
42 | current_period_start: toDateTime(subscription.current_period_start).toISOString(),
43 | current_period_end: toDateTime(subscription.current_period_end).toISOString(),
44 | created: toDateTime(subscription.created).toISOString(),
45 | ended_at: subscription.ended_at ? toDateTime(subscription.ended_at).toISOString() : null,
46 | trial_start: subscription.trial_start ? toDateTime(subscription.trial_start).toISOString() : null,
47 | trial_end: subscription.trial_end ? toDateTime(subscription.trial_end).toISOString() : null,
48 | };
49 |
50 | const { error } = await supabaseAdminClient.from('subscriptions').upsert([subscriptionData]);
51 | if (error) {
52 | throw error;
53 | }
54 | console.info(`Inserted/updated subscription [${subscription.id}] for user [${userId}]`);
55 |
56 | // For a new subscription copy the billing details to the customer object.
57 | // NOTE: This is a costly operation and should happen at the very end.
58 | if (isCreateAction && subscription.default_payment_method && userId) {
59 | await copyBillingDetailsToCustomer(userId, subscription.default_payment_method as Stripe.PaymentMethod);
60 | }
61 | }
62 |
63 | const copyBillingDetailsToCustomer = async (userId: string, paymentMethod: Stripe.PaymentMethod) => {
64 | const customer = paymentMethod.customer;
65 | if (typeof customer !== 'string') {
66 | throw new Error('Customer id not found');
67 | }
68 |
69 | const { name, phone, address } = paymentMethod.billing_details;
70 | if (!name || !phone || !address) return;
71 |
72 | await stripeAdmin.customers.update(customer, { name, phone, address: address as AddressParam });
73 |
74 | const { error } = await supabaseAdminClient
75 | .from('users')
76 | .update({
77 | billing_address: { ...address },
78 | payment_method: { ...paymentMethod[paymentMethod.type] },
79 | })
80 | .eq('id', userId);
81 |
82 | if (error) {
83 | throw error;
84 | }
85 | };
86 |
--------------------------------------------------------------------------------
/src/features/emails/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 |
3 | export default {
4 | content: [],
5 | theme: {},
6 | plugins: [],
7 | } satisfies Config;
8 |
--------------------------------------------------------------------------------
/src/features/emails/welcome.tsx:
--------------------------------------------------------------------------------
1 | import { Body, Button, Container, Head, Heading, Html, Link, Preview, Section, Text } from '@react-email/components';
2 | import { Tailwind } from '@react-email/tailwind';
3 |
4 | import tailwindConfig from './tailwind.config';
5 |
6 | const baseUrl = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000';
7 |
8 | export function WelcomeEmail() {
9 | return (
10 |
11 |
12 | Welcome!
13 |
14 |
15 |
16 |
19 |
20 |
21 | Thanks for signing up.
22 |
23 | Go to your dashboard to get started.
24 |
25 | Dashboard
26 |
27 |
28 |
29 |
30 |
31 | Not interested in receiving this email?
32 |
33 | Turn off this notification in your account settings.
34 |
35 |
36 |
37 |
38 |
39 |
40 | );
41 | }
42 |
43 | export default WelcomeEmail;
44 |
--------------------------------------------------------------------------------
/src/features/pricing/actions/create-checkout-action.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { redirect } from 'next/navigation';
4 |
5 | import { getOrCreateCustomer } from '@/features/account/controllers/get-or-create-customer';
6 | import { getSession } from '@/features/account/controllers/get-session';
7 | import { Price } from '@/features/pricing/types';
8 | import { stripeAdmin } from '@/libs/stripe/stripe-admin';
9 | import { getURL } from '@/utils/get-url';
10 |
11 | export async function createCheckoutAction({ price }: { price: Price }) {
12 | // 1. Get the user from session
13 | const session = await getSession();
14 |
15 | if (!session?.user) {
16 | return redirect(`${getURL()}/signup`);
17 | }
18 |
19 | if (!session.user.email) {
20 | throw Error('Could not get email');
21 | }
22 |
23 | // 2. Retrieve or create the customer in Stripe
24 | const customer = await getOrCreateCustomer({
25 | userId: session.user.id,
26 | email: session.user.email,
27 | });
28 |
29 | // 3. Create a checkout session in Stripe
30 | const checkoutSession = await stripeAdmin.checkout.sessions.create({
31 | payment_method_types: ['card'],
32 | billing_address_collection: 'required',
33 | customer,
34 | customer_update: {
35 | address: 'auto',
36 | },
37 | line_items: [
38 | {
39 | price: price.id,
40 | quantity: 1,
41 | },
42 | ],
43 | mode: price.type === 'recurring' ? 'subscription' : 'payment',
44 | allow_promotion_codes: true,
45 | success_url: `${getURL()}/account`,
46 | cancel_url: `${getURL()}/`,
47 | });
48 |
49 | if (!checkoutSession || !checkoutSession.url) {
50 | throw Error('checkoutSession is not defined');
51 | }
52 |
53 | // 4. Redirect to checkout url
54 | redirect(checkoutSession.url);
55 | }
56 |
--------------------------------------------------------------------------------
/src/features/pricing/components/price-card.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useMemo, useState } from 'react';
4 | import Link from 'next/link';
5 | import { IoCheckmark } from 'react-icons/io5';
6 |
7 | import { SexyBoarder } from '@/components/sexy-boarder';
8 | import { Button } from '@/components/ui/button';
9 | import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
10 |
11 | import { PriceCardVariant, productMetadataSchema } from '../models/product-metadata';
12 | import { BillingInterval, Price, ProductWithPrices } from '../types';
13 |
14 | export function PricingCard({
15 | product,
16 | price,
17 | createCheckoutAction,
18 | }: {
19 | product: ProductWithPrices;
20 | price?: Price;
21 | createCheckoutAction?: ({ price }: { price: Price }) => void;
22 | }) {
23 | const [billingInterval, setBillingInterval] = useState(
24 | price ? (price.interval as BillingInterval) : 'month'
25 | );
26 |
27 | // Determine the price to render
28 | const currentPrice = useMemo(() => {
29 | // If price is passed in we use that one. This is used on the account page when showing the user their current subscription.
30 | if (price) return price;
31 |
32 | // If no price provided we need to find the right one to render for the product.
33 | // First check if the product has a price - in the case of our enterprise product, no price is included.
34 | // We'll return null and handle that case when rendering.
35 | if (product.prices.length === 0) return null;
36 |
37 | // Next determine if the product is a one time purchase - in these cases it will only have a single price.
38 | if (product.prices.length === 1) return product.prices[0];
39 |
40 | // Lastly we can assume the product is a subscription one with a month and year price, so we get the price according to the select billingInterval
41 | return product.prices.find((price) => price.interval === billingInterval);
42 | }, [billingInterval, price, product.prices]);
43 |
44 | const monthPrice = product.prices.find((price) => price.interval === 'month')?.unit_amount;
45 | const yearPrice = product.prices.find((price) => price.interval === 'year')?.unit_amount;
46 | const isBillingIntervalYearly = billingInterval === 'year';
47 | const metadata = productMetadataSchema.parse(product.metadata);
48 | const buttonVariantMap = {
49 | basic: 'default',
50 | pro: 'sexy',
51 | enterprise: 'orange',
52 | } as const;
53 |
54 | function handleBillingIntervalChange(billingInterval: BillingInterval) {
55 | setBillingInterval(billingInterval);
56 | }
57 |
58 | return (
59 |
60 |
61 |
62 |
{product.name}
63 |
64 |
65 | {yearPrice && isBillingIntervalYearly
66 | ? '$' + yearPrice / 100
67 | : monthPrice
68 | ? '$' + monthPrice / 100
69 | : 'Custom'}
70 |
71 | {yearPrice && isBillingIntervalYearly ? '/year' : monthPrice ? '/month' : null}
72 |
73 |
74 |
75 | {!Boolean(price) && product.prices.length > 1 &&
}
76 |
77 |
78 | {metadata.generatedImages === 'enterprise' && }
79 | {metadata.generatedImages !== 'enterprise' && (
80 |
81 | )}
82 | { }
83 | { }
84 |
85 |
86 | {createCheckoutAction && (
87 |
88 | {currentPrice && (
89 | createCheckoutAction({ price: currentPrice })}
93 | >
94 | Get Started
95 |
96 | )}
97 | {!currentPrice && (
98 |
99 | Contact Us
100 |
101 | )}
102 |
103 | )}
104 |
105 |
106 | );
107 | }
108 |
109 | function CheckItem({ text }: { text: string }) {
110 | return (
111 |
115 | );
116 | }
117 |
118 | export function WithSexyBorder({
119 | variant,
120 | className,
121 | children,
122 | }: React.ButtonHTMLAttributes & { variant: PriceCardVariant }) {
123 | if (variant === 'pro') {
124 | return (
125 |
126 | {children}
127 |
128 | );
129 | } else {
130 | return {children}
;
131 | }
132 | }
133 |
134 | function PricingSwitch({ onChange }: { onChange: (value: BillingInterval) => void }) {
135 | return (
136 | onChange(newBillingInterval as BillingInterval)}
140 | >
141 |
142 | Monthly
143 | Yearly
144 |
145 |
146 | );
147 | }
148 |
--------------------------------------------------------------------------------
/src/features/pricing/components/pricing-section.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 |
3 | import { PricingCard } from '@/features/pricing/components/price-card';
4 | import { getProducts } from '@/features/pricing/controllers/get-products';
5 |
6 | import { createCheckoutAction } from '../actions/create-checkout-action';
7 |
8 | export async function PricingSection({ isPricingPage }: { isPricingPage?: boolean }) {
9 | const products = await getProducts();
10 |
11 | const HeadingLevel = isPricingPage ? 'h1' : 'h2';
12 |
13 | return (
14 |
15 |
16 |
17 | Predictable pricing for every use case.
18 |
19 |
20 | Find a plan that fits you. Upgrade at any time to enable additional features.
21 |
22 |
23 | {products.map((product) => {
24 | return
;
25 | })}
26 |
27 |
28 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/features/pricing/controllers/get-products.ts:
--------------------------------------------------------------------------------
1 | import { createSupabaseServerClient } from '@/libs/supabase/supabase-server-client';
2 |
3 | export async function getProducts() {
4 | const supabase = await createSupabaseServerClient();
5 |
6 | const { data, error } = await supabase
7 | .from('products')
8 | .select('*, prices(*)')
9 | .eq('active', true)
10 | .eq('prices.active', true)
11 | .order('metadata->index')
12 | .order('unit_amount', { referencedTable: 'prices' });
13 |
14 | if (error) {
15 | console.error(error.message);
16 | }
17 |
18 | return data ?? [];
19 | }
20 |
--------------------------------------------------------------------------------
/src/features/pricing/controllers/upsert-price.ts:
--------------------------------------------------------------------------------
1 | import Stripe from 'stripe';
2 |
3 | import { supabaseAdminClient } from '@/libs/supabase/supabase-admin';
4 | import type { Database } from '@/libs/supabase/types';
5 |
6 | type Price = Database['public']['Tables']['prices']['Row'];
7 |
8 | export async function upsertPrice(price: Stripe.Price) {
9 | const priceData: Price = {
10 | id: price.id,
11 | product_id: typeof price.product === 'string' ? price.product : '',
12 | active: price.active,
13 | currency: price.currency,
14 | description: price.nickname ?? null,
15 | type: price.type,
16 | unit_amount: price.unit_amount ?? null,
17 | interval: price.recurring?.interval ?? null,
18 | interval_count: price.recurring?.interval_count ?? null,
19 | trial_period_days: price.recurring?.trial_period_days ?? null,
20 | metadata: price.metadata,
21 | };
22 |
23 | const { error } = await supabaseAdminClient.from('prices').upsert([priceData]);
24 |
25 | if (error) {
26 | throw error;
27 | } else {
28 | console.info(`Price inserted/updated: ${price.id}`);
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/features/pricing/controllers/upsert-product.ts:
--------------------------------------------------------------------------------
1 | import Stripe from 'stripe';
2 |
3 | import { supabaseAdminClient } from '@/libs/supabase/supabase-admin';
4 | import type { Database } from '@/libs/supabase/types';
5 |
6 | type Product = Database['public']['Tables']['products']['Row'];
7 |
8 | export async function upsertProduct(product: Stripe.Product) {
9 | const productData: Product = {
10 | id: product.id,
11 | active: product.active,
12 | name: product.name,
13 | description: product.description ?? null,
14 | image: product.images?.[0] ?? null,
15 | metadata: product.metadata,
16 | };
17 |
18 | const { error } = await supabaseAdminClient.from('products').upsert([productData]);
19 |
20 | if (error) {
21 | throw error;
22 | } else {
23 | console.info(`Product inserted/updated: ${product.id}`);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/features/pricing/models/product-metadata.ts:
--------------------------------------------------------------------------------
1 | import z from 'zod';
2 |
3 | export const priceCardVariantSchema = z.enum(['basic', 'pro', 'enterprise']);
4 |
5 | export const productMetadataSchema = z
6 | .object({
7 | price_card_variant: priceCardVariantSchema,
8 | generated_images: z.string().optional(),
9 | image_editor: z.enum(['basic', 'pro']),
10 | support_level: z.enum(['email', 'live']),
11 | })
12 | .transform((data) => ({
13 | priceCardVariant: data.price_card_variant,
14 | generatedImages: data.generated_images ? parseInt(data.generated_images) : 'enterprise',
15 | imageEditor: data.image_editor,
16 | supportLevel: data.support_level,
17 | }));
18 |
19 | export type ProductMetadata = z.infer;
20 | export type PriceCardVariant = z.infer;
21 |
--------------------------------------------------------------------------------
/src/features/pricing/types.ts:
--------------------------------------------------------------------------------
1 | import { Database } from '@/libs/supabase/types';
2 |
3 | export type BillingInterval = 'year' | 'month';
4 | export type Subscription = Database['public']['Tables']['subscriptions']['Row'];
5 | export type Product = Database['public']['Tables']['products']['Row'];
6 | export type Price = Database['public']['Tables']['prices']['Row'];
7 | export type ProductWithPrices = Product & { prices: Price[] };
8 | export type PriceWithProduct = Price & { products: Product | null };
9 | export type SubscriptionWithProduct = Subscription & { prices: PriceWithProduct | null };
10 |
--------------------------------------------------------------------------------
/src/libs/resend/resend-client.ts:
--------------------------------------------------------------------------------
1 | import { Resend } from 'resend';
2 |
3 | import { getEnvVar } from '@/utils/get-env-var';
4 |
5 | export const resendClient = new Resend(getEnvVar(process.env.RESEND_API_KEY, 'RESEND_API_KEY'));
6 |
--------------------------------------------------------------------------------
/src/libs/stripe/stripe-admin.ts:
--------------------------------------------------------------------------------
1 | import Stripe from 'stripe';
2 |
3 | import { getEnvVar } from '@/utils/get-env-var';
4 |
5 | export const stripeAdmin = new Stripe(getEnvVar(process.env.STRIPE_SECRET_KEY, 'STRIPE_SECRET_KEY'), {
6 | // https://github.com/stripe/stripe-node#configuration
7 | apiVersion: '2023-10-16',
8 | // Register this as an official Stripe plugin.
9 | // https://stripe.com/docs/building-plugins#setappinfo
10 | appInfo: {
11 | name: 'UPDATE_THIS_WITH_YOUR_STRIPE_APP_NAME',
12 | version: '0.1.0',
13 | },
14 | });
15 |
--------------------------------------------------------------------------------
/src/libs/supabase/supabase-admin.ts:
--------------------------------------------------------------------------------
1 | import type { Database } from '@/libs/supabase/types';
2 | import { getEnvVar } from '@/utils/get-env-var';
3 | import { createClient } from '@supabase/supabase-js';
4 |
5 | export const supabaseAdminClient = createClient(
6 | getEnvVar(process.env.NEXT_PUBLIC_SUPABASE_URL, 'NEXT_PUBLIC_SUPABASE_URL'),
7 | getEnvVar(process.env.SUPABASE_SERVICE_ROLE_KEY, 'SUPABASE_SERVICE_ROLE_KEY')
8 | );
9 |
--------------------------------------------------------------------------------
/src/libs/supabase/supabase-middleware-client.ts:
--------------------------------------------------------------------------------
1 | // Ref: https://supabase.com/docs/guides/auth/server-side/nextjs
2 |
3 | import { type NextRequest,NextResponse } from 'next/server';
4 |
5 | import { getEnvVar } from '@/utils/get-env-var';
6 | import { createServerClient } from '@supabase/ssr';
7 |
8 | export async function updateSession(request: NextRequest) {
9 | let supabaseResponse = NextResponse.next({
10 | request,
11 | });
12 |
13 | const supabase = createServerClient(
14 | getEnvVar(process.env.NEXT_PUBLIC_SUPABASE_URL, 'NEXT_PUBLIC_SUPABASE_URL'),
15 | getEnvVar(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, 'NEXT_PUBLIC_SUPABASE_URL'),
16 | {
17 | cookies: {
18 | getAll() {
19 | return request.cookies.getAll();
20 | },
21 | setAll(cookiesToSet) {
22 | for (const { name, value, options } of cookiesToSet) {
23 | request.cookies.set(name, value);
24 | }
25 |
26 | supabaseResponse = NextResponse.next({
27 | request,
28 | });
29 |
30 | for (const { name, value, options } of cookiesToSet) {
31 | supabaseResponse.cookies.set(name, value, options);
32 | }
33 | },
34 | },
35 | }
36 | );
37 |
38 | // Do not run code between createServerClient and
39 | // supabase.auth.getUser(). A simple mistake could make it very hard to debug
40 | // issues with users being randomly logged out.
41 |
42 | // IMPORTANT: DO NOT REMOVE auth.getUser()
43 |
44 | const {
45 | data: { user },
46 | } = await supabase.auth.getUser();
47 |
48 | // Add route guards here
49 | // const guardedRoutes = ['/dashboard'];
50 | // if (!user && guardedRoutes.includes(request.nextUrl.pathname)) {
51 | // // no user, potentially respond by redirecting the user to the login page
52 | // const url = request.nextUrl.clone();
53 | // url.pathname = '/login';
54 | // return NextResponse.redirect(url);
55 | // }
56 |
57 | // IMPORTANT: You *must* return the supabaseResponse object as it is.
58 | // If you're creating a new response object with NextResponse.next() make sure to:
59 | // 1. Pass the request in it, like so:
60 | // const myNewResponse = NextResponse.next({ request })
61 | // 2. Copy over the cookies, like so:
62 | // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())
63 | // 3. Change the myNewResponse object to fit your needs, but avoid changing
64 | // the cookies!
65 | // 4. Finally:
66 | // return myNewResponse
67 | // If this is not done, you may be causing the browser and server to go out
68 | // of sync and terminate the user's session prematurely!
69 |
70 | return supabaseResponse;
71 | }
72 |
--------------------------------------------------------------------------------
/src/libs/supabase/supabase-server-client.ts:
--------------------------------------------------------------------------------
1 | // ref: https://github.com/vercel/next.js/blob/canary/examples/with-supabase/utils/supabase/server.ts
2 |
3 | import { cookies } from 'next/headers';
4 |
5 | import { Database } from '@/libs/supabase/types';
6 | import { getEnvVar } from '@/utils/get-env-var';
7 | import { type CookieOptions, createServerClient } from '@supabase/ssr';
8 |
9 | export async function createSupabaseServerClient() {
10 | const cookieStore = await cookies();
11 |
12 | return createServerClient(
13 | getEnvVar(process.env.NEXT_PUBLIC_SUPABASE_URL, 'NEXT_PUBLIC_SUPABASE_URL'),
14 | getEnvVar(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, 'NEXT_PUBLIC_SUPABASE_ANON_KEY'),
15 | {
16 | cookies: {
17 | get(name: string) {
18 | return cookieStore.get(name)?.value;
19 | },
20 | set(name: string, value: string, options: CookieOptions) {
21 | cookieStore.set({ name, value, ...options });
22 | },
23 | remove(name: string, options: CookieOptions) {
24 | cookieStore.set({ name, value: '', ...options });
25 | },
26 | },
27 | }
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/libs/supabase/types.ts:
--------------------------------------------------------------------------------
1 | export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
2 |
3 | export interface Database {
4 | public: {
5 | Tables: {
6 | customers: {
7 | Row: {
8 | id: string;
9 | stripe_customer_id: string | null;
10 | };
11 | Insert: {
12 | id: string;
13 | stripe_customer_id?: string | null;
14 | };
15 | Update: {
16 | id?: string;
17 | stripe_customer_id?: string | null;
18 | };
19 | Relationships: [
20 | {
21 | foreignKeyName: 'customers_id_fkey';
22 | columns: ['id'];
23 | isOneToOne: true;
24 | referencedRelation: 'users';
25 | referencedColumns: ['id'];
26 | }
27 | ];
28 | };
29 | prices: {
30 | Row: {
31 | active: boolean | null;
32 | currency: string | null;
33 | description: string | null;
34 | id: string;
35 | interval: Database['public']['Enums']['pricing_plan_interval'] | null;
36 | interval_count: number | null;
37 | metadata: Json | null;
38 | product_id: string | null;
39 | trial_period_days: number | null;
40 | type: Database['public']['Enums']['pricing_type'] | null;
41 | unit_amount: number | null;
42 | };
43 | Insert: {
44 | active?: boolean | null;
45 | currency?: string | null;
46 | description?: string | null;
47 | id: string;
48 | interval?: Database['public']['Enums']['pricing_plan_interval'] | null;
49 | interval_count?: number | null;
50 | metadata?: Json | null;
51 | product_id?: string | null;
52 | trial_period_days?: number | null;
53 | type?: Database['public']['Enums']['pricing_type'] | null;
54 | unit_amount?: number | null;
55 | };
56 | Update: {
57 | active?: boolean | null;
58 | currency?: string | null;
59 | description?: string | null;
60 | id?: string;
61 | interval?: Database['public']['Enums']['pricing_plan_interval'] | null;
62 | interval_count?: number | null;
63 | metadata?: Json | null;
64 | product_id?: string | null;
65 | trial_period_days?: number | null;
66 | type?: Database['public']['Enums']['pricing_type'] | null;
67 | unit_amount?: number | null;
68 | };
69 | Relationships: [
70 | {
71 | foreignKeyName: 'prices_product_id_fkey';
72 | columns: ['product_id'];
73 | isOneToOne: false;
74 | referencedRelation: 'products';
75 | referencedColumns: ['id'];
76 | }
77 | ];
78 | };
79 | products: {
80 | Row: {
81 | active: boolean | null;
82 | description: string | null;
83 | id: string;
84 | image: string | null;
85 | metadata: Json | null;
86 | name: string | null;
87 | };
88 | Insert: {
89 | active?: boolean | null;
90 | description?: string | null;
91 | id: string;
92 | image?: string | null;
93 | metadata?: Json | null;
94 | name?: string | null;
95 | };
96 | Update: {
97 | active?: boolean | null;
98 | description?: string | null;
99 | id?: string;
100 | image?: string | null;
101 | metadata?: Json | null;
102 | name?: string | null;
103 | };
104 | Relationships: [];
105 | };
106 | subscriptions: {
107 | Row: {
108 | cancel_at: string | null;
109 | cancel_at_period_end: boolean | null;
110 | canceled_at: string | null;
111 | created: string;
112 | current_period_end: string;
113 | current_period_start: string;
114 | ended_at: string | null;
115 | id: string;
116 | metadata: Json | null;
117 | price_id: string | null;
118 | quantity: number | null;
119 | status: Database['public']['Enums']['subscription_status'] | null;
120 | trial_end: string | null;
121 | trial_start: string | null;
122 | user_id: string;
123 | };
124 | Insert: {
125 | cancel_at?: string | null;
126 | cancel_at_period_end?: boolean | null;
127 | canceled_at?: string | null;
128 | created?: string;
129 | current_period_end?: string;
130 | current_period_start?: string;
131 | ended_at?: string | null;
132 | id: string;
133 | metadata?: Json | null;
134 | price_id?: string | null;
135 | quantity?: number | null;
136 | status?: Database['public']['Enums']['subscription_status'] | null;
137 | trial_end?: string | null;
138 | trial_start?: string | null;
139 | user_id: string;
140 | };
141 | Update: {
142 | cancel_at?: string | null;
143 | cancel_at_period_end?: boolean | null;
144 | canceled_at?: string | null;
145 | created?: string;
146 | current_period_end?: string;
147 | current_period_start?: string;
148 | ended_at?: string | null;
149 | id?: string;
150 | metadata?: Json | null;
151 | price_id?: string | null;
152 | quantity?: number | null;
153 | status?: Database['public']['Enums']['subscription_status'] | null;
154 | trial_end?: string | null;
155 | trial_start?: string | null;
156 | user_id?: string;
157 | };
158 | Relationships: [
159 | {
160 | foreignKeyName: 'subscriptions_price_id_fkey';
161 | columns: ['price_id'];
162 | isOneToOne: false;
163 | referencedRelation: 'prices';
164 | referencedColumns: ['id'];
165 | },
166 | {
167 | foreignKeyName: 'subscriptions_user_id_fkey';
168 | columns: ['user_id'];
169 | isOneToOne: false;
170 | referencedRelation: 'users';
171 | referencedColumns: ['id'];
172 | }
173 | ];
174 | };
175 | users: {
176 | Row: {
177 | avatar_url: string | null;
178 | billing_address: Json | null;
179 | full_name: string | null;
180 | id: string;
181 | payment_method: Json | null;
182 | };
183 | Insert: {
184 | avatar_url?: string | null;
185 | billing_address?: Json | null;
186 | full_name?: string | null;
187 | id: string;
188 | payment_method?: Json | null;
189 | };
190 | Update: {
191 | avatar_url?: string | null;
192 | billing_address?: Json | null;
193 | full_name?: string | null;
194 | id?: string;
195 | payment_method?: Json | null;
196 | };
197 | Relationships: [
198 | {
199 | foreignKeyName: 'users_id_fkey';
200 | columns: ['id'];
201 | isOneToOne: true;
202 | referencedRelation: 'users';
203 | referencedColumns: ['id'];
204 | }
205 | ];
206 | };
207 | };
208 | Views: {
209 | [_ in never]: never;
210 | };
211 | Functions: {
212 | [_ in never]: never;
213 | };
214 | Enums: {
215 | pricing_plan_interval: 'day' | 'week' | 'month' | 'year';
216 | pricing_type: 'one_time' | 'recurring';
217 | subscription_status:
218 | | 'trialing'
219 | | 'active'
220 | | 'canceled'
221 | | 'incomplete'
222 | | 'incomplete_expired'
223 | | 'past_due'
224 | | 'unpaid'
225 | | 'paused';
226 | };
227 | CompositeTypes: {
228 | [_ in never]: never;
229 | };
230 | };
231 | }
232 |
233 | export type Tables<
234 | PublicTableNameOrOptions extends
235 | | keyof (Database['public']['Tables'] & Database['public']['Views'])
236 | | { schema: keyof Database },
237 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
238 | ? keyof (Database[PublicTableNameOrOptions['schema']]['Tables'] &
239 | Database[PublicTableNameOrOptions['schema']]['Views'])
240 | : never = never
241 | > = PublicTableNameOrOptions extends { schema: keyof Database }
242 | ? (Database[PublicTableNameOrOptions['schema']]['Tables'] &
243 | Database[PublicTableNameOrOptions['schema']]['Views'])[TableName] extends {
244 | Row: infer R;
245 | }
246 | ? R
247 | : never
248 | : PublicTableNameOrOptions extends keyof (Database['public']['Tables'] & Database['public']['Views'])
249 | ? (Database['public']['Tables'] & Database['public']['Views'])[PublicTableNameOrOptions] extends {
250 | Row: infer R;
251 | }
252 | ? R
253 | : never
254 | : never;
255 |
256 | export type TablesInsert<
257 | PublicTableNameOrOptions extends keyof Database['public']['Tables'] | { schema: keyof Database },
258 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
259 | ? keyof Database[PublicTableNameOrOptions['schema']]['Tables']
260 | : never = never
261 | > = PublicTableNameOrOptions extends { schema: keyof Database }
262 | ? Database[PublicTableNameOrOptions['schema']]['Tables'][TableName] extends {
263 | Insert: infer I;
264 | }
265 | ? I
266 | : never
267 | : PublicTableNameOrOptions extends keyof Database['public']['Tables']
268 | ? Database['public']['Tables'][PublicTableNameOrOptions] extends {
269 | Insert: infer I;
270 | }
271 | ? I
272 | : never
273 | : never;
274 |
275 | export type TablesUpdate<
276 | PublicTableNameOrOptions extends keyof Database['public']['Tables'] | { schema: keyof Database },
277 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
278 | ? keyof Database[PublicTableNameOrOptions['schema']]['Tables']
279 | : never = never
280 | > = PublicTableNameOrOptions extends { schema: keyof Database }
281 | ? Database[PublicTableNameOrOptions['schema']]['Tables'][TableName] extends {
282 | Update: infer U;
283 | }
284 | ? U
285 | : never
286 | : PublicTableNameOrOptions extends keyof Database['public']['Tables']
287 | ? Database['public']['Tables'][PublicTableNameOrOptions] extends {
288 | Update: infer U;
289 | }
290 | ? U
291 | : never
292 | : never;
293 |
294 | export type Enums<
295 | PublicEnumNameOrOptions extends keyof Database['public']['Enums'] | { schema: keyof Database },
296 | EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
297 | ? keyof Database[PublicEnumNameOrOptions['schema']]['Enums']
298 | : never = never
299 | > = PublicEnumNameOrOptions extends { schema: keyof Database }
300 | ? Database[PublicEnumNameOrOptions['schema']]['Enums'][EnumName]
301 | : PublicEnumNameOrOptions extends keyof Database['public']['Enums']
302 | ? Database['public']['Enums'][PublicEnumNameOrOptions]
303 | : never;
304 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { type NextRequest } from 'next/server';
2 |
3 | import { updateSession } from '@/libs/supabase/supabase-middleware-client';
4 |
5 | export async function middleware(request: NextRequest) {
6 | return await updateSession(request);
7 | }
8 |
9 | export const config = {
10 | matcher: [
11 | /*
12 | * Match all request paths except for the ones starting with:
13 | * - _next/static (static files)
14 | * - _next/image (image optimization files)
15 | * - favicon.ico (favicon file)
16 | * Feel free to modify this pattern to include more paths.
17 | */
18 | '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
19 | ],
20 | };
21 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 | @layer base {
5 | :root {
6 | --background: 240 6% 10%;
7 | --foreground: 60 0% 90%;
8 |
9 | --muted: 240 6% 10%;
10 | --muted-foreground: 240 5% 84%;
11 |
12 | --popover: 0 0% 100%;
13 | --popover-foreground: 222.2 47.4% 11.2%;
14 |
15 | --border: 214.3 31.8% 91.4%;
16 | --input: 214.3 31.8% 91.4%;
17 |
18 | --card: 0 0% 100%;
19 | --card-foreground: 222.2 47.4% 11.2%;
20 |
21 | --primary: 222.2 47.4% 11.2%;
22 | --primary-foreground: 210 40% 98%;
23 |
24 | --secondary: 210 40% 96.1%;
25 | --secondary-foreground: 222.2 47.4% 11.2%;
26 |
27 | --accent: 210 40% 96.1%;
28 | --accent-foreground: 222.2 47.4% 11.2%;
29 |
30 | --destructive: 0 100% 50%;
31 | --destructive-foreground: 210 40% 98%;
32 |
33 | --ring: 215 20.2% 65.1%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 224 71% 4%;
40 | --foreground: 213 31% 91%;
41 |
42 | --muted: 223 47% 11%;
43 | --muted-foreground: 215.4 16.3% 56.9%;
44 |
45 | --accent: 216 34% 17%;
46 | --accent-foreground: 210 40% 98%;
47 |
48 | --popover: 224 71% 4%;
49 | --popover-foreground: 215 20.2% 65.1%;
50 |
51 | --border: 216 34% 17%;
52 | --input: 216 34% 17%;
53 |
54 | --card: 224 71% 4%;
55 | --card-foreground: 213 31% 91%;
56 |
57 | --primary: 210 40% 98%;
58 | --primary-foreground: 222.2 47.4% 1.2%;
59 |
60 | --secondary: 222.2 47.4% 11.2%;
61 | --secondary-foreground: 210 40% 98%;
62 |
63 | --destructive: 0 63% 31%;
64 | --destructive-foreground: 210 40% 98%;
65 |
66 | --ring: 216 34% 17%;
67 |
68 | --radius: 0.5rem;
69 | }
70 | ::selection {
71 | @apply text-black;
72 | @apply bg-cyan-400;
73 | }
74 | *:focus-visible {
75 | @apply outline;
76 | @apply outline-2;
77 | @apply outline-offset-2;
78 | @apply outline-pink-500;
79 | }
80 | * {
81 | @apply border-border;
82 | @apply min-w-0;
83 | }
84 | body {
85 | @apply bg-background text-foreground;
86 | font-feature-settings: 'rlig' 1, 'calt' 1;
87 | }
88 | html {
89 | @apply h-full;
90 | }
91 | body {
92 | @apply h-full;
93 | }
94 | h1 {
95 | @apply font-alt;
96 | @apply font-bold;
97 | @apply text-4xl;
98 | @apply text-white;
99 | @apply lg:text-6xl;
100 | @apply bg-clip-text;
101 | @apply drop-shadow-[0_0_15px_rgba(0,0,0,1)];
102 | @apply lg:text-transparent;
103 | @apply lg:bg-gradient-to-br;
104 | @apply from-white;
105 | @apply to-neutral-400;
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/types/action-response.ts:
--------------------------------------------------------------------------------
1 | export type ActionResponse =
2 | | {
3 | data: any;
4 | error: any;
5 | }
6 | | undefined;
7 |
--------------------------------------------------------------------------------
/src/utils/cn.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/src/utils/get-env-var.ts:
--------------------------------------------------------------------------------
1 | export function getEnvVar(varValue: string | undefined, varName: string): string {
2 | if (varValue === undefined) throw new ReferenceError(`Reference to undefined env var: ${varName}`);
3 | return varValue;
4 | }
5 |
--------------------------------------------------------------------------------
/src/utils/get-url.ts:
--------------------------------------------------------------------------------
1 | export function getURL(path = '') {
2 | // Get the base URL, defaulting to localhost if not set.
3 | const baseURL = process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/+$/, '') || 'http://localhost:3000';
4 |
5 | // Ensure HTTPS for non-localhost URLs and format the path.
6 | const formattedURL = baseURL.startsWith('http') ? baseURL : `https://${baseURL}`;
7 | const cleanPath = path.replace(/^\/+/, '');
8 |
9 | // Return the full URL.
10 | return cleanPath ? `${formattedURL}/${cleanPath}` : formattedURL;
11 | }
12 |
--------------------------------------------------------------------------------
/src/utils/to-date-time.ts:
--------------------------------------------------------------------------------
1 | export function toDateTime(secs: number) {
2 | var t = new Date('1970-01-01T00:30:00Z'); // Unix epoch start.
3 | t.setSeconds(secs);
4 | return t;
5 | }
6 |
--------------------------------------------------------------------------------
/stripe-fixtures.json:
--------------------------------------------------------------------------------
1 | {
2 | "_meta": {
3 | "template_version": 0
4 | },
5 | "fixtures": [
6 | {
7 | "name": "basic",
8 | "path": "/v1/products",
9 | "method": "post",
10 | "params": {
11 | "name": "Basic",
12 | "description": "Perfect product for small businesses.",
13 | "metadata": {
14 | "price_card_variant": "basic",
15 | "generated_images": 10,
16 | "image_editor": "basic",
17 | "support_level": "email"
18 | }
19 | }
20 | },
21 | {
22 | "name": "price_basic_month",
23 | "path": "/v1/prices",
24 | "method": "post",
25 | "params": {
26 | "product": "${basic:id}",
27 | "currency": "usd",
28 | "billing_scheme": "per_unit",
29 | "unit_amount": 500,
30 | "recurring": {
31 | "interval": "month",
32 | "interval_count": 1
33 | }
34 | }
35 | },
36 | {
37 | "name": "price_basic_year",
38 | "path": "/v1/prices",
39 | "method": "post",
40 | "params": {
41 | "product": "${basic:id}",
42 | "currency": "usd",
43 | "billing_scheme": "per_unit",
44 | "unit_amount": 5000,
45 | "recurring": {
46 | "interval": "year",
47 | "interval_count": 1
48 | }
49 | }
50 | },
51 | {
52 | "name": "pro",
53 | "path": "/v1/products",
54 | "method": "post",
55 | "params": {
56 | "name": "Pro",
57 | "description": "Perfect product for growing or large businesses.",
58 | "metadata": {
59 | "price_card_variant": "pro",
60 | "generated_images": 100,
61 | "image_editor": "pro",
62 | "support_level": "live"
63 | }
64 | }
65 | },
66 | {
67 | "name": "price_pro_month",
68 | "path": "/v1/prices",
69 | "method": "post",
70 | "params": {
71 | "product": "${pro:id}",
72 | "currency": "usd",
73 | "billing_scheme": "per_unit",
74 | "unit_amount": 1000,
75 | "recurring": {
76 | "interval": "month",
77 | "interval_count": 1
78 | }
79 | }
80 | },
81 | {
82 | "name": "price_pro_year",
83 | "path": "/v1/prices",
84 | "method": "post",
85 | "params": {
86 | "product": "${pro:id}",
87 | "currency": "usd",
88 | "billing_scheme": "per_unit",
89 | "unit_amount": 10000,
90 | "recurring": {
91 | "interval": "year",
92 | "interval_count": 1
93 | }
94 | }
95 | },
96 | {
97 | "name": "enterprise",
98 | "path": "/v1/products",
99 | "method": "post",
100 | "params": {
101 | "name": "Enterprise",
102 | "description": "Perfect product for enterprises.",
103 | "metadata": {
104 | "price_card_variant": "enterprise",
105 | "image_editor": "pro",
106 | "support_level": "live"
107 | }
108 | }
109 | }
110 | ]
111 | }
112 |
--------------------------------------------------------------------------------
/supabase/migrations/20240115041359_init.sql:
--------------------------------------------------------------------------------
1 | /**
2 | * USERS
3 | * Note: This table contains user data. Users should only be able to view and update their own data.
4 | */
5 | create table users (
6 | -- UUID from auth.users
7 | id uuid references auth.users not null primary key,
8 | full_name text,
9 | avatar_url text,
10 | -- The customer's billing address, stored in JSON format.
11 | billing_address jsonb,
12 | -- Stores your customer's payment instruments.
13 | payment_method jsonb
14 | );
15 | alter table users enable row level security;
16 | create policy "Can view own user data." on users for select using (auth.uid() = id);
17 | create policy "Can update own user data." on users for update using (auth.uid() = id);
18 |
19 | /**
20 | * This trigger automatically creates a user entry when a new user signs up via Supabase Auth.
21 | */
22 | create function public.handle_new_user()
23 | returns trigger as $$
24 | begin
25 | insert into public.users (id, full_name, avatar_url)
26 | values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
27 | return new;
28 | end;
29 | $$ language plpgsql security definer;
30 | create trigger on_auth_user_created
31 | after insert on auth.users
32 | for each row execute procedure public.handle_new_user();
33 |
34 | /**
35 | * CUSTOMERS
36 | * Note: this is a private table that contains a mapping of user IDs to Stripe customer IDs.
37 | */
38 | create table customers (
39 | -- UUID from auth.users
40 | id uuid references auth.users not null primary key,
41 | -- The user's customer ID in Stripe. User must not be able to update this.
42 | stripe_customer_id text
43 | );
44 | alter table customers enable row level security;
45 | -- No policies as this is a private table that the user must not have access to.
46 |
47 | /**
48 | * PRODUCTS
49 | * Note: products are created and managed in Stripe and synced to our DB via Stripe webhooks.
50 | */
51 | create table products (
52 | -- Product ID from Stripe, e.g. prod_1234.
53 | id text primary key,
54 | -- Whether the product is currently available for purchase.
55 | active boolean,
56 | -- The product's name, meant to be displayable to the customer. Whenever this product is sold via a subscription, name will show up on associated invoice line item descriptions.
57 | name text,
58 | -- The product's description, meant to be displayable to the customer. Use this field to optionally store a long form explanation of the product being sold for your own rendering purposes.
59 | description text,
60 | -- A URL of the product image in Stripe, meant to be displayable to the customer.
61 | image text,
62 | -- Set of key-value pairs, used to store additional information about the object in a structured format.
63 | metadata jsonb
64 | );
65 | alter table products enable row level security;
66 | create policy "Allow public read-only access." on products for select using (true);
67 |
68 | /**
69 | * PRICES
70 | * Note: prices are created and managed in Stripe and synced to our DB via Stripe webhooks.
71 | */
72 | create type pricing_type as enum ('one_time', 'recurring');
73 | create type pricing_plan_interval as enum ('day', 'week', 'month', 'year');
74 | create table prices (
75 | -- Price ID from Stripe, e.g. price_1234.
76 | id text primary key,
77 | -- The ID of the prduct that this price belongs to.
78 | product_id text references products,
79 | -- Whether the price can be used for new purchases.
80 | active boolean,
81 | -- A brief description of the price.
82 | description text,
83 | -- The unit amount as a positive integer in the smallest currency unit (e.g., 100 cents for US$1.00 or 100 for ¥100, a zero-decimal currency).
84 | unit_amount bigint,
85 | -- Three-letter ISO currency code, in lowercase.
86 | currency text check (char_length(currency) = 3),
87 | -- One of `one_time` or `recurring` depending on whether the price is for a one-time purchase or a recurring (subscription) purchase.
88 | type pricing_type,
89 | -- The frequency at which a subscription is billed. One of `day`, `week`, `month` or `year`.
90 | interval pricing_plan_interval,
91 | -- The number of intervals (specified in the `interval` attribute) between subscription billings. For example, `interval=month` and `interval_count=3` bills every 3 months.
92 | interval_count integer,
93 | -- Default number of trial days when subscribing a customer to this price using [`trial_from_plan=true`](https://stripe.com/docs/api#create_subscription-trial_from_plan).
94 | trial_period_days integer,
95 | -- Set of key-value pairs, used to store additional information about the object in a structured format.
96 | metadata jsonb
97 | );
98 | alter table prices enable row level security;
99 | create policy "Allow public read-only access." on prices for select using (true);
100 |
101 | /**
102 | * SUBSCRIPTIONS
103 | * Note: subscriptions are created and managed in Stripe and synced to our DB via Stripe webhooks.
104 | */
105 | create type subscription_status as enum ('trialing', 'active', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'unpaid', 'paused');
106 | create table subscriptions (
107 | -- Subscription ID from Stripe, e.g. sub_1234.
108 | id text primary key,
109 | user_id uuid references auth.users not null,
110 | -- The status of the subscription object, one of subscription_status type above.
111 | status subscription_status,
112 | -- Set of key-value pairs, used to store additional information about the object in a structured format.
113 | metadata jsonb,
114 | -- ID of the price that created this subscription.
115 | price_id text references prices,
116 | -- Quantity multiplied by the unit amount of the price creates the amount of the subscription. Can be used to charge multiple seats.
117 | quantity integer,
118 | -- If true the subscription has been canceled by the user and will be deleted at the end of the billing period.
119 | cancel_at_period_end boolean,
120 | -- Time at which the subscription was created.
121 | created timestamp with time zone default timezone('utc'::text, now()) not null,
122 | -- Start of the current period that the subscription has been invoiced for.
123 | current_period_start timestamp with time zone default timezone('utc'::text, now()) not null,
124 | -- End of the current period that the subscription has been invoiced for. At the end of this period, a new invoice will be created.
125 | current_period_end timestamp with time zone default timezone('utc'::text, now()) not null,
126 | -- If the subscription has ended, the timestamp of the date the subscription ended.
127 | ended_at timestamp with time zone default timezone('utc'::text, now()),
128 | -- A date in the future at which the subscription will automatically get canceled.
129 | cancel_at timestamp with time zone default timezone('utc'::text, now()),
130 | -- If the subscription has been canceled, the date of that cancellation. If the subscription was canceled with `cancel_at_period_end`, `canceled_at` will still reflect the date of the initial cancellation request, not the end of the subscription period when the subscription is automatically moved to a canceled state.
131 | canceled_at timestamp with time zone default timezone('utc'::text, now()),
132 | -- If the subscription has a trial, the beginning of that trial.
133 | trial_start timestamp with time zone default timezone('utc'::text, now()),
134 | -- If the subscription has a trial, the end of that trial.
135 | trial_end timestamp with time zone default timezone('utc'::text, now())
136 | );
137 | alter table subscriptions enable row level security;
138 | create policy "Can only view own subs data." on subscriptions for select using (auth.uid() = user_id);
139 |
140 | /**
141 | * REALTIME SUBSCRIPTIONS
142 | * Only allow realtime listening on public tables.
143 | */
144 | drop publication if exists supabase_realtime;
145 | create publication supabase_realtime for table products, prices;
--------------------------------------------------------------------------------
/supabase/seed.sql:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KolbySisk/next-supabase-stripe-starter/d193a27501e94ebccdf017c8b60b05a09c2f0834/supabase/seed.sql
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 | import { fontFamily } from 'tailwindcss/defaultTheme';
3 |
4 | /** @type {import('tailwindcss').Config} */
5 | const config: Config = {
6 | darkMode: ['class'],
7 | content: ['./src/**/*.{ts,tsx}'],
8 | theme: {
9 | container: {
10 | center: true,
11 | padding: '2rem',
12 | screens: {
13 | '2xl': '1440px',
14 | },
15 | },
16 | extend: {
17 | colors: {
18 | border: 'hsl(var(--border))',
19 | input: 'hsl(var(--input))',
20 | ring: 'hsl(var(--ring))',
21 | background: 'hsl(var(--background))',
22 | foreground: 'hsl(var(--foreground))',
23 | primary: {
24 | DEFAULT: 'hsl(var(--primary))',
25 | foreground: 'hsl(var(--primary-foreground))',
26 | },
27 | secondary: {
28 | DEFAULT: 'hsl(var(--secondary))',
29 | foreground: 'hsl(var(--secondary-foreground))',
30 | },
31 | destructive: {
32 | DEFAULT: 'hsl(var(--destructive))',
33 | foreground: 'hsl(var(--destructive-foreground))',
34 | },
35 | muted: {
36 | DEFAULT: 'hsl(var(--muted))',
37 | foreground: 'hsl(var(--muted-foreground))',
38 | },
39 | accent: {
40 | DEFAULT: 'hsl(var(--accent))',
41 | foreground: 'hsl(var(--accent-foreground))',
42 | },
43 | popover: {
44 | DEFAULT: 'hsl(var(--popover))',
45 | foreground: 'hsl(var(--popover-foreground))',
46 | },
47 | card: {
48 | DEFAULT: 'hsl(var(--card))',
49 | foreground: 'hsl(var(--card-foreground))',
50 | },
51 | },
52 | borderRadius: {
53 | lg: `var(--radius)`,
54 | md: `calc(var(--radius) - 2px)`,
55 | sm: 'calc(var(--radius) - 4px)',
56 | },
57 | fontFamily: {
58 | sans: ['var(--font-montserrat)', ...fontFamily.sans],
59 | alt: ['var(--font-montserrat-alternates)'],
60 | },
61 | keyframes: {
62 | 'accordion-down': {
63 | from: { height: '0' },
64 | to: { height: 'var(--radix-accordion-content-height)' },
65 | },
66 | 'accordion-up': {
67 | from: { height: 'var(--radix-accordion-content-height)' },
68 | to: { height: '0' },
69 | },
70 | 'spin-slow': {
71 | '0%': { rotate: '0deg' },
72 | '100%': { rotate: '360deg' },
73 | },
74 | },
75 | animation: {
76 | 'accordion-down': 'accordion-down 0.2s ease-out',
77 | 'accordion-up': 'accordion-up 0.2s ease-out',
78 | 'spin-slow': 'spin 10s linear infinite',
79 | },
80 | },
81 | },
82 | plugins: [require('tailwindcss-animate')],
83 | };
84 |
85 | export default config;
86 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | },
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "prettier.config.js"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------