├── .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 | Created by Kolby Sisk 10 | License 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 | [![Deploy with Vercel](https://vercel.com/button)](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 | ![Vercel env config](/delete-me/deplyoment-env.png) 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 | 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 | 81 | 89 | 90 | 91 | 92 | 98 | 99 | 100 |
101 |
102 | 109 |
110 | 113 | 116 |
117 |
118 |
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 |
23 | 24 |
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 |
23 | 24 |
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 |
55 | 56 | 57 |
58 | ); 59 | } 60 | 61 | function Footer() { 62 | return ( 63 |
64 |
65 |
66 | 67 |
68 |
69 |
70 |
Product
71 | 74 |
75 |
76 |
Company
77 | 81 |
82 |
83 |
Support
84 | 87 |
88 |
89 |
Follow us
90 | 107 |
108 |
109 |
110 |
111 | 112 | Copyright {new Date().getFullYear()} © UPDATE_THIS_WITH_YOUR_APP_DISPLAY_NAME 113 | 114 |
115 |
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 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 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 |
11 | 12 | 13 | 14 |
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 | 32 |
33 |
34 | 43 |
44 | ); 45 | } 46 | 47 | function ExamplesSection() { 48 | return ( 49 |
50 |
51 | Example of a generated banner 59 | Example of a generated banner 67 | Example of a generated banner 75 |
76 |
77 | Example of a generated banner 85 | Example of a generated banner 93 | Example of a generated banner 101 |
102 |
103 | Example of a generated banner 111 | Example of a generated banner 119 | Example of a generated banner 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 | UPDATE_THIS_WITH_YOUR_APP_DISPLAY_NAME logo mark 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 |
17 | Welcome! 18 |
19 |
20 | 21 | Thanks for signing up. 22 | 23 | Go to your dashboard to get started. 24 | 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 | 96 | )} 97 | {!currentPrice && ( 98 | 101 | )} 102 |
103 | )} 104 |
105 |
106 | ); 107 | } 108 | 109 | function CheckItem({ text }: { text: string }) { 110 | return ( 111 |
112 | 113 |

{text}

114 |
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 | --------------------------------------------------------------------------------