├── .env.local.example
├── .gitignore
├── LICENSE
├── README.md
├── components
├── Layout.tsx
├── Pricing.tsx
├── icons
│ ├── GitHub.tsx
│ └── Logo.tsx
└── ui
│ ├── Button
│ ├── Button.module.css
│ ├── Button.tsx
│ └── index.ts
│ ├── Footer
│ ├── Footer.tsx
│ └── index.ts
│ ├── Input
│ ├── Input.module.css
│ ├── Input.tsx
│ └── index.ts
│ ├── LoadingDots
│ ├── LoadingDots.module.css
│ ├── LoadingDots.tsx
│ └── index.ts
│ └── Navbar
│ ├── Navbar.module.css
│ ├── Navbar.tsx
│ └── index.ts
├── fixtures
└── stripe-fixtures.json
├── next-env.d.ts
├── next.config.js
├── package.json
├── pages
├── _app.tsx
├── _document.tsx
├── account.tsx
├── api
│ ├── create-checkout-session.ts
│ ├── create-portal-link.ts
│ └── webhooks.ts
├── index.tsx
└── signin.tsx
├── pnpm-lock.yaml
├── postcss.config.js
├── public
├── architecture_diagram.svg
├── demo.png
├── favicon.ico
├── github.svg
├── nextjs.svg
├── og.png
├── stripe.svg
├── supabase.svg
├── vercel-deploy.png
└── vercel.svg
├── schema.sql
├── styles
├── chrome-bug.css
└── main.css
├── supabase
├── .gitignore
├── config.toml
└── seed.sql
├── tailwind.config.js
├── tsconfig.json
├── types.ts
├── types_db.ts
└── utils
├── helpers.ts
├── stripe-client.ts
├── stripe.ts
├── supabase-admin.ts
├── supabase-client.ts
└── useUser.tsx
/.env.local.example:
--------------------------------------------------------------------------------
1 | # Update these with your Supabase details from your project settings > API
2 | NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
3 | NEXT_PUBLIC_SUPABASE_ANON_KEY=
4 | SUPABASE_SERVICE_ROLE_KEY=
5 |
6 | # Update these with your Stripe credentials from https://dashboard.stripe.com/apikeys
7 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_1234
8 | STRIPE_SECRET_KEY=sk_test_1234
9 | STRIPE_WEBHOOK_SECRET=whsec_1234
--------------------------------------------------------------------------------
/.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 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env.local
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | # vercel
34 | .vercel
35 |
36 | # editors
37 | .vscode
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2021 Vercel, Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Next.js Subscription Payments Starter
2 |
3 | The all-in-one starter kit for high-performance SaaS applications.
4 |
5 | ## Features
6 |
7 | - Secure user management and authentication with [Supabase](https://supabase.io/docs/guides/auth)
8 | - Powerful data access & management tooling on top of PostgreSQL with [Supabase](https://supabase.io/docs/guides/database)
9 | - Integration with [Stripe Checkout](https://stripe.com/docs/payments/checkout) and the [Stripe customer portal](https://stripe.com/docs/billing/subscriptions/customer-portal)
10 | - Automatic syncing of pricing plans and subscription statuses via [Stripe webhooks](https://stripe.com/docs/webhooks)
11 |
12 | ## Demo
13 |
14 | - https://subscription-payments.vercel.app/
15 |
16 | [](https://subscription-payments.vercel.app/)
17 |
18 | ## Architecture
19 |
20 | 
21 |
22 | ## Deploy with Vercel
23 |
24 | The Vercel deployment will guide you through creating a Supabase account and project. After installing the Supabase integration, you'll need to configure Stripe with a few simple steps.
25 |
26 | **Note:** We're working on our Stripe integration. We've documented the required steps below under "Configure Stripe" until the integration is ready.
27 |
28 | To get started, click the "Deploy with Vercel" button below.
29 |
30 | [](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnextjs-subscription-payments&project-name=nextjs-subscription-payments&repo-name=nextjs-subscription-payments&demo-title=Next.js%20Subscription%20Payments%20Starter&demo-description=Demo%20project%20on%20Vercel&demo-url=https%3A%2F%2Fsubscription-payments.vercel.app&demo-image=https%3A%2F%2Fsubscription-payments.vercel.app%2Fdemo.png&integration-ids=oac_jUduyjQgOyzev1fjrW83NYOv&external-id=nextjs-subscription-payments)
31 |
32 | [](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnextjs-subscription-payments&project-name=nextjs-subscription-payments&repo-name=nextjs-subscription-payments&demo-title=Next.js%20Subscription%20Payments%20Starter&demo-description=Demo%20project%20on%20Vercel&demo-url=https%3A%2F%2Fsubscription-payments.vercel.app&demo-image=https%3A%2F%2Fsubscription-payments.vercel.app%2Fdemo.png&integration-ids=oac_jUduyjQgOyzev1fjrW83NYOv&external-id=nextjs-subscription-payments)
33 |
34 | Once the project has deployed, continue with the configuration steps below.
35 |
36 | The initial build will fail due to missing Stripe environment variables. After configuring Stripe, redeploy the application.
37 |
38 | ## Configure Supabase Auth
39 |
40 | #### Setup redirect wildcards for deploy previews
41 |
42 | For auth redirects (magic links, OAuth providers) to work correctly in deploy previews, navigate to the auth settings (i.e. `https://app.supabase.com/project/:project-id/auth/url-configuration`) and add the following wildcard URL to "Redirect URLs": `https://**vercel.app/*/*`.
43 |
44 | You can read more about redirect wildcard patterns in the [docs](https://supabase.com/docs/guides/auth#redirect-urls-and-wildcards).
45 |
46 | #### [Optional] - Set up OAuth providers
47 |
48 | You can use third-party login providers like GitHub or Google. Refer to the [docs](https://supabase.io/docs/guides/auth#third-party-logins) to learn how to configure these. Once configured you can add them to the `provider` array of the `Auth` component on the [`signin.tsx`](./pages/signin.tsx) page.
49 |
50 | ## Configure Stripe
51 |
52 | To start developing your SaaS application, we'll need to configure Stripe to handle test payments. For the following steps, make sure you have the ["Test Mode" toggle](https://stripe.com/docs/testing) switched on.
53 |
54 | ### Configure webhook
55 |
56 | We need to configure the webhook pictured in the architecture diagram above. This webhook is the piece that connects Stripe to your Vercel Serverless Functions.
57 |
58 | 1. Click the "Add Endpoint" button on the [test Endpoints page](https://dashboard.stripe.com/test/webhooks).
59 | 1. Set the endpoint URL to `https://your-deployment-url.vercel.app/api/webhooks`.
60 | 1. Click `Select events` under the `Select events to listen to` heading.
61 | 1. Click `Select all events` in the `Select events to send` section.
62 | 1. Copy `Signing secret` as we'll need that in the next step.
63 |
64 | ### Set environment variables
65 |
66 | To securely interact with Stripe, we need to add a few [Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) in the Vercel dashboard.
67 |
68 | - `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`
69 | - `STRIPE_SECRET_KEY`
70 | - `STRIPE_WEBHOOK_SECRET_LIVE`
71 |
72 | You can find the first two keys on the [API keys tab](https://dashboard.stripe.com/test/apikeys) in Stripe. The `STRIPE_WEBHOOK_SECRET_LIVE` is the `Signing secret` copied in the previous webhook configuration step.
73 |
74 | ### Redeploy
75 |
76 | We need to redeploy the application so that the latest environment variables are present.
77 |
78 | Redeploy your application by going to the deployments tab, finding your deployment, and clicking "redeploy."
79 |
80 | ### Create product and pricing information
81 |
82 | For Stripe to automatically bill your users for recurring payments, you need to create your product and pricing information in the [Stripe Dashboard](https://dashboard.stripe.com/test/products). When you create or update your product and price information, the changes automatically sync with your Supabase database.
83 |
84 | Stripe Checkout currently supports pricing that bills a predefined amount at a specific interval. More complex plans (e.g., different pricing tiers or seats) are not yet supported.
85 |
86 | For example, you can create business models with different pricing tiers, e.g.:
87 |
88 | - Product 1: Hobby
89 | - Price 1: 10 USD per month
90 | - Price 2: 100 USD per year
91 | - Product 2: Freelancer
92 | - Price 1: 20 USD per month
93 | - Price 2: 200 USD per year
94 |
95 | #### Generate test data with the Stripe CLI
96 |
97 | The [Stripe CLI](https://stripe.com/docs/stripe-cli#install) `fixtures` command executes a series of API requests defined in a JSON file. To speed up the setup, we have added a [fixtures file](fixtures/stripe-fixtures.json) to bootstrap test product and pricing data in your Stripe account. Simply run `stripe fixtures fixtures/stripe-fixtures.json`.
98 |
99 | **Important:** Be sure to start the webhook forwarding (see below) so that the products created by the fixtures command above are imported into your database.
100 |
101 | ### Configure the Stripe customer portal
102 |
103 | 1. Set your custom branding in the [settings](https://dashboard.stripe.com/settings/branding)
104 | 1. Configure the Customer Portal [settings](https://dashboard.stripe.com/test/settings/billing/portal)
105 | 1. Toggle on "Allow customers to update their payment methods"
106 | 1. Toggle on "Allow customers to update subscriptions"
107 | 1. Toggle on "Allow customers to cancel subscriptions"
108 | 1. Add the products and prices that you want
109 | 1. Set up the required business information and links
110 |
111 | ### Generate types from your Supabase database
112 |
113 | You can use the [Supabase CLI](https://supabase.com/docs/reference/cli/usage#supabase-gen-types-typescript) to generate types from your Database by running
114 |
115 | 1. To install supabase cli
116 |
117 | ```bash
118 | npm install supabase --save-dev
119 | yarn add supabase --dev
120 | ```
121 |
122 | 2. Connect to supabase
123 |
124 | ```bash
125 | npx supabase login
126 | ```
127 |
128 | 3. Enter your access token. You can generate an access token from https://app.supabase.com/account/tokens
129 | 4. Generate types
130 |
131 | ```bash
132 | npx supabase gen types typescript --project-id [YOUR-PROJECT-REF] --schema public > types_db.ts
133 | ```
134 |
135 | ### That's it
136 |
137 | That's it. Now you're ready to earn recurring revenue from your customers 🥳
138 |
139 | ## Going live
140 |
141 | ### Archive testing products
142 |
143 | Archive all test mode Stripe products before going live. Before creating your live mode products, make sure to follow the steps below to set up your live mode env vars and webhooks.
144 |
145 | ### Configure production environment variables
146 |
147 | To run the project in live mode and process payments with Stripe, modify the environment variables from Stripe "test mode" to "production mode." After switching the variables, be sure to redeploy the application.
148 |
149 | To verify you are running in production mode, test checking out with the [Stripe test card](https://stripe.com/docs/testing). The test card should not work.
150 |
151 | ### Redeploy
152 |
153 | Afterward, you will need to rebuild your production deployment for the changes to take effect. Within your project Dashboard, navigate to the "Deployments" tab, select the most recent deployment, click the overflow menu button (next to the "Visit" button) and select "Redeploy."
154 |
155 | ## Develop locally
156 |
157 | Deploying with Vercel will create a repository for you, which you can clone to your local machine.
158 |
159 | Next, use the [Vercel CLI](https://vercel.com/download) to link your project:
160 |
161 | ```bash
162 | vercel login
163 | vercel link
164 | ```
165 |
166 | ### Setting up the env vars locally
167 |
168 | Use the Vercel CLI to download the development env vars:
169 |
170 | ```bash
171 | vercel env pull .env.local
172 | ```
173 |
174 | Running this command will create a new `.env.local` file in your project folder. For security purposes, you will need to set the `SUPABASE_SERVICE_ROLE_KEY` manually from your [Supabase dashboard](https://app.supabase.io/) (Settings > API). Lastly, the webhook secret differs for local testing vs. when deployed to Vercel. Follow the instructions below to get the corresponding webhook secret.
175 |
176 | ### Use the Stripe CLI to test webhooks
177 |
178 | First [install the CLI](https://stripe.com/docs/stripe-cli) and [link your Stripe account](https://stripe.com/docs/stripe-cli#login-account).
179 |
180 | Next, start the webhook forwarding:
181 |
182 | ```bash
183 | stripe listen --forward-to=localhost:3000/api/webhooks
184 | ```
185 |
186 | Running this Stripe command will print a webhook secret (such as, `whsec_***`) to the console. Set `STRIPE_WEBHOOK_SECRET` to this value in your `.env.local` file.
187 |
188 | ### Install dependencies and run the Next.js client
189 |
190 | ```bash
191 | npm install
192 | npm run dev
193 | # or
194 | yarn
195 | yarn dev
196 | ```
197 |
--------------------------------------------------------------------------------
/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren } from 'react';
2 | import Head from 'next/head';
3 | import { useRouter } from 'next/router';
4 |
5 | import Navbar from '@/components/ui/Navbar';
6 | import Footer from '@/components/ui/Footer';
7 |
8 | import { PageMeta } from '../types';
9 |
10 | interface Props extends PropsWithChildren {
11 | meta?: PageMeta;
12 | }
13 |
14 | export default function Layout({ children, meta: pageMeta }: Props) {
15 | const router = useRouter();
16 | const meta = {
17 | title: 'Next.js Subscription Starter',
18 | description: 'Brought to you by Vercel, Stripe, and Supabase.',
19 | cardImage: '/og.png',
20 | ...pageMeta
21 | };
22 |
23 | return (
24 | <>
25 |
26 | {meta.title}
27 |
28 |
29 |
30 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | {children}
47 |
48 | >
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/components/Pricing.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useRouter } from 'next/router';
3 | import cn from 'classnames';
4 |
5 | import Button from '@/components/ui/Button';
6 | import { postData } from '@/utils/helpers';
7 | import { getStripe } from '@/utils/stripe-client';
8 | import { useUser } from '@/utils/useUser';
9 |
10 | import { Price, ProductWithPrice } from 'types';
11 |
12 | interface Props {
13 | products: ProductWithPrice[];
14 | }
15 |
16 | type BillingInterval = 'year' | 'month';
17 |
18 | export default function Pricing({ products }: Props) {
19 | const router = useRouter();
20 | const [billingInterval, setBillingInterval] =
21 | useState('month');
22 | const [priceIdLoading, setPriceIdLoading] = useState();
23 | const { user, isLoading, subscription } = useUser();
24 |
25 | const handleCheckout = async (price: Price) => {
26 | setPriceIdLoading(price.id);
27 | if (!user) {
28 | return router.push('/signin');
29 | }
30 | if (subscription) {
31 | return router.push('/account');
32 | }
33 |
34 | try {
35 | const { sessionId } = await postData({
36 | url: '/api/create-checkout-session',
37 | data: { price }
38 | });
39 |
40 | const stripe = await getStripe();
41 | stripe?.redirectToCheckout({ sessionId });
42 | } catch (error) {
43 | return alert((error as Error)?.message);
44 | } finally {
45 | setPriceIdLoading(undefined);
46 | }
47 | };
48 |
49 | if (!products.length)
50 | return (
51 |
68 | );
69 |
70 | return (
71 |
72 |
73 |
74 |
75 | Pricing Plans
76 |
77 |
78 | Start building for free, then add a site plan to go live. Account
79 | plans unlock additional features.
80 |
81 |
82 | setBillingInterval('month')}
84 | type="button"
85 | className={`${
86 | billingInterval === 'month'
87 | ? 'relative w-1/2 bg-zinc-700 border-zinc-800 shadow-sm text-white'
88 | : 'ml-0.5 relative w-1/2 border border-transparent text-zinc-400'
89 | } rounded-md m-1 py-2 text-sm font-medium whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-pink-500 focus:ring-opacity-50 focus:z-10 sm:w-auto sm:px-8`}
90 | >
91 | Monthly billing
92 |
93 | setBillingInterval('year')}
95 | type="button"
96 | className={`${
97 | billingInterval === 'year'
98 | ? 'relative w-1/2 bg-zinc-700 border-zinc-800 shadow-sm text-white'
99 | : 'ml-0.5 relative w-1/2 border border-transparent text-zinc-400'
100 | } rounded-md m-1 py-2 text-sm font-medium whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-pink-500 focus:ring-opacity-50 focus:z-10 sm:w-auto sm:px-8`}
101 | >
102 | Yearly billing
103 |
104 |
105 |
106 |
107 | {products.map((product) => {
108 | const price = product?.prices?.find(
109 | (price) => price.interval === billingInterval
110 | );
111 | if (!price) return null;
112 | const priceString = new Intl.NumberFormat('en-US', {
113 | style: 'currency',
114 | currency: price.currency,
115 | minimumFractionDigits: 0
116 | }).format((price?.unit_amount || 0) / 100);
117 | return (
118 |
129 |
130 |
131 | {product.name}
132 |
133 |
{product.description}
134 |
135 |
136 | {priceString}
137 |
138 |
139 | /{billingInterval}
140 |
141 |
142 |
handleCheckout(price)}
148 | className="mt-8 block w-full rounded-md py-2 text-sm font-semibold text-white text-center hover:bg-zinc-900"
149 | >
150 | {product.name === subscription?.prices?.products?.name
151 | ? 'Manage'
152 | : 'Subscribe'}
153 |
154 |
155 |
156 | );
157 | })}
158 |
159 |
160 |
161 | Brought to you by
162 |
163 |
164 |
173 |
182 |
191 |
200 |
209 |
210 |
211 |
212 |
213 | );
214 | }
215 |
--------------------------------------------------------------------------------
/components/icons/GitHub.tsx:
--------------------------------------------------------------------------------
1 | const GitHub = ({ ...props }) => {
2 | return (
3 |
10 |
16 |
17 | );
18 | };
19 |
20 | export default GitHub;
21 |
--------------------------------------------------------------------------------
/components/icons/Logo.tsx:
--------------------------------------------------------------------------------
1 | const Logo = ({ className = '', ...props }) => (
2 |
11 |
12 |
18 |
19 | );
20 |
21 | export default Logo;
22 |
--------------------------------------------------------------------------------
/components/ui/Button/Button.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | @apply bg-white text-zinc-800 cursor-pointer inline-flex px-10 rounded-sm leading-6 transition ease-in-out duration-150 shadow-sm font-semibold text-center justify-center uppercase py-4 border border-transparent items-center;
3 | }
4 |
5 | .root:hover {
6 | @apply bg-zinc-800 text-white border border-white;
7 | }
8 |
9 | .root:focus {
10 | @apply outline-none ring-2 ring-pink-500 ring-opacity-50;
11 | }
12 |
13 | .root[data-active] {
14 | @apply bg-zinc-600;
15 | }
16 |
17 | .loading {
18 | @apply bg-zinc-700 text-zinc-500 border-zinc-600 cursor-not-allowed;
19 | }
20 |
21 | .slim {
22 | @apply py-2 transform-none normal-case;
23 | }
24 |
25 | .disabled,
26 | .disabled:hover {
27 | @apply text-zinc-400 border-zinc-600 bg-zinc-700 cursor-not-allowed;
28 | filter: grayscale(1);
29 | -webkit-transform: translateZ(0);
30 | -webkit-perspective: 1000;
31 | -webkit-backface-visibility: hidden;
32 | }
33 |
--------------------------------------------------------------------------------
/components/ui/Button/Button.tsx:
--------------------------------------------------------------------------------
1 | import cn from 'classnames';
2 | import React, { forwardRef, useRef, ButtonHTMLAttributes } from 'react';
3 | import { mergeRefs } from 'react-merge-refs';
4 |
5 | import LoadingDots from '@/components/ui/LoadingDots';
6 |
7 | import styles from './Button.module.css';
8 |
9 | interface Props extends ButtonHTMLAttributes {
10 | variant?: 'slim' | 'flat';
11 | active?: boolean;
12 | width?: number;
13 | loading?: boolean;
14 | Component?: React.ComponentType;
15 | }
16 |
17 | const Button = forwardRef((props, buttonRef) => {
18 | const {
19 | className,
20 | variant = 'flat',
21 | children,
22 | active,
23 | width,
24 | loading = false,
25 | disabled = false,
26 | style = {},
27 | Component = 'button',
28 | ...rest
29 | } = props;
30 | const ref = useRef(null);
31 | const rootClassName = cn(
32 | styles.root,
33 | {
34 | [styles.slim]: variant === 'slim',
35 | [styles.loading]: loading,
36 | [styles.disabled]: disabled
37 | },
38 | className
39 | );
40 | return (
41 |
53 | {children}
54 | {loading && (
55 |
56 |
57 |
58 | )}
59 |
60 | );
61 | });
62 |
63 | export default Button;
64 |
--------------------------------------------------------------------------------
/components/ui/Button/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Button';
2 |
--------------------------------------------------------------------------------
/components/ui/Footer/Footer.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 |
3 | import Logo from '@/components/icons/Logo';
4 | import GitHub from '@/components/icons/GitHub';
5 |
6 | import s from './Footer.module.css';
7 |
8 | export default function Footer() {
9 | return (
10 |
111 | );
112 | }
113 |
--------------------------------------------------------------------------------
/components/ui/Footer/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Footer';
2 |
--------------------------------------------------------------------------------
/components/ui/Input/Input.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | @apply bg-black py-2 px-3 w-full appearance-none transition duration-150 ease-in-out border border-zinc-500 text-zinc-200;
3 | }
4 |
5 | .root:focus {
6 | @apply outline-none;
7 | }
8 |
--------------------------------------------------------------------------------
/components/ui/Input/Input.tsx:
--------------------------------------------------------------------------------
1 | import React, { InputHTMLAttributes, ChangeEvent } from 'react';
2 | import cn from 'classnames';
3 |
4 | import s from './Input.module.css';
5 |
6 | interface Props extends Omit, 'onChange'> {
7 | className?: string;
8 | onChange: (value: string) => void;
9 | }
10 | const Input = (props: Props) => {
11 | const { className, children, onChange, ...rest } = props;
12 |
13 | const rootClassName = cn(s.root, {}, className);
14 |
15 | const handleOnChange = (e: ChangeEvent) => {
16 | if (onChange) {
17 | onChange(e.target.value);
18 | }
19 | return null;
20 | };
21 |
22 | return (
23 |
24 |
33 |
34 | );
35 | };
36 |
37 | export default Input;
38 |
--------------------------------------------------------------------------------
/components/ui/Input/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Input';
2 |
--------------------------------------------------------------------------------
/components/ui/LoadingDots/LoadingDots.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | @apply inline-flex text-center items-center leading-7;
3 | }
4 |
5 | .root span {
6 | @apply bg-zinc-200 rounded-full h-2 w-2;
7 | animation-name: blink;
8 | animation-duration: 1.4s;
9 | animation-iteration-count: infinite;
10 | animation-fill-mode: both;
11 | margin: 0 2px;
12 | }
13 |
14 | .root span:nth-of-type(2) {
15 | animation-delay: 0.2s;
16 | }
17 |
18 | .root span:nth-of-type(3) {
19 | animation-delay: 0.4s;
20 | }
21 |
22 | @keyframes blink {
23 | 0% {
24 | opacity: 0.2;
25 | }
26 | 20% {
27 | opacity: 1;
28 | }
29 | 100% {
30 | opacity: 0.2;
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/components/ui/LoadingDots/LoadingDots.tsx:
--------------------------------------------------------------------------------
1 | import s from './LoadingDots.module.css';
2 |
3 | const LoadingDots = () => {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 | );
11 | };
12 |
13 | export default LoadingDots;
14 |
--------------------------------------------------------------------------------
/components/ui/LoadingDots/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './LoadingDots';
2 |
--------------------------------------------------------------------------------
/components/ui/Navbar/Navbar.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | @apply sticky top-0 bg-black z-40 transition-all duration-150;
3 | }
4 |
5 | .link {
6 | @apply inline-flex items-center leading-6 font-medium transition ease-in-out duration-75 cursor-pointer text-zinc-200 rounded-md p-1;
7 | }
8 |
9 | .link:hover {
10 | @apply text-zinc-100;
11 | }
12 |
13 | .link:focus {
14 | @apply outline-none text-zinc-100 ring-2 ring-pink-500 ring-opacity-50;
15 | }
16 |
17 | .logo {
18 | @apply cursor-pointer rounded-full transform duration-100 ease-in-out;
19 | }
20 |
--------------------------------------------------------------------------------
/components/ui/Navbar/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import { useRouter } from 'next/router';
3 | import { useSupabaseClient } from '@supabase/auth-helpers-react';
4 |
5 | import Logo from '@/components/icons/Logo';
6 | import { useUser } from '@/utils/useUser';
7 |
8 | import s from './Navbar.module.css';
9 |
10 | const Navbar = () => {
11 | const router = useRouter();
12 | const supabaseClient = useSupabaseClient();
13 | const { user } = useUser();
14 |
15 | return (
16 |
17 |
18 | Skip to content
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | Pricing
29 |
30 |
31 | Account
32 |
33 |
34 |
35 |
36 |
37 | {user ? (
38 | {
41 | await supabaseClient.auth.signOut();
42 | router.push('/signin');
43 | }}
44 | >
45 | Sign out
46 |
47 | ) : (
48 |
49 | Sign in
50 |
51 | )}
52 |
53 |
54 |
55 |
56 | );
57 | };
58 |
59 | export default Navbar;
60 |
--------------------------------------------------------------------------------
/components/ui/Navbar/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Navbar'
2 |
--------------------------------------------------------------------------------
/fixtures/stripe-fixtures.json:
--------------------------------------------------------------------------------
1 | {
2 | "_meta": {
3 | "template_version": 0
4 | },
5 | "fixtures": [
6 | {
7 | "name": "prod_hobby",
8 | "path": "/v1/products",
9 | "method": "post",
10 | "params": {
11 | "name": "Hobby",
12 | "description": "Hobby product description"
13 | }
14 | },
15 | {
16 | "name": "price_hobby_month",
17 | "path": "/v1/prices",
18 | "method": "post",
19 | "params": {
20 | "product": "${prod_hobby:id}",
21 | "currency": "usd",
22 | "billing_scheme": "per_unit",
23 | "unit_amount": 1000,
24 | "recurring": {
25 | "interval": "month",
26 | "interval_count": 1
27 | }
28 | }
29 | },
30 | {
31 | "name": "price_hobby_year",
32 | "path": "/v1/prices",
33 | "method": "post",
34 | "params": {
35 | "product": "${prod_hobby:id}",
36 | "currency": "usd",
37 | "billing_scheme": "per_unit",
38 | "unit_amount": 10000,
39 | "recurring": {
40 | "interval": "year",
41 | "interval_count": 1
42 | }
43 | }
44 | },
45 | {
46 | "name": "prod_freelancer",
47 | "path": "/v1/products",
48 | "method": "post",
49 | "params": {
50 | "name": "Freelancer",
51 | "description": "Freelancer product description"
52 | }
53 | },
54 | {
55 | "name": "price_freelancer_month",
56 | "path": "/v1/prices",
57 | "method": "post",
58 | "params": {
59 | "product": "${prod_freelancer:id}",
60 | "currency": "usd",
61 | "billing_scheme": "per_unit",
62 | "unit_amount": 2000,
63 | "recurring": {
64 | "interval": "month",
65 | "interval_count": 1
66 | }
67 | }
68 | },
69 | {
70 | "name": "price_freelancer_year",
71 | "path": "/v1/prices",
72 | "method": "post",
73 | "params": {
74 | "product": "${prod_freelancer:id}",
75 | "currency": "usd",
76 | "billing_scheme": "per_unit",
77 | "unit_amount": 20000,
78 | "recurring": {
79 | "interval": "year",
80 | "interval_count": 1
81 | }
82 | }
83 | }
84 | ]
85 | }
86 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | };
5 |
6 | module.exports = nextConfig;
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs-subscription-payments",
3 | "version": "0.0.0",
4 | "license": "MIT",
5 | "scripts": {
6 | "dev": "next",
7 | "build": "next build",
8 | "start": "next start",
9 | "stripe:listen": "stripe listen --forward-to=localhost:3000/api/webhooks --project-name=saas-starter"
10 | },
11 | "dependencies": {
12 | "@stripe/stripe-js": "^1.48.0",
13 | "@supabase/auth-helpers-nextjs": "^0.5.4",
14 | "@supabase/auth-helpers-react": "^0.3.1",
15 | "@supabase/auth-ui-react": "^0.3.3",
16 | "@supabase/auth-ui-shared": "^0.1.2",
17 | "@supabase/supabase-js": "^2.10.0",
18 | "classnames": "^2.3.2",
19 | "next": "^13.2.3",
20 | "react": "^18.2.0",
21 | "react-dom": "^18.2.0",
22 | "react-merge-refs": "^2.0.1",
23 | "stripe": "^11.13.0",
24 | "swr": "^2.0.4",
25 | "tailwindcss": "^3.2.7"
26 | },
27 | "devDependencies": {
28 | "@types/node": "^18.14.4",
29 | "@types/react": "^18.0.28",
30 | "autoprefixer": "^10.4.13",
31 | "postcss": "^8.4.21",
32 | "prettier": "^2.8.4",
33 | "typescript": "^4.9.5"
34 | },
35 | "prettier": {
36 | "arrowParens": "always",
37 | "singleQuote": true,
38 | "tabWidth": 2,
39 | "trailingComma": "none"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import React from 'react';
3 | import { AppProps } from 'next/app';
4 | import { SessionContextProvider } from '@supabase/auth-helpers-react';
5 | import { createBrowserSupabaseClient } from '@supabase/auth-helpers-nextjs';
6 |
7 | import Layout from '@/components/Layout';
8 | import { MyUserContextProvider } from '@/utils/useUser';
9 | import type { Database } from 'types_db';
10 |
11 | import 'styles/main.css';
12 | import 'styles/chrome-bug.css';
13 |
14 | export default function MyApp({ Component, pageProps }: AppProps) {
15 | const [supabaseClient] = useState(() =>
16 | createBrowserSupabaseClient()
17 | );
18 | useEffect(() => {
19 | document.body.classList?.remove('loading');
20 | }, []);
21 |
22 | return (
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, { Head, Html, Main, NextScript } from 'next/document';
2 |
3 | class MyDocument extends Document {
4 | render() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | );
14 | }
15 | }
16 |
17 | export default MyDocument;
18 |
--------------------------------------------------------------------------------
/pages/account.tsx:
--------------------------------------------------------------------------------
1 | import { useState, ReactNode } from 'react';
2 | import Link from 'next/link';
3 | import { GetServerSidePropsContext } from 'next';
4 | import {
5 | createServerSupabaseClient,
6 | User
7 | } from '@supabase/auth-helpers-nextjs';
8 |
9 | import LoadingDots from '@/components/ui/LoadingDots';
10 | import Button from '@/components/ui/Button';
11 | import { useUser } from '@/utils/useUser';
12 | import { postData } from '@/utils/helpers';
13 |
14 | interface Props {
15 | title: string;
16 | description?: string;
17 | footer?: ReactNode;
18 | children: ReactNode;
19 | }
20 |
21 | function Card({ title, description, footer, children }: Props) {
22 | return (
23 |
24 |
25 |
{title}
26 |
{description}
27 | {children}
28 |
29 |
30 | {footer}
31 |
32 |
33 | );
34 | }
35 |
36 | export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
37 | const supabase = createServerSupabaseClient(ctx);
38 | const {
39 | data: { session }
40 | } = await supabase.auth.getSession();
41 |
42 | if (!session)
43 | return {
44 | redirect: {
45 | destination: '/signin',
46 | permanent: false
47 | }
48 | };
49 |
50 | return {
51 | props: {
52 | initialSession: session,
53 | user: session.user
54 | }
55 | };
56 | };
57 |
58 | export default function Account({ user }: { user: User }) {
59 | const [loading, setLoading] = useState(false);
60 | const { isLoading, subscription, userDetails } = useUser();
61 |
62 | const redirectToCustomerPortal = async () => {
63 | setLoading(true);
64 | try {
65 | const { url, error } = await postData({
66 | url: '/api/create-portal-link'
67 | });
68 | window.location.assign(url);
69 | } catch (error) {
70 | if (error) return alert((error as Error).message);
71 | }
72 | setLoading(false);
73 | };
74 |
75 | const subscriptionPrice =
76 | subscription &&
77 | new Intl.NumberFormat('en-US', {
78 | style: 'currency',
79 | currency: subscription?.prices?.currency,
80 | minimumFractionDigits: 0
81 | }).format((subscription?.prices?.unit_amount || 0) / 100);
82 |
83 | return (
84 |
85 |
86 |
87 |
88 | Account
89 |
90 |
91 | We partnered with Stripe for a simplified billing.
92 |
93 |
94 |
95 |
96 |
105 |
106 | Manage your subscription on Stripe.
107 |
108 |
114 | Open customer portal
115 |
116 |
117 | }
118 | >
119 |
120 | {isLoading ? (
121 |
122 |
123 |
124 | ) : subscription ? (
125 | `${subscriptionPrice}/${subscription?.prices?.interval}`
126 | ) : (
127 |
Choose your plan
128 | )}
129 |
130 |
131 | Please use 64 characters at maximum.}
135 | >
136 |
137 | {userDetails ? (
138 | `${
139 | userDetails.full_name ??
140 | `${userDetails.first_name} ${userDetails.last_name}`
141 | }`
142 | ) : (
143 |
144 |
145 |
146 | )}
147 |
148 |
149 | We will email you to verify the change.}
153 | >
154 |
155 | {user ? user.email : undefined}
156 |
157 |
158 |
159 |
160 | );
161 | }
162 |
--------------------------------------------------------------------------------
/pages/api/create-checkout-session.ts:
--------------------------------------------------------------------------------
1 | import { NextApiHandler } from 'next';
2 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs';
3 |
4 | import { stripe } from '@/utils/stripe';
5 | import { createOrRetrieveCustomer } from '@/utils/supabase-admin';
6 | import { getURL } from '@/utils/helpers';
7 |
8 | const CreateCheckoutSession: NextApiHandler = async (req, res) => {
9 | if (req.method === 'POST') {
10 | const { price, quantity = 1, metadata = {} } = req.body;
11 |
12 | try {
13 | const supabase = createServerSupabaseClient({ req, res });
14 | const {
15 | data: { user }
16 | } = await supabase.auth.getUser();
17 |
18 | const customer = await createOrRetrieveCustomer({
19 | uuid: user?.id || '',
20 | email: user?.email || ''
21 | });
22 |
23 | const session = await stripe.checkout.sessions.create({
24 | payment_method_types: ['card'],
25 | billing_address_collection: 'required',
26 | customer,
27 | line_items: [
28 | {
29 | price: price.id,
30 | quantity
31 | }
32 | ],
33 | mode: 'subscription',
34 | allow_promotion_codes: true,
35 | subscription_data: {
36 | trial_from_plan: true,
37 | metadata
38 | },
39 | success_url: `${getURL()}/account`,
40 | cancel_url: `${getURL()}/`
41 | });
42 |
43 | return res.status(200).json({ sessionId: session.id });
44 | } catch (err: any) {
45 | console.log(err);
46 | res
47 | .status(500)
48 | .json({ error: { statusCode: 500, message: err.message } });
49 | }
50 | } else {
51 | res.setHeader('Allow', 'POST');
52 | res.status(405).end('Method Not Allowed');
53 | }
54 | };
55 |
56 | export default CreateCheckoutSession;
57 |
--------------------------------------------------------------------------------
/pages/api/create-portal-link.ts:
--------------------------------------------------------------------------------
1 | import { NextApiHandler } from 'next';
2 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs';
3 |
4 | import { stripe } from '@/utils/stripe';
5 | import { createOrRetrieveCustomer } from '@/utils/supabase-admin';
6 | import { getURL } from '@/utils/helpers';
7 |
8 | const CreatePortalLink: NextApiHandler = async (req, res) => {
9 | if (req.method === 'POST') {
10 | try {
11 | const supabase = createServerSupabaseClient({ req, res });
12 | const {
13 | data: { user }
14 | } = await supabase.auth.getUser();
15 |
16 | if (!user) throw Error('Could not get user');
17 | const customer = await createOrRetrieveCustomer({
18 | uuid: user.id || '',
19 | email: user.email || ''
20 | });
21 |
22 | if (!customer) throw Error('Could not get customer');
23 | const { url } = await stripe.billingPortal.sessions.create({
24 | customer,
25 | return_url: `${getURL()}/account`
26 | });
27 |
28 | return res.status(200).json({ url });
29 | } catch (err: any) {
30 | console.log(err);
31 | res
32 | .status(500)
33 | .json({ error: { statusCode: 500, message: err.message } });
34 | }
35 | } else {
36 | res.setHeader('Allow', 'POST');
37 | res.status(405).end('Method Not Allowed');
38 | }
39 | };
40 |
41 | export default CreatePortalLink;
42 |
--------------------------------------------------------------------------------
/pages/api/webhooks.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import Stripe from 'stripe';
3 | import { Readable } from 'node:stream';
4 |
5 | import { stripe } from '@/utils/stripe';
6 | import {
7 | upsertProductRecord,
8 | upsertPriceRecord,
9 | manageSubscriptionStatusChange
10 | } from '@/utils/supabase-admin';
11 |
12 | // Stripe requires the raw body to construct the event.
13 | export const config = {
14 | api: {
15 | bodyParser: false
16 | }
17 | };
18 |
19 | async function buffer(readable: Readable) {
20 | const chunks = [];
21 | for await (const chunk of readable) {
22 | chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
23 | }
24 | return Buffer.concat(chunks);
25 | }
26 |
27 | const relevantEvents = new Set([
28 | 'product.created',
29 | 'product.updated',
30 | 'price.created',
31 | 'price.updated',
32 | 'checkout.session.completed',
33 | 'customer.subscription.created',
34 | 'customer.subscription.updated',
35 | 'customer.subscription.deleted'
36 | ]);
37 |
38 | const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
39 | if (req.method === 'POST') {
40 | const buf = await buffer(req);
41 | const sig = req.headers['stripe-signature'];
42 | const webhookSecret =
43 | process.env.STRIPE_WEBHOOK_SECRET_LIVE ??
44 | process.env.STRIPE_WEBHOOK_SECRET;
45 | let event: Stripe.Event;
46 |
47 | try {
48 | if (!sig || !webhookSecret) return;
49 | event = stripe.webhooks.constructEvent(buf, sig, webhookSecret);
50 | } catch (err: any) {
51 | console.log(`❌ Error message: ${err.message}`);
52 | return res.status(400).send(`Webhook Error: ${err.message}`);
53 | }
54 |
55 | if (relevantEvents.has(event.type)) {
56 | try {
57 | switch (event.type) {
58 | case 'product.created':
59 | case 'product.updated':
60 | await upsertProductRecord(event.data.object as Stripe.Product);
61 | break;
62 | case 'price.created':
63 | case 'price.updated':
64 | await upsertPriceRecord(event.data.object as Stripe.Price);
65 | break;
66 | case 'customer.subscription.created':
67 | case 'customer.subscription.updated':
68 | case 'customer.subscription.deleted':
69 | const subscription = event.data.object as Stripe.Subscription;
70 | await manageSubscriptionStatusChange(
71 | subscription.id,
72 | subscription.customer as string,
73 | event.type === 'customer.subscription.created'
74 | );
75 | break;
76 | case 'checkout.session.completed':
77 | const checkoutSession = event.data
78 | .object as Stripe.Checkout.Session;
79 | if (checkoutSession.mode === 'subscription') {
80 | const subscriptionId = checkoutSession.subscription;
81 | await manageSubscriptionStatusChange(
82 | subscriptionId as string,
83 | checkoutSession.customer as string,
84 | true
85 | );
86 | }
87 | break;
88 | default:
89 | throw new Error('Unhandled relevant event!');
90 | }
91 | } catch (error) {
92 | console.log(error);
93 | return res
94 | .status(400)
95 | .send('Webhook error: "Webhook handler failed. View logs."');
96 | }
97 | }
98 |
99 | res.json({ received: true });
100 | } else {
101 | res.setHeader('Allow', 'POST');
102 | res.status(405).end('Method Not Allowed');
103 | }
104 | };
105 |
106 | export default webhookHandler;
107 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { GetStaticPropsResult } from 'next';
2 |
3 | import Pricing from '@/components/Pricing';
4 | import { getActiveProductsWithPrices } from '@/utils/supabase-client';
5 | import { Product } from 'types';
6 |
7 | interface Props {
8 | products: Product[];
9 | }
10 |
11 | export default function PricingPage({ products }: Props) {
12 | return ;
13 | }
14 |
15 | export async function getStaticProps(): Promise> {
16 | const products = await getActiveProductsWithPrices();
17 |
18 | return {
19 | props: {
20 | products
21 | },
22 | revalidate: 60
23 | };
24 | }
25 |
--------------------------------------------------------------------------------
/pages/signin.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { useEffect } from 'react';
3 | import { useUser, useSupabaseClient } from '@supabase/auth-helpers-react';
4 | import { Auth } from '@supabase/auth-ui-react';
5 | import { ThemeSupa } from '@supabase/auth-ui-shared';
6 |
7 | import LoadingDots from '@/components/ui/LoadingDots';
8 | import Logo from '@/components/icons/Logo';
9 | import { getURL } from '@/utils/helpers';
10 |
11 | const SignIn = () => {
12 | const router = useRouter();
13 | const user = useUser();
14 | const supabaseClient = useSupabaseClient();
15 |
16 | useEffect(() => {
17 | if (user) {
18 | router.replace('/account');
19 | }
20 | }, [user]);
21 |
22 | if (!user)
23 | return (
24 |
51 | );
52 |
53 | return (
54 |
55 |
56 |
57 | );
58 | };
59 |
60 | export default SignIn;
61 |
--------------------------------------------------------------------------------
/pnpm-lock.yaml:
--------------------------------------------------------------------------------
1 | lockfileVersion: 5.4
2 |
3 | specifiers:
4 | '@stripe/stripe-js': ^1.48.0
5 | '@supabase/auth-helpers-nextjs': ^0.5.4
6 | '@supabase/auth-helpers-react': ^0.3.1
7 | '@supabase/auth-ui-react': ^0.3.3
8 | '@supabase/auth-ui-shared': ^0.1.2
9 | '@supabase/supabase-js': ^2.10.0
10 | '@types/node': ^18.14.4
11 | '@types/react': ^18.0.28
12 | autoprefixer: ^10.4.13
13 | classnames: ^2.3.2
14 | next: ^13.2.3
15 | postcss: ^8.4.21
16 | prettier: ^2.8.4
17 | react: ^18.2.0
18 | react-dom: ^18.2.0
19 | react-merge-refs: ^2.0.1
20 | stripe: ^11.13.0
21 | swr: ^2.0.4
22 | tailwindcss: ^3.2.7
23 | typescript: ^4.9.5
24 |
25 | dependencies:
26 | '@stripe/stripe-js': 1.48.0
27 | '@supabase/auth-helpers-nextjs': 0.5.4_ku2vntnma2imfrsyohnkxzette
28 | '@supabase/auth-helpers-react': 0.3.1_ku2vntnma2imfrsyohnkxzette
29 | '@supabase/auth-ui-react': 0.3.3_ku2vntnma2imfrsyohnkxzette
30 | '@supabase/auth-ui-shared': 0.1.2_ku2vntnma2imfrsyohnkxzette
31 | '@supabase/supabase-js': 2.10.0
32 | classnames: 2.3.2
33 | next: 13.2.3_biqbaboplfbrettd7655fr4n2y
34 | react: 18.2.0
35 | react-dom: 18.2.0_react@18.2.0
36 | react-merge-refs: 2.0.1
37 | stripe: 11.13.0
38 | swr: 2.0.4_react@18.2.0
39 | tailwindcss: 3.2.7_postcss@8.4.21
40 |
41 | devDependencies:
42 | '@types/node': 18.14.4
43 | '@types/react': 18.0.28
44 | autoprefixer: 10.4.13_postcss@8.4.21
45 | postcss: 8.4.21
46 | prettier: 2.8.4
47 | typescript: 4.9.5
48 |
49 | packages:
50 |
51 | /@next/env/13.2.3:
52 | resolution: {integrity: sha512-FN50r/E+b8wuqyRjmGaqvqNDuWBWYWQiigfZ50KnSFH0f+AMQQyaZl+Zm2+CIpKk0fL9QxhLxOpTVA3xFHgFow==}
53 | dev: false
54 |
55 | /@next/swc-android-arm-eabi/13.2.3:
56 | resolution: {integrity: sha512-mykdVaAXX/gm+eFO2kPeVjnOCKwanJ9mV2U0lsUGLrEdMUifPUjiXKc6qFAIs08PvmTMOLMNnUxqhGsJlWGKSw==}
57 | engines: {node: '>= 10'}
58 | cpu: [arm]
59 | os: [android]
60 | requiresBuild: true
61 | dev: false
62 | optional: true
63 |
64 | /@next/swc-android-arm64/13.2.3:
65 | resolution: {integrity: sha512-8XwHPpA12gdIFtope+n9xCtJZM3U4gH4vVTpUwJ2w1kfxFmCpwQ4xmeGSkR67uOg80yRMuF0h9V1ueo05sws5w==}
66 | engines: {node: '>= 10'}
67 | cpu: [arm64]
68 | os: [android]
69 | requiresBuild: true
70 | dev: false
71 | optional: true
72 |
73 | /@next/swc-darwin-arm64/13.2.3:
74 | resolution: {integrity: sha512-TXOubiFdLpMfMtaRu1K5d1I9ipKbW5iS2BNbu8zJhoqrhk3Kp7aRKTxqFfWrbliAHhWVE/3fQZUYZOWSXVQi1w==}
75 | engines: {node: '>= 10'}
76 | cpu: [arm64]
77 | os: [darwin]
78 | requiresBuild: true
79 | dev: false
80 | optional: true
81 |
82 | /@next/swc-darwin-x64/13.2.3:
83 | resolution: {integrity: sha512-GZctkN6bJbpjlFiS5pylgB2pifHvgkqLAPumJzxnxkf7kqNm6rOGuNjsROvOWVWXmKhrzQkREO/WPS2aWsr/yw==}
84 | engines: {node: '>= 10'}
85 | cpu: [x64]
86 | os: [darwin]
87 | requiresBuild: true
88 | dev: false
89 | optional: true
90 |
91 | /@next/swc-freebsd-x64/13.2.3:
92 | resolution: {integrity: sha512-rK6GpmMt/mU6MPuav0/M7hJ/3t8HbKPCELw/Uqhi4732xoq2hJ2zbo2FkYs56y6w0KiXrIp4IOwNB9K8L/q62g==}
93 | engines: {node: '>= 10'}
94 | cpu: [x64]
95 | os: [freebsd]
96 | requiresBuild: true
97 | dev: false
98 | optional: true
99 |
100 | /@next/swc-linux-arm-gnueabihf/13.2.3:
101 | resolution: {integrity: sha512-yeiCp/Odt1UJ4KUE89XkeaaboIDiVFqKP4esvoLKGJ0fcqJXMofj4ad3tuQxAMs3F+qqrz9MclqhAHkex1aPZA==}
102 | engines: {node: '>= 10'}
103 | cpu: [arm]
104 | os: [linux]
105 | requiresBuild: true
106 | dev: false
107 | optional: true
108 |
109 | /@next/swc-linux-arm64-gnu/13.2.3:
110 | resolution: {integrity: sha512-/miIopDOUsuNlvjBjTipvoyjjaxgkOuvlz+cIbbPcm1eFvzX2ltSfgMgty15GuOiR8Hub4FeTSiq3g2dmCkzGA==}
111 | engines: {node: '>= 10'}
112 | cpu: [arm64]
113 | os: [linux]
114 | requiresBuild: true
115 | dev: false
116 | optional: true
117 |
118 | /@next/swc-linux-arm64-musl/13.2.3:
119 | resolution: {integrity: sha512-sujxFDhMMDjqhruup8LLGV/y+nCPi6nm5DlFoThMJFvaaKr/imhkXuk8uCTq4YJDbtRxnjydFv2y8laBSJVC2g==}
120 | engines: {node: '>= 10'}
121 | cpu: [arm64]
122 | os: [linux]
123 | requiresBuild: true
124 | dev: false
125 | optional: true
126 |
127 | /@next/swc-linux-x64-gnu/13.2.3:
128 | resolution: {integrity: sha512-w5MyxPknVvC9LVnMenAYMXMx4KxPwXuJRMQFvY71uXg68n7cvcas85U5zkdrbmuZ+JvsO5SIG8k36/6X3nUhmQ==}
129 | engines: {node: '>= 10'}
130 | cpu: [x64]
131 | os: [linux]
132 | requiresBuild: true
133 | dev: false
134 | optional: true
135 |
136 | /@next/swc-linux-x64-musl/13.2.3:
137 | resolution: {integrity: sha512-CTeelh8OzSOVqpzMFMFnVRJIFAFQoTsI9RmVJWW/92S4xfECGcOzgsX37CZ8K982WHRzKU7exeh7vYdG/Eh4CA==}
138 | engines: {node: '>= 10'}
139 | cpu: [x64]
140 | os: [linux]
141 | requiresBuild: true
142 | dev: false
143 | optional: true
144 |
145 | /@next/swc-win32-arm64-msvc/13.2.3:
146 | resolution: {integrity: sha512-7N1KBQP5mo4xf52cFCHgMjzbc9jizIlkTepe9tMa2WFvEIlKDfdt38QYcr9mbtny17yuaIw02FXOVEytGzqdOQ==}
147 | engines: {node: '>= 10'}
148 | cpu: [arm64]
149 | os: [win32]
150 | requiresBuild: true
151 | dev: false
152 | optional: true
153 |
154 | /@next/swc-win32-ia32-msvc/13.2.3:
155 | resolution: {integrity: sha512-LzWD5pTSipUXTEMRjtxES/NBYktuZdo7xExJqGDMnZU8WOI+v9mQzsmQgZS/q02eIv78JOCSemqVVKZBGCgUvA==}
156 | engines: {node: '>= 10'}
157 | cpu: [ia32]
158 | os: [win32]
159 | requiresBuild: true
160 | dev: false
161 | optional: true
162 |
163 | /@next/swc-win32-x64-msvc/13.2.3:
164 | resolution: {integrity: sha512-aLG2MaFs4y7IwaMTosz2r4mVbqRyCnMoFqOcmfTi7/mAS+G4IMH0vJp4oLdbshqiVoiVuKrAfqtXj55/m7Qu1Q==}
165 | engines: {node: '>= 10'}
166 | cpu: [x64]
167 | os: [win32]
168 | requiresBuild: true
169 | dev: false
170 | optional: true
171 |
172 | /@nodelib/fs.scandir/2.1.5:
173 | resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
174 | engines: {node: '>= 8'}
175 | dependencies:
176 | '@nodelib/fs.stat': 2.0.5
177 | run-parallel: 1.2.0
178 | dev: false
179 |
180 | /@nodelib/fs.stat/2.0.5:
181 | resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
182 | engines: {node: '>= 8'}
183 | dev: false
184 |
185 | /@nodelib/fs.walk/1.2.8:
186 | resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
187 | engines: {node: '>= 8'}
188 | dependencies:
189 | '@nodelib/fs.scandir': 2.1.5
190 | fastq: 1.15.0
191 | dev: false
192 |
193 | /@stitches/core/1.2.8:
194 | resolution: {integrity: sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg==}
195 | dev: false
196 |
197 | /@stripe/stripe-js/1.48.0:
198 | resolution: {integrity: sha512-Kw2BRXk6z/wnTU9m2IJOhxJNrx9xzmpFSbk6D/Z0x6LJntMafiHeHFag2PvSF30O2quMb1UUdWsnZqGVDruZww==}
199 | dev: false
200 |
201 | /@supabase/auth-helpers-nextjs/0.5.4_ku2vntnma2imfrsyohnkxzette:
202 | resolution: {integrity: sha512-Xm3BMDOYPbNyuh8UXDRgBDc3JkeM5S2PfuC1jFJ73CCay4YiH1YxTz06Cvtp+vLH432qdjnqH+49ehVvUehPnQ==}
203 | peerDependencies:
204 | '@supabase/supabase-js': ^2.0.4
205 | dependencies:
206 | '@supabase/auth-helpers-shared': 0.2.4_ku2vntnma2imfrsyohnkxzette
207 | '@supabase/supabase-js': 2.10.0
208 | dev: false
209 |
210 | /@supabase/auth-helpers-react/0.3.1_ku2vntnma2imfrsyohnkxzette:
211 | resolution: {integrity: sha512-g3SFv08Dz9FapNif/ZY1b7qKGlMJDyTLSayHBz3kb3FuYxg7aLWgQtydDhm5AGbc0XtvpIBuhGTIOVevwpdosA==}
212 | peerDependencies:
213 | '@supabase/supabase-js': ^2.0.4
214 | dependencies:
215 | '@supabase/supabase-js': 2.10.0
216 | dev: false
217 |
218 | /@supabase/auth-helpers-shared/0.2.4_ku2vntnma2imfrsyohnkxzette:
219 | resolution: {integrity: sha512-c+hi3N6DhjPTzjXGOokre4gmMZOqt0f1ORrwZwi2s07pk3cbkmn77DwwVtlYMNi3oZNYRQqAfxJu83UzpJrrmg==}
220 | peerDependencies:
221 | '@supabase/supabase-js': ^2.0.4
222 | dependencies:
223 | '@supabase/supabase-js': 2.10.0
224 | dev: false
225 |
226 | /@supabase/auth-ui-react/0.3.3_ku2vntnma2imfrsyohnkxzette:
227 | resolution: {integrity: sha512-vrXQLwaW8DdKp4WAKRvgFM/DqXoCxBmYNQaUXLXvwdZJ0q/8c/vjSPnUMdjzpJDny2ljzoS3C8QaLimErik/Kg==}
228 | peerDependencies:
229 | '@supabase/supabase-js': ^2.8.0
230 | dependencies:
231 | '@stitches/core': 1.2.8
232 | '@supabase/auth-ui-shared': 0.1.2_ku2vntnma2imfrsyohnkxzette
233 | '@supabase/supabase-js': 2.10.0
234 | prop-types: 15.8.1
235 | react: 18.2.0
236 | react-dom: 18.2.0_react@18.2.0
237 | dev: false
238 |
239 | /@supabase/auth-ui-shared/0.1.2_ku2vntnma2imfrsyohnkxzette:
240 | resolution: {integrity: sha512-RbshJ6YWp2d25mJJeG0iAaJq4q5yaH+eviz9B1JuNG4aVv9y6IFByPmsLa5ab93CAjn8hrkExlOd9N34sSI5IQ==}
241 | peerDependencies:
242 | '@supabase/supabase-js': ^2.8.0
243 | dependencies:
244 | '@supabase/supabase-js': 2.10.0
245 | dev: false
246 |
247 | /@supabase/functions-js/2.1.0:
248 | resolution: {integrity: sha512-vRziB+AqRXRaGHjEFHwBo0kuNDTuAxI7VUeqU24Fe86ISoD8YEQm0dGdpleJEcqgDGWaO6pxT1tfj1BRY5PwMg==}
249 | dependencies:
250 | cross-fetch: 3.1.5
251 | transitivePeerDependencies:
252 | - encoding
253 | dev: false
254 |
255 | /@supabase/gotrue-js/2.12.2:
256 | resolution: {integrity: sha512-4XpeHxlsu/dXAkj32OxWUVV/8VPMa+cwmNVpVmffMpIJWo/cnRllqqYW2tZX3o8ysA05I/AgYy0nDkef1kuHCQ==}
257 | dependencies:
258 | cross-fetch: 3.1.5
259 | transitivePeerDependencies:
260 | - encoding
261 | dev: false
262 |
263 | /@supabase/postgrest-js/1.4.1:
264 | resolution: {integrity: sha512-aruqwV/aTggkM7OVv2JinCeXmRMKHJCZpkuS1nuoa0NgLw7g3NyILSyWOKYTBJ/PxE/zXtWsBhdxFzaaNz5uxg==}
265 | dependencies:
266 | cross-fetch: 3.1.5
267 | transitivePeerDependencies:
268 | - encoding
269 | dev: false
270 |
271 | /@supabase/realtime-js/2.6.0:
272 | resolution: {integrity: sha512-tOVulMobhpxyDuu8VIImpL8FXmZOKsGNOSyS5ihJdj2xYmPPvYG+D2J51Ewfl+MFF65tweiB6p9N9bNIW1cDNA==}
273 | dependencies:
274 | '@types/phoenix': 1.5.5
275 | websocket: 1.0.34
276 | transitivePeerDependencies:
277 | - supports-color
278 | dev: false
279 |
280 | /@supabase/storage-js/2.3.1:
281 | resolution: {integrity: sha512-BaPIvyvjuZW1V0CnfGKUZyzpBUXnsh0XD8eqTOYd+MdiGPmIPI0vtwnT4fAoK8mipp1vpcN62EVQaqeUnWXPtQ==}
282 | dependencies:
283 | cross-fetch: 3.1.5
284 | transitivePeerDependencies:
285 | - encoding
286 | dev: false
287 |
288 | /@supabase/supabase-js/2.10.0:
289 | resolution: {integrity: sha512-/vkpPxGDyLfTASWnVHL8vdgQxn9SX/Cs+BotTxFhLSIeGFSazC6rpQSMKu6RqzO7gjBD1KqTv0h3auWfClWs+Q==}
290 | dependencies:
291 | '@supabase/functions-js': 2.1.0
292 | '@supabase/gotrue-js': 2.12.2
293 | '@supabase/postgrest-js': 1.4.1
294 | '@supabase/realtime-js': 2.6.0
295 | '@supabase/storage-js': 2.3.1
296 | cross-fetch: 3.1.5
297 | transitivePeerDependencies:
298 | - encoding
299 | - supports-color
300 | dev: false
301 |
302 | /@swc/helpers/0.4.14:
303 | resolution: {integrity: sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==}
304 | dependencies:
305 | tslib: 2.5.0
306 | dev: false
307 |
308 | /@types/node/18.14.4:
309 | resolution: {integrity: sha512-VhCw7I7qO2X49+jaKcAUwi3rR+hbxT5VcYF493+Z5kMLI0DL568b7JI4IDJaxWFH0D/xwmGJNoXisyX+w7GH/g==}
310 |
311 | /@types/phoenix/1.5.5:
312 | resolution: {integrity: sha512-1eWWT19k0L4ZiTvdXjAvJ9KvW0B8SdiVftQmFPJGTEx78Q4PCSIQDpz+EfkFVR1N4U9gREjlW4JXL8YCIlY0bw==}
313 | dev: false
314 |
315 | /@types/prop-types/15.7.5:
316 | resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
317 | dev: true
318 |
319 | /@types/react/18.0.28:
320 | resolution: {integrity: sha512-RD0ivG1kEztNBdoAK7lekI9M+azSnitIn85h4iOiaLjaTrMjzslhaqCGaI4IyCJ1RljWiLCEu4jyrLLgqxBTew==}
321 | dependencies:
322 | '@types/prop-types': 15.7.5
323 | '@types/scheduler': 0.16.2
324 | csstype: 3.1.1
325 | dev: true
326 |
327 | /@types/scheduler/0.16.2:
328 | resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==}
329 | dev: true
330 |
331 | /acorn-node/1.8.2:
332 | resolution: {integrity: sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==}
333 | dependencies:
334 | acorn: 7.4.1
335 | acorn-walk: 7.2.0
336 | xtend: 4.0.2
337 | dev: false
338 |
339 | /acorn-walk/7.2.0:
340 | resolution: {integrity: sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==}
341 | engines: {node: '>=0.4.0'}
342 | dev: false
343 |
344 | /acorn/7.4.1:
345 | resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==}
346 | engines: {node: '>=0.4.0'}
347 | hasBin: true
348 | dev: false
349 |
350 | /anymatch/3.1.3:
351 | resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
352 | engines: {node: '>= 8'}
353 | dependencies:
354 | normalize-path: 3.0.0
355 | picomatch: 2.3.1
356 | dev: false
357 |
358 | /arg/5.0.2:
359 | resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
360 | dev: false
361 |
362 | /autoprefixer/10.4.13_postcss@8.4.21:
363 | resolution: {integrity: sha512-49vKpMqcZYsJjwotvt4+h/BCjJVnhGwcLpDt5xkcaOG3eLrG/HUYLagrihYsQ+qrIBgIzX1Rw7a6L8I/ZA1Atg==}
364 | engines: {node: ^10 || ^12 || >=14}
365 | hasBin: true
366 | peerDependencies:
367 | postcss: ^8.1.0
368 | dependencies:
369 | browserslist: 4.21.5
370 | caniuse-lite: 1.0.30001458
371 | fraction.js: 4.2.0
372 | normalize-range: 0.1.2
373 | picocolors: 1.0.0
374 | postcss: 8.4.21
375 | postcss-value-parser: 4.2.0
376 | dev: true
377 |
378 | /binary-extensions/2.2.0:
379 | resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
380 | engines: {node: '>=8'}
381 | dev: false
382 |
383 | /braces/3.0.2:
384 | resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==}
385 | engines: {node: '>=8'}
386 | dependencies:
387 | fill-range: 7.0.1
388 | dev: false
389 |
390 | /browserslist/4.21.5:
391 | resolution: {integrity: sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==}
392 | engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
393 | hasBin: true
394 | dependencies:
395 | caniuse-lite: 1.0.30001458
396 | electron-to-chromium: 1.4.317
397 | node-releases: 2.0.10
398 | update-browserslist-db: 1.0.10_browserslist@4.21.5
399 | dev: true
400 |
401 | /bufferutil/4.0.7:
402 | resolution: {integrity: sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw==}
403 | engines: {node: '>=6.14.2'}
404 | requiresBuild: true
405 | dependencies:
406 | node-gyp-build: 4.6.0
407 | dev: false
408 |
409 | /call-bind/1.0.2:
410 | resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==}
411 | dependencies:
412 | function-bind: 1.1.1
413 | get-intrinsic: 1.2.0
414 | dev: false
415 |
416 | /camelcase-css/2.0.1:
417 | resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
418 | engines: {node: '>= 6'}
419 | dev: false
420 |
421 | /caniuse-lite/1.0.30001458:
422 | resolution: {integrity: sha512-lQ1VlUUq5q9ro9X+5gOEyH7i3vm+AYVT1WDCVB69XOZ17KZRhnZ9J0Sqz7wTHQaLBJccNCHq8/Ww5LlOIZbB0w==}
423 |
424 | /chokidar/3.5.3:
425 | resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==}
426 | engines: {node: '>= 8.10.0'}
427 | dependencies:
428 | anymatch: 3.1.3
429 | braces: 3.0.2
430 | glob-parent: 5.1.2
431 | is-binary-path: 2.1.0
432 | is-glob: 4.0.3
433 | normalize-path: 3.0.0
434 | readdirp: 3.6.0
435 | optionalDependencies:
436 | fsevents: 2.3.2
437 | dev: false
438 |
439 | /classnames/2.3.2:
440 | resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==}
441 | dev: false
442 |
443 | /client-only/0.0.1:
444 | resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
445 | dev: false
446 |
447 | /color-name/1.1.4:
448 | resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
449 | dev: false
450 |
451 | /cross-fetch/3.1.5:
452 | resolution: {integrity: sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==}
453 | dependencies:
454 | node-fetch: 2.6.7
455 | transitivePeerDependencies:
456 | - encoding
457 | dev: false
458 |
459 | /cssesc/3.0.0:
460 | resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
461 | engines: {node: '>=4'}
462 | hasBin: true
463 | dev: false
464 |
465 | /csstype/3.1.1:
466 | resolution: {integrity: sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==}
467 | dev: true
468 |
469 | /d/1.0.1:
470 | resolution: {integrity: sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==}
471 | dependencies:
472 | es5-ext: 0.10.62
473 | type: 1.2.0
474 | dev: false
475 |
476 | /debug/2.6.9:
477 | resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
478 | peerDependencies:
479 | supports-color: '*'
480 | peerDependenciesMeta:
481 | supports-color:
482 | optional: true
483 | dependencies:
484 | ms: 2.0.0
485 | dev: false
486 |
487 | /defined/1.0.1:
488 | resolution: {integrity: sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==}
489 | dev: false
490 |
491 | /detective/5.2.1:
492 | resolution: {integrity: sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==}
493 | engines: {node: '>=0.8.0'}
494 | hasBin: true
495 | dependencies:
496 | acorn-node: 1.8.2
497 | defined: 1.0.1
498 | minimist: 1.2.8
499 | dev: false
500 |
501 | /didyoumean/1.2.2:
502 | resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
503 | dev: false
504 |
505 | /dlv/1.1.3:
506 | resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
507 | dev: false
508 |
509 | /electron-to-chromium/1.4.317:
510 | resolution: {integrity: sha512-JhCRm9v30FMNzQSsjl4kXaygU+qHBD0Yh7mKxyjmF0V8VwYVB6qpBRX28GyAucrM9wDCpSUctT6FpMUQxbyKuA==}
511 | dev: true
512 |
513 | /es5-ext/0.10.62:
514 | resolution: {integrity: sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==}
515 | engines: {node: '>=0.10'}
516 | requiresBuild: true
517 | dependencies:
518 | es6-iterator: 2.0.3
519 | es6-symbol: 3.1.3
520 | next-tick: 1.1.0
521 | dev: false
522 |
523 | /es6-iterator/2.0.3:
524 | resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==}
525 | dependencies:
526 | d: 1.0.1
527 | es5-ext: 0.10.62
528 | es6-symbol: 3.1.3
529 | dev: false
530 |
531 | /es6-symbol/3.1.3:
532 | resolution: {integrity: sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==}
533 | dependencies:
534 | d: 1.0.1
535 | ext: 1.7.0
536 | dev: false
537 |
538 | /escalade/3.1.1:
539 | resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==}
540 | engines: {node: '>=6'}
541 | dev: true
542 |
543 | /ext/1.7.0:
544 | resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==}
545 | dependencies:
546 | type: 2.7.2
547 | dev: false
548 |
549 | /fast-glob/3.2.12:
550 | resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==}
551 | engines: {node: '>=8.6.0'}
552 | dependencies:
553 | '@nodelib/fs.stat': 2.0.5
554 | '@nodelib/fs.walk': 1.2.8
555 | glob-parent: 5.1.2
556 | merge2: 1.4.1
557 | micromatch: 4.0.5
558 | dev: false
559 |
560 | /fastq/1.15.0:
561 | resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==}
562 | dependencies:
563 | reusify: 1.0.4
564 | dev: false
565 |
566 | /fill-range/7.0.1:
567 | resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
568 | engines: {node: '>=8'}
569 | dependencies:
570 | to-regex-range: 5.0.1
571 | dev: false
572 |
573 | /fraction.js/4.2.0:
574 | resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==}
575 | dev: true
576 |
577 | /fsevents/2.3.2:
578 | resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
579 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
580 | os: [darwin]
581 | requiresBuild: true
582 | dev: false
583 | optional: true
584 |
585 | /function-bind/1.1.1:
586 | resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
587 | dev: false
588 |
589 | /get-intrinsic/1.2.0:
590 | resolution: {integrity: sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==}
591 | dependencies:
592 | function-bind: 1.1.1
593 | has: 1.0.3
594 | has-symbols: 1.0.3
595 | dev: false
596 |
597 | /glob-parent/5.1.2:
598 | resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
599 | engines: {node: '>= 6'}
600 | dependencies:
601 | is-glob: 4.0.3
602 | dev: false
603 |
604 | /glob-parent/6.0.2:
605 | resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
606 | engines: {node: '>=10.13.0'}
607 | dependencies:
608 | is-glob: 4.0.3
609 | dev: false
610 |
611 | /has-symbols/1.0.3:
612 | resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==}
613 | engines: {node: '>= 0.4'}
614 | dev: false
615 |
616 | /has/1.0.3:
617 | resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==}
618 | engines: {node: '>= 0.4.0'}
619 | dependencies:
620 | function-bind: 1.1.1
621 | dev: false
622 |
623 | /is-binary-path/2.1.0:
624 | resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
625 | engines: {node: '>=8'}
626 | dependencies:
627 | binary-extensions: 2.2.0
628 | dev: false
629 |
630 | /is-core-module/2.11.0:
631 | resolution: {integrity: sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==}
632 | dependencies:
633 | has: 1.0.3
634 | dev: false
635 |
636 | /is-extglob/2.1.1:
637 | resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
638 | engines: {node: '>=0.10.0'}
639 | dev: false
640 |
641 | /is-glob/4.0.3:
642 | resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
643 | engines: {node: '>=0.10.0'}
644 | dependencies:
645 | is-extglob: 2.1.1
646 | dev: false
647 |
648 | /is-number/7.0.0:
649 | resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
650 | engines: {node: '>=0.12.0'}
651 | dev: false
652 |
653 | /is-typedarray/1.0.0:
654 | resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==}
655 | dev: false
656 |
657 | /js-tokens/4.0.0:
658 | resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
659 | dev: false
660 |
661 | /lilconfig/2.1.0:
662 | resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
663 | engines: {node: '>=10'}
664 | dev: false
665 |
666 | /loose-envify/1.4.0:
667 | resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
668 | hasBin: true
669 | dependencies:
670 | js-tokens: 4.0.0
671 | dev: false
672 |
673 | /merge2/1.4.1:
674 | resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
675 | engines: {node: '>= 8'}
676 | dev: false
677 |
678 | /micromatch/4.0.5:
679 | resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
680 | engines: {node: '>=8.6'}
681 | dependencies:
682 | braces: 3.0.2
683 | picomatch: 2.3.1
684 | dev: false
685 |
686 | /minimist/1.2.8:
687 | resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
688 | dev: false
689 |
690 | /ms/2.0.0:
691 | resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
692 | dev: false
693 |
694 | /nanoid/3.3.4:
695 | resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==}
696 | engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
697 | hasBin: true
698 |
699 | /next-tick/1.1.0:
700 | resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
701 | dev: false
702 |
703 | /next/13.2.3_biqbaboplfbrettd7655fr4n2y:
704 | resolution: {integrity: sha512-nKFJC6upCPN7DWRx4+0S/1PIOT7vNlCT157w9AzbXEgKy6zkiPKEt5YyRUsRZkmpEqBVrGgOqNfwecTociyg+w==}
705 | engines: {node: '>=14.6.0'}
706 | hasBin: true
707 | peerDependencies:
708 | '@opentelemetry/api': ^1.4.0
709 | fibers: '>= 3.1.0'
710 | node-sass: ^6.0.0 || ^7.0.0
711 | react: ^18.2.0
712 | react-dom: ^18.2.0
713 | sass: ^1.3.0
714 | peerDependenciesMeta:
715 | '@opentelemetry/api':
716 | optional: true
717 | fibers:
718 | optional: true
719 | node-sass:
720 | optional: true
721 | sass:
722 | optional: true
723 | dependencies:
724 | '@next/env': 13.2.3
725 | '@swc/helpers': 0.4.14
726 | caniuse-lite: 1.0.30001458
727 | postcss: 8.4.14
728 | react: 18.2.0
729 | react-dom: 18.2.0_react@18.2.0
730 | styled-jsx: 5.1.1_react@18.2.0
731 | optionalDependencies:
732 | '@next/swc-android-arm-eabi': 13.2.3
733 | '@next/swc-android-arm64': 13.2.3
734 | '@next/swc-darwin-arm64': 13.2.3
735 | '@next/swc-darwin-x64': 13.2.3
736 | '@next/swc-freebsd-x64': 13.2.3
737 | '@next/swc-linux-arm-gnueabihf': 13.2.3
738 | '@next/swc-linux-arm64-gnu': 13.2.3
739 | '@next/swc-linux-arm64-musl': 13.2.3
740 | '@next/swc-linux-x64-gnu': 13.2.3
741 | '@next/swc-linux-x64-musl': 13.2.3
742 | '@next/swc-win32-arm64-msvc': 13.2.3
743 | '@next/swc-win32-ia32-msvc': 13.2.3
744 | '@next/swc-win32-x64-msvc': 13.2.3
745 | transitivePeerDependencies:
746 | - '@babel/core'
747 | - babel-plugin-macros
748 | dev: false
749 |
750 | /node-fetch/2.6.7:
751 | resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==}
752 | engines: {node: 4.x || >=6.0.0}
753 | peerDependencies:
754 | encoding: ^0.1.0
755 | peerDependenciesMeta:
756 | encoding:
757 | optional: true
758 | dependencies:
759 | whatwg-url: 5.0.0
760 | dev: false
761 |
762 | /node-gyp-build/4.6.0:
763 | resolution: {integrity: sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==}
764 | hasBin: true
765 | dev: false
766 |
767 | /node-releases/2.0.10:
768 | resolution: {integrity: sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==}
769 | dev: true
770 |
771 | /normalize-path/3.0.0:
772 | resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
773 | engines: {node: '>=0.10.0'}
774 | dev: false
775 |
776 | /normalize-range/0.1.2:
777 | resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==}
778 | engines: {node: '>=0.10.0'}
779 | dev: true
780 |
781 | /object-assign/4.1.1:
782 | resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
783 | engines: {node: '>=0.10.0'}
784 | dev: false
785 |
786 | /object-hash/3.0.0:
787 | resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
788 | engines: {node: '>= 6'}
789 | dev: false
790 |
791 | /object-inspect/1.12.3:
792 | resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==}
793 | dev: false
794 |
795 | /path-parse/1.0.7:
796 | resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
797 | dev: false
798 |
799 | /picocolors/1.0.0:
800 | resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
801 |
802 | /picomatch/2.3.1:
803 | resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
804 | engines: {node: '>=8.6'}
805 | dev: false
806 |
807 | /pify/2.3.0:
808 | resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
809 | engines: {node: '>=0.10.0'}
810 | dev: false
811 |
812 | /postcss-import/14.1.0_postcss@8.4.21:
813 | resolution: {integrity: sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==}
814 | engines: {node: '>=10.0.0'}
815 | peerDependencies:
816 | postcss: ^8.0.0
817 | dependencies:
818 | postcss: 8.4.21
819 | postcss-value-parser: 4.2.0
820 | read-cache: 1.0.0
821 | resolve: 1.22.1
822 | dev: false
823 |
824 | /postcss-js/4.0.1_postcss@8.4.21:
825 | resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==}
826 | engines: {node: ^12 || ^14 || >= 16}
827 | peerDependencies:
828 | postcss: ^8.4.21
829 | dependencies:
830 | camelcase-css: 2.0.1
831 | postcss: 8.4.21
832 | dev: false
833 |
834 | /postcss-load-config/3.1.4_postcss@8.4.21:
835 | resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==}
836 | engines: {node: '>= 10'}
837 | peerDependencies:
838 | postcss: '>=8.0.9'
839 | ts-node: '>=9.0.0'
840 | peerDependenciesMeta:
841 | postcss:
842 | optional: true
843 | ts-node:
844 | optional: true
845 | dependencies:
846 | lilconfig: 2.1.0
847 | postcss: 8.4.21
848 | yaml: 1.10.2
849 | dev: false
850 |
851 | /postcss-nested/6.0.0_postcss@8.4.21:
852 | resolution: {integrity: sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==}
853 | engines: {node: '>=12.0'}
854 | peerDependencies:
855 | postcss: ^8.2.14
856 | dependencies:
857 | postcss: 8.4.21
858 | postcss-selector-parser: 6.0.11
859 | dev: false
860 |
861 | /postcss-selector-parser/6.0.11:
862 | resolution: {integrity: sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==}
863 | engines: {node: '>=4'}
864 | dependencies:
865 | cssesc: 3.0.0
866 | util-deprecate: 1.0.2
867 | dev: false
868 |
869 | /postcss-value-parser/4.2.0:
870 | resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
871 |
872 | /postcss/8.4.14:
873 | resolution: {integrity: sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==}
874 | engines: {node: ^10 || ^12 || >=14}
875 | dependencies:
876 | nanoid: 3.3.4
877 | picocolors: 1.0.0
878 | source-map-js: 1.0.2
879 | dev: false
880 |
881 | /postcss/8.4.21:
882 | resolution: {integrity: sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==}
883 | engines: {node: ^10 || ^12 || >=14}
884 | dependencies:
885 | nanoid: 3.3.4
886 | picocolors: 1.0.0
887 | source-map-js: 1.0.2
888 |
889 | /prettier/2.8.4:
890 | resolution: {integrity: sha512-vIS4Rlc2FNh0BySk3Wkd6xmwxB0FpOndW5fisM5H8hsZSxU2VWVB5CWIkIjWvrHjIhxk2g3bfMKM87zNTrZddw==}
891 | engines: {node: '>=10.13.0'}
892 | hasBin: true
893 | dev: true
894 |
895 | /prop-types/15.8.1:
896 | resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
897 | dependencies:
898 | loose-envify: 1.4.0
899 | object-assign: 4.1.1
900 | react-is: 16.13.1
901 | dev: false
902 |
903 | /qs/6.11.0:
904 | resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
905 | engines: {node: '>=0.6'}
906 | dependencies:
907 | side-channel: 1.0.4
908 | dev: false
909 |
910 | /queue-microtask/1.2.3:
911 | resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
912 | dev: false
913 |
914 | /quick-lru/5.1.1:
915 | resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
916 | engines: {node: '>=10'}
917 | dev: false
918 |
919 | /react-dom/18.2.0_react@18.2.0:
920 | resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
921 | peerDependencies:
922 | react: ^18.2.0
923 | dependencies:
924 | loose-envify: 1.4.0
925 | react: 18.2.0
926 | scheduler: 0.23.0
927 | dev: false
928 |
929 | /react-is/16.13.1:
930 | resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
931 | dev: false
932 |
933 | /react-merge-refs/2.0.1:
934 | resolution: {integrity: sha512-pywF6oouJWuqL26xV3OruRSIqai31R9SdJX/I3gP2q8jLxUnA1IwXcLW8werUHLZOrp4N7YOeQNZrh/BKrHI4A==}
935 | dev: false
936 |
937 | /react/18.2.0:
938 | resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
939 | engines: {node: '>=0.10.0'}
940 | dependencies:
941 | loose-envify: 1.4.0
942 | dev: false
943 |
944 | /read-cache/1.0.0:
945 | resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
946 | dependencies:
947 | pify: 2.3.0
948 | dev: false
949 |
950 | /readdirp/3.6.0:
951 | resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
952 | engines: {node: '>=8.10.0'}
953 | dependencies:
954 | picomatch: 2.3.1
955 | dev: false
956 |
957 | /resolve/1.22.1:
958 | resolution: {integrity: sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==}
959 | hasBin: true
960 | dependencies:
961 | is-core-module: 2.11.0
962 | path-parse: 1.0.7
963 | supports-preserve-symlinks-flag: 1.0.0
964 | dev: false
965 |
966 | /reusify/1.0.4:
967 | resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
968 | engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
969 | dev: false
970 |
971 | /run-parallel/1.2.0:
972 | resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
973 | dependencies:
974 | queue-microtask: 1.2.3
975 | dev: false
976 |
977 | /scheduler/0.23.0:
978 | resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
979 | dependencies:
980 | loose-envify: 1.4.0
981 | dev: false
982 |
983 | /side-channel/1.0.4:
984 | resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==}
985 | dependencies:
986 | call-bind: 1.0.2
987 | get-intrinsic: 1.2.0
988 | object-inspect: 1.12.3
989 | dev: false
990 |
991 | /source-map-js/1.0.2:
992 | resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
993 | engines: {node: '>=0.10.0'}
994 |
995 | /stripe/11.13.0:
996 | resolution: {integrity: sha512-Jx0nDbdvRsTtDSX5OFQ+4rLmYIftoiOE9HAXWIgyhAz1QjRFI3UIiJ/kCyhkdJBoHu019O5Ya6EmQ5Zf635XDw==}
997 | engines: {node: '>=12.*'}
998 | dependencies:
999 | '@types/node': 18.14.4
1000 | qs: 6.11.0
1001 | dev: false
1002 |
1003 | /styled-jsx/5.1.1_react@18.2.0:
1004 | resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
1005 | engines: {node: '>= 12.0.0'}
1006 | peerDependencies:
1007 | '@babel/core': '*'
1008 | babel-plugin-macros: '*'
1009 | react: '>= 16.8.0 || 17.x.x || ^18.0.0-0'
1010 | peerDependenciesMeta:
1011 | '@babel/core':
1012 | optional: true
1013 | babel-plugin-macros:
1014 | optional: true
1015 | dependencies:
1016 | client-only: 0.0.1
1017 | react: 18.2.0
1018 | dev: false
1019 |
1020 | /supports-preserve-symlinks-flag/1.0.0:
1021 | resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
1022 | engines: {node: '>= 0.4'}
1023 | dev: false
1024 |
1025 | /swr/2.0.4_react@18.2.0:
1026 | resolution: {integrity: sha512-4GUiTjknRUVuw4MWUHR7mzJ9G/DWL+yZz/TgWDfiA0OZ9tL6qyrTkN2wPeboBpL3OJTkej3pexh3mWCnv8cFkQ==}
1027 | engines: {pnpm: '7'}
1028 | peerDependencies:
1029 | react: ^16.11.0 || ^17.0.0 || ^18.0.0
1030 | dependencies:
1031 | react: 18.2.0
1032 | use-sync-external-store: 1.2.0_react@18.2.0
1033 | dev: false
1034 |
1035 | /tailwindcss/3.2.7_postcss@8.4.21:
1036 | resolution: {integrity: sha512-B6DLqJzc21x7wntlH/GsZwEXTBttVSl1FtCzC8WP4oBc/NKef7kaax5jeihkkCEWc831/5NDJ9gRNDK6NEioQQ==}
1037 | engines: {node: '>=12.13.0'}
1038 | hasBin: true
1039 | peerDependencies:
1040 | postcss: ^8.0.9
1041 | dependencies:
1042 | arg: 5.0.2
1043 | chokidar: 3.5.3
1044 | color-name: 1.1.4
1045 | detective: 5.2.1
1046 | didyoumean: 1.2.2
1047 | dlv: 1.1.3
1048 | fast-glob: 3.2.12
1049 | glob-parent: 6.0.2
1050 | is-glob: 4.0.3
1051 | lilconfig: 2.1.0
1052 | micromatch: 4.0.5
1053 | normalize-path: 3.0.0
1054 | object-hash: 3.0.0
1055 | picocolors: 1.0.0
1056 | postcss: 8.4.21
1057 | postcss-import: 14.1.0_postcss@8.4.21
1058 | postcss-js: 4.0.1_postcss@8.4.21
1059 | postcss-load-config: 3.1.4_postcss@8.4.21
1060 | postcss-nested: 6.0.0_postcss@8.4.21
1061 | postcss-selector-parser: 6.0.11
1062 | postcss-value-parser: 4.2.0
1063 | quick-lru: 5.1.1
1064 | resolve: 1.22.1
1065 | transitivePeerDependencies:
1066 | - ts-node
1067 | dev: false
1068 |
1069 | /to-regex-range/5.0.1:
1070 | resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
1071 | engines: {node: '>=8.0'}
1072 | dependencies:
1073 | is-number: 7.0.0
1074 | dev: false
1075 |
1076 | /tr46/0.0.3:
1077 | resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
1078 | dev: false
1079 |
1080 | /tslib/2.5.0:
1081 | resolution: {integrity: sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==}
1082 | dev: false
1083 |
1084 | /type/1.2.0:
1085 | resolution: {integrity: sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==}
1086 | dev: false
1087 |
1088 | /type/2.7.2:
1089 | resolution: {integrity: sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==}
1090 | dev: false
1091 |
1092 | /typedarray-to-buffer/3.1.5:
1093 | resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==}
1094 | dependencies:
1095 | is-typedarray: 1.0.0
1096 | dev: false
1097 |
1098 | /typescript/4.9.5:
1099 | resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==}
1100 | engines: {node: '>=4.2.0'}
1101 | hasBin: true
1102 | dev: true
1103 |
1104 | /update-browserslist-db/1.0.10_browserslist@4.21.5:
1105 | resolution: {integrity: sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==}
1106 | hasBin: true
1107 | peerDependencies:
1108 | browserslist: '>= 4.21.0'
1109 | dependencies:
1110 | browserslist: 4.21.5
1111 | escalade: 3.1.1
1112 | picocolors: 1.0.0
1113 | dev: true
1114 |
1115 | /use-sync-external-store/1.2.0_react@18.2.0:
1116 | resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
1117 | peerDependencies:
1118 | react: ^16.8.0 || ^17.0.0 || ^18.0.0
1119 | dependencies:
1120 | react: 18.2.0
1121 | dev: false
1122 |
1123 | /utf-8-validate/5.0.10:
1124 | resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==}
1125 | engines: {node: '>=6.14.2'}
1126 | requiresBuild: true
1127 | dependencies:
1128 | node-gyp-build: 4.6.0
1129 | dev: false
1130 |
1131 | /util-deprecate/1.0.2:
1132 | resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
1133 | dev: false
1134 |
1135 | /webidl-conversions/3.0.1:
1136 | resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
1137 | dev: false
1138 |
1139 | /websocket/1.0.34:
1140 | resolution: {integrity: sha512-PRDso2sGwF6kM75QykIesBijKSVceR6jL2G8NGYyq2XrItNC2P5/qL5XeR056GhA+Ly7JMFvJb9I312mJfmqnQ==}
1141 | engines: {node: '>=4.0.0'}
1142 | dependencies:
1143 | bufferutil: 4.0.7
1144 | debug: 2.6.9
1145 | es5-ext: 0.10.62
1146 | typedarray-to-buffer: 3.1.5
1147 | utf-8-validate: 5.0.10
1148 | yaeti: 0.0.6
1149 | transitivePeerDependencies:
1150 | - supports-color
1151 | dev: false
1152 |
1153 | /whatwg-url/5.0.0:
1154 | resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
1155 | dependencies:
1156 | tr46: 0.0.3
1157 | webidl-conversions: 3.0.1
1158 | dev: false
1159 |
1160 | /xtend/4.0.2:
1161 | resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
1162 | engines: {node: '>=0.4'}
1163 | dev: false
1164 |
1165 | /yaeti/0.0.6:
1166 | resolution: {integrity: sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==}
1167 | engines: {node: '>=0.10.32'}
1168 | dev: false
1169 |
1170 | /yaml/1.10.2:
1171 | resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
1172 | engines: {node: '>= 6'}
1173 | dev: false
1174 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 |
2 | module.exports = {
3 | plugins: {
4 | tailwindcss: {},
5 | autoprefixer: {}
6 | }
7 | };
--------------------------------------------------------------------------------
/public/architecture_diagram.svg:
--------------------------------------------------------------------------------
1 | Supabase Vercel Stripe Authentication: Sign Up/In Database API: CRUD API route: Create Checkout/Portal Sessions Webhook: Products/Prices/Subscriptions API route: Upsert Products/Prices/Subscriptions Supabase Vercel Stripe
2 |
--------------------------------------------------------------------------------
/public/demo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tanveerpot/nextjs-subscription-payments/e51b170d2279055105518d807f049f65a770cf75/public/demo.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tanveerpot/nextjs-subscription-payments/e51b170d2279055105518d807f049f65a770cf75/public/favicon.ico
--------------------------------------------------------------------------------
/public/github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/nextjs.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | next-white-vector
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/public/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tanveerpot/nextjs-subscription-payments/e51b170d2279055105518d807f049f65a770cf75/public/og.png
--------------------------------------------------------------------------------
/public/stripe.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
8 |
9 |
12 |
15 |
16 |
17 |
18 |
20 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/public/supabase.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/public/vercel-deploy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tanveerpot/nextjs-subscription-payments/e51b170d2279055105518d807f049f65a770cf75/public/vercel-deploy.png
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/schema.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;
146 |
--------------------------------------------------------------------------------
/styles/chrome-bug.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Chrome has a bug with transitions on load since 2012!
3 | *
4 | * To prevent a "pop" of content, you have to disable all transitions until
5 | * the page is done loading.
6 | *
7 | * https://lab.laukstein.com/bug/input
8 | * https://twitter.com/timer150/status/1345217126680899584
9 | */
10 | body.loading * {
11 | transition: none !important;
12 | }
13 |
--------------------------------------------------------------------------------
/styles/main.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | *,
6 | *:before,
7 | *:after {
8 | box-sizing: inherit;
9 | }
10 |
11 | *:focus {
12 | @apply outline-none ring-2 ring-pink-500 ring-opacity-50;
13 | }
14 |
15 | html {
16 | height: 100%;
17 | box-sizing: border-box;
18 | touch-action: manipulation;
19 | font-feature-settings: 'case' 1, 'rlig' 1, 'calt' 0;
20 | }
21 |
22 | html,
23 | body {
24 | font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Helvetica Neue',
25 | 'Helvetica', sans-serif;
26 | text-rendering: optimizeLegibility;
27 | -moz-osx-font-smoothing: grayscale;
28 | @apply text-white bg-zinc-800 antialiased;
29 | }
30 |
31 | body {
32 | position: relative;
33 | min-height: 100%;
34 | margin: 0;
35 | }
36 |
37 | a {
38 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
39 | }
40 |
41 | .animated {
42 | -webkit-animation-duration: 1s;
43 | animation-duration: 1s;
44 | -webkit-animation-duration: 1s;
45 | animation-duration: 1s;
46 | -webkit-animation-fill-mode: both;
47 | animation-fill-mode: both;
48 | }
49 |
50 | .height-screen-helper {
51 | height: calc(100vh - 80px);
52 | }
53 |
--------------------------------------------------------------------------------
/supabase/.gitignore:
--------------------------------------------------------------------------------
1 | # Supabase
2 | .branches
3 | .temp
4 |
--------------------------------------------------------------------------------
/supabase/config.toml:
--------------------------------------------------------------------------------
1 | # A string used to distinguish different Supabase projects on the same host. Defaults to the working
2 | # directory name when running `supabase init`.
3 | project_id = "nextjs-subscription-payments"
4 |
5 | [api]
6 | # Port to use for the API URL.
7 | port = 54321
8 | # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
9 | # endpoints. public and storage are always included.
10 | schemas = ["public", "storage", "graphql_public"]
11 | # Extra schemas to add to the search_path of every request. public is always included.
12 | extra_search_path = ["public", "extensions"]
13 | # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
14 | # for accidental or malicious requests.
15 | max_rows = 1000
16 |
17 | [db]
18 | # Port to use for the local database URL.
19 | port = 54322
20 | # The database major version to use. This has to be the same as your remote database's. Run `SHOW
21 | # server_version;` on the remote database to check.
22 | major_version = 15
23 |
24 | [studio]
25 | # Port to use for Supabase Studio.
26 | port = 54323
27 |
28 | # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
29 | # are monitored, and you can view the emails that would have been sent from the web interface.
30 | [inbucket]
31 | # Port to use for the email testing server web interface.
32 | port = 54324
33 | smtp_port = 54325
34 | pop3_port = 54326
35 |
36 | [storage]
37 | # The maximum file size allowed (e.g. "5MB", "500KB").
38 | file_size_limit = "50MiB"
39 |
40 | [auth]
41 | # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
42 | # in emails.
43 | site_url = "http://localhost:3000"
44 | # A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
45 | additional_redirect_urls = ["https://localhost:3000"]
46 | # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one
47 | # week).
48 | jwt_expiry = 3600
49 | # Allow/disallow new user signups to your project.
50 | enable_signup = true
51 |
52 | [auth.email]
53 | # Allow/disallow new user signups via email to your project.
54 | enable_signup = true
55 | # If enabled, a user will be required to confirm any email change on both the old, and new email
56 | # addresses. If disabled, only the new email is required to confirm.
57 | double_confirm_changes = true
58 | # If enabled, users need to confirm their email address before signing in.
59 | enable_confirmations = false
60 |
61 | # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
62 | # `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `twitch`,
63 | # `twitter`, `slack`, `spotify`, `workos`, `zoom`.
64 | [auth.external.twitter]
65 | enabled = false
66 | client_id = ""
67 | secret = ""
68 | # Overrides the default auth redirectUrl.
69 | redirect_uri = "http://localhost:54321/auth/v1/callback"
70 | # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
71 | # or any other third-party OIDC providers.
72 | url = ""
73 | [auth.external.github]
74 | enabled = true
75 | client_id = ""
76 | secret = ""
77 | # Overrides the default auth redirectUrl.
78 | redirect_uri = "http://localhost:54321/auth/v1/callback"
79 | # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
80 | # or any other third-party OIDC providers.
81 | url = ""
82 |
--------------------------------------------------------------------------------
/supabase/seed.sql:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tanveerpot/nextjs-subscription-payments/e51b170d2279055105518d807f049f65a770cf75/supabase/seed.sql
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const { fontFamily } = require('tailwindcss/defaultTheme');
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | module.exports = {
5 | darkMode: ['class', '[data-theme="dark"]'],
6 | content: [
7 | 'app/**/*.{ts,tsx}',
8 | 'components/**/*.{ts,tsx}',
9 | 'pages/**/*.{ts,tsx}'
10 | ],
11 | theme: {
12 | extend: {
13 | fontFamily: {
14 | sans: ['var(--font-sans)', ...fontFamily.sans]
15 | }
16 | }
17 | },
18 | plugins: []
19 | };
20 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "baseUrl": ".",
17 | "paths": {
18 | "@/*": ["./*"]
19 | },
20 | "incremental": true
21 | },
22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
23 | "exclude": ["node_modules"]
24 | }
25 |
--------------------------------------------------------------------------------
/types.ts:
--------------------------------------------------------------------------------
1 | import Stripe from 'stripe';
2 | export interface PageMeta {
3 | title: string;
4 | description: string;
5 | cardImage: string;
6 | }
7 |
8 | export interface Customer {
9 | id: string /* primary key */;
10 | stripe_customer_id?: string;
11 | }
12 |
13 | export interface Product {
14 | id: string /* primary key */;
15 | active?: boolean;
16 | name?: string;
17 | description?: string;
18 | image?: string;
19 | metadata?: Stripe.Metadata;
20 | }
21 |
22 | export interface ProductWithPrice extends Product {
23 | prices?: Price[];
24 | }
25 |
26 | export interface UserDetails {
27 | id: string /* primary key */;
28 | first_name: string;
29 | last_name: string;
30 | full_name?: string;
31 | avatar_url?: string;
32 | billing_address?: Stripe.Address;
33 | payment_method?: Stripe.PaymentMethod[Stripe.PaymentMethod.Type];
34 | }
35 |
36 | export interface Price {
37 | id: string /* primary key */;
38 | product_id?: string /* foreign key to products.id */;
39 | active?: boolean;
40 | description?: string;
41 | unit_amount?: number;
42 | currency?: string;
43 | type?: Stripe.Price.Type;
44 | interval?: Stripe.Price.Recurring.Interval;
45 | interval_count?: number;
46 | trial_period_days?: number | null;
47 | metadata?: Stripe.Metadata;
48 | products?: Product;
49 | }
50 |
51 | export interface PriceWithProduct extends Price {}
52 |
53 | export interface Subscription {
54 | id: string /* primary key */;
55 | user_id: string;
56 | status?: Stripe.Subscription.Status;
57 | metadata?: Stripe.Metadata;
58 | price_id?: string /* foreign key to prices.id */;
59 | quantity?: number;
60 | cancel_at_period_end?: boolean;
61 | created: string;
62 | current_period_start: string;
63 | current_period_end: string;
64 | ended_at?: string;
65 | cancel_at?: string;
66 | canceled_at?: string;
67 | trial_start?: string;
68 | trial_end?: string;
69 | prices?: Price;
70 | }
71 |
--------------------------------------------------------------------------------
/types_db.ts:
--------------------------------------------------------------------------------
1 | export type Json =
2 | | string
3 | | number
4 | | boolean
5 | | null
6 | | { [key: string]: Json }
7 | | Json[];
8 |
9 | export interface Database {
10 | graphql_public: {
11 | Tables: {
12 | [_ in never]: never;
13 | };
14 | Views: {
15 | [_ in never]: never;
16 | };
17 | Functions: {
18 | graphql: {
19 | Args: {
20 | operationName?: string;
21 | query?: string;
22 | variables?: Json;
23 | extensions?: Json;
24 | };
25 | Returns: Json;
26 | };
27 | };
28 | Enums: {
29 | [_ in never]: never;
30 | };
31 | CompositeTypes: {
32 | [_ in never]: never;
33 | };
34 | };
35 | public: {
36 | Tables: {
37 | customers: {
38 | Row: {
39 | id: string;
40 | stripe_customer_id: string | null;
41 | };
42 | Insert: {
43 | id: string;
44 | stripe_customer_id?: string | null;
45 | };
46 | Update: {
47 | id?: string;
48 | stripe_customer_id?: string | null;
49 | };
50 | };
51 | prices: {
52 | Row: {
53 | active: boolean | null;
54 | currency: string | null;
55 | description: string | null;
56 | id: string;
57 | interval: Database['public']['Enums']['pricing_plan_interval'] | null;
58 | interval_count: number | null;
59 | metadata: Json | null;
60 | product_id: string | null;
61 | trial_period_days: number | null;
62 | type: Database['public']['Enums']['pricing_type'] | null;
63 | unit_amount: number | null;
64 | };
65 | Insert: {
66 | active?: boolean | null;
67 | currency?: string | null;
68 | description?: string | null;
69 | id: string;
70 | interval?:
71 | | Database['public']['Enums']['pricing_plan_interval']
72 | | null;
73 | interval_count?: number | null;
74 | metadata?: Json | null;
75 | product_id?: string | null;
76 | trial_period_days?: number | null;
77 | type?: Database['public']['Enums']['pricing_type'] | null;
78 | unit_amount?: number | null;
79 | };
80 | Update: {
81 | active?: boolean | null;
82 | currency?: string | null;
83 | description?: string | null;
84 | id?: string;
85 | interval?:
86 | | Database['public']['Enums']['pricing_plan_interval']
87 | | null;
88 | interval_count?: number | null;
89 | metadata?: Json | null;
90 | product_id?: string | null;
91 | trial_period_days?: number | null;
92 | type?: Database['public']['Enums']['pricing_type'] | null;
93 | unit_amount?: number | null;
94 | };
95 | };
96 | products: {
97 | Row: {
98 | active: boolean | null;
99 | description: string | null;
100 | id: string;
101 | image: string | null;
102 | metadata: Json | null;
103 | name: string | null;
104 | };
105 | Insert: {
106 | active?: boolean | null;
107 | description?: string | null;
108 | id: string;
109 | image?: string | null;
110 | metadata?: Json | null;
111 | name?: string | null;
112 | };
113 | Update: {
114 | active?: boolean | null;
115 | description?: string | null;
116 | id?: string;
117 | image?: string | null;
118 | metadata?: Json | null;
119 | name?: string | null;
120 | };
121 | };
122 | subscriptions: {
123 | Row: {
124 | cancel_at: string | null;
125 | cancel_at_period_end: boolean | null;
126 | canceled_at: string | null;
127 | created: string;
128 | current_period_end: string;
129 | current_period_start: string;
130 | ended_at: string | null;
131 | id: string;
132 | metadata: Json | null;
133 | price_id: string | null;
134 | quantity: number | null;
135 | status: Database['public']['Enums']['subscription_status'] | null;
136 | trial_end: string | null;
137 | trial_start: string | null;
138 | user_id: string;
139 | };
140 | Insert: {
141 | cancel_at?: string | null;
142 | cancel_at_period_end?: boolean | null;
143 | canceled_at?: string | null;
144 | created?: string;
145 | current_period_end?: string;
146 | current_period_start?: string;
147 | ended_at?: string | null;
148 | id: string;
149 | metadata?: Json | null;
150 | price_id?: string | null;
151 | quantity?: number | null;
152 | status?: Database['public']['Enums']['subscription_status'] | null;
153 | trial_end?: string | null;
154 | trial_start?: string | null;
155 | user_id: string;
156 | };
157 | Update: {
158 | cancel_at?: string | null;
159 | cancel_at_period_end?: boolean | null;
160 | canceled_at?: string | null;
161 | created?: string;
162 | current_period_end?: string;
163 | current_period_start?: string;
164 | ended_at?: string | null;
165 | id?: string;
166 | metadata?: Json | null;
167 | price_id?: string | null;
168 | quantity?: number | null;
169 | status?: Database['public']['Enums']['subscription_status'] | null;
170 | trial_end?: string | null;
171 | trial_start?: string | null;
172 | user_id?: string;
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 | };
198 | };
199 | Views: {
200 | [_ in never]: never;
201 | };
202 | Functions: {
203 | [_ in never]: never;
204 | };
205 | Enums: {
206 | pricing_plan_interval: 'day' | 'week' | 'month' | 'year';
207 | pricing_type: 'one_time' | 'recurring';
208 | subscription_status:
209 | | 'trialing'
210 | | 'active'
211 | | 'canceled'
212 | | 'incomplete'
213 | | 'incomplete_expired'
214 | | 'past_due'
215 | | 'unpaid'
216 | | 'paused';
217 | };
218 | CompositeTypes: {
219 | [_ in never]: never;
220 | };
221 | };
222 | storage: {
223 | Tables: {
224 | buckets: {
225 | Row: {
226 | created_at: string | null;
227 | id: string;
228 | name: string;
229 | owner: string | null;
230 | public: boolean | null;
231 | updated_at: string | null;
232 | };
233 | Insert: {
234 | created_at?: string | null;
235 | id: string;
236 | name: string;
237 | owner?: string | null;
238 | public?: boolean | null;
239 | updated_at?: string | null;
240 | };
241 | Update: {
242 | created_at?: string | null;
243 | id?: string;
244 | name?: string;
245 | owner?: string | null;
246 | public?: boolean | null;
247 | updated_at?: string | null;
248 | };
249 | };
250 | migrations: {
251 | Row: {
252 | executed_at: string | null;
253 | hash: string;
254 | id: number;
255 | name: string;
256 | };
257 | Insert: {
258 | executed_at?: string | null;
259 | hash: string;
260 | id: number;
261 | name: string;
262 | };
263 | Update: {
264 | executed_at?: string | null;
265 | hash?: string;
266 | id?: number;
267 | name?: string;
268 | };
269 | };
270 | objects: {
271 | Row: {
272 | bucket_id: string | null;
273 | created_at: string | null;
274 | id: string;
275 | last_accessed_at: string | null;
276 | metadata: Json | null;
277 | name: string | null;
278 | owner: string | null;
279 | path_tokens: string[] | null;
280 | updated_at: string | null;
281 | };
282 | Insert: {
283 | bucket_id?: string | null;
284 | created_at?: string | null;
285 | id?: string;
286 | last_accessed_at?: string | null;
287 | metadata?: Json | null;
288 | name?: string | null;
289 | owner?: string | null;
290 | path_tokens?: string[] | null;
291 | updated_at?: string | null;
292 | };
293 | Update: {
294 | bucket_id?: string | null;
295 | created_at?: string | null;
296 | id?: string;
297 | last_accessed_at?: string | null;
298 | metadata?: Json | null;
299 | name?: string | null;
300 | owner?: string | null;
301 | path_tokens?: string[] | null;
302 | updated_at?: string | null;
303 | };
304 | };
305 | };
306 | Views: {
307 | [_ in never]: never;
308 | };
309 | Functions: {
310 | extension: {
311 | Args: {
312 | name: string;
313 | };
314 | Returns: string;
315 | };
316 | filename: {
317 | Args: {
318 | name: string;
319 | };
320 | Returns: string;
321 | };
322 | foldername: {
323 | Args: {
324 | name: string;
325 | };
326 | Returns: string[];
327 | };
328 | get_size_by_bucket: {
329 | Args: Record;
330 | Returns: {
331 | size: number;
332 | bucket_id: string;
333 | }[];
334 | };
335 | search: {
336 | Args: {
337 | prefix: string;
338 | bucketname: string;
339 | limits?: number;
340 | levels?: number;
341 | offsets?: number;
342 | search?: string;
343 | sortcolumn?: string;
344 | sortorder?: string;
345 | };
346 | Returns: {
347 | name: string;
348 | id: string;
349 | updated_at: string;
350 | created_at: string;
351 | last_accessed_at: string;
352 | metadata: Json;
353 | }[];
354 | };
355 | };
356 | Enums: {
357 | [_ in never]: never;
358 | };
359 | CompositeTypes: {
360 | [_ in never]: never;
361 | };
362 | };
363 | }
364 |
--------------------------------------------------------------------------------
/utils/helpers.ts:
--------------------------------------------------------------------------------
1 | import { Price } from 'types';
2 |
3 | export const getURL = () => {
4 | let url =
5 | process?.env?.NEXT_PUBLIC_SITE_URL ?? // Set this to your site URL in production env.
6 | process?.env?.NEXT_PUBLIC_VERCEL_URL ?? // Automatically set by Vercel.
7 | 'http://localhost:3000/';
8 | // Make sure to include `https://` when not localhost.
9 | url = url.includes('http') ? url : `https://${url}`;
10 | // Make sure to including trailing `/`.
11 | url = url.charAt(url.length - 1) === '/' ? url : `${url}/`;
12 | return url;
13 | };
14 |
15 | export const postData = async ({
16 | url,
17 | data
18 | }: {
19 | url: string;
20 | data?: { price: Price };
21 | }) => {
22 | console.log('posting,', url, data);
23 |
24 | const res: Response = await fetch(url, {
25 | method: 'POST',
26 | headers: new Headers({ 'Content-Type': 'application/json' }),
27 | credentials: 'same-origin',
28 | body: JSON.stringify(data)
29 | });
30 |
31 | if (!res.ok) {
32 | console.log('Error in postData', { url, data, res });
33 |
34 | throw Error(res.statusText);
35 | }
36 |
37 | return res.json();
38 | };
39 |
40 | export const toDateTime = (secs: number) => {
41 | var t = new Date('1970-01-01T00:30:00Z'); // Unix epoch start.
42 | t.setSeconds(secs);
43 | return t;
44 | };
45 |
--------------------------------------------------------------------------------
/utils/stripe-client.ts:
--------------------------------------------------------------------------------
1 | import { loadStripe, Stripe } from '@stripe/stripe-js';
2 |
3 | let stripePromise: Promise;
4 |
5 | export const getStripe = () => {
6 | if (!stripePromise) {
7 | stripePromise = loadStripe(
8 | process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_LIVE ??
9 | process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ??
10 | ''
11 | );
12 | }
13 |
14 | return stripePromise;
15 | };
16 |
--------------------------------------------------------------------------------
/utils/stripe.ts:
--------------------------------------------------------------------------------
1 | import Stripe from 'stripe';
2 |
3 | export const stripe = new Stripe(
4 | process.env.STRIPE_SECRET_KEY_LIVE ?? process.env.STRIPE_SECRET_KEY ?? '',
5 | {
6 | // https://github.com/stripe/stripe-node#configuration
7 | apiVersion: '2022-11-15',
8 | // Register this as an official Stripe plugin.
9 | // https://stripe.com/docs/building-plugins#setappinfo
10 | appInfo: {
11 | name: 'Next.js Subscription Starter',
12 | version: '0.1.0'
13 | }
14 | }
15 | );
16 |
--------------------------------------------------------------------------------
/utils/supabase-admin.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from '@supabase/supabase-js';
2 | import Stripe from 'stripe';
3 |
4 | import { stripe } from './stripe';
5 | import { toDateTime } from './helpers';
6 |
7 | import { Customer, UserDetails, Price, Product } from 'types';
8 | import type { Database } from 'types_db';
9 |
10 | // Note: supabaseAdmin uses the SERVICE_ROLE_KEY which you must only use in a secure server-side context
11 | // as it has admin priviliges and overwrites RLS policies!
12 | const supabaseAdmin = createClient(
13 | process.env.NEXT_PUBLIC_SUPABASE_URL || '',
14 | process.env.SUPABASE_SERVICE_ROLE_KEY || ''
15 | );
16 |
17 | const upsertProductRecord = async (product: Stripe.Product) => {
18 | const productData: Product = {
19 | id: product.id,
20 | active: product.active,
21 | name: product.name,
22 | description: product.description ?? undefined,
23 | image: product.images?.[0] ?? null,
24 | metadata: product.metadata
25 | };
26 |
27 | const { error } = await supabaseAdmin.from('products').upsert([productData]);
28 | if (error) throw error;
29 | console.log(`Product inserted/updated: ${product.id}`);
30 | };
31 |
32 | const upsertPriceRecord = async (price: Stripe.Price) => {
33 | const priceData: Price = {
34 | id: price.id,
35 | product_id: typeof price.product === 'string' ? price.product : '',
36 | active: price.active,
37 | currency: price.currency,
38 | description: price.nickname ?? undefined,
39 | type: price.type,
40 | unit_amount: price.unit_amount ?? undefined,
41 | interval: price.recurring?.interval,
42 | interval_count: price.recurring?.interval_count,
43 | trial_period_days: price.recurring?.trial_period_days,
44 | metadata: price.metadata
45 | };
46 |
47 | const { error } = await supabaseAdmin.from('prices').upsert([priceData]);
48 | if (error) throw error;
49 | console.log(`Price inserted/updated: ${price.id}`);
50 | };
51 |
52 | const createOrRetrieveCustomer = async ({
53 | email,
54 | uuid
55 | }: {
56 | email: string;
57 | uuid: string;
58 | }) => {
59 | const { data, error } = await supabaseAdmin
60 | .from('customers')
61 | .select('stripe_customer_id')
62 | .eq('id', uuid)
63 | .single();
64 | if (error || !data?.stripe_customer_id) {
65 | // No customer record found, let's create one.
66 | const customerData: { metadata: { supabaseUUID: string }; email?: string } =
67 | {
68 | metadata: {
69 | supabaseUUID: uuid
70 | }
71 | };
72 | if (email) customerData.email = email;
73 | const customer = await stripe.customers.create(customerData);
74 | // Now insert the customer ID into our Supabase mapping table.
75 | const { error: supabaseError } = await supabaseAdmin
76 | .from('customers')
77 | .insert([{ id: uuid, stripe_customer_id: customer.id }]);
78 | if (supabaseError) throw supabaseError;
79 | console.log(`New customer created and inserted for ${uuid}.`);
80 | return customer.id;
81 | }
82 | return data.stripe_customer_id;
83 | };
84 |
85 | /**
86 | * Copies the billing details from the payment method to the customer object.
87 | */
88 | const copyBillingDetailsToCustomer = async (
89 | uuid: string,
90 | payment_method: Stripe.PaymentMethod
91 | ) => {
92 | //Todo: check this assertion
93 | const customer = payment_method.customer as string;
94 | const { name, phone, address } = payment_method.billing_details;
95 | if (!name || !phone || !address) return;
96 | //@ts-ignore
97 | await stripe.customers.update(customer, { name, phone, address });
98 | const { error } = await supabaseAdmin
99 | .from('users')
100 | .update({
101 | billing_address: { ...address },
102 | payment_method: { ...payment_method[payment_method.type] }
103 | })
104 | .eq('id', uuid);
105 | if (error) throw error;
106 | };
107 |
108 | const manageSubscriptionStatusChange = async (
109 | subscriptionId: string,
110 | customerId: string,
111 | createAction = false
112 | ) => {
113 | // Get customer's UUID from mapping table.
114 | const { data: customerData, error: noCustomerError } = await supabaseAdmin
115 | .from('customers')
116 | .select('id')
117 | .eq('stripe_customer_id', customerId)
118 | .single();
119 | if (noCustomerError) throw noCustomerError;
120 |
121 | const { id: uuid } = customerData!;
122 |
123 | const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
124 | expand: ['default_payment_method']
125 | });
126 | // Upsert the latest status of the subscription object.
127 | const subscriptionData: Database['public']['Tables']['subscriptions']['Insert'] =
128 | {
129 | id: subscription.id,
130 | user_id: uuid,
131 | metadata: subscription.metadata,
132 | status: subscription.status,
133 | price_id: subscription.items.data[0].price.id,
134 | //TODO check quantity on subscription
135 | // @ts-ignore
136 | quantity: subscription.quantity,
137 | cancel_at_period_end: subscription.cancel_at_period_end,
138 | cancel_at: subscription.cancel_at
139 | ? toDateTime(subscription.cancel_at).toISOString()
140 | : null,
141 | canceled_at: subscription.canceled_at
142 | ? toDateTime(subscription.canceled_at).toISOString()
143 | : null,
144 | current_period_start: toDateTime(
145 | subscription.current_period_start
146 | ).toISOString(),
147 | current_period_end: toDateTime(
148 | subscription.current_period_end
149 | ).toISOString(),
150 | created: toDateTime(subscription.created).toISOString(),
151 | ended_at: subscription.ended_at
152 | ? toDateTime(subscription.ended_at).toISOString()
153 | : null,
154 | trial_start: subscription.trial_start
155 | ? toDateTime(subscription.trial_start).toISOString()
156 | : null,
157 | trial_end: subscription.trial_end
158 | ? toDateTime(subscription.trial_end).toISOString()
159 | : null
160 | };
161 |
162 | const { error } = await supabaseAdmin
163 | .from('subscriptions')
164 | .upsert([subscriptionData]);
165 | if (error) throw error;
166 | console.log(
167 | `Inserted/updated subscription [${subscription.id}] for user [${uuid}]`
168 | );
169 |
170 | // For a new subscription copy the billing details to the customer object.
171 | // NOTE: This is a costly operation and should happen at the very end.
172 | if (createAction && subscription.default_payment_method && uuid)
173 | //@ts-ignore
174 | await copyBillingDetailsToCustomer(
175 | uuid,
176 | subscription.default_payment_method as Stripe.PaymentMethod
177 | );
178 | };
179 |
180 | export {
181 | upsertProductRecord,
182 | upsertPriceRecord,
183 | createOrRetrieveCustomer,
184 | manageSubscriptionStatusChange
185 | };
186 |
--------------------------------------------------------------------------------
/utils/supabase-client.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createBrowserSupabaseClient,
3 | User
4 | } from '@supabase/auth-helpers-nextjs';
5 |
6 | import { ProductWithPrice } from 'types';
7 | import type { Database } from 'types_db';
8 |
9 | export const supabase = createBrowserSupabaseClient();
10 |
11 | export const getActiveProductsWithPrices = async (): Promise<
12 | ProductWithPrice[]
13 | > => {
14 | const { data, error } = await supabase
15 | .from('products')
16 | .select('*, prices(*)')
17 | .eq('active', true)
18 | .eq('prices.active', true)
19 | .order('metadata->index')
20 | .order('unit_amount', { foreignTable: 'prices' });
21 |
22 | if (error) {
23 | console.log(error.message);
24 | }
25 | // TODO: improve the typing here.
26 | return (data as any) || [];
27 | };
28 |
29 | export const updateUserName = async (user: User, name: string) => {
30 | await supabase
31 | .from('users')
32 | .update({
33 | full_name: name
34 | })
35 | .eq('id', user.id);
36 | };
37 |
--------------------------------------------------------------------------------
/utils/useUser.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, createContext, useContext } from 'react';
2 | import {
3 | useUser as useSupaUser,
4 | useSessionContext,
5 | User
6 | } from '@supabase/auth-helpers-react';
7 |
8 | import { UserDetails, Subscription } from 'types';
9 |
10 | type UserContextType = {
11 | accessToken: string | null;
12 | user: User | null;
13 | userDetails: UserDetails | null;
14 | isLoading: boolean;
15 | subscription: Subscription | null;
16 | };
17 |
18 | export const UserContext = createContext(
19 | undefined
20 | );
21 |
22 | export interface Props {
23 | [propName: string]: any;
24 | }
25 |
26 | export const MyUserContextProvider = (props: Props) => {
27 | const {
28 | session,
29 | isLoading: isLoadingUser,
30 | supabaseClient: supabase
31 | } = useSessionContext();
32 | const user = useSupaUser();
33 | const accessToken = session?.access_token ?? null;
34 | const [isLoadingData, setIsloadingData] = useState(false);
35 | const [userDetails, setUserDetails] = useState(null);
36 | const [subscription, setSubscription] = useState(null);
37 |
38 | const getUserDetails = () => supabase.from('users').select('*').single();
39 | const getSubscription = () =>
40 | supabase
41 | .from('subscriptions')
42 | .select('*, prices(*, products(*))')
43 | .in('status', ['trialing', 'active'])
44 | .single();
45 |
46 | useEffect(() => {
47 | if (user && !isLoadingData && !userDetails && !subscription) {
48 | setIsloadingData(true);
49 | Promise.allSettled([getUserDetails(), getSubscription()]).then(
50 | (results) => {
51 | const userDetailsPromise = results[0];
52 | const subscriptionPromise = results[1];
53 |
54 | if (userDetailsPromise.status === 'fulfilled')
55 | setUserDetails(userDetailsPromise.value.data as UserDetails);
56 |
57 | if (subscriptionPromise.status === 'fulfilled')
58 | setSubscription(subscriptionPromise.value.data as Subscription);
59 |
60 | setIsloadingData(false);
61 | }
62 | );
63 | } else if (!user && !isLoadingUser && !isLoadingData) {
64 | setUserDetails(null);
65 | setSubscription(null);
66 | }
67 | }, [user, isLoadingUser]);
68 |
69 | const value = {
70 | accessToken,
71 | user,
72 | userDetails,
73 | isLoading: isLoadingUser || isLoadingData,
74 | subscription
75 | };
76 |
77 | return ;
78 | };
79 |
80 | export const useUser = () => {
81 | const context = useContext(UserContext);
82 | if (context === undefined) {
83 | throw new Error(`useUser must be used within a MyUserContextProvider.`);
84 | }
85 | return context;
86 | };
87 |
--------------------------------------------------------------------------------