├── nhost ├── metadata │ ├── actions.graphql │ ├── allow_list.yaml │ ├── api_limits.yaml │ ├── network.yaml │ ├── cron_triggers.yaml │ ├── inherited_roles.yaml │ ├── rest_endpoints.yaml │ ├── version.yaml │ ├── query_collections.yaml │ ├── graphql_schema_introspection.yaml │ ├── databases │ │ ├── default │ │ │ └── tables │ │ │ │ ├── public_customers.yaml │ │ │ │ ├── tables.yaml │ │ │ │ ├── auth_provider_requests.yaml │ │ │ │ ├── public_profile.yaml │ │ │ │ ├── auth_providers.yaml │ │ │ │ ├── public_plans.yaml │ │ │ │ ├── public_products.yaml │ │ │ │ ├── auth_roles.yaml │ │ │ │ ├── auth_user_roles.yaml │ │ │ │ ├── auth_refresh_tokens.yaml │ │ │ │ ├── auth_user_security_keys.yaml │ │ │ │ ├── public_profiles.yaml │ │ │ │ ├── storage_files.yaml │ │ │ │ ├── auth_user_providers.yaml │ │ │ │ ├── storage_buckets.yaml │ │ │ │ └── auth_users.yaml │ │ └── databases.yaml │ ├── actions.yaml │ └── remote_schemas.yaml ├── emails │ ├── en │ │ ├── email-verify │ │ │ ├── subject.txt │ │ │ └── body.html │ │ ├── password-reset │ │ │ ├── subject.txt │ │ │ └── body.html │ │ ├── signin-passwordless │ │ │ ├── subject.txt │ │ │ └── body.html │ │ ├── email-confirm-change │ │ │ ├── subject.txt │ │ │ └── body.html │ │ └── signin-passwordless-sms │ │ │ └── body.txt │ └── fr │ │ ├── email-verify │ │ ├── subject.txt │ │ └── body.html │ │ ├── signin-passwordless-sms │ │ └── body.txt │ │ ├── signin-passwordless │ │ ├── subject.txt │ │ └── body.html │ │ ├── email-confirm-change │ │ ├── subject.txt │ │ └── body.html │ │ └── password-reset │ │ ├── subject.txt │ │ └── body.html ├── migrations │ └── default │ │ ├── 1669308895353_create_table_public_profile │ │ ├── down.sql │ │ └── up.sql │ │ ├── 1669316801508_create_table_public_products │ │ ├── down.sql │ │ └── up.sql │ │ ├── 1669976516108_rename_table_public_products │ │ ├── down.sql │ │ └── up.sql │ │ ├── 1669325785961_run_sql_migration │ │ ├── up.sql │ │ └── down.sql │ │ ├── 1669975961205_alter_table_public_profiles_add_column_plan_id │ │ ├── down.sql │ │ └── up.sql │ │ ├── 1669975970500_alter_table_public_products_alter_column_price_id │ │ ├── down.sql │ │ └── up.sql │ │ └── 1669317002587_alter_table_public_products_alter_column_price_id │ │ ├── down.sql │ │ └── up.sql ├── seeds │ └── default │ │ └── 001-plans.sql └── config.yaml ├── components ├── ui │ ├── Input │ │ ├── index.ts │ │ ├── Input.module.css │ │ └── Input.tsx │ ├── Navbar │ │ ├── index.ts │ │ ├── Navbar.module.css │ │ └── Navbar.tsx │ ├── Button │ │ ├── index.ts │ │ ├── Button.module.css │ │ └── Button.tsx │ ├── Footer │ │ ├── index.ts │ │ └── Footer.tsx │ └── LoadingDots │ │ ├── index.ts │ │ ├── LoadingDots.tsx │ │ └── LoadingDots.module.css ├── icons │ ├── Logo.tsx │ └── GitHub.tsx ├── Layout.tsx └── Pricing.tsx ├── public ├── demo.png ├── og.png ├── favicon.ico ├── stripe.svg ├── vercel.svg ├── github.svg ├── nextjs.svg └── nhost.svg ├── postcss.config.js ├── utils ├── react-query-client.ts ├── nhost.ts ├── helpers.ts └── graphql-fetcher.ts ├── functions ├── _utils │ ├── graphql │ │ ├── plans.graphql │ │ ├── users.graphql │ │ └── profiles.graphql │ ├── stripe.ts │ ├── graphql-client.ts │ └── helpers.ts ├── test.js ├── graphql │ └── stripe.ts ├── events │ └── users │ │ └── insert │ │ └── stripe.ts ├── webhooks │ └── stripe.ts └── custom │ └── create-checkout-session.ts ├── tailwind.config.js ├── .env.development.example ├── next-env.d.ts ├── graphql ├── stripe.graphql ├── plans.graphql └── user.graphql ├── styles ├── chrome-bug.css └── main.css ├── pages ├── _document.tsx ├── index.tsx ├── _app.tsx ├── signin.tsx └── account.tsx ├── .gitignore ├── graphql.config.yaml ├── tsconfig.json ├── LICENSE ├── package.json ├── types.ts └── README.md /nhost/metadata/actions.graphql: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nhost/metadata/allow_list.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /nhost/metadata/api_limits.yaml: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /nhost/metadata/network.yaml: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /nhost/metadata/cron_triggers.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /nhost/metadata/inherited_roles.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /nhost/metadata/rest_endpoints.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /nhost/metadata/version.yaml: -------------------------------------------------------------------------------- 1 | version: 3 2 | -------------------------------------------------------------------------------- /nhost/metadata/query_collections.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /nhost/emails/en/email-verify/subject.txt: -------------------------------------------------------------------------------- 1 | Verify your email -------------------------------------------------------------------------------- /nhost/emails/en/password-reset/subject.txt: -------------------------------------------------------------------------------- 1 | Reset your password -------------------------------------------------------------------------------- /components/ui/Input/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Input'; 2 | -------------------------------------------------------------------------------- /components/ui/Navbar/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Navbar' 2 | -------------------------------------------------------------------------------- /nhost/emails/en/signin-passwordless/subject.txt: -------------------------------------------------------------------------------- 1 | Secure sign-in link -------------------------------------------------------------------------------- /nhost/emails/fr/email-verify/subject.txt: -------------------------------------------------------------------------------- 1 | Vérifier votre courriel 2 | -------------------------------------------------------------------------------- /components/ui/Button/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Button'; 2 | -------------------------------------------------------------------------------- /components/ui/Footer/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Footer'; 2 | -------------------------------------------------------------------------------- /nhost/emails/en/email-confirm-change/subject.txt: -------------------------------------------------------------------------------- 1 | Change your email address -------------------------------------------------------------------------------- /nhost/emails/en/signin-passwordless-sms/body.txt: -------------------------------------------------------------------------------- 1 | Your code is ${code}. -------------------------------------------------------------------------------- /nhost/emails/fr/signin-passwordless-sms/body.txt: -------------------------------------------------------------------------------- 1 | Votre code est ${code}. -------------------------------------------------------------------------------- /nhost/emails/fr/signin-passwordless/subject.txt: -------------------------------------------------------------------------------- 1 | Lien de connexion sécurisé 2 | -------------------------------------------------------------------------------- /nhost/metadata/graphql_schema_introspection.yaml: -------------------------------------------------------------------------------- 1 | disabled_for_roles: [] 2 | -------------------------------------------------------------------------------- /components/ui/LoadingDots/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './LoadingDots'; 2 | -------------------------------------------------------------------------------- /nhost/emails/fr/email-confirm-change/subject.txt: -------------------------------------------------------------------------------- 1 | Changez votre adresse courriel 2 | -------------------------------------------------------------------------------- /nhost/emails/fr/password-reset/subject.txt: -------------------------------------------------------------------------------- 1 | Réinitialiser votre mot de passe 2 | -------------------------------------------------------------------------------- /public/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nhost/nextjs-stripe-starter/HEAD/public/demo.png -------------------------------------------------------------------------------- /public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nhost/nextjs-stripe-starter/HEAD/public/og.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nhost/nextjs-stripe-starter/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /nhost/migrations/default/1669308895353_create_table_public_profile/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE "public"."profile"; 2 | -------------------------------------------------------------------------------- /nhost/migrations/default/1669316801508_create_table_public_products/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE "public"."products"; 2 | -------------------------------------------------------------------------------- /nhost/metadata/databases/default/tables/public_customers.yaml: -------------------------------------------------------------------------------- 1 | table: 2 | name: customers 3 | schema: public 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | plugins: { 4 | tailwindcss: {}, 5 | autoprefixer: {} 6 | } 7 | }; -------------------------------------------------------------------------------- /nhost/migrations/default/1669976516108_rename_table_public_products/down.sql: -------------------------------------------------------------------------------- 1 | alter table "public"."plans" rename to "products"; 2 | -------------------------------------------------------------------------------- /nhost/migrations/default/1669976516108_rename_table_public_products/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | "public"."products" RENAME TO "plans"; -------------------------------------------------------------------------------- /nhost/metadata/actions.yaml: -------------------------------------------------------------------------------- 1 | actions: [] 2 | custom_types: 3 | enums: [] 4 | input_objects: [] 5 | objects: [] 6 | scalars: [] 7 | -------------------------------------------------------------------------------- /utils/react-query-client.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | 3 | export const queryClient = new QueryClient(); 4 | -------------------------------------------------------------------------------- /nhost/migrations/default/1669325785961_run_sql_migration/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | public.products 3 | ADD 4 | COLUMN description text DEFAULT ''; -------------------------------------------------------------------------------- /functions/_utils/graphql/plans.graphql: -------------------------------------------------------------------------------- 1 | query getPlans($where: plans_bool_exp!) { 2 | plans(where: $where) { 3 | id 4 | name 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /functions/test.js: -------------------------------------------------------------------------------- 1 | export default (req, res) => { 2 | console.log('console log test'); 3 | res.status(200).send(`Hello ${req.query.name}!`); 4 | }; 5 | -------------------------------------------------------------------------------- /nhost/migrations/default/1669975961205_alter_table_public_profiles_add_column_plan_id/down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE public.profiles DROP COLUMN IF EXISTS plan_id; 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './pages/**/*.{js,ts,jsx,tsx}', 4 | './components/**/*.{js,ts,jsx,tsx}' 5 | ] 6 | }; 7 | -------------------------------------------------------------------------------- /nhost/migrations/default/1669975970500_alter_table_public_products_alter_column_price_id/down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE public.products RENAME COLUMN stripe_price_id TO price_id; 2 | -------------------------------------------------------------------------------- /nhost/migrations/default/1669975970500_alter_table_public_products_alter_column_price_id/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | public.products RENAME COLUMN price_id TO stripe_price_id; -------------------------------------------------------------------------------- /nhost/migrations/default/1669317002587_alter_table_public_products_alter_column_price_id/down.sql: -------------------------------------------------------------------------------- 1 | alter table "public"."products" drop constraint "products_price_id_key"; 2 | -------------------------------------------------------------------------------- /functions/_utils/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | 3 | export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '', { 4 | apiVersion: '2020-08-27' 5 | }); 6 | -------------------------------------------------------------------------------- /nhost/migrations/default/1669317002587_alter_table_public_products_alter_column_price_id/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | "public"."products" 3 | ADD 4 | CONSTRAINT "products_price_id_key" UNIQUE ("price_id"); -------------------------------------------------------------------------------- /.env.development.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_NHOST_SUBDOMAIN=localhost 2 | NEXT_PUBLIC_NHOST_REGION= 3 | 4 | # Stripe 5 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_123 6 | STRIPE_SECRET_KEY=sk_test_123 7 | STRIPE_WEBHOOK_SECRET=whsec_123 -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /components/ui/Input/Input.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply bg-black py-2 px-3 w-full appearance-none transition duration-150 ease-in-out border border-zinc-500 text-zinc-200; 3 | } 4 | 5 | .root:focus { 6 | @apply outline-none; 7 | } 8 | -------------------------------------------------------------------------------- /utils/nhost.ts: -------------------------------------------------------------------------------- 1 | import { NhostClient } from '@nhost/nextjs'; 2 | 3 | const nhost = new NhostClient({ 4 | subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN, 5 | region: process.env.NEXT_PUBLIC_NHOST_REGION 6 | }); 7 | 8 | export { nhost }; 9 | -------------------------------------------------------------------------------- /nhost/migrations/default/1669325785961_run_sql_migration/down.sql: -------------------------------------------------------------------------------- 1 | -- Could not auto-generate a down migration. 2 | -- Please write an appropriate down migration for the SQL below: 3 | -- ALTER TABLE public.products ADD COLUMN description text DEFAULT ''; 4 | -------------------------------------------------------------------------------- /graphql/stripe.graphql: -------------------------------------------------------------------------------- 1 | mutation createCustomerPortalSession( 2 | $customerId: String! 3 | $returnUrl: String! 4 | ) { 5 | stripe { 6 | createBillingPortalSession(customer: $customerId, returnUrl: $returnUrl) { 7 | id 8 | url 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /components/ui/LoadingDots/LoadingDots.tsx: -------------------------------------------------------------------------------- 1 | import s from './LoadingDots.module.css'; 2 | 3 | const LoadingDots = () => { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default LoadingDots; 14 | -------------------------------------------------------------------------------- /nhost/migrations/default/1669975961205_alter_table_public_profiles_add_column_plan_id/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | public.profiles 3 | ADD 4 | plan_id uuid; 5 | 6 | ALTER TABLE 7 | public.profiles 8 | ADD 9 | CONSTRAINT profiles_plan_id_fkey FOREIGN KEY (plan_id) REFERENCES public.products (id) ON UPDATE RESTRICT ON DELETE RESTRICT; -------------------------------------------------------------------------------- /nhost/emails/en/email-verify/body.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |

