├── .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 |
16 | {links.map(link => (
17 |
22 | {link.name}
23 |
24 | ))}
25 |
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 |
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 |
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 |
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 |
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 |
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 |
69 |
70 |
71 |
72 |
73 |
74 |
78 | Features
79 |
80 |
81 |
85 | Pricing
86 |
87 |
88 | {isEmpty(user) && (
89 |
93 | Login
94 |
95 | )}
96 | {!isEmpty(user) && (
97 |
98 |
102 |
103 | {picture && (
104 |
111 | )}
112 | {user?.name?.split(' ')?.[0] || ''}
113 | {state.isMenuOpen ? '▲' : '▼'}
114 |
115 |
116 |
117 | {state.isMenuOpen && (
118 |
119 |
120 | {userMenu.map(({ name, url }) => (
121 |
122 |
126 | {name}
127 |
128 |
129 | ))}
130 |
131 |
135 | Logout
136 |
137 |
138 |
139 |
140 | )}
141 |
142 | )}
143 |
144 |
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 | [](https://sonarcloud.io/summary/new_code?id=mustafacagri_next-stripe-supabase-tailwind)
51 | [](https://sonarcloud.io/summary/new_code?id=mustafacagri_next-stripe-supabase-tailwind)
52 | [](https://sonarcloud.io/summary/new_code?id=mustafacagri_next-stripe-supabase-tailwind)
53 | [](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 |
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 |
126 |
127 | {/* Features List Skeleton */}
128 |
129 | {[1, 2, 3].map(item => (
130 |
137 | ))}
138 |
139 |
140 | {/* Button Skeleton */}
141 |
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 |
196 |
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 |
264 | Please wait...
265 |
266 | )}
267 |
268 | {!isLoading && (
269 | subscribe()}
272 | className={`block w-full text-center py-2 px-4 rounded text-white ${getCustomClasses()}`}
273 | >
274 | {getButtonLabel()}
275 |
276 | )}
277 | >
278 | )
279 | }
280 |
--------------------------------------------------------------------------------