Repository: vercel/nextjs-subscription-payments
Branch: main
Commit: bdd0813206e4
Files: 76
Total size: 144.6 KB
Directory structure:
gitextract_dsp2kb45/
├── .gitignore
├── .prettierignore
├── .prettierrc.json
├── LICENSE
├── README.md
├── app/
│ ├── account/
│ │ └── page.tsx
│ ├── api/
│ │ └── webhooks/
│ │ └── route.ts
│ ├── auth/
│ │ ├── callback/
│ │ │ └── route.ts
│ │ └── reset_password/
│ │ └── route.ts
│ ├── layout.tsx
│ ├── page.tsx
│ └── signin/
│ ├── [id]/
│ │ └── page.tsx
│ └── page.tsx
├── components/
│ ├── icons/
│ │ ├── GitHub.tsx
│ │ └── Logo.tsx
│ └── ui/
│ ├── AccountForms/
│ │ ├── CustomerPortalForm.tsx
│ │ ├── EmailForm.tsx
│ │ └── NameForm.tsx
│ ├── AuthForms/
│ │ ├── EmailSignIn.tsx
│ │ ├── ForgotPassword.tsx
│ │ ├── OauthSignIn.tsx
│ │ ├── PasswordSignIn.tsx
│ │ ├── Separator.tsx
│ │ ├── Signup.tsx
│ │ └── UpdatePassword.tsx
│ ├── Button/
│ │ ├── Button.module.css
│ │ ├── Button.tsx
│ │ └── index.ts
│ ├── Card/
│ │ ├── Card.tsx
│ │ └── index.ts
│ ├── Footer/
│ │ ├── Footer.tsx
│ │ └── index.ts
│ ├── Input/
│ │ ├── Input.module.css
│ │ ├── Input.tsx
│ │ └── index.ts
│ ├── LoadingDots/
│ │ ├── LoadingDots.module.css
│ │ ├── LoadingDots.tsx
│ │ └── index.ts
│ ├── LogoCloud/
│ │ ├── LogoCloud.tsx
│ │ └── index.ts
│ ├── Navbar/
│ │ ├── Navbar.module.css
│ │ ├── Navbar.tsx
│ │ ├── Navlinks.tsx
│ │ └── index.ts
│ ├── Pricing/
│ │ └── Pricing.tsx
│ └── Toasts/
│ ├── toast.tsx
│ ├── toaster.tsx
│ └── use-toast.ts
├── components.json
├── fixtures/
│ └── stripe-fixtures.json
├── middleware.ts
├── next-env.d.ts
├── package.json
├── postcss.config.js
├── schema.sql
├── styles/
│ └── main.css
├── supabase/
│ ├── .gitignore
│ ├── config.toml
│ ├── migrations/
│ │ └── 20230530034630_init.sql
│ └── seed.sql
├── tailwind.config.js
├── tsconfig.json
├── types_db.ts
└── utils/
├── auth-helpers/
│ ├── client.ts
│ ├── server.ts
│ └── settings.ts
├── cn.ts
├── helpers.ts
├── stripe/
│ ├── client.ts
│ ├── config.ts
│ └── server.ts
└── supabase/
├── admin.ts
├── client.ts
├── middleware.ts
├── queries.ts
└── server.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# editors
.vscode
# certificates
certificates
.env*.local
================================================
FILE: .prettierignore
================================================
# Build artifacts
.next/
.turbo/
_next/
__tmp__/
dist/
node_modules/
target/
compiled/
pnpm-lock.yaml
types_db.ts
================================================
FILE: .prettierrc.json
================================================
{
"arrowParens": "always",
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none"
}
================================================
FILE: LICENSE
================================================
The MIT License (MIT)
Copyright (c) 2024 Vercel, Inc.
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
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.
================================================
FILE: README.md
================================================
# Next.js Subscription Payments Starter
> [!WARNING]
> This repo has been sunset and replaced by a new template: https://github.com/nextjs/saas-starter
## Features
- Secure user management and authentication with [Supabase](https://supabase.io/docs/guides/auth)
- Powerful data access & management tooling on top of PostgreSQL with [Supabase](https://supabase.io/docs/guides/database)
- Integration with [Stripe Checkout](https://stripe.com/docs/payments/checkout) and the [Stripe customer portal](https://stripe.com/docs/billing/subscriptions/customer-portal)
- Automatic syncing of pricing plans and subscription statuses via [Stripe webhooks](https://stripe.com/docs/webhooks)
## Demo
- https://subscription-payments.vercel.app/
[](https://subscription-payments.vercel.app/)
## Architecture

## Step-by-step setup
When deploying this template, the sequence of steps is important. Follow the steps below in order to get up and running.
### Initiate Deployment
#### Vercel Deploy Button
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnextjs-subscription-payments&env=NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,STRIPE_SECRET_KEY&envDescription=Enter%20your%20Stripe%20API%20keys.&envLink=https%3A%2F%2Fdashboard.stripe.com%2Fapikeys&project-name=nextjs-subscription-payments&repository-name=nextjs-subscription-payments&integration-ids=oac_VqOgBHqhEoFTPzGkPd7L0iH6&external-id=https%3A%2F%2Fgithub.com%2Fvercel%2Fnextjs-subscription-payments%2Ftree%2Fmain)
The Vercel Deployment will create a new repository with this template on your GitHub account and guide you through a new Supabase project creation. The [Supabase Vercel Deploy Integration](https://vercel.com/integrations/supabase) will set up the necessary Supabase environment variables and run the [SQL migrations](./supabase/migrations/20230530034630_init.sql) to set up the Database schema on your account. You can inspect the created tables in your project's [Table editor](https://app.supabase.com/project/_/editor).
Should the automatic setup fail, please [create a Supabase account](https://app.supabase.com/projects), and a new project if needed. In your project, navigate to the [SQL editor](https://app.supabase.com/project/_/sql) and select the "Stripe Subscriptions" starter template from the Quick start section.
### Configure Auth
Follow [this guide](https://supabase.com/docs/guides/auth/social-login/auth-github) to set up an OAuth app with GitHub and configure Supabase to use it as an auth provider.
In your Supabase project, navigate to [auth > URL configuration](https://app.supabase.com/project/_/auth/url-configuration) and set your main production URL (e.g. https://your-deployment-url.vercel.app) as the site url.
Next, in your Vercel deployment settings, add a new **Production** environment variable called `NEXT_PUBLIC_SITE_URL` and set it to the same URL. Make sure to deselect preview and development environments to make sure that preview branches and local development work correctly.
#### [Optional] - Set up redirect wildcards for deploy previews (not needed if you installed via the Deploy Button)
If you've deployed this template via the "Deploy to Vercel" button above, you can skip this step. The Supabase Vercel Integration will have set redirect wildcards for you. You can check this by going to your Supabase [auth settings](https://app.supabase.com/project/_/auth/url-configuration) and you should see a list of redirects under "Redirect URLs".
Otherwise, for auth redirects (email confirmations, magic links, OAuth providers) to work correctly in deploy previews, navigate to the [auth settings](https://app.supabase.com/project/_/auth/url-configuration) and add the following wildcard URL to "Redirect URLs": `https://*-username.vercel.app/**`. You can read more about redirect wildcard patterns in the [docs](https://supabase.com/docs/guides/auth#redirect-urls-and-wildcards).
If you've deployed this template via the "Deploy to Vercel" button above, you can skip this step. The Supabase Vercel Integration will have run database migrations for you. You can check this by going to [the Table Editor for your Supabase project](https://supabase.com/dashboard/project/_/editor), and confirming there are tables with seed data.
Otherwise, navigate to the [SQL Editor](https://supabase.com/dashboard/project/_/sql/new), paste the contents of [the Supabase `schema.sql` file](./schema.sql), and click RUN to initialize the database.
#### [Maybe Optional] - Set up Supabase environment variables (not needed if you installed via the Deploy Button)
If you've deployed this template via the "Deploy to Vercel" button above, you can skip this step. The Supabase Vercel Integration will have set your environment variables for you. You can check this by going to your Vercel project settings, and clicking on 'Environment variables', there will be a list of environment variables with the Supabase icon displayed next to them.
Otherwise navigate to the [API settings](https://app.supabase.com/project/_/settings/api) and paste them into the Vercel deployment interface. Copy project API keys and paste into the `NEXT_PUBLIC_SUPABASE_ANON_KEY` and `SUPABASE_SERVICE_ROLE_KEY` fields, and copy the project URL and paste to Vercel as `NEXT_PUBLIC_SUPABASE_URL`.
Congrats, this completes the Supabase setup, almost there!
### Configure Stripe
Next, we'll need to configure [Stripe](https://stripe.com/) to handle test payments. If you don't already have a Stripe account, create one now.
For the following steps, make sure you have the ["Test Mode" toggle](https://stripe.com/docs/testing) switched on.
#### Create a Webhook
We need to create a webhook in the `Developers` section of Stripe. Pictured in the architecture diagram above, this webhook is the piece that connects Stripe to your Vercel Serverless Functions.
1. Click the "Add Endpoint" button on the [test Endpoints page](https://dashboard.stripe.com/test/webhooks).
1. Enter your production deployment URL followed by `/api/webhooks` for the endpoint URL. (e.g. `https://your-deployment-url.vercel.app/api/webhooks`)
1. Click `Select events` under the `Select events to listen to` heading.
1. Click `Select all events` in the `Select events to send` section.
1. Copy `Signing secret` as we'll need that in the next step (e.g `whsec_xxx`) (/!\ be careful not to copy the webook id we_xxxx).
1. In addition to the `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` and the `STRIPE_SECRET_KEY` we've set earlier during deployment, we need to add the webhook secret as `STRIPE_WEBHOOK_SECRET` env var.
#### Redeploy with new env vars
For the newly set environment variables to take effect and everything to work together correctly, we need to redeploy our app in Vercel. In your Vercel Dashboard, navigate to deployments, click the overflow menu button and select "Redeploy" (do NOT enable the "Use existing Build Cache" option). Once Vercel has rebuilt and redeployed your app, you're ready to set up your products and prices.
#### Create product and pricing information
Your application's webhook listens for product updates on Stripe and automatically propagates them to your Supabase database. So with your webhook listener running, you can now create your product and pricing information in the [Stripe Dashboard](https://dashboard.stripe.com/test/products).
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.
For example, you can create business models with different pricing tiers, e.g.:
- Product 1: Hobby
- Price 1: 10 USD per month
- Price 2: 100 USD per year
- Product 2: Freelancer
- Price 1: 20 USD per month
- Price 2: 200 USD per year
Optionally, 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. The [Stripe CLI](https://stripe.com/docs/stripe-cli#install) `fixtures` command executes a series of API requests defined in this JSON file. Simply run `stripe fixtures fixtures/stripe-fixtures.json`.
**Important:** Make sure that you've configured your Stripe webhook correctly and redeployed with all needed environment variables.
#### Configure the Stripe customer portal
1. Set your custom branding in the [settings](https://dashboard.stripe.com/settings/branding)
1. Configure the Customer Portal [settings](https://dashboard.stripe.com/test/settings/billing/portal)
1. Toggle on "Allow customers to update their payment methods"
1. Toggle on "Allow customers to update subscriptions"
1. Toggle on "Allow customers to cancel subscriptions"
1. Add the products and prices that you want
1. Set up the required business information and links
### That's it
I know, that was quite a lot to get through, but it's worth it. You're now ready to earn recurring revenue from your customers. 🥳
## Develop locally
If you haven't already done so, clone your Github repository to your local machine.
### Install dependencies
Ensure you have [pnpm](https://pnpm.io/installation) installed and run:
```bash
pnpm install
```
Next, use the [Vercel CLI](https://vercel.com/docs/cli) to link your project:
```bash
pnpm dlx vercel login
pnpm dlx vercel link
```
`pnpm dlx` runs a package from the registry, without installing it as a dependency. Alternatively, you can install these packages globally, and drop the `pnpm dlx` part.
If you don't intend to use a local Supabase instance for development and testing, you can use the Vercel CLI to download the development env vars:
```bash
pnpm dlx vercel env pull .env.local
```
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`). If you are not using a local Supabase instance, you should also change the `--local` flag to `--linked' or '--project-id <string>' in the `supabase:generate-types` script in `package.json`.(see -> [https://supabase.com/docs/reference/cli/supabase-gen-types-typescript])
### Local development with Supabase
It's highly recommended to use a local Supabase instance for development and testing. We have provided a set of custom commands for this in `package.json`.
First, you will need to install [Docker](https://www.docker.com/get-started/). You should also copy or rename:
- `.env.local.example` -> `.env.local`
- `.env.example` -> `.env`
Next, run the following command to start a local Supabase instance and run the migrations to set up the database schema:
```bash
pnpm supabase:start
```
The terminal output will provide you with URLs to access the different services within the Supabase stack. The Supabase Studio is where you can make changes to your local database instance.
Copy the value for the `service_role_key` and paste it as the value for the `SUPABASE_SERVICE_ROLE_KEY` in your `.env.local` file.
You can print out these URLs at any time with the following command:
```bash
pnpm supabase:status
```
To link your local Supabase instance to your project, run the following command, navigate to the Supabase project you created above, and enter your database password.
```bash
pnpm supabase:link
```
If you need to reset your database password, head over to [your database settings](https://supabase.com/dashboard/project/_/settings/database) and click "Reset database password", and this time copy it across to a password manager! 😄
🚧 Warning: This links our Local Development instance to the project we are using for `production`. Currently, it only has test records, but once it has customer data, we recommend using [Branching](https://supabase.com/docs/guides/platform/branching) or manually creating a separate `preview` or `staging` environment, to ensure your customer's data is not used locally, and schema changes/migrations can be thoroughly tested before shipping to `production`.
Once you've linked your project, you can pull down any schema changes you made in your remote database with:
```bash
pnpm supabase:pull
```
You can seed your local database with any data you added in your remote database with:
```bash
pnpm supabase:generate-seed
pnpm supabase:reset
```
🚧 Warning: this is seeding data from the `production` database. Currently, this only contains test data, but we recommend using [Branching](https://supabase.com/docs/guides/platform/branching) or manually setting up a `preview` or `staging` environment once this contains real customer data.
You can make changes to the database schema in your local Supabase Studio and run the following command to generate TypeScript types to match your schema:
```bash
pnpm supabase:generate-types
```
You can also automatically generate a migration file with all the changes you've made to your local database schema with the following command:
```bash
pnpm supabase:generate-migration
```
And push those changes to your remote database with:
```bash
pnpm supabase:push
```
Remember to test your changes thoroughly in your `local` and `staging` or `preview` environments before deploying them to `production`!
### Use the Stripe CLI to test webhooks
Use the [Stripe CLI](https://stripe.com/docs/stripe-cli) to [login to your Stripe account](https://stripe.com/docs/stripe-cli#login-account):
```bash
pnpm stripe:login
```
This will print a URL to navigate to in your browser and provide access to your Stripe account.
Next, start local webhook forwarding:
```bash
pnpm stripe:listen
```
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. If you haven't already, you should also set `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` and `STRIPE_SECRET_KEY` in your `.env.local` file using the **test mode**(!) keys from your Stripe dashboard.
### Run the Next.js client
In a separate terminal, run the following command to start the development server:
```bash
pnpm dev
```
Note that webhook forwarding and the development server must be running concurrently in two separate terminals for the application to work correctly.
Finally, navigate to [http://localhost:3000](http://localhost:3000) in your browser to see the application rendered.
## Going live
### Archive testing products
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.
### Configure production environment variables
To run the project in live mode and process payments with Stripe, switch Stripe from "test mode" to "production mode." Your Stripe API keys will be different in production mode, and you will have to create a separate production mode webhook. Copy these values and paste them into Vercel, replacing the test mode values.
### Redeploy
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" (do NOT enable the "Use existing Build Cache" option).
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.
================================================
FILE: app/account/page.tsx
================================================
import CustomerPortalForm from '@/components/ui/AccountForms/CustomerPortalForm';
import EmailForm from '@/components/ui/AccountForms/EmailForm';
import NameForm from '@/components/ui/AccountForms/NameForm';
import { redirect } from 'next/navigation';
import { createClient } from '@/utils/supabase/server';
import {
getUserDetails,
getSubscription,
getUser
} from '@/utils/supabase/queries';
export default async function Account() {
const supabase = createClient();
const [user, userDetails, subscription] = await Promise.all([
getUser(supabase),
getUserDetails(supabase),
getSubscription(supabase)
]);
if (!user) {
return redirect('/signin');
}
return (
<section className="mb-32 bg-black">
<div className="max-w-6xl px-4 py-8 mx-auto sm:px-6 sm:pt-24 lg:px-8">
<div className="sm:align-center sm:flex sm:flex-col">
<h1 className="text-4xl font-extrabold text-white sm:text-center sm:text-6xl">
Account
</h1>
<p className="max-w-2xl m-auto mt-5 text-xl text-zinc-200 sm:text-center sm:text-2xl">
We partnered with Stripe for a simplified billing.
</p>
</div>
</div>
<div className="p-4">
<CustomerPortalForm subscription={subscription} />
<NameForm userName={userDetails?.full_name ?? ''} />
<EmailForm userEmail={user.email} />
</div>
</section>
);
}
================================================
FILE: app/api/webhooks/route.ts
================================================
import Stripe from 'stripe';
import { stripe } from '@/utils/stripe/config';
import {
upsertProductRecord,
upsertPriceRecord,
manageSubscriptionStatusChange,
deleteProductRecord,
deletePriceRecord
} from '@/utils/supabase/admin';
const relevantEvents = new Set([
'product.created',
'product.updated',
'product.deleted',
'price.created',
'price.updated',
'price.deleted',
'checkout.session.completed',
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted'
]);
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get('stripe-signature') as string;
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event: Stripe.Event;
try {
if (!sig || !webhookSecret)
return new Response('Webhook secret not found.', { status: 400 });
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
console.log(`🔔 Webhook received: ${event.type}`);
} catch (err: any) {
console.log(`❌ Error message: ${err.message}`);
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
if (relevantEvents.has(event.type)) {
try {
switch (event.type) {
case 'product.created':
case 'product.updated':
await upsertProductRecord(event.data.object as Stripe.Product);
break;
case 'price.created':
case 'price.updated':
await upsertPriceRecord(event.data.object as Stripe.Price);
break;
case 'price.deleted':
await deletePriceRecord(event.data.object as Stripe.Price);
break;
case 'product.deleted':
await deleteProductRecord(event.data.object as Stripe.Product);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
const subscription = event.data.object as Stripe.Subscription;
await manageSubscriptionStatusChange(
subscription.id,
subscription.customer as string,
event.type === 'customer.subscription.created'
);
break;
case 'checkout.session.completed':
const checkoutSession = event.data.object as Stripe.Checkout.Session;
if (checkoutSession.mode === 'subscription') {
const subscriptionId = checkoutSession.subscription;
await manageSubscriptionStatusChange(
subscriptionId as string,
checkoutSession.customer as string,
true
);
}
break;
default:
throw new Error('Unhandled relevant event!');
}
} catch (error) {
console.log(error);
return new Response(
'Webhook handler failed. View your Next.js function logs.',
{
status: 400
}
);
}
} else {
return new Response(`Unsupported event type: ${event.type}`, {
status: 400
});
}
return new Response(JSON.stringify({ received: true }));
}
================================================
FILE: app/auth/callback/route.ts
================================================
import { createClient } from '@/utils/supabase/server';
import { NextResponse } from 'next/server';
import { NextRequest } from 'next/server';
import { getErrorRedirect, getStatusRedirect } from '@/utils/helpers';
export async function GET(request: NextRequest) {
// The `/auth/callback` route is required for the server-side auth flow implemented
// by the `@supabase/ssr` package. It exchanges an auth code for the user's session.
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get('code');
if (code) {
const supabase = createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) {
return NextResponse.redirect(
getErrorRedirect(
`${requestUrl.origin}/signin`,
error.name,
"Sorry, we weren't able to log you in. Please try again."
)
);
}
}
// URL to redirect to after sign in process completes
return NextResponse.redirect(
getStatusRedirect(
`${requestUrl.origin}/account`,
'Success!',
'You are now signed in.'
)
);
}
================================================
FILE: app/auth/reset_password/route.ts
================================================
import { createClient } from '@/utils/supabase/server';
import { NextResponse } from 'next/server';
import { NextRequest } from 'next/server';
import { getErrorRedirect, getStatusRedirect } from '@/utils/helpers';
export async function GET(request: NextRequest) {
// The `/auth/callback` route is required for the server-side auth flow implemented
// by the `@supabase/ssr` package. It exchanges an auth code for the user's session.
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get('code');
if (code) {
const supabase = createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) {
return NextResponse.redirect(
getErrorRedirect(
`${requestUrl.origin}/signin/forgot_password`,
error.name,
"Sorry, we weren't able to log you in. Please try again."
)
);
}
}
// URL to redirect to after sign in process completes
return NextResponse.redirect(
getStatusRedirect(
`${requestUrl.origin}/signin/update_password`,
'You are now signed in.',
'Please enter a new password for your account.'
)
);
}
================================================
FILE: app/layout.tsx
================================================
import { Metadata } from 'next';
import Footer from '@/components/ui/Footer';
import Navbar from '@/components/ui/Navbar';
import { Toaster } from '@/components/ui/Toasts/toaster';
import { PropsWithChildren, Suspense } from 'react';
import { getURL } from '@/utils/helpers';
import 'styles/main.css';
const title = 'Next.js Subscription Starter';
const description = 'Brought to you by Vercel, Stripe, and Supabase.';
export const metadata: Metadata = {
metadataBase: new URL(getURL()),
title: title,
description: description,
openGraph: {
title: title,
description: description
}
};
export default async function RootLayout({ children }: PropsWithChildren) {
return (
<html lang="en">
<body className="bg-black">
<Navbar />
<main
id="skip"
className="min-h-[calc(100dvh-4rem)] md:min-h[calc(100dvh-5rem)]"
>
{children}
</main>
<Footer />
<Suspense>
<Toaster />
</Suspense>
</body>
</html>
);
}
================================================
FILE: app/page.tsx
================================================
import Pricing from '@/components/ui/Pricing/Pricing';
import { createClient } from '@/utils/supabase/server';
import {
getProducts,
getSubscription,
getUser
} from '@/utils/supabase/queries';
export default async function PricingPage() {
const supabase = createClient();
const [user, products, subscription] = await Promise.all([
getUser(supabase),
getProducts(supabase),
getSubscription(supabase)
]);
return (
<Pricing
user={user}
products={products ?? []}
subscription={subscription}
/>
);
}
================================================
FILE: app/signin/[id]/page.tsx
================================================
import Logo from '@/components/icons/Logo';
import { createClient } from '@/utils/supabase/server';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import {
getAuthTypes,
getViewTypes,
getDefaultSignInView,
getRedirectMethod
} from '@/utils/auth-helpers/settings';
import Card from '@/components/ui/Card';
import PasswordSignIn from '@/components/ui/AuthForms/PasswordSignIn';
import EmailSignIn from '@/components/ui/AuthForms/EmailSignIn';
import Separator from '@/components/ui/AuthForms/Separator';
import OauthSignIn from '@/components/ui/AuthForms/OauthSignIn';
import ForgotPassword from '@/components/ui/AuthForms/ForgotPassword';
import UpdatePassword from '@/components/ui/AuthForms/UpdatePassword';
import SignUp from '@/components/ui/AuthForms/Signup';
export default async function SignIn({
params,
searchParams
}: {
params: { id: string };
searchParams: { disable_button: boolean };
}) {
const { allowOauth, allowEmail, allowPassword } = getAuthTypes();
const viewTypes = getViewTypes();
const redirectMethod = getRedirectMethod();
// Declare 'viewProp' and initialize with the default value
let viewProp: string;
// Assign url id to 'viewProp' if it's a valid string and ViewTypes includes it
if (typeof params.id === 'string' && viewTypes.includes(params.id)) {
viewProp = params.id;
} else {
const preferredSignInView =
cookies().get('preferredSignInView')?.value || null;
viewProp = getDefaultSignInView(preferredSignInView);
return redirect(`/signin/${viewProp}`);
}
// Check if the user is already logged in and redirect to the account page if so
const supabase = createClient();
const {
data: { user }
} = await supabase.auth.getUser();
if (user && viewProp !== 'update_password') {
return redirect('/');
} else if (!user && viewProp === 'update_password') {
return redirect('/signin');
}
return (
<div className="flex justify-center height-screen-helper">
<div className="flex flex-col justify-between max-w-lg p-3 m-auto w-80 ">
<div className="flex justify-center pb-12 ">
<Logo width="64px" height="64px" />
</div>
<Card
title={
viewProp === 'forgot_password'
? 'Reset Password'
: viewProp === 'update_password'
? 'Update Password'
: viewProp === 'signup'
? 'Sign Up'
: 'Sign In'
}
>
{viewProp === 'password_signin' && (
<PasswordSignIn
allowEmail={allowEmail}
redirectMethod={redirectMethod}
/>
)}
{viewProp === 'email_signin' && (
<EmailSignIn
allowPassword={allowPassword}
redirectMethod={redirectMethod}
disableButton={searchParams.disable_button}
/>
)}
{viewProp === 'forgot_password' && (
<ForgotPassword
allowEmail={allowEmail}
redirectMethod={redirectMethod}
disableButton={searchParams.disable_button}
/>
)}
{viewProp === 'update_password' && (
<UpdatePassword redirectMethod={redirectMethod} />
)}
{viewProp === 'signup' && (
<SignUp allowEmail={allowEmail} redirectMethod={redirectMethod} />
)}
{viewProp !== 'update_password' &&
viewProp !== 'signup' &&
allowOauth && (
<>
<Separator text="Third-party sign-in" />
<OauthSignIn />
</>
)}
</Card>
</div>
</div>
);
}
================================================
FILE: app/signin/page.tsx
================================================
import { redirect } from 'next/navigation';
import { getDefaultSignInView } from '@/utils/auth-helpers/settings';
import { cookies } from 'next/headers';
export default function SignIn() {
const preferredSignInView =
cookies().get('preferredSignInView')?.value || null;
const defaultView = getDefaultSignInView(preferredSignInView);
return redirect(`/signin/${defaultView}`);
}
================================================
FILE: components/icons/GitHub.tsx
================================================
const GitHub = ({ ...props }) => {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 0C5.37 0 0 5.50583 0 12.3035C0 17.7478 3.435 22.3463 8.205 23.9765C8.805 24.0842 9.03 23.715 9.03 23.3921C9.03 23.0999 9.015 22.131 9.015 21.1005C6 21.6696 5.22 20.347 4.98 19.6549C4.845 19.3012 4.26 18.2092 3.75 17.917C3.33 17.6863 2.73 17.1173 3.735 17.1019C4.68 17.0865 5.355 17.9939 5.58 18.363C6.66 20.2239 8.385 19.701 9.075 19.3781C9.18 18.5783 9.495 18.04 9.84 17.7325C7.17 17.4249 4.38 16.3637 4.38 11.6576C4.38 10.3196 4.845 9.21227 5.61 8.35102C5.49 8.04343 5.07 6.78232 5.73 5.09058C5.73 5.09058 6.735 4.76762 9.03 6.3517C9.99 6.07487 11.01 5.93645 12.03 5.93645C13.05 5.93645 14.07 6.07487 15.03 6.3517C17.325 4.75224 18.33 5.09058 18.33 5.09058C18.99 6.78232 18.57 8.04343 18.45 8.35102C19.215 9.21227 19.68 10.3042 19.68 11.6576C19.68 16.3791 16.875 17.4249 14.205 17.7325C14.64 18.1169 15.015 18.8552 15.015 20.0086C15.015 21.6542 15 22.9768 15 23.3921C15 23.715 15.225 24.0995 15.825 23.9765C18.2072 23.1519 20.2773 21.5822 21.7438 19.4882C23.2103 17.3942 23.9994 14.8814 24 12.3035C24 5.50583 18.63 0 12 0Z"
fill="currentColor"
/>
</svg>
);
};
export default GitHub;
================================================
FILE: components/icons/Logo.tsx
================================================
const Logo = ({ ...props }) => (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect width="100%" height="100%" rx="16" fill="white" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M17.6482 10.1305L15.8785 7.02583L7.02979 22.5499H10.5278L17.6482 10.1305ZM19.8798 14.0457L18.11 17.1983L19.394 19.4511H16.8453L15.1056 22.5499H24.7272L19.8798 14.0457Z"
fill="black"
/>
</svg>
);
export default Logo;
================================================
FILE: components/ui/AccountForms/CustomerPortalForm.tsx
================================================
'use client';
import Button from '@/components/ui/Button';
import { useRouter, usePathname } from 'next/navigation';
import { useState } from 'react';
import { createStripePortal } from '@/utils/stripe/server';
import Link from 'next/link';
import Card from '@/components/ui/Card';
import { Tables } from '@/types_db';
type Subscription = Tables<'subscriptions'>;
type Price = Tables<'prices'>;
type Product = Tables<'products'>;
type SubscriptionWithPriceAndProduct = Subscription & {
prices:
| (Price & {
products: Product | null;
})
| null;
};
interface Props {
subscription: SubscriptionWithPriceAndProduct | null;
}
export default function CustomerPortalForm({ subscription }: Props) {
const router = useRouter();
const currentPath = usePathname();
const [isSubmitting, setIsSubmitting] = useState(false);
const subscriptionPrice =
subscription &&
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: subscription?.prices?.currency!,
minimumFractionDigits: 0
}).format((subscription?.prices?.unit_amount || 0) / 100);
const handleStripePortalRequest = async () => {
setIsSubmitting(true);
const redirectUrl = await createStripePortal(currentPath);
setIsSubmitting(false);
return router.push(redirectUrl);
};
return (
<Card
title="Your Plan"
description={
subscription
? `You are currently on the ${subscription?.prices?.products?.name} plan.`
: 'You are not currently subscribed to any plan.'
}
footer={
<div className="flex flex-col items-start justify-between sm:flex-row sm:items-center">
<p className="pb-4 sm:pb-0">Manage your subscription on Stripe.</p>
<Button
variant="slim"
onClick={handleStripePortalRequest}
loading={isSubmitting}
>
Open customer portal
</Button>
</div>
}
>
<div className="mt-8 mb-4 text-xl font-semibold">
{subscription ? (
`${subscriptionPrice}/${subscription?.prices?.interval}`
) : (
<Link href="/">Choose your plan</Link>
)}
</div>
</Card>
);
}
================================================
FILE: components/ui/AccountForms/EmailForm.tsx
================================================
'use client';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { updateEmail } from '@/utils/auth-helpers/server';
import { handleRequest } from '@/utils/auth-helpers/client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
export default function EmailForm({
userEmail
}: {
userEmail: string | undefined;
}) {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
setIsSubmitting(true);
// Check if the new email is the same as the old email
if (e.currentTarget.newEmail.value === userEmail) {
e.preventDefault();
setIsSubmitting(false);
return;
}
handleRequest(e, updateEmail, router);
setIsSubmitting(false);
};
return (
<Card
title="Your Email"
description="Please enter the email address you want to use to login."
footer={
<div className="flex flex-col items-start justify-between sm:flex-row sm:items-center">
<p className="pb-4 sm:pb-0">
We will email you to verify the change.
</p>
<Button
variant="slim"
type="submit"
form="emailForm"
loading={isSubmitting}
>
Update Email
</Button>
</div>
}
>
<div className="mt-8 mb-4 text-xl font-semibold">
<form id="emailForm" onSubmit={(e) => handleSubmit(e)}>
<input
type="text"
name="newEmail"
className="w-1/2 p-3 rounded-md bg-zinc-800"
defaultValue={userEmail ?? ''}
placeholder="Your email"
maxLength={64}
/>
</form>
</div>
</Card>
);
}
================================================
FILE: components/ui/AccountForms/NameForm.tsx
================================================
'use client';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { updateName } from '@/utils/auth-helpers/server';
import { handleRequest } from '@/utils/auth-helpers/client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
export default function NameForm({ userName }: { userName: string }) {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
setIsSubmitting(true);
// Check if the new name is the same as the old name
if (e.currentTarget.fullName.value === userName) {
e.preventDefault();
setIsSubmitting(false);
return;
}
handleRequest(e, updateName, router);
setIsSubmitting(false);
};
return (
<Card
title="Your Name"
description="Please enter your full name, or a display name you are comfortable with."
footer={
<div className="flex flex-col items-start justify-between sm:flex-row sm:items-center">
<p className="pb-4 sm:pb-0">64 characters maximum</p>
<Button
variant="slim"
type="submit"
form="nameForm"
loading={isSubmitting}
>
Update Name
</Button>
</div>
}
>
<div className="mt-8 mb-4 text-xl font-semibold">
<form id="nameForm" onSubmit={(e) => handleSubmit(e)}>
<input
type="text"
name="fullName"
className="w-1/2 p-3 rounded-md bg-zinc-800"
defaultValue={userName}
placeholder="Your name"
maxLength={64}
/>
</form>
</div>
</Card>
);
}
================================================
FILE: components/ui/AuthForms/EmailSignIn.tsx
================================================
'use client';
import Button from '@/components/ui/Button';
import Link from 'next/link';
import { signInWithEmail } from '@/utils/auth-helpers/server';
import { handleRequest } from '@/utils/auth-helpers/client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
// Define prop type with allowPassword boolean
interface EmailSignInProps {
allowPassword: boolean;
redirectMethod: string;
disableButton?: boolean;
}
export default function EmailSignIn({
allowPassword,
redirectMethod,
disableButton
}: EmailSignInProps) {
const router = redirectMethod === 'client' ? useRouter() : null;
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
setIsSubmitting(true); // Disable the button while the request is being handled
await handleRequest(e, signInWithEmail, router);
setIsSubmitting(false);
};
return (
<div className="my-8">
<form
noValidate={true}
className="mb-4"
onSubmit={(e) => handleSubmit(e)}
>
<div className="grid gap-2">
<div className="grid gap-1">
<label htmlFor="email">Email</label>
<input
id="email"
placeholder="name@example.com"
type="email"
name="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
className="w-full p-3 rounded-md bg-zinc-800"
/>
</div>
<Button
variant="slim"
type="submit"
className="mt-1"
loading={isSubmitting}
disabled={disableButton}
>
Sign in
</Button>
</div>
</form>
{allowPassword && (
<>
<p>
<Link href="/signin/password_signin" className="font-light text-sm">
Sign in with email and password
</Link>
</p>
<p>
<Link href="/signin/signup" className="font-light text-sm">
Don't have an account? Sign up
</Link>
</p>
</>
)}
</div>
);
}
================================================
FILE: components/ui/AuthForms/ForgotPassword.tsx
================================================
'use client';
import Button from '@/components/ui/Button';
import Link from 'next/link';
import { requestPasswordUpdate } from '@/utils/auth-helpers/server';
import { handleRequest } from '@/utils/auth-helpers/client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
// Define prop type with allowEmail boolean
interface ForgotPasswordProps {
allowEmail: boolean;
redirectMethod: string;
disableButton?: boolean;
}
export default function ForgotPassword({
allowEmail,
redirectMethod,
disableButton
}: ForgotPasswordProps) {
const router = redirectMethod === 'client' ? useRouter() : null;
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
setIsSubmitting(true); // Disable the button while the request is being handled
await handleRequest(e, requestPasswordUpdate, router);
setIsSubmitting(false);
};
return (
<div className="my-8">
<form
noValidate={true}
className="mb-4"
onSubmit={(e) => handleSubmit(e)}
>
<div className="grid gap-2">
<div className="grid gap-1">
<label htmlFor="email">Email</label>
<input
id="email"
placeholder="name@example.com"
type="email"
name="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
className="w-full p-3 rounded-md bg-zinc-800"
/>
</div>
<Button
variant="slim"
type="submit"
className="mt-1"
loading={isSubmitting}
disabled={disableButton}
>
Send Email
</Button>
</div>
</form>
<p>
<Link href="/signin/password_signin" className="font-light text-sm">
Sign in with email and password
</Link>
</p>
{allowEmail && (
<p>
<Link href="/signin/email_signin" className="font-light text-sm">
Sign in via magic link
</Link>
</p>
)}
<p>
<Link href="/signin/signup" className="font-light text-sm">
Don't have an account? Sign up
</Link>
</p>
</div>
);
}
================================================
FILE: components/ui/AuthForms/OauthSignIn.tsx
================================================
'use client';
import Button from '@/components/ui/Button';
import { signInWithOAuth } from '@/utils/auth-helpers/client';
import { type Provider } from '@supabase/supabase-js';
import { Github } from 'lucide-react';
import { useState } from 'react';
type OAuthProviders = {
name: Provider;
displayName: string;
icon: JSX.Element;
};
export default function OauthSignIn() {
const oAuthProviders: OAuthProviders[] = [
{
name: 'github',
displayName: 'GitHub',
icon: <Github className="h-5 w-5" />
}
/* Add desired OAuth providers here */
];
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
setIsSubmitting(true); // Disable the button while the request is being handled
await signInWithOAuth(e);
setIsSubmitting(false);
};
return (
<div className="mt-8">
{oAuthProviders.map((provider) => (
<form
key={provider.name}
className="pb-2"
onSubmit={(e) => handleSubmit(e)}
>
<input type="hidden" name="provider" value={provider.name} />
<Button
variant="slim"
type="submit"
className="w-full"
loading={isSubmitting}
>
<span className="mr-2">{provider.icon}</span>
<span>{provider.displayName}</span>
</Button>
</form>
))}
</div>
);
}
================================================
FILE: components/ui/AuthForms/PasswordSignIn.tsx
================================================
'use client';
import Button from '@/components/ui/Button';
import Link from 'next/link';
import { signInWithPassword } from '@/utils/auth-helpers/server';
import { handleRequest } from '@/utils/auth-helpers/client';
import { useRouter } from 'next/navigation';
import React, { useState } from 'react';
// Define prop type with allowEmail boolean
interface PasswordSignInProps {
allowEmail: boolean;
redirectMethod: string;
}
export default function PasswordSignIn({
allowEmail,
redirectMethod
}: PasswordSignInProps) {
const router = redirectMethod === 'client' ? useRouter() : null;
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
setIsSubmitting(true); // Disable the button while the request is being handled
await handleRequest(e, signInWithPassword, router);
setIsSubmitting(false);
};
return (
<div className="my-8">
<form
noValidate={true}
className="mb-4"
onSubmit={(e) => handleSubmit(e)}
>
<div className="grid gap-2">
<div className="grid gap-1">
<label htmlFor="email">Email</label>
<input
id="email"
placeholder="name@example.com"
type="email"
name="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
className="w-full p-3 rounded-md bg-zinc-800"
/>
<label htmlFor="password">Password</label>
<input
id="password"
placeholder="Password"
type="password"
name="password"
autoComplete="current-password"
className="w-full p-3 rounded-md bg-zinc-800"
/>
</div>
<Button
variant="slim"
type="submit"
className="mt-1"
loading={isSubmitting}
>
Sign in
</Button>
</div>
</form>
<p>
<Link href="/signin/forgot_password" className="font-light text-sm">
Forgot your password?
</Link>
</p>
{allowEmail && (
<p>
<Link href="/signin/email_signin" className="font-light text-sm">
Sign in via magic link
</Link>
</p>
)}
<p>
<Link href="/signin/signup" className="font-light text-sm">
Don't have an account? Sign up
</Link>
</p>
</div>
);
}
================================================
FILE: components/ui/AuthForms/Separator.tsx
================================================
interface SeparatorProps {
text: string;
}
export default function Separator({ text }: SeparatorProps) {
return (
<div className="relative">
<div className="relative flex items-center py-1">
<div className="grow border-t border-zinc-700"></div>
<span className="mx-3 shrink text-sm leading-8 text-zinc-500">
{text}
</span>
<div className="grow border-t border-zinc-700"></div>
</div>
</div>
);
}
================================================
FILE: components/ui/AuthForms/Signup.tsx
================================================
'use client';
import Button from '@/components/ui/Button';
import React from 'react';
import Link from 'next/link';
import { signUp } from '@/utils/auth-helpers/server';
import { handleRequest } from '@/utils/auth-helpers/client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
// Define prop type with allowEmail boolean
interface SignUpProps {
allowEmail: boolean;
redirectMethod: string;
}
export default function SignUp({ allowEmail, redirectMethod }: SignUpProps) {
const router = redirectMethod === 'client' ? useRouter() : null;
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
setIsSubmitting(true); // Disable the button while the request is being handled
await handleRequest(e, signUp, router);
setIsSubmitting(false);
};
return (
<div className="my-8">
<form
noValidate={true}
className="mb-4"
onSubmit={(e) => handleSubmit(e)}
>
<div className="grid gap-2">
<div className="grid gap-1">
<label htmlFor="email">Email</label>
<input
id="email"
placeholder="name@example.com"
type="email"
name="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
className="w-full p-3 rounded-md bg-zinc-800"
/>
<label htmlFor="password">Password</label>
<input
id="password"
placeholder="Password"
type="password"
name="password"
autoComplete="current-password"
className="w-full p-3 rounded-md bg-zinc-800"
/>
</div>
<Button
variant="slim"
type="submit"
className="mt-1"
loading={isSubmitting}
>
Sign up
</Button>
</div>
</form>
<p>Already have an account?</p>
<p>
<Link href="/signin/password_signin" className="font-light text-sm">
Sign in with email and password
</Link>
</p>
{allowEmail && (
<p>
<Link href="/signin/email_signin" className="font-light text-sm">
Sign in via magic link
</Link>
</p>
)}
</div>
);
}
================================================
FILE: components/ui/AuthForms/UpdatePassword.tsx
================================================
'use client';
import Button from '@/components/ui/Button';
import { updatePassword } from '@/utils/auth-helpers/server';
import { handleRequest } from '@/utils/auth-helpers/client';
import { useRouter } from 'next/navigation';
import React, { useState } from 'react';
interface UpdatePasswordProps {
redirectMethod: string;
}
export default function UpdatePassword({
redirectMethod
}: UpdatePasswordProps) {
const router = redirectMethod === 'client' ? useRouter() : null;
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
setIsSubmitting(true); // Disable the button while the request is being handled
await handleRequest(e, updatePassword, router);
setIsSubmitting(false);
};
return (
<div className="my-8">
<form
noValidate={true}
className="mb-4"
onSubmit={(e) => handleSubmit(e)}
>
<div className="grid gap-2">
<div className="grid gap-1">
<label htmlFor="password">New Password</label>
<input
id="password"
placeholder="Password"
type="password"
name="password"
autoComplete="current-password"
className="w-full p-3 rounded-md bg-zinc-800"
/>
<label htmlFor="passwordConfirm">Confirm New Password</label>
<input
id="passwordConfirm"
placeholder="Password"
type="password"
name="passwordConfirm"
autoComplete="current-password"
className="w-full p-3 rounded-md bg-zinc-800"
/>
</div>
<Button
variant="slim"
type="submit"
className="mt-1"
loading={isSubmitting}
>
Update Password
</Button>
</div>
</form>
</div>
);
}
================================================
FILE: components/ui/Button/Button.module.css
================================================
.root {
@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;
}
.root:hover {
@apply bg-zinc-800 text-white border border-white;
}
.root:focus {
@apply outline-none ring-2 ring-pink-500 ring-opacity-50;
}
.root[data-active] {
@apply bg-zinc-600;
}
.loading {
@apply bg-zinc-700 text-zinc-500 border-zinc-600 cursor-not-allowed;
}
.slim {
@apply py-2 transform-none normal-case;
}
.disabled,
.disabled:hover {
@apply text-zinc-400 border-zinc-600 bg-zinc-700 cursor-not-allowed;
filter: grayscale(1);
-webkit-transform: translateZ(0);
-webkit-perspective: 1000;
-webkit-backface-visibility: hidden;
}
================================================
FILE: components/ui/Button/Button.tsx
================================================
'use client';
import cn from 'classnames';
import React, { forwardRef, useRef, ButtonHTMLAttributes } from 'react';
import { mergeRefs } from 'react-merge-refs';
import LoadingDots from '@/components/ui/LoadingDots';
import styles from './Button.module.css';
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'slim' | 'flat';
active?: boolean;
width?: number;
loading?: boolean;
Component?: React.ComponentType;
}
const Button = forwardRef<HTMLButtonElement, Props>((props, buttonRef) => {
const {
className,
variant = 'flat',
children,
active,
width,
loading = false,
disabled = false,
style = {},
Component = 'button',
...rest
} = props;
const ref = useRef(null);
const rootClassName = cn(
styles.root,
{
[styles.slim]: variant === 'slim',
[styles.loading]: loading,
[styles.disabled]: disabled
},
className
);
return (
<Component
aria-pressed={active}
data-variant={variant}
ref={mergeRefs([ref, buttonRef])}
className={rootClassName}
disabled={disabled}
style={{
width,
...style
}}
{...rest}
>
{children}
{loading && (
<i className="flex pl-2 m-0">
<LoadingDots />
</i>
)}
</Component>
);
});
Button.displayName = 'Button';
export default Button;
================================================
FILE: components/ui/Button/index.ts
================================================
export { default } from './Button';
================================================
FILE: components/ui/Card/Card.tsx
================================================
import { ReactNode } from 'react';
interface Props {
title: string;
description?: string;
footer?: ReactNode;
children: ReactNode;
}
export default function Card({ title, description, footer, children }: Props) {
return (
<div className="w-full max-w-3xl m-auto my-8 border rounded-md p border-zinc-700">
<div className="px-5 py-4">
<h3 className="mb-1 text-2xl font-medium">{title}</h3>
<p className="text-zinc-300">{description}</p>
{children}
</div>
{footer && (
<div className="p-4 border-t rounded-b-md border-zinc-700 bg-zinc-900 text-zinc-500">
{footer}
</div>
)}
</div>
);
}
================================================
FILE: components/ui/Card/index.ts
================================================
export { default } from './Card';
================================================
FILE: components/ui/Footer/Footer.tsx
================================================
import Link from 'next/link';
import Logo from '@/components/icons/Logo';
import GitHub from '@/components/icons/GitHub';
export default function Footer() {
return (
<footer className="mx-auto max-w-[1920px] px-6 bg-zinc-900">
<div className="grid grid-cols-1 gap-8 py-12 text-white transition-colors duration-150 border-b lg:grid-cols-12 border-zinc-600 bg-zinc-900">
<div className="col-span-1 lg:col-span-2">
<Link
href="/"
className="flex items-center flex-initial font-bold md:mr-24"
>
<span className="mr-2 border rounded-full border-zinc-700">
<Logo />
</span>
<span>ACME</span>
</Link>
</div>
<div className="col-span-1 lg:col-span-2">
<ul className="flex flex-col flex-initial md:flex-1">
<li className="py-3 md:py-0 md:pb-4">
<Link
href="/"
className="text-white transition duration-150 ease-in-out hover:text-zinc-200"
>
Home
</Link>
</li>
<li className="py-3 md:py-0 md:pb-4">
<Link
href="/"
className="text-white transition duration-150 ease-in-out hover:text-zinc-200"
>
About
</Link>
</li>
<li className="py-3 md:py-0 md:pb-4">
<Link
href="/"
className="text-white transition duration-150 ease-in-out hover:text-zinc-200"
>
Careers
</Link>
</li>
<li className="py-3 md:py-0 md:pb-4">
<Link
href="/"
className="text-white transition duration-150 ease-in-out hover:text-zinc-200"
>
Blog
</Link>
</li>
</ul>
</div>
<div className="col-span-1 lg:col-span-2">
<ul className="flex flex-col flex-initial md:flex-1">
<li className="py-3 md:py-0 md:pb-4">
<p className="font-bold text-white transition duration-150 ease-in-out hover:text-zinc-200">
LEGAL
</p>
</li>
<li className="py-3 md:py-0 md:pb-4">
<Link
href="/"
className="text-white transition duration-150 ease-in-out hover:text-zinc-200"
>
Privacy Policy
</Link>
</li>
<li className="py-3 md:py-0 md:pb-4">
<Link
href="/"
className="text-white transition duration-150 ease-in-out hover:text-zinc-200"
>
Terms of Use
</Link>
</li>
</ul>
</div>
<div className="flex items-start col-span-1 text-white lg:col-span-6 lg:justify-end">
<div className="flex items-center h-10 space-x-6">
<a
aria-label="Github Repository"
href="https://github.com/vercel/nextjs-subscription-payments"
>
<GitHub />
</a>
</div>
</div>
</div>
<div className="flex flex-col items-center justify-between py-12 space-y-4 md:flex-row bg-zinc-900">
<div>
<span>
© {new Date().getFullYear()} ACME, Inc. All rights reserved.
</span>
</div>
<div className="flex items-center">
<span className="text-white">Crafted by</span>
<a href="https://vercel.com" aria-label="Vercel.com Link">
<img
src="/vercel.svg"
alt="Vercel.com Logo"
className="inline-block h-6 ml-4 text-white"
/>
</a>
</div>
</div>
</footer>
);
}
================================================
FILE: components/ui/Footer/index.ts
================================================
export { default } from './Footer';
================================================
FILE: components/ui/Input/Input.module.css
================================================
.root {
@apply bg-black py-2 px-3 w-full appearance-none transition duration-150 ease-in-out border border-zinc-500 text-zinc-200;
}
.root:focus {
@apply outline-none;
}
================================================
FILE: components/ui/Input/Input.tsx
================================================
import React, { InputHTMLAttributes, ChangeEvent } from 'react';
import cn from 'classnames';
import s from './Input.module.css';
interface Props extends Omit<InputHTMLAttributes<any>, 'onChange'> {
className?: string;
onChange: (value: string) => void;
}
const Input = (props: Props) => {
const { className, children, onChange, ...rest } = props;
const rootClassName = cn(s.root, {}, className);
const handleOnChange = (e: ChangeEvent<HTMLInputElement>) => {
if (onChange) {
onChange(e.target.value);
}
return null;
};
return (
<label>
<input
className={rootClassName}
onChange={handleOnChange}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
{...rest}
/>
</label>
);
};
export default Input;
================================================
FILE: components/ui/Input/index.ts
================================================
export { default } from './Input';
================================================
FILE: components/ui/LoadingDots/LoadingDots.module.css
================================================
.root {
@apply inline-flex text-center items-center leading-7;
}
.root span {
@apply bg-zinc-200 rounded-full h-2 w-2;
animation-name: blink;
animation-duration: 1.4s;
animation-iteration-count: infinite;
animation-fill-mode: both;
margin: 0 2px;
}
.root span:nth-of-type(2) {
animation-delay: 0.2s;
}
.root span:nth-of-type(3) {
animation-delay: 0.4s;
}
@keyframes blink {
0% {
opacity: 0.2;
}
20% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}
================================================
FILE: components/ui/LoadingDots/LoadingDots.tsx
================================================
import s from './LoadingDots.module.css';
const LoadingDots = () => {
return (
<span className={s.root}>
<span />
<span />
<span />
</span>
);
};
export default LoadingDots;
================================================
FILE: components/ui/LoadingDots/index.ts
================================================
export { default } from './LoadingDots';
================================================
FILE: components/ui/LogoCloud/LogoCloud.tsx
================================================
export default function LogoCloud() {
return (
<div>
<p className="mt-24 text-xs uppercase text-zinc-400 text-center font-bold tracking-[0.3em]">
Brought to you by
</p>
<div className="grid grid-cols-1 place-items-center my-12 space-y-4 sm:mt-8 sm:space-y-0 md:mx-auto md:max-w-2xl sm:grid sm:gap-6 sm:grid-cols-5">
<div className="flex items-center justify-start h-12">
<a href="https://nextjs.org" aria-label="Next.js Link">
<img
src="/nextjs.svg"
alt="Next.js Logo"
className="h-6 sm:h-12 text-white"
/>
</a>
</div>
<div className="flex items-center justify-start h-12">
<a href="https://vercel.com" aria-label="Vercel.com Link">
<img
src="/vercel.svg"
alt="Vercel.com Logo"
className="h-6 text-white"
/>
</a>
</div>
<div className="flex items-center justify-start h-12">
<a href="https://stripe.com" aria-label="stripe.com Link">
<img
src="/stripe.svg"
alt="stripe.com Logo"
className="h-12 text-white"
/>
</a>
</div>
<div className="flex items-center justify-start h-12">
<a href="https://supabase.io" aria-label="supabase.io Link">
<img
src="/supabase.svg"
alt="supabase.io Logo"
className="h-10 text-white"
/>
</a>
</div>
<div className="flex items-center justify-start h-12">
<a href="https://github.com" aria-label="github.com Link">
<img
src="/github.svg"
alt="github.com Logo"
className="h-8 text-white"
/>
</a>
</div>
</div>
</div>
);
}
================================================
FILE: components/ui/LogoCloud/index.ts
================================================
export { default } from './LogoCloud';
================================================
FILE: components/ui/Navbar/Navbar.module.css
================================================
.root {
@apply sticky top-0 bg-black z-40 transition-all duration-150 h-16 md:h-20;
}
.link {
@apply inline-flex items-center leading-6 font-medium transition ease-in-out duration-75 cursor-pointer text-zinc-200 rounded-md p-1;
}
.link:hover {
@apply text-zinc-100;
}
.link:focus {
@apply outline-none text-zinc-100 ring-2 ring-pink-500 ring-opacity-50;
}
.logo {
@apply cursor-pointer rounded-full transform duration-100 ease-in-out;
}
================================================
FILE: components/ui/Navbar/Navbar.tsx
================================================
import { createClient } from '@/utils/supabase/server';
import s from './Navbar.module.css';
import Navlinks from './Navlinks';
export default async function Navbar() {
const supabase = createClient();
const {
data: { user }
} = await supabase.auth.getUser();
return (
<nav className={s.root}>
<a href="#skip" className="sr-only focus:not-sr-only">
Skip to content
</a>
<div className="max-w-6xl px-6 mx-auto">
<Navlinks user={user} />
</div>
</nav>
);
}
================================================
FILE: components/ui/Navbar/Navlinks.tsx
================================================
'use client';
import Link from 'next/link';
import { SignOut } from '@/utils/auth-helpers/server';
import { handleRequest } from '@/utils/auth-helpers/client';
import Logo from '@/components/icons/Logo';
import { usePathname, useRouter } from 'next/navigation';
import { getRedirectMethod } from '@/utils/auth-helpers/settings';
import s from './Navbar.module.css';
interface NavlinksProps {
user?: any;
}
export default function Navlinks({ user }: NavlinksProps) {
const router = getRedirectMethod() === 'client' ? useRouter() : null;
return (
<div className="relative flex flex-row justify-between py-4 align-center md:py-6">
<div className="flex items-center flex-1">
<Link href="/" className={s.logo} aria-label="Logo">
<Logo />
</Link>
<nav className="ml-6 space-x-2 lg:block">
<Link href="/" className={s.link}>
Pricing
</Link>
{user && (
<Link href="/account" className={s.link}>
Account
</Link>
)}
</nav>
</div>
<div className="flex justify-end space-x-8">
{user ? (
<form onSubmit={(e) => handleRequest(e, SignOut, router)}>
<input type="hidden" name="pathName" value={usePathname()} />
<button type="submit" className={s.link}>
Sign out
</button>
</form>
) : (
<Link href="/signin" className={s.link}>
Sign In
</Link>
)}
</div>
</div>
);
}
================================================
FILE: components/ui/Navbar/index.ts
================================================
export { default } from './Navbar';
================================================
FILE: components/ui/Pricing/Pricing.tsx
================================================
'use client';
import Button from '@/components/ui/Button';
import LogoCloud from '@/components/ui/LogoCloud';
import type { Tables } from '@/types_db';
import { getStripe } from '@/utils/stripe/client';
import { checkoutWithStripe } from '@/utils/stripe/server';
import { getErrorRedirect } from '@/utils/helpers';
import { User } from '@supabase/supabase-js';
import cn from 'classnames';
import { useRouter, usePathname } from 'next/navigation';
import { useState } from 'react';
type Subscription = Tables<'subscriptions'>;
type Product = Tables<'products'>;
type Price = Tables<'prices'>;
interface ProductWithPrices extends Product {
prices: Price[];
}
interface PriceWithProduct extends Price {
products: Product | null;
}
interface SubscriptionWithProduct extends Subscription {
prices: PriceWithProduct | null;
}
interface Props {
user: User | null | undefined;
products: ProductWithPrices[];
subscription: SubscriptionWithProduct | null;
}
type BillingInterval = 'lifetime' | 'year' | 'month';
export default function Pricing({ user, products, subscription }: Props) {
const intervals = Array.from(
new Set(
products.flatMap((product) =>
product?.prices?.map((price) => price?.interval)
)
)
);
const router = useRouter();
const [billingInterval, setBillingInterval] =
useState<BillingInterval>('month');
const [priceIdLoading, setPriceIdLoading] = useState<string>();
const currentPath = usePathname();
const handleStripeCheckout = async (price: Price) => {
setPriceIdLoading(price.id);
if (!user) {
setPriceIdLoading(undefined);
return router.push('/signin/signup');
}
const { errorRedirect, sessionId } = await checkoutWithStripe(
price,
currentPath
);
if (errorRedirect) {
setPriceIdLoading(undefined);
return router.push(errorRedirect);
}
if (!sessionId) {
setPriceIdLoading(undefined);
return router.push(
getErrorRedirect(
currentPath,
'An unknown error occurred.',
'Please try again later or contact a system administrator.'
)
);
}
const stripe = await getStripe();
stripe?.redirectToCheckout({ sessionId });
setPriceIdLoading(undefined);
};
if (!products.length) {
return (
<section className="bg-black">
<div className="max-w-6xl px-4 py-8 mx-auto sm:py-24 sm:px-6 lg:px-8">
<div className="sm:flex sm:flex-col sm:align-center"></div>
<p className="text-4xl font-extrabold text-white sm:text-center sm:text-6xl">
No subscription pricing plans found. Create them in your{' '}
<a
className="text-pink-500 underline"
href="https://dashboard.stripe.com/products"
rel="noopener noreferrer"
target="_blank"
>
Stripe Dashboard
</a>
.
</p>
</div>
<LogoCloud />
</section>
);
} else {
return (
<section className="bg-black">
<div className="max-w-6xl px-4 py-8 mx-auto sm:py-24 sm:px-6 lg:px-8">
<div className="sm:flex sm:flex-col sm:align-center">
<h1 className="text-4xl font-extrabold text-white sm:text-center sm:text-6xl">
Pricing Plans
</h1>
<p className="max-w-2xl m-auto mt-5 text-xl text-zinc-200 sm:text-center sm:text-2xl">
Start building for free, then add a site plan to go live. Account
plans unlock additional features.
</p>
<div className="relative self-center mt-6 bg-zinc-900 rounded-lg p-0.5 flex sm:mt-8 border border-zinc-800">
{intervals.includes('month') && (
<button
onClick={() => setBillingInterval('month')}
type="button"
className={`${
billingInterval === 'month'
? 'relative w-1/2 bg-zinc-700 border-zinc-800 shadow-sm text-white'
: 'ml-0.5 relative w-1/2 border border-transparent text-zinc-400'
} 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`}
>
Monthly billing
</button>
)}
{intervals.includes('year') && (
<button
onClick={() => setBillingInterval('year')}
type="button"
className={`${
billingInterval === 'year'
? 'relative w-1/2 bg-zinc-700 border-zinc-800 shadow-sm text-white'
: 'ml-0.5 relative w-1/2 border border-transparent text-zinc-400'
} 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`}
>
Yearly billing
</button>
)}
</div>
</div>
<div className="mt-12 space-y-0 sm:mt-16 flex flex-wrap justify-center gap-6 lg:max-w-4xl lg:mx-auto xl:max-w-none xl:mx-0">
{products.map((product) => {
const price = product?.prices?.find(
(price) => price.interval === billingInterval
);
if (!price) return null;
const priceString = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: price.currency!,
minimumFractionDigits: 0
}).format((price?.unit_amount || 0) / 100);
return (
<div
key={product.id}
className={cn(
'flex flex-col rounded-lg shadow-sm divide-y divide-zinc-600 bg-zinc-900',
{
'border border-pink-500': subscription
? product.name === subscription?.prices?.products?.name
: product.name === 'Freelancer'
},
'flex-1', // This makes the flex item grow to fill the space
'basis-1/3', // Assuming you want each card to take up roughly a third of the container's width
'max-w-xs' // Sets a maximum width to the cards to prevent them from getting too large
)}
>
<div className="p-6">
<h2 className="text-2xl font-semibold leading-6 text-white">
{product.name}
</h2>
<p className="mt-4 text-zinc-300">{product.description}</p>
<p className="mt-8">
<span className="text-5xl font-extrabold white">
{priceString}
</span>
<span className="text-base font-medium text-zinc-100">
/{billingInterval}
</span>
</p>
<Button
variant="slim"
type="button"
loading={priceIdLoading === price.id}
onClick={() => handleStripeCheckout(price)}
className="block w-full py-2 mt-8 text-sm font-semibold text-center text-white rounded-md hover:bg-zinc-900"
>
{subscription ? 'Manage' : 'Subscribe'}
</Button>
</div>
</div>
);
})}
</div>
<LogoCloud />
</div>
</section>
);
}
}
================================================
FILE: components/ui/Toasts/toast.tsx
================================================
import * as React from 'react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/utils/cn';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border border-zinc-200 p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-zinc-800',
{
variants: {
variant: {
default:
'border bg-white text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50',
destructive:
'destructive group border-red-500 bg-red-500 text-zinc-50 dark:border-red-900 dark:bg-red-900 dark:text-zinc-50'
}
},
defaultVariants: {
variant: 'default'
}
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-zinc-200 bg-transparent px-3 text-sm font-medium ring-offset-white transition-colors hover:bg-zinc-100 focus:outline-none focus:ring-2 focus:ring-zinc-950 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-zinc-100/40 group-[.destructive]:hover:border-red-500/30 group-[.destructive]:hover:bg-red-500 group-[.destructive]:hover:text-zinc-50 group-[.destructive]:focus:ring-red-500 dark:border-zinc-800 dark:ring-offset-zinc-950 dark:hover:bg-zinc-800 dark:focus:ring-zinc-300 dark:group-[.destructive]:border-zinc-800/40 dark:group-[.destructive]:hover:border-red-900/30 dark:group-[.destructive]:hover:bg-red-900 dark:group-[.destructive]:hover:text-zinc-50 dark:group-[.destructive]:focus:ring-red-900',
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-zinc-950/50 opacity-0 transition-opacity hover:text-zinc-950 focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:text-zinc-50/50 dark:hover:text-zinc-50',
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold', className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction
};
================================================
FILE: components/ui/Toasts/toaster.tsx
================================================
'use client';
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport
} from '@/components/ui/Toasts/toast';
import { useToast } from '@/components/ui/Toasts/use-toast';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
export function Toaster() {
const { toast, toasts } = useToast();
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
const status = searchParams.get('status');
const status_description = searchParams.get('status_description');
const error = searchParams.get('error');
const error_description = searchParams.get('error_description');
if (error || status) {
toast({
title: error
? error ?? 'Hmm... Something went wrong.'
: status ?? 'Alright!',
description: error ? error_description : status_description,
variant: error ? 'destructive' : undefined
});
// Clear any 'error', 'status', 'status_description', and 'error_description' search params
// so that the toast doesn't show up again on refresh, but leave any other search params
// intact.
const newSearchParams = new URLSearchParams(searchParams.toString());
const paramsToRemove = [
'error',
'status',
'status_description',
'error_description'
];
paramsToRemove.forEach((param) => newSearchParams.delete(param));
const redirectPath = `${pathname}?${newSearchParams.toString()}`;
router.replace(redirectPath, { scroll: false });
}
}, [searchParams]);
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}
================================================
FILE: components/ui/Toasts/use-toast.ts
================================================
// Inspired by react-hot-toast library
import * as React from 'react';
import type {
ToastActionElement,
ToastProps
} from '@/components/ui/Toasts/toast';
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST'
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>;
}
| {
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
}
| {
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT)
};
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
)
};
case 'DISMISS_TOAST': {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false
}
: t
)
};
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: []
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId)
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id }
});
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
}
}
});
return {
id: id,
dismiss,
update
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId })
};
}
export { useToast, toast };
================================================
FILE: components.json
================================================
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "styles/main.css",
"baseColor": "zinc",
"cssVariables": false
},
"aliases": {
"components": "@/components",
"utils": "@/utils/cn"
}
}
================================================
FILE: fixtures/stripe-fixtures.json
================================================
{
"_meta": {
"template_version": 0
},
"fixtures": [
{
"name": "prod_hobby",
"path": "/v1/products",
"method": "post",
"params": {
"name": "Hobby",
"description": "Hobby product description",
"metadata": {
"index": 0
}
}
},
{
"name": "price_hobby_month",
"path": "/v1/prices",
"method": "post",
"params": {
"product": "${prod_hobby:id}",
"currency": "usd",
"billing_scheme": "per_unit",
"unit_amount": 1000,
"recurring": {
"interval": "month",
"interval_count": 1
}
}
},
{
"name": "price_hobby_year",
"path": "/v1/prices",
"method": "post",
"params": {
"product": "${prod_hobby:id}",
"currency": "usd",
"billing_scheme": "per_unit",
"unit_amount": 10000,
"recurring": {
"interval": "year",
"interval_count": 1
}
}
},
{
"name": "prod_freelancer",
"path": "/v1/products",
"method": "post",
"params": {
"name": "Freelancer",
"description": "Freelancer product description",
"metadata": {
"index": 1
}
}
},
{
"name": "price_freelancer_month",
"path": "/v1/prices",
"method": "post",
"params": {
"product": "${prod_freelancer:id}",
"currency": "usd",
"billing_scheme": "per_unit",
"unit_amount": 2000,
"recurring": {
"interval": "month",
"interval_count": 1
}
}
},
{
"name": "price_freelancer_year",
"path": "/v1/prices",
"method": "post",
"params": {
"product": "${prod_freelancer:id}",
"currency": "usd",
"billing_scheme": "per_unit",
"unit_amount": 20000,
"recurring": {
"interval": "year",
"interval_count": 1
}
}
}
]
}
================================================
FILE: middleware.ts
================================================
import { type NextRequest } from 'next/server';
import { updateSession } from '@/utils/supabase/middleware';
export async function middleware(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: [
/*
* Match all request paths except:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - images - .svg, .png, .jpg, .jpeg, .gif, .webp
* Feel free to modify this pattern to include more paths.
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'
]
};
================================================
FILE: next-env.d.ts
================================================
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
================================================
FILE: package.json
================================================
{
"private": true,
"scripts": {
"dev": "next dev --turbo",
"build": "next build",
"start": "next start",
"lint": "next lint",
"prettier-fix": "prettier --write .",
"stripe:login": "stripe login",
"stripe:listen": "stripe listen --forward-to=localhost:3000/api/webhooks",
"stripe:fixtures": "stripe fixtures fixtures/stripe-fixtures.json",
"supabase:start": "npx supabase start",
"supabase:stop": "npx supabase stop",
"supabase:status": "npx supabase status",
"supabase:restart": "npm run supabase:stop && npm run supabase:start",
"supabase:reset": "npx supabase db reset",
"supabase:link": "npx supabase link",
"supabase:generate-types": "npx supabase gen types typescript --local --schema public > types_db.ts",
"supabase:generate-migration": "npx supabase db diff | npx supabase migration new",
"supabase:generate-seed": "npx supabase db dump --data-only -f supabase/seed.sql",
"supabase:push": "npx supabase db push",
"supabase:pull": "npx supabase db pull"
},
"dependencies": {
"@radix-ui/react-toast": "^1.1.5",
"@stripe/stripe-js": "2.4.0",
"@supabase/ssr": "^0.1.0",
"@supabase/supabase-js": "^2.43.4",
"class-variance-authority": "^0.7.0",
"classnames": "^2.5.1",
"clsx": "^2.1.1",
"lucide-react": "0.330.0",
"next": "14.2.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-merge-refs": "^2.1.1",
"stripe": "^14.25.0",
"tailwind-merge": "^2.3.0",
"tailwindcss": "^3.4.4",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^20.14.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-next": "14.1.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.34.2",
"eslint-plugin-tailwindcss": "^3.17.3",
"postcss": "^8.4.38",
"prettier": "^3.3.1",
"prettier-plugin-tailwindcss": "^0.5.14",
"supabase": "^1.172.2",
"typescript": "^5.4.5"
}
}
================================================
FILE: postcss.config.js
================================================
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
================================================
FILE: schema.sql
================================================
/**
* USERS
* Note: This table contains user data. Users should only be able to view and update their own data.
*/
create table users (
-- UUID from auth.users
id uuid references auth.users not null primary key,
full_name text,
avatar_url text,
-- The customer's billing address, stored in JSON format.
billing_address jsonb,
-- Stores your customer's payment instruments.
payment_method jsonb
);
alter table users enable row level security;
create policy "Can view own user data." on users for select using (auth.uid() = id);
create policy "Can update own user data." on users for update using (auth.uid() = id);
/**
* This trigger automatically creates a user entry when a new user signs up via Supabase Auth.
*/
create function public.handle_new_user()
returns trigger as $$
begin
insert into public.users (id, full_name, avatar_url)
values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
/**
* CUSTOMERS
* Note: this is a private table that contains a mapping of user IDs to Stripe customer IDs.
*/
create table customers (
-- UUID from auth.users
id uuid references auth.users not null primary key,
-- The user's customer ID in Stripe. User must not be able to update this.
stripe_customer_id text
);
alter table customers enable row level security;
-- No policies as this is a private table that the user must not have access to.
/**
* PRODUCTS
* Note: products are created and managed in Stripe and synced to our DB via Stripe webhooks.
*/
create table products (
-- Product ID from Stripe, e.g. prod_1234.
id text primary key,
-- Whether the product is currently available for purchase.
active boolean,
-- 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.
name text,
-- 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.
description text,
-- A URL of the product image in Stripe, meant to be displayable to the customer.
image text,
-- Set of key-value pairs, used to store additional information about the object in a structured format.
metadata jsonb
);
alter table products enable row level security;
create policy "Allow public read-only access." on products for select using (true);
/**
* PRICES
* Note: prices are created and managed in Stripe and synced to our DB via Stripe webhooks.
*/
create type pricing_type as enum ('one_time', 'recurring');
create type pricing_plan_interval as enum ('day', 'week', 'month', 'year');
create table prices (
-- Price ID from Stripe, e.g. price_1234.
id text primary key,
-- The ID of the prduct that this price belongs to.
product_id text references products,
-- Whether the price can be used for new purchases.
active boolean,
-- A brief description of the price.
description text,
-- 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).
unit_amount bigint,
-- Three-letter ISO currency code, in lowercase.
currency text check (char_length(currency) = 3),
-- One of `one_time` or `recurring` depending on whether the price is for a one-time purchase or a recurring (subscription) purchase.
type pricing_type,
-- The frequency at which a subscription is billed. One of `day`, `week`, `month` or `year`.
interval pricing_plan_interval,
-- The number of intervals (specified in the `interval` attribute) between subscription billings. For example, `interval=month` and `interval_count=3` bills every 3 months.
interval_count integer,
-- 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).
trial_period_days integer,
-- Set of key-value pairs, used to store additional information about the object in a structured format.
metadata jsonb
);
alter table prices enable row level security;
create policy "Allow public read-only access." on prices for select using (true);
/**
* SUBSCRIPTIONS
* Note: subscriptions are created and managed in Stripe and synced to our DB via Stripe webhooks.
*/
create type subscription_status as enum ('trialing', 'active', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'unpaid', 'paused');
create table subscriptions (
-- Subscription ID from Stripe, e.g. sub_1234.
id text primary key,
user_id uuid references auth.users not null,
-- The status of the subscription object, one of subscription_status type above.
status subscription_status,
-- Set of key-value pairs, used to store additional information about the object in a structured format.
metadata jsonb,
-- ID of the price that created this subscription.
price_id text references prices,
-- Quantity multiplied by the unit amount of the price creates the amount of the subscription. Can be used to charge multiple seats.
quantity integer,
-- If true the subscription has been canceled by the user and will be deleted at the end of the billing period.
cancel_at_period_end boolean,
-- Time at which the subscription was created.
created timestamp with time zone default timezone('utc'::text, now()) not null,
-- Start of the current period that the subscription has been invoiced for.
current_period_start timestamp with time zone default timezone('utc'::text, now()) not null,
-- End of the current period that the subscription has been invoiced for. At the end of this period, a new invoice will be created.
current_period_end timestamp with time zone default timezone('utc'::text, now()) not null,
-- If the subscription has ended, the timestamp of the date the subscription ended.
ended_at timestamp with time zone default timezone('utc'::text, now()),
-- A date in the future at which the subscription will automatically get canceled.
cancel_at timestamp with time zone default timezone('utc'::text, now()),
-- 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.
canceled_at timestamp with time zone default timezone('utc'::text, now()),
-- If the subscription has a trial, the beginning of that trial.
trial_start timestamp with time zone default timezone('utc'::text, now()),
-- If the subscription has a trial, the end of that trial.
trial_end timestamp with time zone default timezone('utc'::text, now())
);
alter table subscriptions enable row level security;
create policy "Can only view own subs data." on subscriptions for select using (auth.uid() = user_id);
/**
* REALTIME SUBSCRIPTIONS
* Only allow realtime listening on public tables.
*/
drop publication if exists supabase_realtime;
create publication supabase_realtime for table products, prices;
================================================
FILE: styles/main.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
*,
*:before,
*:after {
box-sizing: inherit;
}
*:focus:not(ol) {
@apply outline-none ring-2 ring-pink-500 ring-opacity-50;
}
html {
height: 100%;
box-sizing: border-box;
touch-action: manipulation;
font-feature-settings:
'case' 1,
'rlig' 1,
'calt' 0;
}
html,
body {
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Helvetica Neue',
'Helvetica', sans-serif;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
@apply text-white bg-zinc-800 antialiased;
}
body {
position: relative;
min-height: 100%;
margin: 0;
}
a {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
p a {
@apply hover:underline;
}
.animated {
-webkit-animation-duration: 1s;
animation-duration: 1s;
-webkit-animation-duration: 1s;
animation-duration: 1s;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
}
.height-screen-helper {
min-height: calc(100vh - 80px);
}
================================================
FILE: supabase/.gitignore
================================================
# Supabase
.branches
.temp
.env
================================================
FILE: supabase/config.toml
================================================
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "nextjs-subscription-payments"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. public and storage are always included.
schemas = ["public", "storage", "graphql_public"]
# Extra schemas to add to the search_path of every request. public is always included.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 15
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv6)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://localhost:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = true
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }} ."
# Use pre-defined map of phone number to OTP for testing.
[auth.sms.test_otp]
# 4152127777 = "123456"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
[auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.github]
enabled = true
client_id = "env(SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID)"
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET)"
# Overrides the default auth redirectUrl.
redirect_uri = "env(SUPABASE_AUTH_EXTERNAL_GITHUB_REDIRECT_URI)"
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
[analytics]
enabled = false
port = 54327
vector_port = 54328
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
================================================
FILE: supabase/migrations/20230530034630_init.sql
================================================
/**
* USERS
* Note: This table contains user data. Users should only be able to view and update their own data.
*/
create table users (
-- UUID from auth.users
id uuid references auth.users not null primary key,
full_name text,
avatar_url text,
-- The customer's billing address, stored in JSON format.
billing_address jsonb,
-- Stores your customer's payment instruments.
payment_method jsonb
);
alter table users enable row level security;
create policy "Can view own user data." on users for select using (auth.uid() = id);
create policy "Can update own user data." on users for update using (auth.uid() = id);
/**
* This trigger automatically creates a user entry when a new user signs up via Supabase Auth.
*/
create function public.handle_new_user()
returns trigger as $$
begin
insert into public.users (id, full_name, avatar_url)
values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
/**
* CUSTOMERS
* Note: this is a private table that contains a mapping of user IDs to Stripe customer IDs.
*/
create table customers (
-- UUID from auth.users
id uuid references auth.users not null primary key,
-- The user's customer ID in Stripe. User must not be able to update this.
stripe_customer_id text
);
alter table customers enable row level security;
-- No policies as this is a private table that the user must not have access to.
/**
* PRODUCTS
* Note: products are created and managed in Stripe and synced to our DB via Stripe webhooks.
*/
create table products (
-- Product ID from Stripe, e.g. prod_1234.
id text primary key,
-- Whether the product is currently available for purchase.
active boolean,
-- 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.
name text,
-- 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.
description text,
-- A URL of the product image in Stripe, meant to be displayable to the customer.
image text,
-- Set of key-value pairs, used to store additional information about the object in a structured format.
metadata jsonb
);
alter table products enable row level security;
create policy "Allow public read-only access." on products for select using (true);
/**
* PRICES
* Note: prices are created and managed in Stripe and synced to our DB via Stripe webhooks.
*/
create type pricing_type as enum ('one_time', 'recurring');
create type pricing_plan_interval as enum ('day', 'week', 'month', 'year');
create table prices (
-- Price ID from Stripe, e.g. price_1234.
id text primary key,
-- The ID of the prduct that this price belongs to.
product_id text references products,
-- Whether the price can be used for new purchases.
active boolean,
-- A brief description of the price.
description text,
-- 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).
unit_amount bigint,
-- Three-letter ISO currency code, in lowercase.
currency text check (char_length(currency) = 3),
-- One of `one_time` or `recurring` depending on whether the price is for a one-time purchase or a recurring (subscription) purchase.
type pricing_type,
-- The frequency at which a subscription is billed. One of `day`, `week`, `month` or `year`.
interval pricing_plan_interval,
-- The number of intervals (specified in the `interval` attribute) between subscription billings. For example, `interval=month` and `interval_count=3` bills every 3 months.
interval_count integer,
-- 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).
trial_period_days integer,
-- Set of key-value pairs, used to store additional information about the object in a structured format.
metadata jsonb
);
alter table prices enable row level security;
create policy "Allow public read-only access." on prices for select using (true);
/**
* SUBSCRIPTIONS
* Note: subscriptions are created and managed in Stripe and synced to our DB via Stripe webhooks.
*/
create type subscription_status as enum ('trialing', 'active', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'unpaid', 'paused');
create table subscriptions (
-- Subscription ID from Stripe, e.g. sub_1234.
id text primary key,
user_id uuid references auth.users not null,
-- The status of the subscription object, one of subscription_status type above.
status subscription_status,
-- Set of key-value pairs, used to store additional information about the object in a structured format.
metadata jsonb,
-- ID of the price that created this subscription.
price_id text references prices,
-- Quantity multiplied by the unit amount of the price creates the amount of the subscription. Can be used to charge multiple seats.
quantity integer,
-- If true the subscription has been canceled by the user and will be deleted at the end of the billing period.
cancel_at_period_end boolean,
-- Time at which the subscription was created.
created timestamp with time zone default timezone('utc'::text, now()) not null,
-- Start of the current period that the subscription has been invoiced for.
current_period_start timestamp with time zone default timezone('utc'::text, now()) not null,
-- End of the current period that the subscription has been invoiced for. At the end of this period, a new invoice will be created.
current_period_end timestamp with time zone default timezone('utc'::text, now()) not null,
-- If the subscription has ended, the timestamp of the date the subscription ended.
ended_at timestamp with time zone default timezone('utc'::text, now()),
-- A date in the future at which the subscription will automatically get canceled.
cancel_at timestamp with time zone default timezone('utc'::text, now()),
-- 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.
canceled_at timestamp with time zone default timezone('utc'::text, now()),
-- If the subscription has a trial, the beginning of that trial.
trial_start timestamp with time zone default timezone('utc'::text, now()),
-- If the subscription has a trial, the end of that trial.
trial_end timestamp with time zone default timezone('utc'::text, now())
);
alter table subscriptions enable row level security;
create policy "Can only view own subs data." on subscriptions for select using (auth.uid() = user_id);
/**
* REALTIME SUBSCRIPTIONS
* Only allow realtime listening on public tables.
*/
drop publication if exists supabase_realtime;
create publication supabase_realtime for table products, prices;
================================================
FILE: supabase/seed.sql
================================================
================================================
FILE: tailwind.config.js
================================================
const { fontFamily } = require('tailwindcss/defaultTheme');
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class', '[data-theme="dark"]'],
content: [
'app/**/*.{ts,tsx}',
'components/**/*.{ts,tsx}',
'pages/**/*.{ts,tsx}'
],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px'
}
},
extend: {
fontFamily: {
sans: ['var(--font-sans)', ...fontFamily.sans]
},
keyframes: {
'accordion-down': {
from: { height: 0 },
to: { height: 'var(--radix-accordion-content-height)' }
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 }
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
}
}
},
plugins: [require('tailwindcss-animate')]
};
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
"incremental": true,
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
================================================
FILE: utils/auth-helpers/client.ts
================================================
'use client';
import { createClient } from '@/utils/supabase/client';
import { type Provider } from '@supabase/supabase-js';
import { getURL } from '@/utils/helpers';
import { redirectToPath } from './server';
import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime';
export async function handleRequest(
e: React.FormEvent<HTMLFormElement>,
requestFunc: (formData: FormData) => Promise<string>,
router: AppRouterInstance | null = null
): Promise<boolean | void> {
// Prevent default form submission refresh
e.preventDefault();
const formData = new FormData(e.currentTarget);
const redirectUrl: string = await requestFunc(formData);
if (router) {
// If client-side router is provided, use it to redirect
return router.push(redirectUrl);
} else {
// Otherwise, redirect server-side
return await redirectToPath(redirectUrl);
}
}
export async function signInWithOAuth(e: React.FormEvent<HTMLFormElement>) {
// Prevent default form submission refresh
e.preventDefault();
const formData = new FormData(e.currentTarget);
const provider = String(formData.get('provider')).trim() as Provider;
// Create client-side supabase client and call signInWithOAuth
const supabase = createClient();
const redirectURL = getURL('/auth/callback');
await supabase.auth.signInWithOAuth({
provider: provider,
options: {
redirectTo: redirectURL
}
});
}
================================================
FILE: utils/auth-helpers/server.ts
================================================
'use server';
import { createClient } from '@/utils/supabase/server';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { getURL, getErrorRedirect, getStatusRedirect } from 'utils/helpers';
import { getAuthTypes } from 'utils/auth-helpers/settings';
function isValidEmail(email: string) {
var regex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/;
return regex.test(email);
}
export async function redirectToPath(path: string) {
return redirect(path);
}
export async function SignOut(formData: FormData) {
const pathName = String(formData.get('pathName')).trim();
const supabase = createClient();
const { error } = await supabase.auth.signOut();
if (error) {
return getErrorRedirect(
pathName,
'Hmm... Something went wrong.',
'You could not be signed out.'
);
}
return '/signin';
}
export async function signInWithEmail(formData: FormData) {
const cookieStore = cookies();
const callbackURL = getURL('/auth/callback');
const email = String(formData.get('email')).trim();
let redirectPath: string;
if (!isValidEmail(email)) {
redirectPath = getErrorRedirect(
'/signin/email_signin',
'Invalid email address.',
'Please try again.'
);
}
const supabase = createClient();
let options = {
emailRedirectTo: callbackURL,
shouldCreateUser: true
};
// If allowPassword is false, do not create a new user
const { allowPassword } = getAuthTypes();
if (allowPassword) options.shouldCreateUser = false;
const { data, error } = await supabase.auth.signInWithOtp({
email,
options: options
});
if (error) {
redirectPath = getErrorRedirect(
'/signin/email_signin',
'You could not be signed in.',
error.message
);
} else if (data) {
cookieStore.set('preferredSignInView', 'email_signin', { path: '/' });
redirectPath = getStatusRedirect(
'/signin/email_signin',
'Success!',
'Please check your email for a magic link. You may now close this tab.',
true
);
} else {
redirectPath = getErrorRedirect(
'/signin/email_signin',
'Hmm... Something went wrong.',
'You could not be signed in.'
);
}
return redirectPath;
}
export async function requestPasswordUpdate(formData: FormData) {
const callbackURL = getURL('/auth/reset_password');
// Get form data
const email = String(formData.get('email')).trim();
let redirectPath: string;
if (!isValidEmail(email)) {
redirectPath = getErrorRedirect(
'/signin/forgot_password',
'Invalid email address.',
'Please try again.'
);
}
const supabase = createClient();
const { data, error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: callbackURL
});
if (error) {
redirectPath = getErrorRedirect(
'/signin/forgot_password',
error.message,
'Please try again.'
);
} else if (data) {
redirectPath = getStatusRedirect(
'/signin/forgot_password',
'Success!',
'Please check your email for a password reset link. You may now close this tab.',
true
);
} else {
redirectPath = getErrorRedirect(
'/signin/forgot_password',
'Hmm... Something went wrong.',
'Password reset email could not be sent.'
);
}
return redirectPath;
}
export async function signInWithPassword(formData: FormData) {
const cookieStore = cookies();
const email = String(formData.get('email')).trim();
const password = String(formData.get('password')).trim();
let redirectPath: string;
const supabase = createClient();
const { error, data } = await supabase.auth.signInWithPassword({
email,
password
});
if (error) {
redirectPath = getErrorRedirect(
'/signin/password_signin',
'Sign in failed.',
error.message
);
} else if (data.user) {
cookieStore.set('preferredSignInView', 'password_signin', { path: '/' });
redirectPath = getStatusRedirect('/', 'Success!', 'You are now signed in.');
} else {
redirectPath = getErrorRedirect(
'/signin/password_signin',
'Hmm... Something went wrong.',
'You could not be signed in.'
);
}
return redirectPath;
}
export async function signUp(formData: FormData) {
const callbackURL = getURL('/auth/callback');
const email = String(formData.get('email')).trim();
const password = String(formData.get('password')).trim();
let redirectPath: string;
if (!isValidEmail(email)) {
redirectPath = getErrorRedirect(
'/signin/signup',
'Invalid email address.',
'Please try again.'
);
}
const supabase = createClient();
const { error, data } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: callbackURL
}
});
if (error) {
redirectPath = getErrorRedirect(
'/signin/signup',
'Sign up failed.',
error.message
);
} else if (data.session) {
redirectPath = getStatusRedirect('/', 'Success!', 'You are now signed in.');
} else if (
data.user &&
data.user.identities &&
data.user.identities.length == 0
) {
redirectPath = getErrorRedirect(
'/signin/signup',
'Sign up failed.',
'There is already an account associated with this email address. Try resetting your password.'
);
} else if (data.user) {
redirectPath = getStatusRedirect(
'/',
'Success!',
'Please check your email for a confirmation link. You may now close this tab.'
);
} else {
redirectPath = getErrorRedirect(
'/signin/signup',
'Hmm... Something went wrong.',
'You could not be signed up.'
);
}
return redirectPath;
}
export async function updatePassword(formData: FormData) {
const password = String(formData.get('password')).trim();
const passwordConfirm = String(formData.get('passwordConfirm')).trim();
let redirectPath: string;
// Check that the password and confirmation match
if (password !== passwordConfirm) {
redirectPath = getErrorRedirect(
'/signin/update_password',
'Your password could not be updated.',
'Passwords do not match.'
);
}
const supabase = createClient();
const { error, data } = await supabase.auth.updateUser({
password
});
if (error) {
redirectPath = getErrorRedirect(
'/signin/update_password',
'Your password could not be updated.',
error.message
);
} else if (data.user) {
redirectPath = getStatusRedirect(
'/',
'Success!',
'Your password has been updated.'
);
} else {
redirectPath = getErrorRedirect(
'/signin/update_password',
'Hmm... Something went wrong.',
'Your password could not be updated.'
);
}
return redirectPath;
}
export async function updateEmail(formData: FormData) {
// Get form data
const newEmail = String(formData.get('newEmail')).trim();
// Check that the email is valid
if (!isValidEmail(newEmail)) {
return getErrorRedirect(
'/account',
'Your email could not be updated.',
'Invalid email address.'
);
}
const supabase = createClient();
const callbackUrl = getURL(
getStatusRedirect('/account', 'Success!', `Your email has been updated.`)
);
const { error } = await supabase.auth.updateUser(
{ email: newEmail },
{
emailRedirectTo: callbackUrl
}
);
if (error) {
return getErrorRedirect(
'/account',
'Your email could not be updated.',
error.message
);
} else {
return getStatusRedirect(
'/account',
'Confirmation emails sent.',
`You will need to confirm the update by clicking the links sent to both the old and new email addresses.`
);
}
}
export async function updateName(formData: FormData) {
// Get form data
const fullName = String(formData.get('fullName')).trim();
const supabase = createClient();
const { error, data } = await supabase.auth.updateUser({
data: { full_name: fullName }
});
if (error) {
return getErrorRedirect(
'/account',
'Your name could not be updated.',
error.message
);
} else if (data.user) {
return getStatusRedirect(
'/account',
'Success!',
'Your name has been updated.'
);
} else {
return getErrorRedirect(
'/account',
'Hmm... Something went wrong.',
'Your name could not be updated.'
);
}
}
================================================
FILE: utils/auth-helpers/settings.ts
================================================
// Boolean toggles to determine which auth types are allowed
const allowOauth = true;
const allowEmail = true;
const allowPassword = true;
// Boolean toggle to determine whether auth interface should route through server or client
// (Currently set to false because screen sometimes flickers with server redirects)
const allowServerRedirect = false;
// Check that at least one of allowPassword and allowEmail is true
if (!allowPassword && !allowEmail)
throw new Error('At least one of allowPassword and allowEmail must be true');
export const getAuthTypes = () => {
return { allowOauth, allowEmail, allowPassword };
};
export const getViewTypes = () => {
// Define the valid view types
let viewTypes: string[] = [];
if (allowEmail) {
viewTypes = [...viewTypes, 'email_signin'];
}
if (allowPassword) {
viewTypes = [
...viewTypes,
'password_signin',
'forgot_password',
'update_password',
'signup'
];
}
return viewTypes;
};
export const getDefaultSignInView = (preferredSignInView: string | null) => {
// Define the default sign in view
let defaultView = allowPassword ? 'password_signin' : 'email_signin';
if (preferredSignInView && getViewTypes().includes(preferredSignInView)) {
defaultView = preferredSignInView;
}
return defaultView;
};
export const getRedirectMethod = () => {
return allowServerRedirect ? 'server' : 'client';
};
================================================
FILE: utils/cn.ts
================================================
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
================================================
FILE: utils/helpers.ts
================================================
import type { Tables } from '@/types_db';
type Price = Tables<'prices'>;
export const getURL = (path: string = '') => {
// Check if NEXT_PUBLIC_SITE_URL is set and non-empty. Set this to your site URL in production env.
let url =
process?.env?.NEXT_PUBLIC_SITE_URL &&
process.env.NEXT_PUBLIC_SITE_URL.trim() !== ''
? process.env.NEXT_PUBLIC_SITE_URL
: // If not set, check for NEXT_PUBLIC_VERCEL_URL, which is automatically set by Vercel.
process?.env?.NEXT_PUBLIC_VERCEL_URL &&
process.env.NEXT_PUBLIC_VERCEL_URL.trim() !== ''
? process.env.NEXT_PUBLIC_VERCEL_URL
: // If neither is set, default to localhost for local development.
'http://localhost:3000/';
// Trim the URL and remove trailing slash if exists.
url = url.replace(/\/+$/, '');
// Make sure to include `https://` when not localhost.
url = url.includes('http') ? url : `https://${url}`;
// Ensure path starts without a slash to avoid double slashes in the final URL.
path = path.replace(/^\/+/, '');
// Concatenate the URL and the path.
return path ? `${url}/${path}` : url;
};
export const postData = async ({
url,
data
}: {
url: string;
data?: { price: Price };
}) => {
const res = await fetch(url, {
method: 'POST',
headers: new Headers({ 'Content-Type': 'application/json' }),
credentials: 'same-origin',
body: JSON.stringify(data)
});
return res.json();
};
export const toDateTime = (secs: number) => {
var t = new Date(+0); // Unix epoch start.
t.setSeconds(secs);
return t;
};
export const calculateTrialEndUnixTimestamp = (
trialPeriodDays: number | null | undefined
) => {
// Check if trialPeriodDays is null, undefined, or less than 2 days
if (
trialPeriodDays === null ||
trialPeriodDays === undefined ||
trialPeriodDays < 2
) {
return undefined;
}
const currentDate = new Date(); // Current date and time
const trialEnd = new Date(
currentDate.getTime() + (trialPeriodDays + 1) * 24 * 60 * 60 * 1000
); // Add trial days
return Math.floor(trialEnd.getTime() / 1000); // Convert to Unix timestamp in seconds
};
const toastKeyMap: { [key: string]: string[] } = {
status: ['status', 'status_description'],
error: ['error', 'error_description']
};
const getToastRedirect = (
path: string,
toastType: string,
toastName: string,
toastDescription: string = '',
disableButton: boolean = false,
arbitraryParams: string = ''
): string => {
const [nameKey, descriptionKey] = toastKeyMap[toastType];
let redirectPath = `${path}?${nameKey}=${encodeURIComponent(toastName)}`;
if (toastDescription) {
redirectPath += `&${descriptionKey}=${encodeURIComponent(toastDescription)}`;
}
if (disableButton) {
redirectPath += `&disable_button=true`;
}
if (arbitraryParams) {
redirectPath += `&${arbitraryParams}`;
}
return redirectPath;
};
export const getStatusRedirect = (
path: string,
statusName: string,
statusDescription: string = '',
disableButton: boolean = false,
arbitraryParams: string = ''
) =>
getToastRedirect(
path,
'status',
statusName,
statusDescription,
disableButton,
arbitraryParams
);
export const getErrorRedirect = (
path: string,
errorName: string,
errorDescription: string = '',
disableButton: boolean = false,
arbitraryParams: string = ''
) =>
getToastRedirect(
path,
'error',
errorName,
errorDescription,
disableButton,
arbitraryParams
);
================================================
FILE: utils/stripe/client.ts
================================================
import { loadStripe, Stripe } from '@stripe/stripe-js';
let stripePromise: Promise<Stripe | null>;
export const getStripe = () => {
if (!stripePromise) {
stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_LIVE ??
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ??
''
);
}
return stripePromise;
};
================================================
FILE: utils/stripe/config.ts
================================================
import Stripe from 'stripe';
export const stripe = new Stripe(
process.env.STRIPE_SECRET_KEY_LIVE ?? process.env.STRIPE_SECRET_KEY ?? '',
{
// https://github.com/stripe/stripe-node#configuration
// https://stripe.com/docs/api/versioning
// @ts-ignore
apiVersion: null,
// Register this as an official Stripe plugin.
// https://stripe.com/docs/building-plugins#setappinfo
appInfo: {
name: 'Next.js Subscription Starter',
version: '0.0.0',
url: 'https://github.com/vercel/nextjs-subscription-payments'
}
}
);
================================================
FILE: utils/stripe/server.ts
================================================
'use server';
import Stripe from 'stripe';
import { stripe } from '@/utils/stripe/config';
import { createClient } from '@/utils/supabase/server';
import { createOrRetrieveCustomer } from '@/utils/supabase/admin';
import {
getURL,
getErrorRedirect,
calculateTrialEndUnixTimestamp
} from '@/utils/helpers';
import { Tables } from '@/types_db';
type Price = Tables<'prices'>;
type CheckoutResponse = {
errorRedirect?: string;
sessionId?: string;
};
export async function checkoutWithStripe(
price: Price,
redirectPath: string = '/account'
): Promise<CheckoutResponse> {
try {
// Get the user from Supabase auth
const supabase = createClient();
const {
error,
data: { user }
} = await supabase.auth.getUser();
if (error || !user) {
console.error(error);
throw new Error('Could not get user session.');
}
// Retrieve or create the customer in Stripe
let customer: string;
try {
customer = await createOrRetrieveCustomer({
uuid: user?.id || '',
email: user?.email || ''
});
} catch (err) {
console.error(err);
throw new Error('Unable to access customer record.');
}
let params: Stripe.Checkout.SessionCreateParams = {
allow_promotion_codes: true,
billing_address_collection: 'required',
customer,
customer_update: {
address: 'auto'
},
line_items: [
{
price: price.id,
quantity: 1
}
],
cancel_url: getURL(),
success_url: getURL(redirectPath)
};
console.log(
'Trial end:',
calculateTrialEndUnixTimestamp(price.trial_period_days)
);
if (price.type === 'recurring') {
params = {
...params,
mode: 'subscription',
subscription_data: {
trial_end: calculateTrialEndUnixTimestamp(price.trial_period_days)
}
};
} else if (price.type === 'one_time') {
params = {
...params,
mode: 'payment'
};
}
// Create a checkout session in Stripe
let session;
try {
session = await stripe.checkout.sessions.create(params);
} catch (err) {
console.error(err);
throw new Error('Unable to create checkout session.');
}
// Instead of returning a Response, just return the data or error.
if (session) {
return { sessionId: session.id };
} else {
throw new Error('Unable to create checkout session.');
}
} catch (error) {
if (error instanceof Error) {
return {
errorRedirect: getErrorRedirect(
redirectPath,
error.message,
'Please try again later or contact a system administrator.'
)
};
} else {
return {
errorRedirect: getErrorRedirect(
redirectPath,
'An unknown error occurred.',
'Please try again later or contact a system administrator.'
)
};
}
}
}
export async function createStripePortal(currentPath: string) {
try {
const supabase = createClient();
const {
error,
data: { user }
} = await supabase.auth.getUser();
if (!user) {
if (error) {
console.error(error);
}
throw new Error('Could not get user session.');
}
let customer;
try {
customer = await createOrRetrieveCustomer({
uuid: user.id || '',
email: user.email || ''
});
} catch (err) {
console.error(err);
throw new Error('Unable to access customer record.');
}
if (!customer) {
throw new Error('Could not get customer.');
}
try {
const { url } = await stripe.billingPortal.sessions.create({
customer,
return_url: getURL('/account')
});
if (!url) {
throw new Error('Could not create billing portal');
}
return url;
} catch (err) {
console.error(err);
throw new Error('Could not create billing portal');
}
} catch (error) {
if (error instanceof Error) {
console.error(error);
return getErrorRedirect(
currentPath,
error.message,
'Please try again later or contact a system administrator.'
);
} else {
return getErrorRedirect(
currentPath,
'An unknown error occurred.',
'Please try again later or contact a system administrator.'
);
}
}
}
================================================
FILE: utils/supabase/admin.ts
================================================
import { toDateTime } from '@/utils/helpers';
import { stripe } from '@/utils/stripe/config';
import { createClient } from '@supabase/supabase-js';
import Stripe from 'stripe';
import type { Database, Tables, TablesInsert } from 'types_db';
type Product = Tables<'products'>;
type Price = Tables<'prices'>;
// Change to control trial period length
const TRIAL_PERIOD_DAYS = 0;
// Note: supabaseAdmin uses the SERVICE_ROLE_KEY which you must only use in a secure server-side context
// as it has admin privileges and overwrites RLS policies!
const supabaseAdmin = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL || '',
process.env.SUPABASE_SERVICE_ROLE_KEY || ''
);
const upsertProductRecord = async (product: Stripe.Product) => {
const productData: Product = {
id: product.id,
active: product.active,
name: product.name,
description: product.description ?? null,
image: product.images?.[0] ?? null,
metadata: product.metadata
};
const { error: upsertError } = await supabaseAdmin
.from('products')
.upsert([productData]);
if (upsertError)
throw new Error(`Product insert/update failed: ${upsertError.message}`);
console.log(`Product inserted/updated: ${product.id}`);
};
const upsertPriceRecord = async (
price: Stripe.Price,
retryCount = 0,
maxRetries = 3
) => {
const priceData: Price = {
id: price.id,
product_id: typeof price.product === 'string' ? price.product : '',
active: price.active,
currency: price.currency,
type: price.type,
unit_amount: price.unit_amount ?? null,
interval: price.recurring?.interval ?? null,
interval_count: price.recurring?.interval_count ?? null,
trial_period_days: price.recurring?.trial_period_days ?? TRIAL_PERIOD_DAYS
};
const { error: upsertError } = await supabaseAdmin
.from('prices')
.upsert([priceData]);
if (upsertError?.message.includes('foreign key constraint')) {
if (retryCount < maxRetries) {
console.log(`Retry attempt ${retryCount + 1} for price ID: ${price.id}`);
await new Promise((resolve) => setTimeout(resolve, 2000));
await upsertPriceRecord(price, retryCount + 1, maxRetries);
} else {
throw new Error(
`Price insert/update failed after ${maxRetries} retries: ${upsertError.message}`
);
}
} else if (upsertError) {
throw new Error(`Price insert/update failed: ${upsertError.message}`);
} else {
console.log(`Price inserted/updated: ${price.id}`);
}
};
const deleteProductRecord = async (product: Stripe.Product) => {
const { error: deletionError } = await supabaseAdmin
.from('products')
.delete()
.eq('id', product.id);
if (deletionError)
throw new Error(`Product deletion failed: ${deletionError.message}`);
console.log(`Product deleted: ${product.id}`);
};
const deletePriceRecord = async (price: Stripe.Price) => {
const { error: deletionError } = await supabaseAdmin
.from('prices')
.delete()
.eq('id', price.id);
if (deletionError) throw new Error(`Price deletion failed: ${deletionError.message}`);
console.log(`Price deleted: ${price.id}`);
};
const upsertCustomerToSupabase = async (uuid: string, customerId: string) => {
const { error: upsertError } = await supabaseAdmin
.from('customers')
.upsert([{ id: uuid, stripe_customer_id: customerId }]);
if (upsertError)
throw new Error(`Supabase customer record creation failed: ${upsertError.message}`);
return customerId;
};
const createCustomerInStripe = async (uuid: string, email: string) => {
const customerData = { metadata: { supabaseUUID: uuid }, email: email };
const newCustomer = await stripe.customers.create(customerData);
if (!newCustomer) throw new Error('Stripe customer creation failed.');
return newCustomer.id;
};
const createOrRetrieveCustomer = async ({
email,
uuid
}: {
email: string;
uuid: string;
}) => {
// Check if the customer already exists in Supabase
const { data: existingSupabaseCustomer, error: queryError } =
await supabaseAdmin
.from('customers')
.select('*')
.eq('id', uuid)
.maybeSingle();
if (queryError) {
throw new Error(`Supabase customer lookup failed: ${queryError.message}`);
}
// Retrieve the Stripe customer ID using the Supabase customer ID, with email fallback
let stripeCustomerId: string | undefined;
if (existingSupabaseCustomer?.stripe_customer_id) {
const existingStripeCustomer = await stripe.customers.retrieve(
existingSupabaseCustomer.stripe_customer_id
);
stripeCustomerId = existingStripeCustomer.id;
} else {
// If Stripe ID is missing from Supabase, try to retrieve Stripe customer ID by email
const stripeCustomers = await stripe.customers.list({ email: email });
stripeCustomerId =
stripeCustomers.data.length > 0 ? stripeCustomers.data[0].id : undefined;
}
// If still no stripeCustomerId, create a new customer in Stripe
const stripeIdToInsert = stripeCustomerId
? stripeCustomerId
: await createCustomerInStripe(uuid, email);
if (!stripeIdToInsert) throw new Error('Stripe customer creation failed.');
if (existingSupabaseCustomer && stripeCustomerId) {
// If Supabase has a record but doesn't match Stripe, update Supabase record
if (existingSupabaseCustomer.stripe_customer_id !== stripeCustomerId) {
const { error: updateError } = await supabaseAdmin
.from('customers')
.update({ stripe_customer_id: stripeCustomerId })
.eq('id', uuid);
if (updateError)
throw new Error(
`Supabase customer record update failed: ${updateError.message}`
);
console.warn(
`Supabase customer record mismatched Stripe ID. Supabase record updated.`
);
}
// If Supabase has a record and matches Stripe, return Stripe customer ID
return stripeCustomerId;
} else {
console.warn(
`Supabase customer record was missing. A new record was created.`
);
// If Supabase has no record, create a new record and return Stripe customer ID
const upsertedStripeCustomer = await upsertCustomerToSupabase(
uuid,
stripeIdToInsert
);
if (!upsertedStripeCustomer)
throw new Error('Supabase customer record creation failed.');
return upsertedStripeCustomer;
}
};
/**
* Copies the billing details from the payment method to the customer object.
*/
const copyBillingDetailsToCustomer = async (
uuid: string,
payment_method: Stripe.PaymentMethod
) => {
//Todo: check this assertion
const customer = payment_method.customer as string;
const { name, phone, address } = payment_method.billing_details;
if (!name || !phone || !address) return;
//@ts-ignore
await stripe.customers.update(customer, { name, phone, address });
const { error: updateError } = await supabaseAdmin
.from('users')
.update({
billing_address: { ...address },
payment_method: { ...payment_method[payment_method.type] }
})
.eq('id', uuid);
if (updateError) throw new Error(`Customer update failed: ${updateError.message}`);
};
const manageSubscriptionStatusChange = async (
subscriptionId: string,
customerId: string,
createAction = false
) => {
// Get customer's UUID from mapping table.
const { data: customerData, error: noCustomerError } = await supabaseAdmin
.from('customers')
.select('id')
.eq('stripe_customer_id', customerId)
.single();
if (noCustomerError)
throw new Error(`Customer lookup failed: ${noCustomerError.message}`);
const { id: uuid } = customerData!;
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ['default_payment_method']
});
// Upsert the latest status of the subscription object.
const subscriptionData: TablesInsert<'subscriptions'> = {
id: subscription.id,
user_id: uuid,
metadata: subscription.metadata,
status: subscription.status,
price_id: subscription.items.data[0].price.id,
//TODO check quantity on subscription
// @ts-ignore
quantity: subscription.quantity,
cancel_at_period_end: subscription.cancel_at_period_end,
cancel_at: subscription.cancel_at
? toDateTime(subscription.cancel_at).toISOString()
: null,
canceled_at: subscription.canceled_at
? toDateTime(subscription.canceled_at).toISOString()
: null,
current_period_start: toDateTime(
subscription.current_period_start
).toISOString(),
current_period_end: toDateTime(
subscription.current_period_end
).toISOString(),
created: toDateTime(subscription.created).toISOString(),
ended_at: subscription.ended_at
? toDateTime(subscription.ended_at).toISOString()
: null,
trial_start: subscription.trial_start
? toDateTime(subscription.trial_start).toISOString()
: null,
trial_end: subscription.trial_end
? toDateTime(subscription.trial_end).toISOString()
: null
};
const { error: upsertError } = await supabaseAdmin
.from('subscriptions')
.upsert([subscriptionData]);
if (upsertError)
throw new Error(`Subscription insert/update failed: ${upsertError.message}`);
console.log(
`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`
);
// For a new subscription copy the billing details to the customer object.
// NOTE: This is a costly operation and should happen at the very end.
if (createAction && subscription.default_payment_method && uuid)
//@ts-ignore
await copyBillingDetailsToCustomer(
uuid,
subscription.default_payment_method as Stripe.PaymentMethod
);
};
export {
upsertProductRecord,
upsertPriceRecord,
deleteProductRecord,
deletePriceRecord,
createOrRetrieveCustomer,
manageSubscriptionStatusChange
};
================================================
FILE: utils/supabase/client.ts
================================================
import { createBrowserClient } from '@supabase/ssr';
import { Database } from '@/types_db';
// Define a function to create a Supabase client for client-side operations
export const createClient = () =>
createBrowserClient<Database>(
// Pass Supabase URL and anonymous key from the environment to the client
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
================================================
FILE: utils/supabase/middleware.ts
================================================
import { createServerClient, type CookieOptions } from '@supabase/ssr';
import { type NextRequest, NextResponse } from 'next/server';
export const createClient = (request: NextRequest) => {
// Create an unmodified response
let response = NextResponse.next({
request: {
headers: request.headers
}
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return request.cookies.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
// If the cookie is updated, update the cookies for the request and response
request.cookies.set({
name,
value,
...options
});
response = NextResponse.next({
request: {
headers: request.headers
}
});
response.cookies.set({
name,
value,
...options
});
},
remove(name: string, options: CookieOptions) {
// If the cookie is removed, update the cookies for the request and response
request.cookies.set({
name,
value: '',
...options
});
response = NextResponse.next({
request: {
headers: request.headers
}
});
response.cookies.set({
name,
value: '',
...options
});
}
}
}
);
return { supabase, response };
};
export const updateSession = async (request: NextRequest) => {
try {
// This `try/catch` block is only here for the interactive tutorial.
// Feel free to remove once you have Supabase connected.
const { supabase, response } = createClient(request);
// This will refresh session if expired - required for Server Components
// https://supabase.com/docs/guides/auth/server-side/nextjs
await supabase.auth.getUser();
return response;
} catch (e) {
// If you are here, a Supabase client could not be created!
// This is likely because you have not set up environment variables.
// Check out http://localhost:3000 for Next Steps.
return NextResponse.next({
request: {
headers: request.headers
}
});
}
};
================================================
FILE: utils/supabase/queries.ts
================================================
import { SupabaseClient } from '@supabase/supabase-js';
import { cache } from 'react';
export const getUser = cache(async (supabase: SupabaseClient) => {
const {
data: { user }
} = await supabase.auth.getUser();
return user;
});
export const getSubscription = cache(async (supabase: SupabaseClient) => {
const { data: subscription, error } = await supabase
.from('subscriptions')
.select('*, prices(*, products(*))')
.in('status', ['trialing', 'active'])
.maybeSingle();
return subscription;
});
export const getProducts = cache(async (supabase: SupabaseClient) => {
const { data: products, error } = await supabase
.from('products')
.select('*, prices(*)')
.eq('active', true)
.eq('prices.active', true)
.order('metadata->index')
.order('unit_amount', { referencedTable: 'prices' });
return products;
});
export const getUserDetails = cache(async (supabase: SupabaseClient) => {
const { data: userDetails } = await supabase
.from('users')
.select('*')
.single();
return userDetails;
});
================================================
FILE: utils/supabase/server.ts
================================================
import { createServerClient, type CookieOptions } from '@supabase/ssr';
import { cookies } from 'next/headers';
import { Database } from '@/types_db';
// Define a function to create a Supabase client for server-side operations
// The function takes a cookie store created with next/headers cookies as an argument
export const createClient = () => {
const cookieStore = cookies();
return createServerClient<Database>(
// Pass Supabase URL and anonymous key from the environment to the client
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
// Define a cookies object with methods for interacting with the cookie store and pass it to the client
{
cookies: {
// The get method is used to retrieve a cookie by its name
get(name: string) {
return cookieStore.get(name)?.value;
},
// The set method is used to set a cookie with a given name, value, and options
set(name: string, value: string, options: CookieOptions) {
try {
cookieStore.set({ name, value, ...options });
} catch (error) {
// If the set method is called from a Server Component, an error may occur
// This can be ignored if there is middleware refreshing user sessions
}
},
// The remove method is used to delete a cookie by its name
remove(name: string, options: CookieOptions) {
try {
cookieStore.set({ name, value: '', ...options });
} catch (error) {
// If the remove method is called from a Server Component, an error may occur
// This can be ignored if there is middleware refreshing user sessions
}
}
}
}
);
};
gitextract_dsp2kb45/
├── .gitignore
├── .prettierignore
├── .prettierrc.json
├── LICENSE
├── README.md
├── app/
│ ├── account/
│ │ └── page.tsx
│ ├── api/
│ │ └── webhooks/
│ │ └── route.ts
│ ├── auth/
│ │ ├── callback/
│ │ │ └── route.ts
│ │ └── reset_password/
│ │ └── route.ts
│ ├── layout.tsx
│ ├── page.tsx
│ └── signin/
│ ├── [id]/
│ │ └── page.tsx
│ └── page.tsx
├── components/
│ ├── icons/
│ │ ├── GitHub.tsx
│ │ └── Logo.tsx
│ └── ui/
│ ├── AccountForms/
│ │ ├── CustomerPortalForm.tsx
│ │ ├── EmailForm.tsx
│ │ └── NameForm.tsx
│ ├── AuthForms/
│ │ ├── EmailSignIn.tsx
│ │ ├── ForgotPassword.tsx
│ │ ├── OauthSignIn.tsx
│ │ ├── PasswordSignIn.tsx
│ │ ├── Separator.tsx
│ │ ├── Signup.tsx
│ │ └── UpdatePassword.tsx
│ ├── Button/
│ │ ├── Button.module.css
│ │ ├── Button.tsx
│ │ └── index.ts
│ ├── Card/
│ │ ├── Card.tsx
│ │ └── index.ts
│ ├── Footer/
│ │ ├── Footer.tsx
│ │ └── index.ts
│ ├── Input/
│ │ ├── Input.module.css
│ │ ├── Input.tsx
│ │ └── index.ts
│ ├── LoadingDots/
│ │ ├── LoadingDots.module.css
│ │ ├── LoadingDots.tsx
│ │ └── index.ts
│ ├── LogoCloud/
│ │ ├── LogoCloud.tsx
│ │ └── index.ts
│ ├── Navbar/
│ │ ├── Navbar.module.css
│ │ ├── Navbar.tsx
│ │ ├── Navlinks.tsx
│ │ └── index.ts
│ ├── Pricing/
│ │ └── Pricing.tsx
│ └── Toasts/
│ ├── toast.tsx
│ ├── toaster.tsx
│ └── use-toast.ts
├── components.json
├── fixtures/
│ └── stripe-fixtures.json
├── middleware.ts
├── next-env.d.ts
├── package.json
├── postcss.config.js
├── schema.sql
├── styles/
│ └── main.css
├── supabase/
│ ├── .gitignore
│ ├── config.toml
│ ├── migrations/
│ │ └── 20230530034630_init.sql
│ └── seed.sql
├── tailwind.config.js
├── tsconfig.json
├── types_db.ts
└── utils/
├── auth-helpers/
│ ├── client.ts
│ ├── server.ts
│ └── settings.ts
├── cn.ts
├── helpers.ts
├── stripe/
│ ├── client.ts
│ ├── config.ts
│ └── server.ts
└── supabase/
├── admin.ts
├── client.ts
├── middleware.ts
├── queries.ts
└── server.ts
SYMBOL INDEX (102 symbols across 40 files)
FILE: app/account/page.tsx
function Account (line 12) | async function Account() {
FILE: app/api/webhooks/route.ts
function POST (line 24) | async function POST(req: Request) {
FILE: app/auth/callback/route.ts
function GET (line 6) | async function GET(request: NextRequest) {
FILE: app/auth/reset_password/route.ts
function GET (line 6) | async function GET(request: NextRequest) {
FILE: app/layout.tsx
function RootLayout (line 22) | async function RootLayout({ children }: PropsWithChildren) {
FILE: app/page.tsx
function PricingPage (line 9) | async function PricingPage() {
FILE: app/signin/[id]/page.tsx
function SignIn (line 20) | async function SignIn({
FILE: app/signin/page.tsx
function SignIn (line 5) | function SignIn() {
FILE: components/ui/AccountForms/CustomerPortalForm.tsx
type Subscription (line 11) | type Subscription = Tables<'subscriptions'>;
type Price (line 12) | type Price = Tables<'prices'>;
type Product (line 13) | type Product = Tables<'products'>;
type SubscriptionWithPriceAndProduct (line 15) | type SubscriptionWithPriceAndProduct = Subscription & {
type Props (line 23) | interface Props {
function CustomerPortalForm (line 27) | function CustomerPortalForm({ subscription }: Props) {
FILE: components/ui/AccountForms/EmailForm.tsx
function EmailForm (line 10) | function EmailForm({
FILE: components/ui/AccountForms/NameForm.tsx
function NameForm (line 10) | function NameForm({ userName }: { userName: string }) {
FILE: components/ui/AuthForms/EmailSignIn.tsx
type EmailSignInProps (line 11) | interface EmailSignInProps {
function EmailSignIn (line 17) | function EmailSignIn({
FILE: components/ui/AuthForms/ForgotPassword.tsx
type ForgotPasswordProps (line 11) | interface ForgotPasswordProps {
function ForgotPassword (line 17) | function ForgotPassword({
FILE: components/ui/AuthForms/OauthSignIn.tsx
type OAuthProviders (line 9) | type OAuthProviders = {
function OauthSignIn (line 15) | function OauthSignIn() {
FILE: components/ui/AuthForms/PasswordSignIn.tsx
type PasswordSignInProps (line 11) | interface PasswordSignInProps {
function PasswordSignIn (line 16) | function PasswordSignIn({
FILE: components/ui/AuthForms/Separator.tsx
type SeparatorProps (line 1) | interface SeparatorProps {
function Separator (line 5) | function Separator({ text }: SeparatorProps) {
FILE: components/ui/AuthForms/Signup.tsx
type SignUpProps (line 12) | interface SignUpProps {
function SignUp (line 17) | function SignUp({ allowEmail, redirectMethod }: SignUpProps) {
FILE: components/ui/AuthForms/UpdatePassword.tsx
type UpdatePasswordProps (line 9) | interface UpdatePasswordProps {
function UpdatePassword (line 13) | function UpdatePassword({
FILE: components/ui/Button/Button.tsx
type Props (line 11) | interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
FILE: components/ui/Card/Card.tsx
type Props (line 3) | interface Props {
function Card (line 10) | function Card({ title, description, footer, children }: Props) {
FILE: components/ui/Footer/Footer.tsx
function Footer (line 6) | function Footer() {
FILE: components/ui/Input/Input.tsx
type Props (line 6) | interface Props extends Omit<InputHTMLAttributes<any>, 'onChange'> {
FILE: components/ui/LogoCloud/LogoCloud.tsx
function LogoCloud (line 1) | function LogoCloud() {
FILE: components/ui/Navbar/Navbar.tsx
function Navbar (line 5) | async function Navbar() {
FILE: components/ui/Navbar/Navlinks.tsx
type NavlinksProps (line 11) | interface NavlinksProps {
function Navlinks (line 15) | function Navlinks({ user }: NavlinksProps) {
FILE: components/ui/Pricing/Pricing.tsx
type Subscription (line 14) | type Subscription = Tables<'subscriptions'>;
type Product (line 15) | type Product = Tables<'products'>;
type Price (line 16) | type Price = Tables<'prices'>;
type ProductWithPrices (line 17) | interface ProductWithPrices extends Product {
type PriceWithProduct (line 20) | interface PriceWithProduct extends Price {
type SubscriptionWithProduct (line 23) | interface SubscriptionWithProduct extends Subscription {
type Props (line 27) | interface Props {
type BillingInterval (line 33) | type BillingInterval = 'lifetime' | 'year' | 'month';
function Pricing (line 35) | function Pricing({ user, products, subscription }: Props) {
FILE: components/ui/Toasts/toast.tsx
type ToastProps (line 114) | type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement (line 116) | type ToastActionElement = React.ReactElement<typeof ToastAction>;
FILE: components/ui/Toasts/toaster.tsx
function Toaster (line 15) | function Toaster() {
FILE: components/ui/Toasts/use-toast.ts
constant TOAST_LIMIT (line 9) | const TOAST_LIMIT = 1;
constant TOAST_REMOVE_DELAY (line 10) | const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast (line 12) | type ToasterToast = ToastProps & {
function genId (line 28) | function genId() {
type ActionType (line 33) | type ActionType = typeof actionTypes;
type Action (line 35) | type Action =
type State (line 53) | interface State {
function dispatch (line 134) | function dispatch(action: Action) {
type Toast (line 141) | type Toast = Omit<ToasterToast, 'id'>;
function toast (line 143) | function toast({ ...props }: Toast) {
function useToast (line 172) | function useToast() {
FILE: middleware.ts
function middleware (line 4) | async function middleware(request: NextRequest) {
FILE: schema.sql
type users (line 5) | create table users (
function public (line 22) | create function public.handle_new_user()
type customers (line 38) | create table customers (
type products (line 51) | create table products (
type prices (line 74) | create table prices (
type subscriptions (line 106) | create table subscriptions (
FILE: supabase/migrations/20230530034630_init.sql
type users (line 5) | create table users (
function public (line 22) | create function public.handle_new_user()
type customers (line 38) | create table customers (
type products (line 51) | create table products (
type prices (line 74) | create table prices (
type subscriptions (line 106) | create table subscriptions (
FILE: utils/auth-helpers/client.ts
function handleRequest (line 9) | async function handleRequest(
function signInWithOAuth (line 29) | async function signInWithOAuth(e: React.FormEvent<HTMLFormElement>) {
FILE: utils/auth-helpers/server.ts
function isValidEmail (line 9) | function isValidEmail(email: string) {
function redirectToPath (line 14) | async function redirectToPath(path: string) {
function SignOut (line 18) | async function SignOut(formData: FormData) {
function signInWithEmail (line 35) | async function signInWithEmail(formData: FormData) {
function requestPasswordUpdate (line 89) | async function requestPasswordUpdate(formData: FormData) {
function signInWithPassword (line 134) | async function signInWithPassword(formData: FormData) {
function signUp (line 166) | async function signUp(formData: FormData) {
function updatePassword (line 225) | async function updatePassword(formData: FormData) {
function updateEmail (line 267) | async function updateEmail(formData: FormData) {
function updateName (line 308) | async function updateName(formData: FormData) {
FILE: utils/cn.ts
function cn (line 4) | function cn(...inputs: ClassValue[]) {
FILE: utils/helpers.ts
type Price (line 3) | type Price = Tables<'prices'>;
FILE: utils/stripe/server.ts
type Price (line 14) | type Price = Tables<'prices'>;
type CheckoutResponse (line 16) | type CheckoutResponse = {
function checkoutWithStripe (line 21) | async function checkoutWithStripe(
function createStripePortal (line 122) | async function createStripePortal(currentPath: string) {
FILE: utils/supabase/admin.ts
type Product (line 7) | type Product = Tables<'products'>;
type Price (line 8) | type Price = Tables<'prices'>;
constant TRIAL_PERIOD_DAYS (line 11) | const TRIAL_PERIOD_DAYS = 0;
FILE: utils/supabase/middleware.ts
method get (line 17) | get(name: string) {
method set (line 20) | set(name: string, value: string, options: CookieOptions) {
method remove (line 38) | remove(name: string, options: CookieOptions) {
FILE: utils/supabase/server.ts
method get (line 19) | get(name: string) {
method set (line 23) | set(name: string, value: string, options: CookieOptions) {
method remove (line 32) | remove(name: string, options: CookieOptions) {
Condensed preview — 76 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (158K chars).
[
{
"path": ".gitignore",
"chars": 451,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": ".prettierignore",
"chars": 116,
"preview": "# Build artifacts\n.next/\n.turbo/\n_next/\n__tmp__/\ndist/\nnode_modules/\ntarget/\ncompiled/\n\npnpm-lock.yaml\n\ntypes_db.ts\n"
},
{
"path": ".prettierrc.json",
"chars": 97,
"preview": "{\n \"arrowParens\": \"always\",\n \"singleQuote\": true,\n \"tabWidth\": 2,\n \"trailingComma\": \"none\"\n}\n"
},
{
"path": "LICENSE",
"chars": 1078,
"preview": "The MIT License (MIT)\n\nCopyright (c) 2024 Vercel, Inc.\n\nPermission is hereby granted, free of charge, to any person obta"
},
{
"path": "README.md",
"chars": 15635,
"preview": "# Next.js Subscription Payments Starter\n\n\n> [!WARNING] \n> This repo has been sunset and replaced by a new template: htt"
},
{
"path": "app/account/page.tsx",
"chars": 1430,
"preview": "import CustomerPortalForm from '@/components/ui/AccountForms/CustomerPortalForm';\nimport EmailForm from '@/components/ui"
},
{
"path": "app/api/webhooks/route.ts",
"chars": 3092,
"preview": "import Stripe from 'stripe';\nimport { stripe } from '@/utils/stripe/config';\nimport {\n upsertProductRecord,\n upsertPri"
},
{
"path": "app/auth/callback/route.ts",
"chars": 1110,
"preview": "import { createClient } from '@/utils/supabase/server';\nimport { NextResponse } from 'next/server';\nimport { NextRequest"
},
{
"path": "app/auth/reset_password/route.ts",
"chars": 1178,
"preview": "import { createClient } from '@/utils/supabase/server';\nimport { NextResponse } from 'next/server';\nimport { NextRequest"
},
{
"path": "app/layout.tsx",
"chars": 1037,
"preview": "import { Metadata } from 'next';\nimport Footer from '@/components/ui/Footer';\nimport Navbar from '@/components/ui/Navbar"
},
{
"path": "app/page.tsx",
"chars": 551,
"preview": "import Pricing from '@/components/ui/Pricing/Pricing';\nimport { createClient } from '@/utils/supabase/server';\nimport {\n"
},
{
"path": "app/signin/[id]/page.tsx",
"chars": 3742,
"preview": "import Logo from '@/components/icons/Logo';\nimport { createClient } from '@/utils/supabase/server';\nimport { cookies } f"
},
{
"path": "app/signin/page.tsx",
"chars": 390,
"preview": "import { redirect } from 'next/navigation';\nimport { getDefaultSignInView } from '@/utils/auth-helpers/settings';\nimport"
},
{
"path": "components/icons/GitHub.tsx",
"chars": 1385,
"preview": "const GitHub = ({ ...props }) => {\n return (\n <svg\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n "
},
{
"path": "components/icons/Logo.tsx",
"chars": 528,
"preview": "const Logo = ({ ...props }) => (\n <svg\n width=\"32\"\n height=\"32\"\n viewBox=\"0 0 32 32\"\n fill=\"none\"\n xmlns"
},
{
"path": "components/ui/AccountForms/CustomerPortalForm.tsx",
"chars": 2219,
"preview": "'use client';\n\nimport Button from '@/components/ui/Button';\nimport { useRouter, usePathname } from 'next/navigation';\nim"
},
{
"path": "components/ui/AccountForms/EmailForm.tsx",
"chars": 1824,
"preview": "'use client';\n\nimport Button from '@/components/ui/Button';\nimport Card from '@/components/ui/Card';\nimport { updateEmai"
},
{
"path": "components/ui/AccountForms/NameForm.tsx",
"chars": 1761,
"preview": "'use client';\n\nimport Button from '@/components/ui/Button';\nimport Card from '@/components/ui/Card';\nimport { updateName"
},
{
"path": "components/ui/AuthForms/EmailSignIn.tsx",
"chars": 2219,
"preview": "'use client';\n\nimport Button from '@/components/ui/Button';\nimport Link from 'next/link';\nimport { signInWithEmail } fro"
},
{
"path": "components/ui/AuthForms/ForgotPassword.tsx",
"chars": 2322,
"preview": "'use client';\n\nimport Button from '@/components/ui/Button';\nimport Link from 'next/link';\nimport { requestPasswordUpdate"
},
{
"path": "components/ui/AuthForms/OauthSignIn.tsx",
"chars": 1457,
"preview": "'use client';\n\nimport Button from '@/components/ui/Button';\nimport { signInWithOAuth } from '@/utils/auth-helpers/client"
},
{
"path": "components/ui/AuthForms/PasswordSignIn.tsx",
"chars": 2549,
"preview": "'use client';\n\nimport Button from '@/components/ui/Button';\nimport Link from 'next/link';\nimport { signInWithPassword } "
},
{
"path": "components/ui/AuthForms/Separator.tsx",
"chars": 465,
"preview": "interface SeparatorProps {\n text: string;\n}\n\nexport default function Separator({ text }: SeparatorProps) {\n return (\n "
},
{
"path": "components/ui/AuthForms/Signup.tsx",
"chars": 2419,
"preview": "'use client';\n\nimport Button from '@/components/ui/Button';\nimport React from 'react';\nimport Link from 'next/link';\nimp"
},
{
"path": "components/ui/AuthForms/UpdatePassword.tsx",
"chars": 1947,
"preview": "'use client';\n\nimport Button from '@/components/ui/Button';\nimport { updatePassword } from '@/utils/auth-helpers/server'"
},
{
"path": "components/ui/Button/Button.module.css",
"chars": 801,
"preview": ".root {\n @apply bg-white text-zinc-800 cursor-pointer inline-flex px-10 rounded-sm leading-6 transition ease-in-out du"
},
{
"path": "components/ui/Button/Button.tsx",
"chars": 1401,
"preview": "'use client';\n\nimport cn from 'classnames';\nimport React, { forwardRef, useRef, ButtonHTMLAttributes } from 'react';\nimp"
},
{
"path": "components/ui/Button/index.ts",
"chars": 36,
"preview": "export { default } from './Button';\n"
},
{
"path": "components/ui/Card/Card.tsx",
"chars": 679,
"preview": "import { ReactNode } from 'react';\n\ninterface Props {\n title: string;\n description?: string;\n footer?: ReactNode;\n c"
},
{
"path": "components/ui/Card/index.ts",
"chars": 34,
"preview": "export { default } from './Card';\n"
},
{
"path": "components/ui/Footer/Footer.tsx",
"chars": 3901,
"preview": "import Link from 'next/link';\n\nimport Logo from '@/components/icons/Logo';\nimport GitHub from '@/components/icons/GitHub"
},
{
"path": "components/ui/Footer/index.ts",
"chars": 36,
"preview": "export { default } from './Footer';\n"
},
{
"path": "components/ui/Input/Input.module.css",
"chars": 175,
"preview": ".root {\n @apply bg-black py-2 px-3 w-full appearance-none transition duration-150 ease-in-out border border-zinc-500 te"
},
{
"path": "components/ui/Input/Input.tsx",
"chars": 839,
"preview": "import React, { InputHTMLAttributes, ChangeEvent } from 'react';\nimport cn from 'classnames';\n\nimport s from './Input.mo"
},
{
"path": "components/ui/Input/index.ts",
"chars": 35,
"preview": "export { default } from './Input';\n"
},
{
"path": "components/ui/LoadingDots/LoadingDots.module.css",
"chars": 486,
"preview": ".root {\n @apply inline-flex text-center items-center leading-7;\n}\n\n.root span {\n @apply bg-zinc-200 rounded-full h-2 w"
},
{
"path": "components/ui/LoadingDots/LoadingDots.tsx",
"chars": 206,
"preview": "import s from './LoadingDots.module.css';\n\nconst LoadingDots = () => {\n return (\n <span className={s.root}>\n <s"
},
{
"path": "components/ui/LoadingDots/index.ts",
"chars": 41,
"preview": "export { default } from './LoadingDots';\n"
},
{
"path": "components/ui/LogoCloud/LogoCloud.tsx",
"chars": 1903,
"preview": "export default function LogoCloud() {\n return (\n <div>\n <p className=\"mt-24 text-xs uppercase text-zinc-400 tex"
},
{
"path": "components/ui/LogoCloud/index.ts",
"chars": 39,
"preview": "export { default } from './LogoCloud';\n"
},
{
"path": "components/ui/Navbar/Navbar.module.css",
"chars": 451,
"preview": ".root {\n @apply sticky top-0 bg-black z-40 transition-all duration-150 h-16 md:h-20;\n}\n\n.link {\n @apply inline-flex it"
},
{
"path": "components/ui/Navbar/Navbar.tsx",
"chars": 520,
"preview": "import { createClient } from '@/utils/supabase/server';\nimport s from './Navbar.module.css';\nimport Navlinks from './Nav"
},
{
"path": "components/ui/Navbar/Navlinks.tsx",
"chars": 1552,
"preview": "'use client';\n\nimport Link from 'next/link';\nimport { SignOut } from '@/utils/auth-helpers/server';\nimport { handleReque"
},
{
"path": "components/ui/Navbar/index.ts",
"chars": 36,
"preview": "export { default } from './Navbar';\n"
},
{
"path": "components/ui/Pricing/Pricing.tsx",
"chars": 7795,
"preview": "'use client';\n\nimport Button from '@/components/ui/Button';\nimport LogoCloud from '@/components/ui/LogoCloud';\nimport ty"
},
{
"path": "components/ui/Toasts/toast.tsx",
"chars": 5322,
"preview": "import * as React from 'react';\nimport * as ToastPrimitives from '@radix-ui/react-toast';\nimport { cva, type VariantProp"
},
{
"path": "components/ui/Toasts/toaster.tsx",
"chars": 2213,
"preview": "'use client';\n\nimport {\n Toast,\n ToastClose,\n ToastDescription,\n ToastProvider,\n ToastTitle,\n ToastViewport\n} from"
},
{
"path": "components/ui/Toasts/use-toast.ts",
"chars": 3988,
"preview": "// Inspired by react-hot-toast library\nimport * as React from 'react';\n\nimport type {\n ToastActionElement,\n ToastProps"
},
{
"path": "components.json",
"chars": 323,
"preview": "{\n \"$schema\": \"https://ui.shadcn.com/schema.json\",\n \"style\": \"default\",\n \"rsc\": true,\n \"tsx\": true,\n \"tailwind\": {\n"
},
{
"path": "fixtures/stripe-fixtures.json",
"chars": 2014,
"preview": "{\n \"_meta\": {\n \"template_version\": 0\n },\n \"fixtures\": [\n {\n \"name\": \"prod_hobby\",\n \"path\": \"/v1/produ"
},
{
"path": "middleware.ts",
"chars": 634,
"preview": "import { type NextRequest } from 'next/server';\nimport { updateSession } from '@/utils/supabase/middleware';\n\nexport asy"
},
{
"path": "next-env.d.ts",
"chars": 201,
"preview": "/// <reference types=\"next\" />\n/// <reference types=\"next/image-types/global\" />\n\n// NOTE: This file should not be edite"
},
{
"path": "package.json",
"chars": 2063,
"preview": "{\n \"private\": true,\n \"scripts\": {\n \"dev\": \"next dev --turbo\",\n \"build\": \"next build\",\n \"start\": \"next start\","
},
{
"path": "postcss.config.js",
"chars": 81,
"preview": "module.exports = {\n plugins: {\n tailwindcss: {},\n autoprefixer: {}\n }\n};\n"
},
{
"path": "schema.sql",
"chars": 7330,
"preview": "/** \n* USERS\n* Note: This table contains user data. Users should only be able to view and update their own data.\n*/\ncrea"
},
{
"path": "styles/main.css",
"chars": 1001,
"preview": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n*,\n*:before,\n*:after {\n box-sizing: inherit;\n}\n\n*:focus:not"
},
{
"path": "supabase/.gitignore",
"chars": 32,
"preview": "# Supabase\n.branches\n.temp\n.env\n"
},
{
"path": "supabase/config.toml",
"chars": 6114,
"preview": "# A string used to distinguish different Supabase projects on the same host. Defaults to the\n# working directory name wh"
},
{
"path": "supabase/migrations/20230530034630_init.sql",
"chars": 7330,
"preview": "/** \n* USERS\n* Note: This table contains user data. Users should only be able to view and update their own data.\n*/\ncrea"
},
{
"path": "supabase/seed.sql",
"chars": 0,
"preview": ""
},
{
"path": "tailwind.config.js",
"chars": 989,
"preview": "const { fontFamily } = require('tailwindcss/defaultTheme');\n\n/** @type {import('tailwindcss').Config} */\nmodule.exports "
},
{
"path": "tsconfig.json",
"chars": 658,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"es5\",\n \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n \"allowJs\": true,\n \"sk"
},
{
"path": "utils/auth-helpers/client.ts",
"chars": 1440,
"preview": "'use client';\n\nimport { createClient } from '@/utils/supabase/client';\nimport { type Provider } from '@supabase/supabase"
},
{
"path": "utils/auth-helpers/server.ts",
"chars": 8470,
"preview": "'use server';\n\nimport { createClient } from '@/utils/supabase/server';\nimport { cookies } from 'next/headers';\nimport { "
},
{
"path": "utils/auth-helpers/settings.ts",
"chars": 1417,
"preview": "// Boolean toggles to determine which auth types are allowed\nconst allowOauth = true;\nconst allowEmail = true;\nconst all"
},
{
"path": "utils/cn.ts",
"chars": 169,
"preview": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: C"
},
{
"path": "utils/helpers.ts",
"chars": 3517,
"preview": "import type { Tables } from '@/types_db';\n\ntype Price = Tables<'prices'>;\n\nexport const getURL = (path: string = '') => "
},
{
"path": "utils/stripe/client.ts",
"chars": 359,
"preview": "import { loadStripe, Stripe } from '@stripe/stripe-js';\n\nlet stripePromise: Promise<Stripe | null>;\n\nexport const getStr"
},
{
"path": "utils/stripe/config.ts",
"chars": 564,
"preview": "import Stripe from 'stripe';\n\nexport const stripe = new Stripe(\n process.env.STRIPE_SECRET_KEY_LIVE ?? process.env.STRI"
},
{
"path": "utils/stripe/server.ts",
"chars": 4426,
"preview": "'use server';\n\nimport Stripe from 'stripe';\nimport { stripe } from '@/utils/stripe/config';\nimport { createClient } from"
},
{
"path": "utils/supabase/admin.ts",
"chars": 9821,
"preview": "import { toDateTime } from '@/utils/helpers';\nimport { stripe } from '@/utils/stripe/config';\nimport { createClient } fr"
},
{
"path": "utils/supabase/client.ts",
"chars": 409,
"preview": "import { createBrowserClient } from '@supabase/ssr';\nimport { Database } from '@/types_db';\n\n// Define a function to cre"
},
{
"path": "utils/supabase/middleware.ts",
"chars": 2409,
"preview": "import { createServerClient, type CookieOptions } from '@supabase/ssr';\nimport { type NextRequest, NextResponse } from '"
},
{
"path": "utils/supabase/queries.ts",
"chars": 1068,
"preview": "import { SupabaseClient } from '@supabase/supabase-js';\nimport { cache } from 'react';\n\nexport const getUser = cache(asy"
},
{
"path": "utils/supabase/server.ts",
"chars": 1773,
"preview": "import { createServerClient, type CookieOptions } from '@supabase/ssr';\nimport { cookies } from 'next/headers';\nimport {"
}
]
// ... and 1 more files (download for full content)
About this extraction
This page contains the full source code of the vercel/nextjs-subscription-payments GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 76 files (144.6 KB), approximately 37.7k tokens, and a symbol index with 102 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.