Verify Email

10 |

Use this link to verify your email:

11 |

12 | 13 | Verify Email 14 | 15 |

16 | 17 | 18 | -------------------------------------------------------------------------------- /nhost/emails/en/signin-passwordless/body.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |

Magic Link

10 |

Use this link to securely sign in:

11 |

12 | 13 | Sign In 14 | 15 |

16 | 17 | 18 | -------------------------------------------------------------------------------- /nhost/emails/en/password-reset/body.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |

Reset Password

10 |

Use this link to reset your password:

11 |

12 | 13 | Reset password 14 | 15 |

16 | 17 | 18 | -------------------------------------------------------------------------------- /nhost/emails/en/email-confirm-change/body.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |

Confirm Email Change

10 |

Use this link to confirm changing email:

11 |

12 | 13 | Change email 14 | 15 |

16 | 17 | 18 | -------------------------------------------------------------------------------- /functions/_utils/graphql/users.graphql: -------------------------------------------------------------------------------- 1 | query getUser($id: uuid!) { 2 | user(id: $id) { 3 | id 4 | email 5 | displayName 6 | profile { 7 | id 8 | stripeCustomerId 9 | stripeCustomer { 10 | subscriptions { 11 | data { 12 | id 13 | } 14 | } 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /nhost/migrations/default/1669308895353_create_table_public_profile/up.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 2 | 3 | CREATE TABLE "public"."profiles" ( 4 | "id" uuid NOT NULL DEFAULT gen_random_uuid(), 5 | "stripe_customer_id" text, 6 | PRIMARY KEY ("id"), 7 | FOREIGN KEY ("id") REFERENCES "auth"."users"("id") ON UPDATE RESTRICT ON DELETE CASCADE 8 | ); 9 | -------------------------------------------------------------------------------- /nhost/emails/fr/signin-passwordless/body.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |

Lien magique

10 |

Utilisez ce lien pour vous connecter de façon sécuritaire:

11 |

12 | 13 | Connexion 14 | 15 |

16 | 17 | 18 | -------------------------------------------------------------------------------- /nhost/emails/fr/email-verify/body.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |

Vérifiez votre courriel

10 |

Utilisez ce lien pour vérifier votre courriel:

11 |

12 | 13 | Vérifier courriel 14 | 15 |

16 | 17 | 18 | -------------------------------------------------------------------------------- /nhost/emails/fr/email-confirm-change/body.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |

Confirmer changement de courriel

10 |

Utilisez ce lien pour confirmer le changement de courriel:

11 |

12 | 13 | Changer courriel 14 | 15 |

16 | 17 | 18 | -------------------------------------------------------------------------------- /styles/chrome-bug.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Chrome has a bug with transitions on load since 2012! 3 | * 4 | * To prevent a "pop" of content, you have to disable all transitions until 5 | * the page is done loading. 6 | * 7 | * https://lab.laukstein.com/bug/input 8 | * https://twitter.com/timer150/status/1345217126680899584 9 | */ 10 | body.loading * { 11 | transition: none !important; 12 | } 13 | -------------------------------------------------------------------------------- /utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Price } from 'types'; 2 | 3 | export const getURL = () => { 4 | const url = 5 | process?.env?.URL && process.env.URL !== '' 6 | ? process.env.URL 7 | : process?.env?.VERCEL_URL && process.env.VERCEL_URL !== '' 8 | ? process.env.VERCEL_URL 9 | : 'http://localhost:3000'; 10 | return url.includes('http') ? url : `https://${url}`; 11 | }; 12 | -------------------------------------------------------------------------------- /nhost/emails/fr/password-reset/body.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |

Réinitializer votre mot de passe

10 |

Utilisez ce lien pour réinitializer votre mot de passe:

11 |

12 | 13 | Réinitializer mot de passe 14 | 15 |

16 | 17 | 18 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Head, Html, Main, NextScript } from 'next/document'; 2 | 3 | class MyDocument extends Document { 4 | render() { 5 | return ( 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | ); 14 | } 15 | } 16 | 17 | export default MyDocument; 18 | -------------------------------------------------------------------------------- /functions/_utils/graphql/profiles.graphql: -------------------------------------------------------------------------------- 1 | mutation InsertProfile($profile: profiles_insert_input!) { 2 | insertProfile(object: $profile) { 3 | id 4 | } 5 | } 6 | 7 | mutation UpdateProfileUsingStripeCustomerId( 8 | $stripeCustomerId: String! 9 | $profile: profiles_set_input! 10 | ) { 11 | update_profiles( 12 | where: { stripeCustomerId: { _eq: $stripeCustomerId } } 13 | _set: $profile 14 | ) { 15 | affected_rows 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /nhost/metadata/databases/databases.yaml: -------------------------------------------------------------------------------- 1 | - name: default 2 | kind: postgres 3 | configuration: 4 | connection_info: 5 | database_url: 6 | from_env: HASURA_GRAPHQL_DATABASE_URL 7 | isolation_level: read-committed 8 | pool_settings: 9 | connection_lifetime: 600 10 | idle_timeout: 180 11 | max_connections: 50 12 | retries: 20 13 | use_prepared_statements: true 14 | tables: "!include default/tables/tables.yaml" 15 | -------------------------------------------------------------------------------- /functions/_utils/graphql-client.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from 'graphql-request'; 2 | import { getSdk } from './__generated__/graphql-request'; 3 | 4 | const endpoint = `${process.env.NHOST_BACKEND_URL}/v1/graphql`; 5 | 6 | // This GraphQL Client is only used in serverless functions (secure). 7 | const client = new GraphQLClient(endpoint, { 8 | headers: { 9 | ['x-hasura-admin-secret']: process.env.NHOST_ADMIN_SECRET as string 10 | } 11 | }); 12 | 13 | export const sdk = getSdk(client); 14 | -------------------------------------------------------------------------------- /nhost/metadata/databases/default/tables/tables.yaml: -------------------------------------------------------------------------------- 1 | - "!include auth_provider_requests.yaml" 2 | - "!include auth_providers.yaml" 3 | - "!include auth_refresh_tokens.yaml" 4 | - "!include auth_roles.yaml" 5 | - "!include auth_user_providers.yaml" 6 | - "!include auth_user_roles.yaml" 7 | - "!include auth_user_security_keys.yaml" 8 | - "!include auth_users.yaml" 9 | - "!include public_plans.yaml" 10 | - "!include public_profiles.yaml" 11 | - "!include storage_buckets.yaml" 12 | - "!include storage_files.yaml" 13 | -------------------------------------------------------------------------------- /components/ui/Navbar/Navbar.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply sticky top-0 bg-black z-40 transition-all duration-150; 3 | } 4 | 5 | .link { 6 | @apply inline-flex items-center leading-6 font-medium transition ease-in-out duration-75 cursor-pointer text-zinc-200 rounded-md p-1; 7 | } 8 | 9 | .link:hover { 10 | @apply text-zinc-100; 11 | } 12 | 13 | .link:focus { 14 | @apply outline-none text-zinc-100 ring-2 ring-pink-500 ring-opacity-50; 15 | } 16 | 17 | .logo { 18 | @apply cursor-pointer rounded-full transform duration-100 ease-in-out; 19 | } 20 | -------------------------------------------------------------------------------- /functions/graphql/stripe.ts: -------------------------------------------------------------------------------- 1 | import { Context, createStripeGraphQLServer } from '@nhost/stripe-graphql-js'; 2 | 3 | const isAllowed = async (stripeCustomerId: string, context: Context) => { 4 | const { isAdmin, userClaims } = context; 5 | 6 | if (isAdmin) { 7 | return true; 8 | } 9 | 10 | //TODO: Make sure the user can only access their own stripe customer id 11 | 12 | return true; 13 | }; 14 | 15 | process.env.NODE_ENVIRONMENT = 'development'; 16 | const server = createStripeGraphQLServer({ isAllowed }); 17 | 18 | export default server; 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # editors 37 | .vscode 38 | 39 | .nhost 40 | .env.development 41 | functions/node_modules -------------------------------------------------------------------------------- /components/ui/LoadingDots/LoadingDots.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply inline-flex text-center items-center leading-7; 3 | } 4 | 5 | .root span { 6 | @apply bg-zinc-200 rounded-full h-2 w-2; 7 | animation-name: blink; 8 | animation-duration: 1.4s; 9 | animation-iteration-count: infinite; 10 | animation-fill-mode: both; 11 | margin: 0 2px; 12 | } 13 | 14 | .root span:nth-of-type(2) { 15 | animation-delay: 0.2s; 16 | } 17 | 18 | .root span:nth-of-type(3) { 19 | animation-delay: 0.4s; 20 | } 21 | 22 | @keyframes blink { 23 | 0% { 24 | opacity: 0.2; 25 | } 26 | 20% { 27 | opacity: 1; 28 | } 29 | 100% { 30 | opacity: 0.2; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /components/icons/Logo.tsx: -------------------------------------------------------------------------------- 1 | const Logo = ({ className = '', ...props }) => ( 2 | 11 | 12 | 18 | 19 | ); 20 | 21 | export default Logo; 22 | -------------------------------------------------------------------------------- /graphql/plans.graphql: -------------------------------------------------------------------------------- 1 | fragment plan on plans { 2 | id 3 | name 4 | description 5 | amount 6 | currency 7 | stripePriceId 8 | } 9 | 10 | fragment UserWithSubscription on users { 11 | id 12 | profile { 13 | id 14 | stripeCustomer { 15 | subscriptions { 16 | data { 17 | id 18 | items { 19 | data { 20 | id 21 | price { 22 | id 23 | } 24 | } 25 | } 26 | } 27 | } 28 | } 29 | } 30 | } 31 | 32 | query getPlansAndSubscription($userId: uuid!) { 33 | plans { 34 | ...plan 35 | } 36 | user(id: $userId) { 37 | ...UserWithSubscription 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /graphql.config.yaml: -------------------------------------------------------------------------------- 1 | schema: 2 | - http://localhost:1337/v1/graphql: 3 | headers: 4 | x-hasura-admin-secret: nhost-admin-secret 5 | generates: 6 | ./utils/__generated__/graphql.ts: 7 | documents: 8 | - './**/*.graphql' 9 | - '!functions/**/*.graphql' 10 | plugins: 11 | - 'typescript' 12 | - 'typescript-operations' 13 | - 'typescript-react-query' 14 | config: 15 | fetcher: 16 | func: '../graphql-fetcher#fetchData' 17 | isReactHook: false 18 | functions/_utils/__generated__/graphql-request.ts: 19 | documents: 20 | - 'functions/**/*.graphql' 21 | plugins: 22 | - 'typescript' 23 | - 'typescript-operations' 24 | - 'typescript-graphql-request' 25 | -------------------------------------------------------------------------------- /nhost/seeds/default/001-plans.sql: -------------------------------------------------------------------------------- 1 | -- update the price_id with the price id from stripe 2 | -- amount is in cents 3 | INSERT INTO 4 | public.plans (name, stripe_price_id, amount, currency, description) 5 | VALUES 6 | ( 7 | 'Hobby', 8 | 'price_1M82BOCCF9wuB4fX3BplVWB9', 9 | 1200, 10 | 'USD', 11 | 'Hobby Description' 12 | ), 13 | ( 14 | 'Startup', 15 | 'price_1M82BuCCF9wuB4fXhdtUU6Pk', 16 | 2400, 17 | 'USD', 18 | 'Startup Description' 19 | ), 20 | ( 21 | 'Pro', 22 | 'price_1M82CACCF9wuB4fXZ019vYFb', 23 | 3200, 24 | 'USD', 25 | 'Pro Description' 26 | ), 27 | ( 28 | 'Enterprise', 29 | 'price_1M82CTCCF9wuB4fXA9Sg5jyT', 30 | 4800, 31 | 'USD', 32 | 'Enterprise Description' 33 | ); -------------------------------------------------------------------------------- /nhost/metadata/databases/default/tables/auth_provider_requests.yaml: -------------------------------------------------------------------------------- 1 | table: 2 | name: provider_requests 3 | schema: auth 4 | configuration: 5 | column_config: 6 | id: 7 | custom_name: id 8 | options: 9 | custom_name: options 10 | custom_column_names: 11 | id: id 12 | options: options 13 | custom_name: authProviderRequests 14 | custom_root_fields: 15 | delete: deleteAuthProviderRequests 16 | delete_by_pk: deleteAuthProviderRequest 17 | insert: insertAuthProviderRequests 18 | insert_one: insertAuthProviderRequest 19 | select: authProviderRequests 20 | select_aggregate: authProviderRequestsAggregate 21 | select_by_pk: authProviderRequest 22 | update: updateAuthProviderRequests 23 | update_by_pk: updateAuthProviderRequest 24 | -------------------------------------------------------------------------------- /nhost/metadata/databases/default/tables/public_profile.yaml: -------------------------------------------------------------------------------- 1 | table: 2 | name: profile 3 | schema: public 4 | object_relationships: 5 | - name: user 6 | using: 7 | foreign_key_constraint_on: id 8 | remote_relationships: 9 | - definition: 10 | to_remote_schema: 11 | lhs_fields: 12 | - stripe_customer_id 13 | remote_field: 14 | stripe: 15 | arguments: {} 16 | field: 17 | customer: 18 | arguments: 19 | id: $stripe_customer_id 20 | remote_schema: stripe 21 | name: stripeCustomer 22 | select_permissions: 23 | - role: user 24 | permission: 25 | columns: 26 | - stripe_customer_id 27 | - id 28 | filter: 29 | id: 30 | _eq: X-Hasura-User-Id 31 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import LoadingDots from '@/components/ui/LoadingDots'; 2 | import { nhost } from '@/utils/nhost'; 3 | import { useGetPlansAndSubscriptionQuery } from '@/utils/__generated__/graphql'; 4 | import Pricing from 'components/Pricing'; 5 | 6 | export default function PricingPage() { 7 | const user = nhost.auth.getUser(); 8 | 9 | const { data, isLoading } = useGetPlansAndSubscriptionQuery({ 10 | userId: user?.id 11 | }); 12 | 13 | if (isLoading) { 14 | return ( 15 |
16 | 17 |
18 | ); 19 | } 20 | 21 | if (!data) { 22 | return
Failed to load data
; 23 | } 24 | 25 | const { plans, user: userWithSubscription } = data; 26 | 27 | return ; 28 | } 29 | -------------------------------------------------------------------------------- /nhost/metadata/databases/default/tables/auth_providers.yaml: -------------------------------------------------------------------------------- 1 | table: 2 | name: providers 3 | schema: auth 4 | configuration: 5 | column_config: 6 | id: 7 | custom_name: id 8 | custom_column_names: 9 | id: id 10 | custom_name: authProviders 11 | custom_root_fields: 12 | delete: deleteAuthProviders 13 | delete_by_pk: deleteAuthProvider 14 | insert: insertAuthProviders 15 | insert_one: insertAuthProvider 16 | select: authProviders 17 | select_aggregate: authProvidersAggregate 18 | select_by_pk: authProvider 19 | update: updateAuthProviders 20 | update_by_pk: updateAuthProvider 21 | array_relationships: 22 | - name: userProviders 23 | using: 24 | foreign_key_constraint_on: 25 | column: provider_id 26 | table: 27 | name: user_providers 28 | schema: auth 29 | -------------------------------------------------------------------------------- /graphql/user.graphql: -------------------------------------------------------------------------------- 1 | query getUser($id: uuid!) { 2 | user(id: $id) { 3 | id 4 | profile { 5 | id 6 | stripeCustomerId 7 | stripeCustomer { 8 | invoices { 9 | data { 10 | id 11 | created 12 | invoicePdf 13 | hostedInvoiceUrl 14 | paid 15 | } 16 | } 17 | subscriptions { 18 | data { 19 | id 20 | items { 21 | data { 22 | id 23 | price { 24 | id 25 | unitAmount 26 | currency 27 | product { 28 | id 29 | name 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "baseUrl": ".", 21 | "paths": { 22 | "@/components/*": ["components/*"], 23 | "@/utils/*": ["utils/*"], 24 | "@/styles/*": ["styles/*"] 25 | }, 26 | "incremental": true 27 | }, 28 | "include": [ 29 | "next-env.d.ts", 30 | "**/*.ts", 31 | "**/*.tsx" 32 | ], 33 | "exclude": [ 34 | "node_modules" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /nhost/metadata/databases/default/tables/public_plans.yaml: -------------------------------------------------------------------------------- 1 | table: 2 | name: plans 3 | schema: public 4 | configuration: 5 | column_config: 6 | created_at: 7 | custom_name: createdAt 8 | stripe_price_id: 9 | custom_name: stripePriceId 10 | updated_at: 11 | custom_name: updatedAt 12 | custom_column_names: 13 | created_at: createdAt 14 | stripe_price_id: stripePriceId 15 | updated_at: updatedAt 16 | custom_root_fields: {} 17 | array_relationships: 18 | - name: profiles 19 | using: 20 | foreign_key_constraint_on: 21 | column: plan_id 22 | table: 23 | name: profiles 24 | schema: public 25 | select_permissions: 26 | - role: user 27 | permission: 28 | columns: 29 | - amount 30 | - currency 31 | - description 32 | - id 33 | - name 34 | - stripe_price_id 35 | filter: {} 36 | -------------------------------------------------------------------------------- /nhost/metadata/databases/default/tables/public_products.yaml: -------------------------------------------------------------------------------- 1 | table: 2 | name: products 3 | schema: public 4 | configuration: 5 | column_config: 6 | created_at: 7 | custom_name: createdAt 8 | stripe_price_id: 9 | custom_name: stripePriceId 10 | updated_at: 11 | custom_name: updatedAt 12 | custom_column_names: 13 | created_at: createdAt 14 | stripe_price_id: stripePriceId 15 | updated_at: updatedAt 16 | custom_root_fields: {} 17 | array_relationships: 18 | - name: profiles 19 | using: 20 | foreign_key_constraint_on: 21 | column: plan_id 22 | table: 23 | name: profiles 24 | schema: public 25 | select_permissions: 26 | - role: user 27 | permission: 28 | columns: 29 | - amount 30 | - currency 31 | - description 32 | - id 33 | - name 34 | - stripe_price_id 35 | filter: {} 36 | -------------------------------------------------------------------------------- /components/ui/Button/Button.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | @apply bg-white text-zinc-800 cursor-pointer inline-flex px-10 rounded-sm leading-6 transition ease-in-out duration-150 shadow-sm font-semibold text-center justify-center uppercase py-4 border border-transparent items-center; 3 | } 4 | 5 | .root:hover { 6 | @apply bg-zinc-800 text-white border border-white; 7 | } 8 | 9 | .root:focus { 10 | @apply outline-none ring-2 ring-pink-500 ring-opacity-50; 11 | } 12 | 13 | .root[data-active] { 14 | @apply bg-zinc-600; 15 | } 16 | 17 | .loading { 18 | @apply bg-zinc-700 text-zinc-500 border-zinc-600 cursor-not-allowed; 19 | } 20 | 21 | .slim { 22 | @apply py-2 transform-none normal-case; 23 | } 24 | 25 | .disabled, 26 | .disabled:hover { 27 | @apply text-zinc-400 border-zinc-600 bg-zinc-700 cursor-not-allowed; 28 | filter: grayscale(1); 29 | -webkit-transform: translateZ(0); 30 | -webkit-perspective: 1000; 31 | -webkit-backface-visibility: hidden; 32 | } 33 | -------------------------------------------------------------------------------- /nhost/metadata/databases/default/tables/auth_roles.yaml: -------------------------------------------------------------------------------- 1 | table: 2 | name: roles 3 | schema: auth 4 | configuration: 5 | column_config: 6 | role: 7 | custom_name: role 8 | custom_column_names: 9 | role: role 10 | custom_name: authRoles 11 | custom_root_fields: 12 | delete: deleteAuthRoles 13 | delete_by_pk: deleteAuthRole 14 | insert: insertAuthRoles 15 | insert_one: insertAuthRole 16 | select: authRoles 17 | select_aggregate: authRolesAggregate 18 | select_by_pk: authRole 19 | update: updateAuthRoles 20 | update_by_pk: updateAuthRole 21 | array_relationships: 22 | - name: userRoles 23 | using: 24 | foreign_key_constraint_on: 25 | column: role 26 | table: 27 | name: user_roles 28 | schema: auth 29 | - name: usersByDefaultRole 30 | using: 31 | foreign_key_constraint_on: 32 | column: default_role 33 | table: 34 | name: users 35 | schema: auth 36 | -------------------------------------------------------------------------------- /components/ui/Input/Input.tsx: -------------------------------------------------------------------------------- 1 | import React, { InputHTMLAttributes, ChangeEvent } from 'react'; 2 | import cn from 'classnames'; 3 | import s from './Input.module.css'; 4 | 5 | interface Props extends Omit, 'onChange'> { 6 | className?: string; 7 | onChange: (value: string) => void; 8 | } 9 | const Input = (props: Props) => { 10 | const { className, children, onChange, ...rest } = props; 11 | 12 | const rootClassName = cn(s.root, {}, className); 13 | 14 | const handleOnChange = (e: ChangeEvent) => { 15 | if (onChange) { 16 | onChange(e.target.value); 17 | } 18 | return null; 19 | }; 20 | 21 | return ( 22 | 33 | ); 34 | }; 35 | 36 | export default Input; 37 | -------------------------------------------------------------------------------- /nhost/metadata/databases/default/tables/auth_user_roles.yaml: -------------------------------------------------------------------------------- 1 | table: 2 | name: user_roles 3 | schema: auth 4 | configuration: 5 | column_config: 6 | created_at: 7 | custom_name: createdAt 8 | id: 9 | custom_name: id 10 | role: 11 | custom_name: role 12 | user_id: 13 | custom_name: userId 14 | custom_column_names: 15 | created_at: createdAt 16 | id: id 17 | role: role 18 | user_id: userId 19 | custom_name: authUserRoles 20 | custom_root_fields: 21 | delete: deleteAuthUserRoles 22 | delete_by_pk: deleteAuthUserRole 23 | insert: insertAuthUserRoles 24 | insert_one: insertAuthUserRole 25 | select: authUserRoles 26 | select_aggregate: authUserRolesAggregate 27 | select_by_pk: authUserRole 28 | update: updateAuthUserRoles 29 | update_by_pk: updateAuthUserRole 30 | object_relationships: 31 | - name: roleByRole 32 | using: 33 | foreign_key_constraint_on: role 34 | - name: user 35 | using: 36 | foreign_key_constraint_on: user_id 37 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import 'styles/main.css'; 2 | import 'styles/chrome-bug.css'; 3 | 4 | import React from 'react'; 5 | 6 | import Layout from 'components/Layout'; 7 | import { AppProps } from 'next/app'; 8 | 9 | import { NhostNextProvider, SignedIn, SignedOut } from '@nhost/nextjs'; 10 | import { nhost } from '@/utils/nhost'; 11 | import { QueryClientProvider } from '@tanstack/react-query'; 12 | import { queryClient } from '@/utils/react-query-client'; 13 | import SignIn from './signin'; 14 | 15 | export default function MyApp({ Component, pageProps }: AppProps) { 16 | return ( 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /nhost/metadata/databases/default/tables/auth_refresh_tokens.yaml: -------------------------------------------------------------------------------- 1 | table: 2 | name: refresh_tokens 3 | schema: auth 4 | configuration: 5 | column_config: 6 | created_at: 7 | custom_name: createdAt 8 | expires_at: 9 | custom_name: expiresAt 10 | refresh_token: 11 | custom_name: refreshToken 12 | user_id: 13 | custom_name: userId 14 | custom_column_names: 15 | created_at: createdAt 16 | expires_at: expiresAt 17 | refresh_token: refreshToken 18 | user_id: userId 19 | custom_name: authRefreshTokens 20 | custom_root_fields: 21 | delete: deleteAuthRefreshTokens 22 | delete_by_pk: deleteAuthRefreshToken 23 | insert: insertAuthRefreshTokens 24 | insert_one: insertAuthRefreshToken 25 | select: authRefreshTokens 26 | select_aggregate: authRefreshTokensAggregate 27 | select_by_pk: authRefreshToken 28 | update: updateAuthRefreshTokens 29 | update_by_pk: updateAuthRefreshToken 30 | object_relationships: 31 | - name: user 32 | using: 33 | foreign_key_constraint_on: user_id 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Vercel, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /nhost/migrations/default/1669316801508_create_table_public_products/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "public"."products" ( 2 | "id" uuid NOT NULL DEFAULT gen_random_uuid(), 3 | "created_at" timestamptz NOT NULL DEFAULT NOW(), 4 | "updated_at" timestamptz NOT NULL DEFAULT NOW(), 5 | "name" text NOT NULL, 6 | "price_id" text NOT NULL, 7 | "amount" bigint NOT NULL, 8 | "currency" text NOT NULL, 9 | PRIMARY KEY ("id") 10 | ); 11 | 12 | CREATE 13 | OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"() RETURNS TRIGGER AS $$ DECLARE _new record; 14 | 15 | BEGIN _new := NEW; 16 | 17 | _new."updated_at" = NOW(); 18 | 19 | RETURN _new; 20 | 21 | END; 22 | 23 | $$ LANGUAGE plpgsql; 24 | 25 | CREATE TRIGGER "set_public_products_updated_at" BEFORE 26 | UPDATE 27 | ON "public"."products" FOR EACH ROW EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"(); 28 | 29 | COMMENT ON TRIGGER "set_public_products_updated_at" ON "public"."products" IS 'trigger to set value of column "updated_at" to current timestamp on row update'; 30 | 31 | CREATE EXTENSION IF NOT EXISTS pgcrypto; -------------------------------------------------------------------------------- /utils/graphql-fetcher.ts: -------------------------------------------------------------------------------- 1 | import { nhost } from './nhost'; 2 | 3 | type AuthHeaderProps = { 4 | authorization: string; 5 | }; 6 | 7 | export const fetchData = ( 8 | query: string, 9 | variables?: TVariables, 10 | options?: RequestInit['headers'] 11 | ): (() => Promise) => { 12 | return async () => { 13 | const authHeaders = {} as AuthHeaderProps; 14 | 15 | if (nhost.auth.isAuthenticated()) { 16 | authHeaders['authorization'] = `Bearer ${nhost.auth.getAccessToken()}`; 17 | } 18 | 19 | const res = await fetch(nhost.graphql.getUrl(), { 20 | method: 'POST', 21 | headers: { 22 | 'Content-Type': 'application/json', 23 | ...authHeaders, 24 | ...(options ?? {}) 25 | }, 26 | body: JSON.stringify({ 27 | query, 28 | variables 29 | }) 30 | }); 31 | 32 | const json = await res.json(); 33 | 34 | if (json.errors) { 35 | const { message } = json.errors[0] || 'Error..'; 36 | throw new Error(message); 37 | } 38 | 39 | return json.data; 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /nhost/metadata/databases/default/tables/auth_user_security_keys.yaml: -------------------------------------------------------------------------------- 1 | table: 2 | name: user_security_keys 3 | schema: auth 4 | configuration: 5 | column_config: 6 | credential_id: 7 | custom_name: credentialId 8 | credential_public_key: 9 | custom_name: credentialPublicKey 10 | id: 11 | custom_name: id 12 | user_id: 13 | custom_name: userId 14 | custom_column_names: 15 | credential_id: credentialId 16 | credential_public_key: credentialPublicKey 17 | id: id 18 | user_id: userId 19 | custom_name: authUserSecurityKeys 20 | custom_root_fields: 21 | delete: deleteAuthUserSecurityKeys 22 | delete_by_pk: deleteAuthUserSecurityKey 23 | insert: insertAuthUserSecurityKeys 24 | insert_one: insertAuthUserSecurityKey 25 | select: authUserSecurityKeys 26 | select_aggregate: authUserSecurityKeysAggregate 27 | select_by_pk: authUserSecurityKey 28 | update: updateAuthUserSecurityKeys 29 | update_by_pk: updateAuthUserSecurityKey 30 | object_relationships: 31 | - name: user 32 | using: 33 | foreign_key_constraint_on: user_id 34 | -------------------------------------------------------------------------------- /nhost/metadata/databases/default/tables/public_profiles.yaml: -------------------------------------------------------------------------------- 1 | table: 2 | name: profiles 3 | schema: public 4 | configuration: 5 | column_config: 6 | stripe_customer_id: 7 | custom_name: stripeCustomerId 8 | custom_column_names: 9 | stripe_customer_id: stripeCustomerId 10 | custom_root_fields: 11 | insert: insertProfiles 12 | insert_one: insertProfile 13 | object_relationships: 14 | - name: plan 15 | using: 16 | foreign_key_constraint_on: plan_id 17 | - name: user 18 | using: 19 | foreign_key_constraint_on: id 20 | remote_relationships: 21 | - definition: 22 | to_remote_schema: 23 | lhs_fields: 24 | - stripe_customer_id 25 | remote_field: 26 | stripe: 27 | arguments: {} 28 | field: 29 | customer: 30 | arguments: 31 | id: $stripe_customer_id 32 | remote_schema: stripe 33 | name: stripeCustomer 34 | select_permissions: 35 | - role: user 36 | permission: 37 | columns: 38 | - stripe_customer_id 39 | - id 40 | filter: 41 | id: 42 | _eq: X-Hasura-User-Id 43 | -------------------------------------------------------------------------------- /styles/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | *, 6 | *:before, 7 | *:after { 8 | box-sizing: inherit; 9 | } 10 | 11 | *:focus { 12 | @apply outline-none ring-2 ring-pink-500 ring-opacity-50; 13 | } 14 | 15 | html { 16 | height: 100%; 17 | box-sizing: border-box; 18 | touch-action: manipulation; 19 | font-feature-settings: 'case' 1, 'rlig' 1, 'calt' 0; 20 | } 21 | 22 | html, 23 | body { 24 | font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Helvetica Neue', 25 | 'Helvetica', sans-serif; 26 | text-rendering: optimizeLegibility; 27 | -moz-osx-font-smoothing: grayscale; 28 | @apply text-white bg-zinc-800 antialiased; 29 | } 30 | 31 | body { 32 | position: relative; 33 | min-height: 100%; 34 | margin: 0; 35 | } 36 | 37 | a { 38 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 39 | } 40 | 41 | .animated { 42 | -webkit-animation-duration: 1s; 43 | animation-duration: 1s; 44 | -webkit-animation-duration: 1s; 45 | animation-duration: 1s; 46 | -webkit-animation-fill-mode: both; 47 | animation-fill-mode: both; 48 | } 49 | 50 | .height-screen-helper { 51 | height: calc(100vh - 80px); 52 | } 53 | -------------------------------------------------------------------------------- /functions/events/users/insert/stripe.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { sdk } from '../../../_utils/graphql-client'; 3 | import { stripe } from '../../../_utils/stripe'; 4 | 5 | const handler = async (req: Request, res: Response) => { 6 | const { body } = req; 7 | 8 | const { user } = await sdk.getUser({ 9 | id: body.event.data.new.id 10 | }); 11 | 12 | // check if the user exists 13 | if (!user) { 14 | return res.status(400).send('User not found'); 15 | } 16 | 17 | // check if the user already has a stripe customer attached 18 | if (user.profile?.stripeCustomerId) { 19 | return res.status(200).send('User already has a stripe customer attached'); 20 | } 21 | 22 | // create stripe customer 23 | const stripeCustomer = await stripe.customers.create({ 24 | name: user.displayName, 25 | email: user.email, 26 | metadata: { 27 | userId: user.id 28 | } 29 | }); 30 | 31 | // insert profile into database 32 | await sdk.InsertProfile({ 33 | profile: { 34 | id: user.id, 35 | stripeCustomerId: stripeCustomer.id 36 | } 37 | }); 38 | 39 | res.sendStatus(204); 40 | }; 41 | 42 | export default handler; 43 | -------------------------------------------------------------------------------- /nhost/metadata/databases/default/tables/storage_files.yaml: -------------------------------------------------------------------------------- 1 | table: 2 | name: files 3 | schema: storage 4 | configuration: 5 | column_config: 6 | bucket_id: 7 | custom_name: bucketId 8 | created_at: 9 | custom_name: createdAt 10 | etag: 11 | custom_name: etag 12 | id: 13 | custom_name: id 14 | is_uploaded: 15 | custom_name: isUploaded 16 | mime_type: 17 | custom_name: mimeType 18 | name: 19 | custom_name: name 20 | size: 21 | custom_name: size 22 | updated_at: 23 | custom_name: updatedAt 24 | uploaded_by_user_id: 25 | custom_name: uploadedByUserId 26 | custom_column_names: 27 | bucket_id: bucketId 28 | created_at: createdAt 29 | etag: etag 30 | id: id 31 | is_uploaded: isUploaded 32 | mime_type: mimeType 33 | name: name 34 | size: size 35 | updated_at: updatedAt 36 | uploaded_by_user_id: uploadedByUserId 37 | custom_name: files 38 | custom_root_fields: 39 | delete: deleteFiles 40 | delete_by_pk: deleteFile 41 | insert: insertFiles 42 | insert_one: insertFile 43 | select: files 44 | select_aggregate: filesAggregate 45 | select_by_pk: file 46 | update: updateFiles 47 | update_by_pk: updateFile 48 | object_relationships: 49 | - name: bucket 50 | using: 51 | foreign_key_constraint_on: bucket_id 52 | -------------------------------------------------------------------------------- /components/icons/GitHub.tsx: -------------------------------------------------------------------------------- 1 | const GitHub = ({ ...props }) => { 2 | return ( 3 | 10 | 16 | 17 | ); 18 | }; 19 | 20 | export default GitHub; 21 | -------------------------------------------------------------------------------- /nhost/metadata/databases/default/tables/auth_user_providers.yaml: -------------------------------------------------------------------------------- 1 | table: 2 | name: user_providers 3 | schema: auth 4 | configuration: 5 | column_config: 6 | access_token: 7 | custom_name: accessToken 8 | created_at: 9 | custom_name: createdAt 10 | id: 11 | custom_name: id 12 | provider_id: 13 | custom_name: providerId 14 | provider_user_id: 15 | custom_name: providerUserId 16 | refresh_token: 17 | custom_name: refreshToken 18 | updated_at: 19 | custom_name: updatedAt 20 | user_id: 21 | custom_name: userId 22 | custom_column_names: 23 | access_token: accessToken 24 | created_at: createdAt 25 | id: id 26 | provider_id: providerId 27 | provider_user_id: providerUserId 28 | refresh_token: refreshToken 29 | updated_at: updatedAt 30 | user_id: userId 31 | custom_name: authUserProviders 32 | custom_root_fields: 33 | delete: deleteAuthUserProviders 34 | delete_by_pk: deleteAuthUserProvider 35 | insert: insertAuthUserProviders 36 | insert_one: insertAuthUserProvider 37 | select: authUserProviders 38 | select_aggregate: authUserProvidersAggregate 39 | select_by_pk: authUserProvider 40 | update: updateAuthUserProviders 41 | update_by_pk: updateAuthUserProvider 42 | object_relationships: 43 | - name: provider 44 | using: 45 | foreign_key_constraint_on: provider_id 46 | - name: user 47 | using: 48 | foreign_key_constraint_on: user_id 49 | -------------------------------------------------------------------------------- /nhost/metadata/databases/default/tables/storage_buckets.yaml: -------------------------------------------------------------------------------- 1 | table: 2 | name: buckets 3 | schema: storage 4 | configuration: 5 | column_config: 6 | cache_control: 7 | custom_name: cacheControl 8 | created_at: 9 | custom_name: createdAt 10 | download_expiration: 11 | custom_name: downloadExpiration 12 | id: 13 | custom_name: id 14 | max_upload_file_size: 15 | custom_name: maxUploadFileSize 16 | min_upload_file_size: 17 | custom_name: minUploadFileSize 18 | presigned_urls_enabled: 19 | custom_name: presignedUrlsEnabled 20 | updated_at: 21 | custom_name: updatedAt 22 | custom_column_names: 23 | cache_control: cacheControl 24 | created_at: createdAt 25 | download_expiration: downloadExpiration 26 | id: id 27 | max_upload_file_size: maxUploadFileSize 28 | min_upload_file_size: minUploadFileSize 29 | presigned_urls_enabled: presignedUrlsEnabled 30 | updated_at: updatedAt 31 | custom_name: buckets 32 | custom_root_fields: 33 | delete: deleteBuckets 34 | delete_by_pk: deleteBucket 35 | insert: insertBuckets 36 | insert_one: insertBucket 37 | select: buckets 38 | select_aggregate: bucketsAggregate 39 | select_by_pk: bucket 40 | update: updateBuckets 41 | update_by_pk: updateBucket 42 | array_relationships: 43 | - name: files 44 | using: 45 | foreign_key_constraint_on: 46 | column: bucket_id 47 | table: 48 | name: files 49 | schema: storage 50 | -------------------------------------------------------------------------------- /components/ui/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | import React, { forwardRef, useRef, ButtonHTMLAttributes } from 'react'; 3 | import mergeRefs from 'react-merge-refs'; 4 | import styles from './Button.module.css'; 5 | 6 | import LoadingDots from 'components/ui/LoadingDots'; 7 | 8 | interface Props extends ButtonHTMLAttributes { 9 | variant?: 'slim' | 'flat'; 10 | active?: boolean; 11 | width?: number; 12 | loading?: boolean; 13 | Component?: React.ComponentType; 14 | } 15 | 16 | const Button = forwardRef((props, buttonRef) => { 17 | const { 18 | className, 19 | variant = 'flat', 20 | children, 21 | active, 22 | width, 23 | loading = false, 24 | disabled = false, 25 | style = {}, 26 | Component = 'button', 27 | ...rest 28 | } = props; 29 | const ref = useRef(null); 30 | const rootClassName = cn( 31 | styles.root, 32 | { 33 | [styles.slim]: variant === 'slim', 34 | [styles.loading]: loading, 35 | [styles.disabled]: disabled 36 | }, 37 | className 38 | ); 39 | return ( 40 | 52 | {children} 53 | {loading && ( 54 | 55 | 56 | 57 | )} 58 | 59 | ); 60 | }); 61 | 62 | export default Button; 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-subscription-payments", 3 | "version": "0.0.1", 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "next", 7 | "build": "next build", 8 | "start": "next start", 9 | "codegen": "graphql-codegen --config graphql.config.yaml --errors-only", 10 | "stripe:listen": "stripe listen --forward-to=localhost:1337/v1/functions/webhook/stripe" 11 | }, 12 | "dependencies": { 13 | "@nhost/nextjs": "^1.13.3", 14 | "@nhost/stripe-graphql-js": "^0.0.6", 15 | "@stripe/stripe-js": "1.22.0", 16 | "@tanstack/react-query": "^4.16.1", 17 | "classnames": "2.3.1", 18 | "date-fns": "^2.29.3", 19 | "graphql": "^16.6.0", 20 | "graphql-request": "^5.0.0", 21 | "graphql-tag": "^2.12.6", 22 | "jsonwebtoken": "^8.5.1", 23 | "next": "^12.2.5", 24 | "react": "17.0.2", 25 | "react-dom": "17.0.2", 26 | "react-merge-refs": "1.1.0", 27 | "stripe": "8.201.0", 28 | "swr": "1.2.0", 29 | "tailwindcss": "3.0.18" 30 | }, 31 | "devDependencies": { 32 | "@graphql-codegen/cli": "^2.14.0", 33 | "@graphql-codegen/typescript-graphql-request": "^4.5.8", 34 | "@graphql-codegen/typescript-operations": "^2.5.7", 35 | "@graphql-codegen/typescript-react-query": "^4.0.6", 36 | "@types/classnames": "2.3.1", 37 | "@types/express": "^4.17.14", 38 | "@types/jsonwebtoken": "^8.5.9", 39 | "@types/node": "^17.0.13", 40 | "@types/react": "^17.0.38", 41 | "autoprefixer": "^10.4.2", 42 | "postcss": "8.4.5", 43 | "prettier": "2.5.1", 44 | "typescript": "^4.5.5" 45 | }, 46 | "prettier": { 47 | "arrowParens": "always", 48 | "singleQuote": true, 49 | "tabWidth": 2, 50 | "trailingComma": "none" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /functions/_utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import jwt from 'jsonwebtoken'; 3 | 4 | export type UserHasuraClaims = { 5 | 'x-hasura-user-id': string; 6 | 'x-hasura-default-role': string; 7 | 'x-hasura-allowed-roles': string[]; 8 | }; 9 | 10 | export type User = { 11 | id: string; 12 | defaultRole: string; 13 | allowedRoles: string[]; 14 | }; 15 | 16 | export const getUser = (req: Request): User | null => { 17 | const authorizationHeader = req.headers['authorization']; 18 | const accessToken = authorizationHeader?.split(' ')[1]; 19 | if (!accessToken) { 20 | return null; 21 | } 22 | const jwtSecret = JSON.parse(process.env.NHOST_JWT_SECRET as string); 23 | const decodedToken = jwt.verify(accessToken, jwtSecret.key) as any; 24 | 25 | const hasuraClaims = decodedToken[ 26 | 'https://hasura.io/jwt/claims' 27 | ] as UserHasuraClaims; 28 | 29 | const user = { 30 | id: hasuraClaims['x-hasura-user-id'], 31 | defaultRole: hasuraClaims['x-hasura-default-role'], 32 | allowedRoles: hasuraClaims['x-hasura-allowed-roles'] 33 | }; 34 | 35 | return user; 36 | }; 37 | 38 | export const allowCors = (fn: any) => async (req: Request, res: Response) => { 39 | res.setHeader('Access-Control-Allow-Credentials', 'true'); 40 | res.setHeader('Access-Control-Allow-Origin', '*'); 41 | res.setHeader( 42 | 'Access-Control-Allow-Methods', 43 | 'GET,OPTIONS,PATCH,DELETE,POST,PUT' 44 | ); 45 | res.setHeader( 46 | 'Access-Control-Allow-Headers', 47 | 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version' 48 | ); 49 | if (req.method === 'OPTIONS') { 50 | return res.status(200).end(); 51 | } 52 | return await fn(req, res); 53 | }; 54 | -------------------------------------------------------------------------------- /components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { useRouter } from 'next/router'; 3 | 4 | import Navbar from 'components/ui/Navbar'; 5 | import Footer from 'components/ui/Footer'; 6 | import { ReactNode } from 'react'; 7 | import { PageMeta } from '../types'; 8 | 9 | interface Props { 10 | children: ReactNode; 11 | meta?: PageMeta; 12 | } 13 | 14 | export default function Layout({ children, meta: pageMeta }: Props) { 15 | const router = useRouter(); 16 | const meta = { 17 | title: 'Next.js Subscription Starter', 18 | description: 'Brought to you by Next.js, Stripe, and Nhost.', 19 | cardImage: '/og.png', 20 | ...pageMeta 21 | }; 22 | 23 | return ( 24 | <> 25 | 26 | {meta.title} 27 | 28 | 29 | 30 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
{children}
47 |