├── .yarnrc.yml ├── .eslintrc.json ├── .gitattributes ├── src ├── app │ ├── favicon.ico │ ├── fonts │ │ ├── GeistVF.woff │ │ └── GeistMonoVF.woff │ ├── (general) │ │ ├── features │ │ │ └── page.tsx │ │ ├── page.tsx │ │ ├── layout.tsx │ │ ├── login │ │ │ └── page.tsx │ │ └── pricing │ │ │ └── page.tsx │ ├── api │ │ └── stripe │ │ │ ├── create-portal-session │ │ │ └── route.ts │ │ │ ├── webhooks │ │ │ └── route.ts │ │ │ └── create-checkout-session │ │ │ └── route.ts │ └── globals.css ├── services │ └── api │ │ ├── index.ts │ │ ├── stripeService.ts │ │ └── supabaseService.ts ├── lib │ ├── supabase.ts │ ├── stripe.ts │ └── utils.ts ├── components │ ├── ui │ │ ├── index.ts │ │ ├── Footer.tsx │ │ ├── button.tsx │ │ ├── Logo.tsx │ │ ├── Icon.tsx │ │ └── Header.tsx │ ├── home │ │ ├── index.ts │ │ ├── Banner.tsx │ │ ├── Features.tsx │ │ ├── Testimonials.tsx │ │ ├── Bottom.tsx │ │ └── Pricing.tsx │ └── layouts │ │ └── General.tsx ├── utils │ ├── supabase │ │ ├── client.ts │ │ ├── server.ts │ │ └── middleware.ts │ └── errors │ │ └── handlerApi.ts ├── assets │ └── images │ │ └── icons │ │ ├── linkedin.svg │ │ ├── github.svg │ │ └── medium.svg ├── hooks │ └── useUserProfile.ts ├── interfaces │ ├── User.ts │ └── index.ts └── providers │ └── app-providers.tsx ├── public └── images │ ├── pricing │ ├── 1.jpg │ └── 2.jpg │ └── testimonials │ ├── emily.jpg │ ├── jane.jpg │ ├── john.jpg │ └── small │ ├── emily.jpg │ ├── jane.jpg │ └── john.jpg ├── postcss.config.mjs ├── .env.example ├── next.config.mjs ├── components.json ├── .prettierrc.json ├── .gitignore ├── tsconfig.json ├── package.json ├── tailwind.config.ts └── README.md /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafacagri/next-stripe-supabase-tailwind-typescript/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /public/images/pricing/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafacagri/next-stripe-supabase-tailwind-typescript/HEAD/public/images/pricing/1.jpg -------------------------------------------------------------------------------- /public/images/pricing/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafacagri/next-stripe-supabase-tailwind-typescript/HEAD/public/images/pricing/2.jpg -------------------------------------------------------------------------------- /src/app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafacagri/next-stripe-supabase-tailwind-typescript/HEAD/src/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /src/services/api/index.ts: -------------------------------------------------------------------------------- 1 | export { useSupabaseService } from './supabaseService' 2 | export { useStripeService } from './stripeService' 3 | -------------------------------------------------------------------------------- /src/lib/supabase.ts: -------------------------------------------------------------------------------- 1 | import { createClientContainer } from '@/utils/supabase/client' 2 | 3 | export const supabase = createClientContainer() 4 | -------------------------------------------------------------------------------- /src/app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafacagri/next-stripe-supabase-tailwind-typescript/HEAD/src/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /public/images/testimonials/emily.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafacagri/next-stripe-supabase-tailwind-typescript/HEAD/public/images/testimonials/emily.jpg -------------------------------------------------------------------------------- /public/images/testimonials/jane.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafacagri/next-stripe-supabase-tailwind-typescript/HEAD/public/images/testimonials/jane.jpg -------------------------------------------------------------------------------- /public/images/testimonials/john.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafacagri/next-stripe-supabase-tailwind-typescript/HEAD/public/images/testimonials/john.jpg -------------------------------------------------------------------------------- /src/lib/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe' 2 | 3 | export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-09-30.acacia' }) 4 | -------------------------------------------------------------------------------- /src/app/(general)/features/page.tsx: -------------------------------------------------------------------------------- 1 | import { Features } from '@/components/home' 2 | 3 | export default function FeaturesPage() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /public/images/testimonials/small/emily.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafacagri/next-stripe-supabase-tailwind-typescript/HEAD/public/images/testimonials/small/emily.jpg -------------------------------------------------------------------------------- /public/images/testimonials/small/jane.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafacagri/next-stripe-supabase-tailwind-typescript/HEAD/public/images/testimonials/small/jane.jpg -------------------------------------------------------------------------------- /public/images/testimonials/small/john.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mustafacagri/next-stripe-supabase-tailwind-typescript/HEAD/public/images/testimonials/small/john.jpg -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_SUPABASE_URL= 2 | NEXT_PUBLIC_SUPABASE_ANON_KEY= 3 | SUPABASE_SERVICE_ROLE_KEY= 4 | NEXT_SITE_URL= 5 | STRIPE_WEBHOOK_SECRET= 6 | STRIPE_SECRET_KEY= 7 | NEXT_PUBLIC_STRIPE_PUBLISAHEBLE_KEY= -------------------------------------------------------------------------------- /src/components/ui/index.ts: -------------------------------------------------------------------------------- 1 | export { Button, buttonVariants } from "./button"; 2 | export { Footer } from "./Footer"; 3 | export { Header } from "./Header"; 4 | export { Icon } from "./Icon"; 5 | export { Logo } from "./Logo"; 6 | -------------------------------------------------------------------------------- /src/components/home/index.ts: -------------------------------------------------------------------------------- 1 | export { Banner } from './Banner' 2 | export { Bottom } from './Bottom' 3 | export { Features } from './Features' 4 | export { Pricing } from './Pricing' 5 | export { Testimonials } from './Testimonials' 6 | -------------------------------------------------------------------------------- /src/utils/supabase/client.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserClient } from '@supabase/ssr' 2 | 3 | export const createClientContainer = () => 4 | createBrowserClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!) 5 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: 'https', 7 | hostname: 'avatars.githubusercontent.com', // GitHub avatars 8 | }, 9 | ], 10 | }, 11 | } 12 | 13 | export default nextConfig 14 | -------------------------------------------------------------------------------- /src/app/(general)/page.tsx: -------------------------------------------------------------------------------- 1 | import { Banner, Bottom, Features, Pricing, Testimonials } from '@/components/home' 2 | 3 | export default function Home() { 4 | return ( 5 | <> 6 | 7 | 8 | 9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/components/layouts/General.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Footer, Header } from '@/components/ui' 4 | 5 | export default function GeneralLayout({ 6 | children, 7 | }: Readonly<{ 8 | children: React.ReactNode 9 | }>) { 10 | return ( 11 |
12 |
13 | {children} 14 |
15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/errors/handlerApi.ts: -------------------------------------------------------------------------------- 1 | export const handlerApiError = (error: unknown) => { 2 | if (error instanceof Error) { 3 | // Log the error or send it to a tracking service 4 | console.error('An error occurred:', error.message) 5 | } else { 6 | console.error('An unknown error occurred:', error) 7 | } 8 | 9 | // Optionally, return a user-friendly error message 10 | return 'Something went wrong. Please try again later.' 11 | } 12 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "htmlWhitespaceSensitivity": "css", 5 | "insertPragma": false, 6 | "jsxBracketSameLine": false, 7 | "jsxSingleQuote": true, 8 | "printWidth": 120, 9 | "proseWrap": "preserve", 10 | "quoteProps": "as-needed", 11 | "requirePragma": false, 12 | "semi": false, 13 | "singleQuote": true, 14 | "tabWidth": 2, 15 | "useTabs": false, 16 | "vueIndentScriptAndStyle": false, 17 | "endOfLine": "lf", 18 | "singleAttributePerLine": true 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | .env 38 | -------------------------------------------------------------------------------- /src/assets/images/icons/linkedin.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "baseUrl": ".", 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/hooks/useUserProfile.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useState } from 'react' 4 | import { useSupabaseService } from '@/services/api/supabaseService' 5 | import type { Profile } from '@/interfaces' 6 | 7 | export const useUserProfile = () => { 8 | const supabaseService = useSupabaseService() 9 | 10 | const [profile, setProfile] = useState(null) 11 | const [loading, setLoading] = useState(true) 12 | 13 | useEffect(() => { 14 | const loadProfile = async () => { 15 | setLoading(true) 16 | const profileData = await supabaseService.fetchUserProfile() 17 | 18 | setProfile(profileData) 19 | setLoading(false) 20 | } 21 | 22 | loadProfile() 23 | }, []) 24 | 25 | return { profile, loading } 26 | } 27 | -------------------------------------------------------------------------------- /src/interfaces/User.ts: -------------------------------------------------------------------------------- 1 | export type AppMetadata = { 2 | provider?: string 3 | providers?: string[] 4 | } 5 | 6 | export type UserMetadata = { 7 | name?: string 8 | avatar_url?: string 9 | full_name?: string 10 | user_name?: string 11 | preferred_username?: string 12 | } 13 | 14 | export type Identity = { 15 | provider: string 16 | id: string 17 | } 18 | 19 | export interface User { 20 | name?: string 21 | app_metadata: AppMetadata 22 | aud: string 23 | confirmed_at?: string 24 | created_at: string 25 | email?: string 26 | email_confirmed_at?: string 27 | id: string 28 | identities?: Identity[] 29 | is_anonymous?: boolean 30 | last_sign_in_at?: string 31 | phone?: string 32 | role?: string 33 | updated_at?: string 34 | user_metadata: UserMetadata 35 | } 36 | -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export type { User } from './User' 2 | 3 | export type PricingPlan = { 4 | id?: string 5 | name: string 6 | slug: string 7 | price_monthly: number 8 | price_yearly: number 9 | description?: string 10 | cta?: string 11 | most_popular?: boolean 12 | is_featured?: boolean 13 | pricing_features?: PricingFeatures[] 14 | created_at?: string 15 | } 16 | 17 | export interface PricingFeatures { 18 | id?: string 19 | name: string 20 | plan_id?: string 21 | created_at?: string 22 | } 23 | 24 | export interface Profile { 25 | id?: string 26 | first_name?: string 27 | last_name?: string 28 | email: string 29 | picture?: string 30 | name?: string 31 | is_subscribed: boolean 32 | plan_id?: string 33 | pricing_plans?: PricingPlan 34 | stripe_customer_id?: string 35 | last_plan_update?: string 36 | } 37 | -------------------------------------------------------------------------------- /src/components/home/Banner.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | export function Banner() { 4 | return ( 5 |
6 |
7 |
8 |
9 |
10 | Next.js SaaS Starter with Stripe 11 |
12 |
13 | 💡 Includes Stripe integration 💳 TypeScript 📝 Supabase 🔐 Tailwind CSS 🎨 14 |
15 | —everything you need to build and scale fast! 16 |
17 |
18 |
19 |
20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/app/api/stripe/create-portal-session/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient as CreateServerClient } from '@/utils/supabase/server' 2 | import { NextResponse } from 'next/server' 3 | import { stripe } from '@/lib/stripe' 4 | 5 | export async function POST() { 6 | try { 7 | const supabaseServer = CreateServerClient({}) 8 | 9 | const { data } = await supabaseServer.from('profiles').select('stripe_customer_id').single() 10 | 11 | if (!data) { 12 | throw new Error('Profile data not found') 13 | } 14 | const portalSession = await stripe.billingPortal.sessions.create({ 15 | customer: data.stripe_customer_id, 16 | return_url: process.env.NEXT_SITE_URL, 17 | }) 18 | 19 | return NextResponse.json({ url: portalSession?.url }) 20 | } catch (error) { 21 | console.error('Error creating portal session:', error) 22 | return NextResponse.json({ error: 'Error creating portal session' }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/supabase/server.ts: -------------------------------------------------------------------------------- 1 | import { createServerClient } from '@supabase/ssr' 2 | import { cookies } from 'next/headers' 3 | 4 | export const createClient = (payload: { isServiceWorker?: boolean } = {}) => { 5 | const cookieStore = cookies() 6 | const isServiceWorker = payload?.isServiceWorker 7 | 8 | return createServerClient( 9 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 10 | isServiceWorker ? process.env.SUPABASE_SERVICE_ROLE_KEY! : process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 11 | { 12 | cookies: { 13 | getAll() { 14 | return cookieStore.getAll() 15 | }, 16 | setAll(cookiesToSet) { 17 | try { 18 | cookiesToSet.forEach(({ name, value, options }) => { 19 | cookieStore.set(name, value, options) 20 | }) 21 | } catch (error) { 22 | console.error('Error setting cookies:', error) 23 | } 24 | }, 25 | }, 26 | } 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/assets/images/icons/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* globals.css or another global CSS file */ 6 | .apply-now-btn { 7 | display: inline-flex; 8 | align-items: center; 9 | justify-content: center; 10 | position: relative; 11 | overflow: hidden; 12 | border: none; 13 | padding: 10px 20px; 14 | cursor: pointer; 15 | transition: background-color 0.3s ease; 16 | } 17 | 18 | .apply-now-btn:hover .emoji { 19 | animation: flyRocket 1s ease forwards; 20 | } 21 | 22 | .spinner { 23 | border: 4px solid rgba(0, 0, 0, 0.1); 24 | border-left-color: #4299e1; /* Change color here */ 25 | border-radius: 50%; 26 | width: 50px; 27 | height: 50px; 28 | animation: spin 1s linear infinite; 29 | } 30 | 31 | @keyframes spin { 32 | to { 33 | transform: rotate(360deg); 34 | } 35 | } 36 | 37 | @keyframes flyRocket { 38 | 0% { 39 | transform: translateY(0) scale(1); 40 | } 41 | 50% { 42 | transform: translateY(-10px) scale(1.2); 43 | } 44 | 100% { 45 | transform: translateY(0) scale(1); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/services/api/stripeService.ts: -------------------------------------------------------------------------------- 1 | import { handlerApiError } from '@/utils/errors/handlerApi' 2 | import axios from 'axios' 3 | 4 | export const useStripeService = () => { 5 | const headers = { 'Content-Type': 'application/json' } 6 | 7 | const checkout = async ({ lookup_key }: Readonly<{ lookup_key: string }>) => { 8 | try { 9 | const { data } = await axios('/api/stripe/create-checkout-session', { 10 | method: 'POST', 11 | headers, 12 | data: { lookup_key }, 13 | }) 14 | 15 | return data 16 | } catch (error: unknown) { 17 | handlerApiError(error) 18 | 19 | return null 20 | } 21 | } 22 | 23 | const navigateToStripeDashboard = async () => { 24 | const { data } = await axios('/api/stripe/create-portal-session', { 25 | method: 'POST', 26 | headers, 27 | }) 28 | 29 | if (data?.url) { 30 | window.location.href = data.url 31 | } else { 32 | console.error('Error creating portal session:', data?.error) 33 | } 34 | } 35 | 36 | return { checkout, navigateToStripeDashboard } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/(general)/layout.tsx: -------------------------------------------------------------------------------- 1 | import '@/app/globals.css' 2 | import type { Metadata } from 'next' 3 | import { AppProviders } from '@/providers/app-providers' 4 | import GeneralLayout from '@/components/layouts/General' 5 | import { Poppins } from 'next/font/google' 6 | 7 | const poppins = Poppins({ 8 | weight: ['200', '400', '500', '600', '700', '800'], 9 | subsets: ['latin'], 10 | display: 'swap', 11 | }) 12 | 13 | export const metadata: Metadata = { 14 | title: 'Next (14) Stripe with Typescript, TailwindCSS, Supabase and React 18', 15 | description: 16 | 'Next Stripe has been built with Typescript, TailwindCSS, Supabase and React 18. It is a simple, next & stripe starter kit for developers.', 17 | } 18 | 19 | export default async function RootLayout({ 20 | children, 21 | }: Readonly<{ 22 | children: React.ReactNode 23 | }>) { 24 | return ( 25 | 26 | 27 | 28 | {children} 29 | 30 | 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/assets/images/icons/medium.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/providers/app-providers.tsx: -------------------------------------------------------------------------------- 1 | // providers/app-providers.tsx 2 | 'use client' 3 | 4 | import { ComponentType, ComponentProps, useState, type ReactNode } from 'react' 5 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 6 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools' 7 | 8 | interface AppProvidersProps { 9 | children: ReactNode 10 | } 11 | 12 | export function AppProviders({ children }: Readonly) { 13 | const [queryClient] = useState( 14 | () => 15 | new QueryClient({ 16 | defaultOptions: { 17 | queries: { 18 | staleTime: 1000 * 60 * 5, 19 | gcTime: 1000 * 60 * 60, 20 | retry: 1, 21 | refetchOnWindowFocus: false, 22 | }, 23 | }, 24 | }) 25 | ) 26 | 27 | return ( 28 | 29 | {children} 30 | 31 | 32 | ) 33 | } 34 | 35 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 36 | export function withProviders>(Component: T) { 37 | return function ProviderWrapper(props: ComponentProps) { 38 | return ( 39 | 40 | 41 | 42 | ) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-stripe-supabase-tailwind", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev -p 3216", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@heroicons/react": "^2.1.5", 13 | "@next/third-parties": "^14.2.15", 14 | "@radix-ui/react-icons": "^1.3.0", 15 | "@radix-ui/react-slot": "^1.1.0", 16 | "@supabase/ssr": "^0.5.1", 17 | "@supabase/supabase-js": "^2.45.6", 18 | "@tanstack/react-query": "^5.59.15", 19 | "axios": "^1.7.7", 20 | "class-variance-authority": "^0.7.0", 21 | "clsx": "^2.1.1", 22 | "lodash": "^4.17.21", 23 | "lucide-react": "^0.453.0", 24 | "next": "^14.2.15", 25 | "react": "^18.3.1", 26 | "react-dom": "^18.3.1", 27 | "stripe": "^17.2.1", 28 | "tailwind-merge": "^2.5.4", 29 | "tailwindcss-animate": "^1.0.7" 30 | }, 31 | "devDependencies": { 32 | "@tanstack/react-query-devtools": "^5.59.15", 33 | "@types/lodash": "^4", 34 | "@types/next": "^9.0.0", 35 | "@types/node": "^22.7.7", 36 | "@types/react": "^18.3.11", 37 | "@types/react-dom": "^18", 38 | "eslint": "^8", 39 | "eslint-config-next": "14.2.15", 40 | "postcss": "^8", 41 | "tailwindcss": "^3.4.14", 42 | "typescript": "^5" 43 | }, 44 | "packageManager": "yarn@4.5.1" 45 | } 46 | -------------------------------------------------------------------------------- /src/components/ui/Footer.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Link from 'next/link' 4 | 5 | const links: { name: string; url: string }[] = [ 6 | { name: 'Homepage', url: '/' }, 7 | { name: 'Features', url: '/features' }, 8 | { name: 'Pricing', url: '/pricing' }, 9 | ] 10 | 11 | export function Footer() { 12 | return ( 13 |
14 |
15 | 26 | 27 |

28 | © 2024{' '} 29 | 33 | ReactCompanies 34 | 35 | . All rights reserved. 36 |

37 |

38 | Made with 🚀 React 18, 🔥 Next 14, 🔧 Typescript, 🎁 TailwindCSS, 💚 Supabase, 💳 Stripe and ❤️ in 📍 Istanbul 39 |

40 |
41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/supabase/middleware.ts: -------------------------------------------------------------------------------- 1 | import { createServerClient } from '@supabase/ssr' 2 | import { type NextRequest, NextResponse } from 'next/server' 3 | 4 | export const updateSession = async (request: NextRequest) => { 5 | // This `try/catch` block is only here for the interactive tutorial. 6 | // Feel free to remove once you have Supabase connected. 7 | try { 8 | // Create an unmodified response 9 | let response = NextResponse.next({ 10 | request: { 11 | headers: request.headers, 12 | }, 13 | }) 14 | 15 | const supabase = createServerClient( 16 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 17 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 18 | { 19 | cookies: { 20 | getAll() { 21 | return request.cookies.getAll() 22 | }, 23 | setAll(cookiesToSet) { 24 | cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value)) 25 | response = NextResponse.next({ 26 | request, 27 | }) 28 | cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options)) 29 | }, 30 | }, 31 | } 32 | ) 33 | 34 | // This will refresh session if expired - required for Server Components 35 | // https://supabase.com/docs/guides/auth/server-side/nextjs 36 | const user = await supabase.auth.getUser() 37 | 38 | // protected routes 39 | if (request.nextUrl.pathname.startsWith('/user') && user.error) { 40 | return NextResponse.redirect(new URL('/login', request.url)) 41 | } 42 | 43 | // if (request.nextUrl.pathname === '/' && !user.error) { 44 | // return NextResponse.redirect(new URL('/user', request.url)) 45 | // } 46 | 47 | return response 48 | } catch (e) { 49 | console.error(e) 50 | 51 | return NextResponse.next({ 52 | request: { 53 | headers: request.headers, 54 | }, 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /src/components/home/Features.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | const features = [ 4 | { 5 | name: '💳 Stripe Integration', 6 | description: 'Seamlessly integrated with Stripe for payment processing.', 7 | }, 8 | { 9 | name: '⚡ Built with TypeScript', 10 | description: 'Strongly typed and scalable with TypeScript for faster development.', 11 | }, 12 | { 13 | name: '🛠️ Powered by Supabase', 14 | description: 'Leverage the power of Supabase for real-time database and authentication.', 15 | }, 16 | { 17 | name: '🎨 Tailwind CSS', 18 | description: 'Responsive and modern designs with Tailwind CSS.', 19 | }, 20 | { 21 | name: '🚀 Ready to Scale', 22 | description: 'Everything you need to scale fast and efficiently.', 23 | }, 24 | { 25 | name: '🔒 Secure Authentication', 26 | description: 'Advanced security with secure authentication and user management.', 27 | }, 28 | ] 29 | 30 | export function Features() { 31 | return ( 32 |
33 |
34 |
35 |

🚀 Features

36 |

37 | Everything you need to build and scale fast 38 |

39 |

40 | Our SaaS starter is packed with all the essentials to kickstart your project. 41 |

42 |
43 | 44 |
45 |
46 | {features.map(feature => ( 47 |
51 |
52 |

{feature.name}

53 |
54 |
{feature.description}
55 |
56 | ))} 57 |
58 |
59 |
60 |
61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /src/components/home/Testimonials.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Image from 'next/image' 4 | 5 | const testimonials = [ 6 | { 7 | name: 'John Doe', 8 | role: 'CEO, NextJS', 9 | imageUrl: 'john.jpg', 10 | feedback: 11 | 'This service has transformed the way we work. The integration with Stripe and Supabase is flawless, and scaling has never been easier.', 12 | }, 13 | { 14 | name: 'Jane Smith', 15 | role: 'CTO, ReactJS', 16 | imageUrl: 'jane.jpg', 17 | feedback: 18 | 'The features provided have drastically improved our development workflow. Built with TypeScript and Tailwind CSS, it’s a dream to work with!', 19 | }, 20 | { 21 | name: 'Emily Johnson', 22 | role: 'Lead Developer, CREAMIVE', 23 | imageUrl: 'emily.jpg', 24 | feedback: 25 | 'Amazing service with excellent support! It’s everything we needed to launch quickly and scale efficiently.', 26 | }, 27 | ] 28 | 29 | export function Testimonials() { 30 | return ( 31 |
32 |
33 |
34 |

What Our Customers Say

35 |

36 | Hear from our happy clients 37 |

38 |
39 | 40 |
41 | {testimonials.map(testimonial => ( 42 |
46 |
47 | {`${testimonial.name} 54 |
55 |

{testimonial.name}

56 |

{testimonial.role}

57 |
58 |
59 |

" {testimonial.feedback} "

60 |
61 | ))} 62 |
63 |
64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/app/api/stripe/webhooks/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient as CreateServerClient } from '@/utils/supabase/server' 2 | import { stripe } from '@/lib/stripe' 3 | import { NextResponse } from 'next/server' 4 | 5 | export async function POST(request: Request) { 6 | const rawBody = await request.text() 7 | const signature = request.headers.get('stripe-signature') 8 | const supabaseServer = CreateServerClient({ isServiceWorker: true }) 9 | 10 | let plan 11 | let stripeEvent 12 | 13 | if (!rawBody) return NextResponse.json({ error: 'Invalid request body' }) 14 | if (!signature) return NextResponse.json({ error: 'Invalid stripe-signature' }) 15 | 16 | try { 17 | stripeEvent = stripe.webhooks.constructEvent(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET!) 18 | } catch (err) { 19 | console.error('Error verifying webhook:', err) 20 | return NextResponse.json({ 21 | error: { statusCode: 400, statusMessage: `Webhook error: ${err}` }, 22 | }) 23 | } 24 | 25 | const updateProfilePlan = async (customerId: string, slug: string | null) => { 26 | const last_plan_update = new Date() 27 | 28 | try { 29 | if (slug) { 30 | const [baseSlug] = slug.split('_') 31 | 32 | const { data: planData, error: planError } = await supabaseServer 33 | .from('pricing_plans') 34 | .select('id') 35 | .eq('slug', baseSlug) 36 | .single() 37 | 38 | if (planError || !planData) { 39 | console.error('Error fetching plan ID:', planError) 40 | return 41 | } 42 | 43 | await supabaseServer 44 | .from('profiles') 45 | .update({ is_subscribed: true, last_plan_update, plan_id: planData.id }) 46 | .eq('stripe_customer_id', customerId) 47 | } else { 48 | await supabaseServer 49 | .from('profiles') 50 | .update({ is_subscribed: false, last_plan_update, plan_id: null }) 51 | .eq('stripe_customer_id', customerId) 52 | } 53 | } catch (error) { 54 | console.log('Error updating profile plan:', error) 55 | } 56 | } 57 | 58 | switch (stripeEvent.type) { 59 | case 'customer.subscription.deleted': 60 | await updateProfilePlan(stripeEvent?.data?.object?.customer as string, null) 61 | break 62 | 63 | case 'customer.subscription.created': 64 | case 'customer.subscription.updated': 65 | plan = stripeEvent.data.object.items.data[0].price.lookup_key 66 | await updateProfilePlan(stripeEvent?.data?.object?.customer as string, plan) 67 | break 68 | 69 | default: 70 | console.log(`Unhandled event type ${stripeEvent.type}.`) 71 | } 72 | 73 | return NextResponse.json({ received: true }) 74 | } 75 | -------------------------------------------------------------------------------- /src/components/home/Bottom.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Image from 'next/image' 4 | 5 | import githubIcon from '@/assets/images/icons/github.svg' 6 | import linkedinIcon from '@/assets/images/icons/linkedin.svg' 7 | import mediumIcon from '@/assets/images/icons/medium.svg' 8 | 9 | // Define the type for social keys 10 | type SocialKey = 'github' | 'linkedin' | 'medium' 11 | 12 | const socials: Record = { 13 | github: 'https://github.com/mustafacagri', 14 | linkedin: 'https://www.linkedin.com/in/mustafacagri', 15 | medium: 'https://mustafacagri.medium.com/', 16 | } 17 | 18 | const icons = { 19 | github: githubIcon, 20 | linkedin: linkedinIcon, 21 | medium: mediumIcon, 22 | } 23 | 24 | export function Bottom() { 25 | return ( 26 |
27 |
28 |
29 |
30 |

🚀 Ready to boost your React skills?

31 |

32 | Join ReactCompanies today and explore a world of opportunities for React developers. 33 | Whether you're a junior or a senior, we have something for everyone. 🚀 Build, collaborate, and level 34 | up your career with the best in the business! 35 |

36 |
37 | 38 |
39 |

💚 Why ReactCompanies?

40 | 41 |

🌟 Access exclusive job listings tailored for React developers.

42 |

💼 Connect with top companies and showcase your React skills.

43 |

🚀 Stay ahead with the latest industry trends and tips.

44 |
45 | 46 |
47 |

🗣️ Let's connect!

48 | 49 |

📩 Follow us on our social channels for updates, articles, and more.

50 |

📢 Don't miss out on valuable insights from the React community.

51 | 52 |
53 | {Object.entries(icons).map(([social, icon]) => ( 54 | 59 | {social} 65 | 66 | ))} 67 |
68 |
69 |
70 |
71 |
72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | darkMode: ['class'], 5 | content: [ 6 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 7 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 8 | './src/app/**/*.{js,ts,jsx,tsx,mdx}', 9 | ], 10 | theme: { 11 | extend: { 12 | colors: { 13 | primary: { 14 | '50': '#f0f9ff', 15 | '100': '#e0f2fe', 16 | '200': '#bae6fd', 17 | '300': '#7dd3fc', 18 | '400': '#38bdf8', 19 | '500': '#0ea5e9', 20 | '600': '#0284c7', 21 | '700': '#0369a1', 22 | '800': '#075985', 23 | '900': '#0c4a6e', 24 | '950': '#00253b', 25 | DEFAULT: 'hsl(var(--primary))', 26 | foreground: 'hsl(var(--primary-foreground))', 27 | }, 28 | secondary: { 29 | '50': '#f3f4f6', 30 | '100': '#e5e7eb', 31 | '200': '#d1d5db', 32 | '300': '#9ca3af', 33 | '400': '#6b7280', 34 | '500': '#4b5563', 35 | '600': '#374151', 36 | '700': '#1f2937', 37 | '800': '#111827', 38 | '900': '#0f172a', 39 | '950': '#090e1a', 40 | DEFAULT: 'hsl(var(--secondary))', 41 | foreground: 'hsl(var(--secondary-foreground))', 42 | }, 43 | background: 'hsl(var(--background))', 44 | foreground: 'hsl(var(--foreground))', 45 | card: { 46 | DEFAULT: 'hsl(var(--card))', 47 | foreground: 'hsl(var(--card-foreground))', 48 | }, 49 | popover: { 50 | DEFAULT: 'hsl(var(--popover))', 51 | foreground: 'hsl(var(--popover-foreground))', 52 | }, 53 | muted: { 54 | DEFAULT: 'hsl(var(--muted))', 55 | foreground: 'hsl(var(--muted-foreground))', 56 | }, 57 | accent: { 58 | DEFAULT: 'hsl(var(--accent))', 59 | foreground: 'hsl(var(--accent-foreground))', 60 | }, 61 | destructive: { 62 | DEFAULT: 'hsl(var(--destructive))', 63 | foreground: 'hsl(var(--destructive-foreground))', 64 | }, 65 | border: 'hsl(var(--border))', 66 | input: 'hsl(var(--input))', 67 | ring: 'hsl(var(--ring))', 68 | chart: { 69 | '1': 'hsl(var(--chart-1))', 70 | '2': 'hsl(var(--chart-2))', 71 | '3': 'hsl(var(--chart-3))', 72 | '4': 'hsl(var(--chart-4))', 73 | '5': 'hsl(var(--chart-5))', 74 | }, 75 | }, 76 | borderRadius: { 77 | lg: 'var(--radius)', 78 | md: 'calc(var(--radius) - 2px)', 79 | sm: 'calc(var(--radius) - 4px)', 80 | }, 81 | }, 82 | fontFamily: { 83 | sans: ['Poppins', 'ui-sans-serif', 'system-ui'], 84 | body: ['Poppins', 'ui-sans-serif', 'system-ui'], 85 | }, 86 | }, 87 | // plugins: [require("tailwindcss-animate")], 88 | } 89 | export default config 90 | -------------------------------------------------------------------------------- /src/app/(general)/login/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | import { useSupabaseService } from '@/services/api/supabaseService' 5 | 6 | import Image from 'next/image' 7 | import github from '@/assets/images/icons/github.svg' 8 | import linkedin from '@/assets/images/icons/linkedin.svg' 9 | 10 | export default function Login() { 11 | const supabaseService = useSupabaseService() 12 | 13 | const loginWithGithub = async () => { 14 | supabaseService.loginWithGithub() 15 | } 16 | 17 | const loginWithLinkedIn = async () => { 18 | supabaseService.loginWithLinkedIn() 19 | } 20 | 21 | const loginText = () => (isLogin ? 'You can use your account to log in' : 'You can use your account to register') 22 | 23 | const [isLogin, setIsLogin] = useState(true) 24 | 25 | return ( 26 |
27 |
28 |

{isLogin ? 'login' : 'register'}

29 | 30 |
31 |
32 |

{loginText()}

33 |
34 | 47 | 48 | 61 |
62 |
63 | 64 | {isLogin && ( 65 | 71 | )} 72 | 73 | {!isLogin && ( 74 | 80 | )} 81 |
82 |
83 |
84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /src/components/ui/Logo.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | 3 | export function Logo() { 4 | return ( 5 | 9 | 15 | 16 | 17 | 22 | 23 | 24 | 25 | Next Stripe 26 | 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/app/api/stripe/create-checkout-session/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient as CreateServerClient } from '@/utils/supabase/server' 2 | import { isEmpty } from 'lodash' 3 | import { NextResponse } from 'next/server' 4 | import { stripe } from '@/lib/stripe' 5 | 6 | export async function POST(request: Request) { 7 | const supabaseServer = CreateServerClient({}) 8 | const body = await request.json() 9 | const { lookup_key } = body 10 | 11 | let isSuccess = true 12 | 13 | const { data } = await supabaseServer 14 | .from('profiles') 15 | .select('email, is_subscribed, name, stripe_customer_id') 16 | .single() 17 | 18 | if (!data) { 19 | throw new Error('Profile data not found') 20 | } 21 | 22 | const { email, is_subscribed, name } = data 23 | let { stripe_customer_id } = data 24 | 25 | // Create Stripe customer if it doesn't exist 26 | if (!stripe_customer_id && email) { 27 | const customer = await stripe.customers.create({ email, name }) 28 | 29 | if (!customer?.id) { 30 | throw new Error('Failed to create customer') 31 | } 32 | 33 | stripe_customer_id = customer.id 34 | await supabaseServer.from('profiles').update({ stripe_customer_id }).eq('email', email) 35 | } 36 | 37 | // Lookup the plan price in Stripe 38 | if (email && lookup_key) { 39 | const { data: prices } = await stripe.prices.list({ 40 | expand: ['data.product'], 41 | lookup_keys: [lookup_key], 42 | }) 43 | 44 | if (isEmpty(prices)) isSuccess = false 45 | 46 | const planPrice = prices?.[0] 47 | if (!planPrice) isSuccess = false 48 | 49 | if (isSuccess) { 50 | // Check if user is already subscribed 51 | if (is_subscribed) { 52 | // Get the existing subscription 53 | const subscriptions = await stripe.subscriptions.list({ 54 | customer: stripe_customer_id, 55 | status: 'active', 56 | limit: 1, 57 | }) 58 | 59 | const activeSubscription = subscriptions.data?.[0] 60 | 61 | if (activeSubscription) { 62 | // Update the subscription with the new price ID 63 | await stripe.subscriptions.update(activeSubscription.id, { 64 | items: [ 65 | { 66 | id: activeSubscription.items.data[0].id, 67 | price: planPrice.id, 68 | }, 69 | ], 70 | }) 71 | return NextResponse.json({ isSuccess, message: 'Subscription updated' }) 72 | } 73 | } 74 | 75 | // Create a new checkout session if not subscribed 76 | const session = await stripe.checkout.sessions.create({ 77 | customer: stripe_customer_id, 78 | billing_address_collection: 'auto', 79 | line_items: [ 80 | { 81 | price: planPrice.id, 82 | quantity: 1, 83 | }, 84 | ], 85 | mode: 'subscription', 86 | success_url: `${process.env.NEXT_SITE_URL}pricing`, 87 | cancel_url: `${process.env.NEXT_SITE_URL}`, 88 | }) 89 | 90 | return NextResponse.json({ isSuccess, session_id: session.id, plan_id: planPrice.id, url: session?.url }) 91 | } else { 92 | return NextResponse.json({ isSuccess, message: 'Plan not found' }) 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/services/api/supabaseService.ts: -------------------------------------------------------------------------------- 1 | import type { PricingPlan, Profile } from '@/interfaces' 2 | 3 | import { handlerApiError } from '@/utils/errors/handlerApi' 4 | import { supabase } from '@/lib/supabase' 5 | 6 | export const useSupabaseService = () => { 7 | const fetchPricingPlans = async ({ 8 | limit, 9 | is_featured, 10 | }: { 11 | limit?: number 12 | is_featured?: boolean 13 | }): Promise => { 14 | try { 15 | limit ??= 1000 16 | is_featured ??= false 17 | 18 | const selectQuery = 19 | 'id, name, slug, price_monthly, price_yearly, description, cta, most_popular, is_featured, pricing_features(id,name)' 20 | 21 | // Build the query 22 | let query = supabase.from('pricing_plans').select(selectQuery) 23 | 24 | // Apply filter for isFeatured only if it's defined 25 | if (typeof is_featured === 'boolean') { 26 | query = query.eq('is_featured', is_featured) 27 | } 28 | 29 | const { data, error } = await query.order('price_monthly', { ascending: true }).limit(limit) 30 | 31 | if (error) throw error 32 | 33 | return data 34 | } catch (error: unknown) { 35 | // Specify error as unknown here 36 | handlerApiError(error) 37 | return [] 38 | } 39 | } 40 | 41 | const fetchUserProfile = async (): Promise => { 42 | const { 43 | data: { user }, 44 | error: userError, 45 | } = await supabase.auth.getUser() 46 | 47 | if (userError) { 48 | return null 49 | } 50 | 51 | if (!user) { 52 | return null // Return null if there is no user. 53 | } 54 | 55 | const { data, error } = await supabase 56 | .from('profiles') 57 | .select( 58 | 'id, name, first_name, last_name, email, picture, is_subscribed, plan_id, stripe_customer_id, pricing_plans(id, name, slug, price_monthly, price_yearly)' 59 | ) 60 | .eq('id', user.id) 61 | .single() 62 | 63 | if (error) { 64 | console.error(error) 65 | return null // Return null on error. 66 | } 67 | 68 | return Array.isArray(data) && data.length > 0 ? data[0] : data ?? {} // Return the user profile data or null if undefined. 69 | } 70 | 71 | const loginWithLinkedIn = async () => { 72 | try { 73 | await supabase.auth.signInWithOAuth({ 74 | provider: 'linkedin_oidc', 75 | options: { 76 | redirectTo: `${process.env.NEXT_SITE_URL}auth/callback`, 77 | }, 78 | }) 79 | } catch (error: unknown) { 80 | // Specify error as unknown here 81 | handlerApiError(error) 82 | } 83 | } 84 | 85 | const loginWithGithub = async () => { 86 | try { 87 | await supabase.auth.signInWithOAuth({ 88 | provider: 'github', 89 | options: { 90 | redirectTo: `auth/callback`, 91 | }, 92 | }) 93 | } catch (error: unknown) { 94 | // Specify error as unknown here 95 | handlerApiError(error) 96 | } 97 | } 98 | 99 | const logout = async () => { 100 | try { 101 | const { error } = await supabase.auth.signOut() 102 | 103 | return { error } 104 | } catch (error: unknown) { 105 | // Specify error as unknown here 106 | handlerApiError(error) 107 | } 108 | } 109 | 110 | return { 111 | fetchPricingPlans, 112 | fetchUserProfile, 113 | 114 | loginWithGithub, 115 | loginWithLinkedIn, 116 | logout, 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/components/ui/Icon.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { 4 | ArchiveBoxXMarkIcon, 5 | ArrowDownTrayIcon, 6 | ArrowSmallDownIcon, 7 | ArrowSmallLeftIcon, 8 | ArrowSmallRightIcon, 9 | ArrowSmallUpIcon, 10 | BackwardIcon, 11 | BanknotesIcon, 12 | Bars3Icon, 13 | BriefcaseIcon, 14 | BuildingOffice2Icon, 15 | ChatBubbleLeftRightIcon, 16 | ChevronDownIcon, 17 | ChevronRightIcon, 18 | ChevronUpIcon, 19 | ClipboardDocumentListIcon, 20 | ClockIcon, 21 | DevicePhoneMobileIcon, 22 | ExclamationTriangleIcon, 23 | FolderOpenIcon, 24 | HomeIcon, 25 | InformationCircleIcon, 26 | ListBulletIcon, 27 | PhotoIcon, 28 | PauseCircleIcon, 29 | PauseIcon, 30 | PencilIcon, 31 | PencilSquareIcon, 32 | PlusIcon, 33 | PlusCircleIcon, 34 | StopCircleIcon, 35 | UserCircleIcon, 36 | UserIcon, 37 | XCircleIcon, 38 | } from '@heroicons/react/24/outline' 39 | 40 | import { CheckCircleIcon } from '@heroicons/react/24/solid' 41 | 42 | type IconName = 43 | | 'archiveBoxXMark' 44 | | 'arrowDownTray' 45 | | 'arrowSmallDown' 46 | | 'arrowSmallLeft' 47 | | 'arrowSmallRight' 48 | | 'arrowSmallUp' 49 | | 'backward' 50 | | 'banknotes' 51 | | 'bars3' 52 | | 'briefcase' 53 | | 'buildingOffice2' 54 | | 'chat' 55 | | 'checkCircle' 56 | | 'chevronDown' 57 | | 'chevronRight' 58 | | 'chevronUp' 59 | | 'clipboardDocumentList' 60 | | 'clock' 61 | | 'devicePhoneMobile' 62 | | 'exclamationTriangle' 63 | | 'home' 64 | | 'information' 65 | | 'folderOpen' 66 | | 'listBullet' 67 | | 'pause' 68 | | 'pauseCircle' 69 | | 'pencil' 70 | | 'pencilSquare' 71 | | 'plus' 72 | | 'plusCircle' 73 | | 'photo' 74 | | 'userCircle' 75 | | 'user' 76 | | 'stopCircle' 77 | | 'xCircle' 78 | 79 | const iconMaps: Record>> = { 80 | archiveBoxXMark: ArchiveBoxXMarkIcon, 81 | arrowDownTray: ArrowDownTrayIcon, 82 | arrowSmallDown: ArrowSmallDownIcon, 83 | arrowSmallLeft: ArrowSmallLeftIcon, 84 | arrowSmallRight: ArrowSmallRightIcon, 85 | arrowSmallUp: ArrowSmallUpIcon, 86 | backward: BackwardIcon, 87 | banknotes: BanknotesIcon, 88 | bars3: Bars3Icon, 89 | briefcase: BriefcaseIcon, 90 | buildingOffice2: BuildingOffice2Icon, 91 | chat: ChatBubbleLeftRightIcon, 92 | checkCircle: CheckCircleIcon, 93 | chevronDown: ChevronDownIcon, 94 | chevronRight: ChevronRightIcon, 95 | chevronUp: ChevronUpIcon, 96 | clipboardDocumentList: ClipboardDocumentListIcon, 97 | clock: ClockIcon, 98 | devicePhoneMobile: DevicePhoneMobileIcon, 99 | exclamationTriangle: ExclamationTriangleIcon, 100 | home: HomeIcon, 101 | information: InformationCircleIcon, 102 | folderOpen: FolderOpenIcon, 103 | listBullet: ListBulletIcon, 104 | pause: PauseIcon, 105 | pauseCircle: PauseCircleIcon, 106 | pencil: PencilIcon, 107 | pencilSquare: PencilSquareIcon, 108 | plus: PlusIcon, 109 | plusCircle: PlusCircleIcon, 110 | photo: PhotoIcon, 111 | userCircle: UserCircleIcon, 112 | user: UserIcon, 113 | stopCircle: StopCircleIcon, 114 | xCircle: XCircleIcon, 115 | } 116 | 117 | // Type guard to check if the icon name is valid 118 | function isValidIconName(icon: string): icon is IconName { 119 | return icon in iconMaps 120 | } 121 | 122 | export function Icon({ 123 | name, 124 | className = '', 125 | size = 24, 126 | }: Readonly<{ 127 | name: string // Keep it as string for external use, but validate it internally 128 | className?: string 129 | size?: number 130 | }>) { 131 | if (!isValidIconName(name)) { 132 | return null 133 | } 134 | 135 | const IconComponent = iconMaps[name] 136 | 137 | return ( 138 | 142 | ) 143 | } 144 | -------------------------------------------------------------------------------- /src/app/(general)/pricing/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | 3 | import { Check, Sparkles, Shield, CreditCard, Building2, Gauge } from 'lucide-react' 4 | import { Pricing } from '@/components/home' 5 | 6 | export default function PricingPage() { 7 | return ( 8 | <> 9 | 10 |
11 |
12 |
13 |
14 | Transparent Pricing 21 |
22 |
23 |
24 | 25 |

Transparent Pricing

26 |
27 |

28 | Discover our crystal-clear pricing model! 💎 No hidden fees, no surprises - just honest and 29 | straightforward pricing that puts you in control of your investment. 30 |

31 |
    32 |
  • 33 | 34 | Clear pricing with no hidden costs 35 |
  • 36 |
  • 37 | 38 | 100% transparent billing system 39 |
  • 40 |
  • 41 | 42 | Flexible payment options available 43 |
  • 44 |
45 |
46 |
47 | 48 |
49 |
50 |
51 | 52 |

Flexible Plans

53 |
54 |

55 | Customized solutions for businesses of every size! 🚀 From small startups to enterprise corporations, we 56 | have the perfect plan to fuel your growth. 57 |

58 |
    59 |
  • 60 | 61 | Scalable plans that grow with you 62 |
  • 63 |
  • 64 | 65 | Monthly/annual billing options 66 |
  • 67 |
  • 68 | 69 | Switch plans anytime you need 70 |
  • 71 |
72 |
73 | 74 | 30-Day Free Trial 🎁 75 | 76 |
77 |
78 |
79 | Flexible Plans 86 |
87 |
88 |
89 |
90 | 91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /src/components/ui/Header.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Image from 'next/image' 4 | 5 | import isEmpty from 'lodash/isEmpty' 6 | import Link from 'next/link' 7 | import { Logo } from '@/components/ui' 8 | import { supabase } from '@/lib/supabase' 9 | import { useEffect, useState } from 'react' 10 | import { User } from '@/interfaces' 11 | import { useRouter } from 'next/navigation' 12 | import { useSupabaseService } from '@/services/api/supabaseService' 13 | 14 | export function Header() { 15 | const supabaseService = useSupabaseService() 16 | const router = useRouter() 17 | 18 | const [user, setUser] = useState(null) 19 | const [picture, setPicture] = useState('') 20 | 21 | const [state, setState] = useState({ 22 | isMenuOpen: false, 23 | isMobileMenuOpen: false, 24 | }) 25 | 26 | const userMenu: { name: string; url: string }[] = [ 27 | { name: 'Profile', url: 'profile' }, 28 | { name: 'Subscriptions', url: 'subscriptions' }, 29 | ] 30 | 31 | const toggleDropdown = () => { 32 | setState(prevState => ({ 33 | ...prevState, 34 | isMenuOpen: !prevState.isMenuOpen, 35 | })) 36 | } 37 | 38 | useEffect(() => { 39 | const { data: authListener } = supabase.auth.onAuthStateChange( 40 | (_event: string, session: { user: User | null } | null) => { 41 | if (session?.user) { 42 | const user: User = { 43 | ...session.user, 44 | ...session.user?.user_metadata, 45 | } 46 | 47 | setPicture(user?.user_metadata?.avatar_url ?? '') 48 | setUser(user) 49 | } else { 50 | setUser(null) 51 | } 52 | } 53 | ) 54 | 55 | return () => { 56 | authListener?.subscription?.unsubscribe() 57 | } 58 | }, []) 59 | 60 | const logout = async () => { 61 | await supabaseService.logout() 62 | setUser(null) 63 | router.push('/login') 64 | } 65 | 66 | return ( 67 |
68 | 145 |
146 | ) 147 | } 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 🚀 Next.js Stripe Supabase Starter Kit ⚡ 2 | 3 | Welcome to the `Next.js SaaS Starter with Stripe` – your ultimate boilerplate for building modern, scalable SaaS applications! This powerful starter kit combines the best of Next.js, Stripe, Supabase, Typescript and Tailwind CSS to help you launch your SaaS project faster than ever! 🌟 4 | 5 | ## 🎯 Overview 6 | 7 | A powerful SaaS starter-kit built with Next.js, Stripe, Supabase, Typescript, and Tailwind CSS. Perfect for launching your next SaaS project! 8 | 9 | ### 🎥 Demo Video 10 | 11 | https://github.com/user-attachments/assets/0c7ab869-6042-490d-9064-f3988b57c8d2 12 | 13 | ### 🌍 Live Demo 14 | 15 | https://next-stripe-supabase-tailwind-typescript.vercel.app/ 16 | 17 | ## ✨ Key Features 18 | 19 | ### 💳 Complete Stripe Integration 20 | 21 | - Subscription management 22 | - Usage-based billing 23 | - Multiple pricing tiers 24 | - Secure payment processing 25 | 26 | ### 🔐 Authentication & Authorization 27 | 28 | - Supabase Auth integration 29 | - Social login providers 30 | - Role-based access control 31 | 32 | ### 🎨 Modern UI/UX 33 | 34 | - Custom components 35 | - Loading states & animations & skeletons 36 | - Responsive design with Tailwind CSS 37 | - Dark/Light mode support 38 | 39 | ### ⚡ Performance Optimized 40 | 41 | - Server-side rendering 42 | - Incremental static regeneration 43 | - Optimized images 44 | - Fast page loads 45 | 46 | ## 🛠️ Tech Stack 47 | 48 | ### 🔍 Code Quality 49 | 50 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=mustafacagri_next-stripe-supabase-tailwind-typescript&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=mustafacagri_next-stripe-supabase-tailwind) 51 | [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=mustafacagri_next-stripe-supabase-tailwind-typescript&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=mustafacagri_next-stripe-supabase-tailwind) 52 | [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=mustafacagri_next-stripe-supabase-tailwind-typescript&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=mustafacagri_next-stripe-supabase-tailwind) 53 | [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=mustafacagri_next-stripe-supabase-tailwind-typescript&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=mustafacagri_next-stripe-supabase-tailwind) 54 | 55 | This project is analyzed by: 56 | 57 | - 🔍 **SonarCloud** - Continuous code quality analysis 58 | - 🤖 **CodeRabbitAI** - AI-powered code reviews 59 | 60 | ### 🎨 Frontend 61 | 62 | - ⚛️ Next.js 14 63 | - 🌐 React.js 18 64 | - 🎨 Tailwind CSS 65 | - 📝 TypeScript 66 | - 🔄 React Query / Tanstack 67 | 68 | ### 🔐 Backend 69 | 70 | - 🗄️ Supabase 71 | - 💳 Stripe API 72 | - 🔒 Auth Providers 73 | 74 | ## 🚀 Getting Started 75 | 76 | ### 📋 Clone the repository 77 | 78 | ``` 79 | git clone https://github.com/mustafacagri/next-stripe-supabase-tailwind.git 80 | cd next-stripe-supabase-tailwind 81 | ``` 82 | 83 | ### 📦 Install dependencies 84 | 85 | ``` 86 | npm install 87 | # or 88 | yarn install 89 | # or 90 | pnpm install 91 | ``` 92 | 93 | ### 🔑 Set up environment variables 94 | 95 | ``` 96 | cp .env.example .env.local 97 | ``` 98 | 99 | Fill in your environment variables: 100 | 101 | ``` 102 | NEXT_PUBLIC_SUPABASE_URL= 103 | NEXT_PUBLIC_SUPABASE_ANON_KEY= 104 | SUPABASE_SERVICE_ROLE_KEY= 105 | NEXT_SITE_URL=http://localhost:3216 or whatever you are using 106 | STRIPE_WEBHOOK_SECRET=whsec_xxxxxxx 107 | STRIPE_SECRET_KEY=sk_test_xxxxxx 108 | NEXT_PUBLIC_STRIPE_PUBLISAHEBLE_KEY=pk_test_xxxx 109 | ``` 110 | 111 | ### 🏃‍♂️ Run the development server 112 | 113 | ``` 114 | npm run dev 115 | # or 116 | yarn dev 117 | # or 118 | pnpm dev 119 | ``` 120 | 121 | Visit http://localhost:3216 to see your app! 🎉 122 | 123 | ## 🔄 Database Schema 124 | 125 | Our Supabase schema includes: 126 | 127 | ``` 128 | -- Create profiles table 129 | CREATE TABLE profiles ( 130 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Automatically generate UUID 131 | first_name TEXT, 132 | last_name TEXT, 133 | email TEXT UNIQUE, 134 | picture TEXT, 135 | name TEXT, 136 | is_subscribed BOOLEAN DEFAULT FALSE, 137 | plan_id UUID REFERENCES pricing_plans(id) ON DELETE SET NULL, -- Foreign key to pricing_plans 138 | stripe_customer_id TEXT, 139 | last_plan_update TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 140 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 141 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP 142 | ); 143 | 144 | -- Create pricing_plans table 145 | CREATE TABLE pricing_plans ( 146 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Automatically generate UUID 147 | name TEXT, 148 | slug TEXT UNIQUE, 149 | price_monthly INTEGER, 150 | price_yearly INTEGER, 151 | description TEXT, 152 | cta TEXT, 153 | most_popular BOOLEAN DEFAULT FALSE, 154 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 155 | is_active BOOLEAN DEFAULT TRUE, 156 | is_featured BOOLEAN DEFAULT FALSE 157 | ); 158 | 159 | -- Create pricing_features table 160 | CREATE TABLE pricing_features ( 161 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Automatically generate UUID 162 | name TEXT, 163 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, 164 | plan_id UUID REFERENCES pricing_plans(id) ON DELETE CASCADE -- Foreign key to pricing_plans 165 | ); 166 | ``` 167 | 168 | ## Database Triggers and Functions 169 | 170 | ### Function: `handle_new_user` 171 | 172 | This function is triggered when a new user is created via authentication. It inserts a record into the `profiles` table with the relevant user details. 173 | 174 | #### Function Definition 175 | 176 | ```sql 177 | CREATE OR REPLACE FUNCTION handle_new_user() 178 | RETURNS TRIGGER AS $$ 179 | BEGIN 180 | INSERT INTO public.profiles (id, first_name, last_name, email, picture, name) 181 | VALUES ( 182 | NEW.id, 183 | NEW.raw_user_meta_data ->> 'first_name', 184 | NEW.raw_user_meta_data ->> 'last_name', 185 | NEW.raw_user_meta_data ->> 'email', 186 | COALESCE(NEW.raw_user_meta_data ->> 'picture', NEW.raw_user_meta_data ->> 'avatar_url'), 187 | NEW.raw_user_meta_data ->> 'name' 188 | ); 189 | RETURN NEW; 190 | END; 191 | $$ LANGUAGE plpgsql; 192 | 193 | ``` 194 | 195 | ### 👾 How can I contribute? 196 | 197 | - ⭐ Star the repository 198 | - 🛠️ Submit pull requests, report bugs, or suggest features 199 | 200 | ### 📬 Get in Touch 201 | 202 | Feel free to reach out if you have any questions or need help: 203 | 204 | - **GitHub:** https://github.com/mustafacagri 205 | - **Linkedin:** [@MustafaCagri](https://www.linkedin.com/in/mustafacagri/) 206 | 207 | Made with ❤️ in 📍 Istanbul, using React.js 18 ⚛️ Next.js 14 🌐 Stripe 💳 TailwindCSS 🎨 TypeScript 🔧 React Query / Tanstack 🔄 and Lodash 🛠️! 208 | -------------------------------------------------------------------------------- /src/components/home/Pricing.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { PricingPlan, Profile } from '@/interfaces' 4 | import { Icon } from '@/components/ui' 5 | import { useQuery } from '@tanstack/react-query' 6 | import { useState } from 'react' 7 | import { useStripeService, useSupabaseService } from '@/services/api' 8 | import { useUserProfile } from '@/hooks/useUserProfile' 9 | import { useRouter } from 'next/navigation' 10 | import { isEmpty } from 'lodash' 11 | 12 | const PRICING_QUERY_KEY = ['pricing-plans'] as const 13 | 14 | export function Pricing() { 15 | const router = useRouter() 16 | const supabaseService = useSupabaseService() 17 | const stripeService = useStripeService() 18 | const { profile } = useUserProfile() 19 | const [isLoading, setIsLoading] = useState(false) 20 | const [isYearly, setIsYearly] = useState(false) 21 | 22 | const { data: pricingPlans = [] } = useQuery({ 23 | queryKey: PRICING_QUERY_KEY, 24 | queryFn: () => supabaseService.fetchPricingPlans({}), 25 | staleTime: 1000 * 60 * 60, // Consider data fresh for 1 hour since it is not an always changing data 26 | }) 27 | 28 | const subscribe = async (slug: string) => { 29 | setIsLoading(true) 30 | 31 | if (!profile) return router.push('/login') 32 | 33 | const lookup_key = `${slug}_${isYearly ? 'yearly' : 'monthly'}` 34 | 35 | const res = await stripeService.checkout({ lookup_key }) 36 | if (profile) profile.pricing_plans = res.pricing_plans 37 | if (res?.url) window.location.href = res.url 38 | 39 | setIsLoading(false) 40 | } 41 | 42 | const renderPriceSection = (plan: PricingPlan) => ( 43 |

44 | 51 | ${isYearly ? plan.price_yearly : plan.price_monthly} 52 | 53 | 60 | /{isYearly ? 'year' : 'month'} 61 | 62 |

63 | ) 64 | 65 | const renderFeatures = (plan: PricingPlan) => ( 66 |
    67 | {plan?.pricing_features && 68 | plan.pricing_features.map(({ id, name }) => ( 69 |
  • 73 |
    74 | 78 |
    79 |

    {name}

    80 |
  • 81 | ))} 82 |
83 | ) 84 | 85 | const renderPricingCard = (isLoading: boolean, plan: PricingPlan) => ( 86 |
92 | {plan.most_popular &&

Most Popular

} 93 |

{plan.name}

94 |

{plan.description}

95 | {renderPriceSection(plan)} 96 | {renderFeatures(plan)} 97 | 98 |
99 | 105 |
106 |
107 | ) 108 | 109 | const PricingTableSkeleton = () => { 110 | const Card = ({ isPopular = false }) => ( 111 |
115 | {/* Title Skeleton */} 116 |
117 | 118 | {/* Description Skeleton */} 119 |
120 | 121 | {/* Price Skeleton */} 122 |
123 |
124 |
125 |
126 | 127 | {/* Features List Skeleton */} 128 |
129 | {[1, 2, 3].map(item => ( 130 |
134 |
135 |
136 |
137 | ))} 138 |
139 | 140 | {/* Button Skeleton */} 141 |
142 |
143 |
144 |
145 | ) 146 | 147 | return ( 148 |
149 | 150 |
151 | {/* Popular badge skeleton */} 152 |
153 | 154 |
155 | 156 |
157 | ) 158 | } 159 | 160 | return ( 161 |
162 |
163 |
164 |

Pricing

165 |

166 | Simple, transparent pricing 167 |

168 |

Choose the plan that fits your needs.

169 |
170 | 171 | {isEmpty(pricingPlans) && } 172 | 173 | {!isEmpty(pricingPlans) && ( 174 | <> 175 |
176 |
177 | Monthly 178 | 179 |
180 | setIsYearly(!isYearly)} 188 | /> 189 |
197 | 198 |
199 | Yearly 200 |
201 |
202 |
203 | 204 |
205 | 210 | 2 months free by paying yearly 211 | 212 |
213 |
214 | {pricingPlans.map(plan => renderPricingCard(isLoading, plan))} 215 |
216 | 217 | )} 218 |
219 |
220 | ) 221 | } 222 | 223 | const PricingButton = ({ 224 | isLoading, 225 | plan, 226 | profile, 227 | onSubscribe, 228 | }: Readonly<{ 229 | isLoading: boolean 230 | plan: PricingPlan 231 | profile: Profile | null 232 | onSubscribe: (slug: string) => void 233 | }>) => { 234 | const isCurrentPlan = profile?.pricing_plans?.id === plan.id 235 | const isUpgrade = profile?.pricing_plans ? profile?.pricing_plans?.price_monthly < plan.price_monthly : false 236 | const stripeService = useStripeService() 237 | 238 | const getButtonLabel = () => { 239 | if (isCurrentPlan) return 'Manage Subscription' 240 | if (isUpgrade) return '⬆️ Upgrade Plan' 241 | if (profile?.pricing_plans) return '⬇️ Downgrade Plan' 242 | 243 | return plan.cta 244 | } 245 | 246 | const subscribe = () => { 247 | if (profile?.pricing_plans) { 248 | stripeService.navigateToStripeDashboard() 249 | } else { 250 | onSubscribe(plan.slug) 251 | } 252 | } 253 | 254 | const getCustomClasses = () => { 255 | if (plan.most_popular) return 'bg-primary-600 hover:bg-primary-700' 256 | 257 | return 'bg-secondary-600 hover:bg-secondary-700' 258 | } 259 | 260 | return ( 261 | <> 262 | {isLoading && ( 263 | 266 | )} 267 | 268 | {!isLoading && ( 269 | 276 | )} 277 | 278 | ) 279 | } 280 | --------------------------------------------------------------------------------