├── .eslintrc.json ├── public ├── hero.png ├── demo-for-readme.png ├── vercel.svg └── next.svg ├── src ├── app │ ├── favicon.ico │ ├── api │ │ ├── auth │ │ │ └── [kindeAuth] │ │ │ │ └── route.ts │ │ └── webhooks │ │ │ └── stripe │ │ │ └── route.ts │ ├── page.tsx │ ├── premium │ │ ├── actions.ts │ │ └── page.tsx │ ├── auth │ │ └── callback │ │ │ ├── actions.ts │ │ │ └── page.tsx │ ├── layout.tsx │ └── globals.css ├── lib │ ├── stripe.ts │ └── utils.ts ├── components │ ├── providers │ │ ├── ThemeProvider.tsx │ │ └── TanStackProvider.tsx │ ├── PaymentLink.tsx │ ├── ModeToggle.tsx │ ├── ui │ │ ├── badge.tsx │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── navigation-menu.tsx │ │ └── dropdown-menu.tsx │ ├── Hero.tsx │ ├── Navbar.tsx │ └── Pricing.tsx └── db │ └── prisma.ts ├── next.config.mjs ├── postcss.config.mjs ├── components.json ├── .env-example ├── .gitignore ├── tsconfig.json ├── LICENSE ├── prisma └── schema.prisma ├── package.json ├── README.md └── tailwind.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/stripe-subscriptions/HEAD/public/hero.png -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/stripe-subscriptions/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /public/demo-for-readme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/stripe-subscriptions/HEAD/public/demo-for-readme.png -------------------------------------------------------------------------------- /src/app/api/auth/[kindeAuth]/route.ts: -------------------------------------------------------------------------------- 1 | import { handleAuth } from "@kinde-oss/kinde-auth-nextjs/server"; 2 | export const GET = handleAuth(); 3 | -------------------------------------------------------------------------------- /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/stripe.ts: -------------------------------------------------------------------------------- 1 | import { Stripe } from "stripe"; 2 | 3 | export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { 4 | apiVersion: "2024-06-20", 5 | typescript: true, 6 | }); 7 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Hero } from "@/components/Hero"; 2 | import { Pricing } from "@/components/Pricing"; 3 | 4 | export default function Home() { 5 | return ( 6 |
7 | 8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/components/providers/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes/dist/types"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /src/components/providers/TanStackProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 | import { ReactNode } from "react"; 5 | 6 | const queryClient = new QueryClient(); 7 | 8 | const TanStackProvider = ({ children }: { children: ReactNode }) => { 9 | return {children}; 10 | }; 11 | 12 | export default TanStackProvider; 13 | -------------------------------------------------------------------------------- /src/db/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | const prismaClientSingleton = () => { 4 | return new PrismaClient(); 5 | }; 6 | 7 | declare const globalThis: { 8 | prismaGlobal: ReturnType; 9 | } & typeof global; 10 | 11 | const prisma = globalThis.prismaGlobal ?? prismaClientSingleton(); 12 | 13 | export default prisma; 14 | 15 | if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prisma; 16 | -------------------------------------------------------------------------------- /.env-example: -------------------------------------------------------------------------------- 1 | DATABASE_URL= 2 | 3 | KINDE_CLIENT_ID= 4 | KINDE_CLIENT_SECRET= 5 | KINDE_ISSUER_URL= 6 | KINDE_SITE_URL= 7 | KINDE_POST_LOGOUT_REDIRECT_URL= 8 | KINDE_POST_LOGIN_REDIRECT_URL= 9 | 10 | STRIPE_MONTHLY_PLAN_LINK= 11 | STRIPE_YEARLY_PLAN_LINK= 12 | 13 | STRIPE_MONTHLY_PRICE_ID= 14 | STRIPE_YEARLY_PRICE_ID= 15 | 16 | STRIPE_SECRET_KEY= 17 | STRIPE_WEBHOOK_SECRET= 18 | NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL_URL= -------------------------------------------------------------------------------- /.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 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /src/app/premium/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import prisma from "@/db/prisma"; 4 | import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server"; 5 | 6 | export async function isUserSubscribed() { 7 | const { getUser } = getKindeServerSession(); 8 | const user = await getUser(); 9 | 10 | if (!user) return { success: false }; 11 | 12 | const existingUser = await prisma.user.findUnique({ where: { id: user.id } }); 13 | 14 | if (!existingUser) return { success: false }; 15 | 16 | return { success: true, subscribed: existingUser.plan === "premium" }; 17 | } 18 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/PaymentLink.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { buttonVariants } from "./ui/button"; 5 | 6 | type PaymentLinkProps = { 7 | href: string; 8 | paymentLink?: string; 9 | text: string; 10 | }; 11 | 12 | const PaymentLink = ({ href, paymentLink, text }: PaymentLinkProps) => { 13 | return ( 14 | { 18 | if (paymentLink) { 19 | localStorage.setItem("stripePaymentLink", paymentLink); 20 | } 21 | }} 22 | > 23 | {text} 24 | 25 | ); 26 | }; 27 | export default PaymentLink; 28 | -------------------------------------------------------------------------------- /src/app/premium/page.tsx: -------------------------------------------------------------------------------- 1 | import prisma from "@/db/prisma"; 2 | import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server"; 3 | import { redirect } from "next/navigation"; 4 | 5 | const Page = async () => { 6 | const { getUser } = getKindeServerSession(); 7 | const user = await getUser(); 8 | if (!user) return redirect("/"); 9 | 10 | const userProfile = await prisma.user.findUnique({ where: { id: user.id } }); 11 | if (userProfile?.plan === "free") return redirect("/"); 12 | 13 | return
You are on the premium plan so you can see this page
; 14 | }; 15 | export default Page; 16 | -------------------------------------------------------------------------------- /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 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /src/app/auth/callback/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import prisma from "@/db/prisma"; 4 | import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server"; 5 | 6 | export async function checkAuthStatus() { 7 | const { getUser } = getKindeServerSession(); 8 | const user = await getUser(); 9 | 10 | if (!user) return { success: false }; 11 | 12 | const existingUser = await prisma.user.findUnique({ where: { id: user.id } }); 13 | 14 | // sign up 15 | if (!existingUser) { 16 | await prisma.user.create({ 17 | data: { 18 | id: user.id, 19 | email: user.email!, 20 | name: user.given_name + " " + user.family_name, 21 | image: user.picture, 22 | }, 23 | }); 24 | } 25 | 26 | return { success: true }; 27 | } 28 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | import { ThemeProvider } from "@/components/providers/ThemeProvider"; 5 | import { Navbar } from "@/components/Navbar"; 6 | import TanStackProvider from "@/components/providers/TanStackProvider"; 7 | 8 | const inter = Inter({ subsets: ["latin"] }); 9 | 10 | export const metadata: Metadata = { 11 | title: "Stripe Subscriptions", 12 | description: "Learn how to integrate Stripe subscriptions with Next.js", 13 | }; 14 | 15 | export default function RootLayout({ 16 | children, 17 | }: Readonly<{ 18 | children: React.ReactNode; 19 | }>) { 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | {children} 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Burak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "mongodb" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | enum Plan { 11 | free 12 | premium 13 | } 14 | 15 | enum SubscriptionPeriod { 16 | monthly 17 | yearly 18 | } 19 | 20 | model User { 21 | id String @id @default(cuid()) @map("_id") 22 | email String @unique 23 | name String? 24 | image String? 25 | plan Plan @default(free) 26 | customerId String? @unique // Stripe customer ID, this will be important when we need to delete the subscription 27 | 28 | Subscription Subscription? 29 | 30 | createdAt DateTime @default(now()) 31 | updatedAt DateTime @updatedAt 32 | } 33 | 34 | model Subscription { 35 | id String @id @default(cuid()) @map("_id") 36 | userId String @unique 37 | plan Plan 38 | period SubscriptionPeriod 39 | 40 | startDate DateTime @default(now()) 41 | endDate DateTime 42 | 43 | createdAt DateTime @default(now()) 44 | updatedAt DateTime @updatedAt 45 | 46 | User User @relation(fields: [userId], references: [id]) 47 | } 48 | -------------------------------------------------------------------------------- /src/components/ModeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuContent, 5 | DropdownMenuItem, 6 | DropdownMenuTrigger, 7 | } from "@/components/ui/dropdown-menu"; 8 | import { Moon, Sun } from "lucide-react"; 9 | import { useTheme } from "next-themes"; 10 | 11 | export function ModeToggle() { 12 | const { setTheme } = useTheme(); 13 | 14 | return ( 15 | 16 | 17 | 22 | 23 | 24 | setTheme("light")}>Light 25 | setTheme("dark")}>Dark 26 | setTheme("system")}>System 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stripe-subscriptions", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "postinstall": "prisma generate" 11 | }, 12 | "dependencies": { 13 | "@kinde-oss/kinde-auth-nextjs": "^2.3.1", 14 | "@prisma/client": "^5.16.0", 15 | "@radix-ui/react-avatar": "^1.1.0", 16 | "@radix-ui/react-dropdown-menu": "^2.1.1", 17 | "@radix-ui/react-icons": "^1.3.0", 18 | "@radix-ui/react-navigation-menu": "^1.2.0", 19 | "@radix-ui/react-slot": "^1.1.0", 20 | "@tanstack/react-query": "^5.48.0", 21 | "class-variance-authority": "^0.7.0", 22 | "clsx": "^2.1.1", 23 | "lucide-react": "^0.396.0", 24 | "next": "14.2.4", 25 | "next-themes": "^0.3.0", 26 | "prisma": "^5.16.0", 27 | "react": "^18", 28 | "react-dom": "^18", 29 | "stripe": "^16.0.0", 30 | "tailwind-merge": "^2.3.0", 31 | "tailwindcss-animate": "^1.0.7" 32 | }, 33 | "devDependencies": { 34 | "@types/node": "^20", 35 | "@types/react": "^18", 36 | "@types/react-dom": "^18", 37 | "eslint": "^8", 38 | "eslint-config-next": "14.2.4", 39 | "postcss": "^8", 40 | "tailwindcss": "^3.4.1", 41 | "typescript": "^5" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/auth/callback/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useKindeBrowserClient } from "@kinde-oss/kinde-auth-nextjs"; 3 | import { useQuery } from "@tanstack/react-query"; 4 | import { Loader } from "lucide-react"; 5 | import { useRouter } from "next/navigation"; 6 | import { checkAuthStatus } from "./actions"; 7 | import { useEffect } from "react"; 8 | 9 | const Page = () => { 10 | const router = useRouter(); 11 | const { user } = useKindeBrowserClient(); 12 | const { data } = useQuery({ 13 | queryKey: ["checkAuthStatus"], 14 | queryFn: async () => await checkAuthStatus(), 15 | }); 16 | 17 | useEffect(() => { 18 | const stripePaymentLink = localStorage.getItem("stripePaymentLink"); 19 | if (data?.success && stripePaymentLink && user?.email) { 20 | localStorage.removeItem("stripePaymentLink"); 21 | router.push(stripePaymentLink + `?prefilled_email=${user.email}`); 22 | } else if (data?.success === false) { 23 | router.push("/"); 24 | } 25 | }, [router, user, data]); 26 | 27 | if (data?.success) router.push("/"); 28 | 29 | return ( 30 |
31 |
32 | 33 |

Redirecting...

34 |

Please wait...

35 |
36 |
37 | ); 38 | }; 39 | export default Page; 40 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Stripe Subscriptions Course

2 | 3 | ![Demo App](/public/demo-for-readme.png) 4 | 5 | [Video Tutorial on Youtube](https://youtu.be/R9PwoQwVpPQ) 6 | 7 | Some Features: 8 | 9 | - ⚛️ Tech Stack: Next.js 14, TypeScript, Prisma, MongoDB, Stripe 10 | - 🔐 Authentication with Kinde Auth 11 | - 💸 Monthly and Annually Subscriptions with Stripe 12 | - 💵 Building a Stripe Billing Portal 13 | - 🛠️ What are Webhooks 14 | - 🔄 Stripe Event Types 15 | - 🌗 Light/Dark Mode 16 | - 🌐 Deployment 17 | - ✅ This is a lot of work. Support me by subscribing to the [Youtube Channel](https://www.youtube.com/@asaprogrammer_) 18 | 19 | ### Setup .env file 20 | 21 | ```js 22 | DATABASE_URL= 23 | 24 | KINDE_CLIENT_ID= 25 | KINDE_CLIENT_SECRET= 26 | KINDE_ISSUER_URL= 27 | KINDE_SITE_URL= 28 | KINDE_POST_LOGOUT_REDIRECT_URL= 29 | KINDE_POST_LOGIN_REDIRECT_URL= 30 | 31 | STRIPE_MONTHLY_PLAN_LINK= 32 | STRIPE_YEARLY_PLAN_LINK= 33 | 34 | STRIPE_MONTHLY_PRICE_ID= 35 | STRIPE_YEARLY_PRICE_ID= 36 | 37 | STRIPE_SECRET_KEY= 38 | STRIPE_WEBHOOK_SECRET= 39 | NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL_URL= 40 | ``` 41 | 42 | ### Install dependencies 43 | 44 | ```shell 45 | npm install 46 | ``` 47 | 48 | ### Start the app 49 | 50 | ```shell 51 | npm run dev 52 | ``` 53 | 54 | ## `Timestamps` for your convenience 👇 55 | 56 | - 00:00:00 - Demo 57 | - 00:01:04 - App Setup 58 | - 00:06:20 - MongoDB and Prisma Setup 59 | - 00:16:40 - Auth Setup with Kinde 60 | - 00:19:40 - Understanding Auth Callbacks 61 | - 00:32:50 - Stripe Subscriptions Setup 62 | - 00:49:00 - Webhooks and API Route 63 | - 01:16:10 - Building a Customer Portal 64 | - 01:22:40 - Detailed Deployment and thank you! 65 | 66 | ### I'll see you in the next one! 🚀 67 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 224 71.4% 4.1%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 224 71.4% 4.1%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 224 71.4% 4.1%; 13 | --primary: 262.1 83.3% 57.8%; 14 | --primary-foreground: 210 20% 98%; 15 | --secondary: 220 14.3% 95.9%; 16 | --secondary-foreground: 220.9 39.3% 11%; 17 | --muted: 220 14.3% 95.9%; 18 | --muted-foreground: 220 8.9% 46.1%; 19 | --accent: 220 14.3% 95.9%; 20 | --accent-foreground: 220.9 39.3% 11%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 210 20% 98%; 23 | --border: 220 13% 91%; 24 | --input: 220 13% 91%; 25 | --ring: 262.1 83.3% 57.8%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 224 71.4% 4.1%; 31 | --foreground: 210 20% 98%; 32 | --card: 224 71.4% 4.1%; 33 | --card-foreground: 210 20% 98%; 34 | --popover: 224 71.4% 4.1%; 35 | --popover-foreground: 210 20% 98%; 36 | --primary: 263.4 70% 50.4%; 37 | --primary-foreground: 210 20% 98%; 38 | --secondary: 215 27.9% 16.9%; 39 | --secondary-foreground: 210 20% 98%; 40 | --muted: 215 27.9% 16.9%; 41 | --muted-foreground: 217.9 10.6% 64.9%; 42 | --accent: 215 27.9% 16.9%; 43 | --accent-foreground: 210 20% 98%; 44 | --destructive: 0 62.8% 30.6%; 45 | --destructive-foreground: 210 20% 98%; 46 | --border: 215 27.9% 16.9%; 47 | --input: 215 27.9% 16.9%; 48 | --ring: 263.4 70% 50.4%; 49 | } 50 | } 51 | 52 | @layer base { 53 | * { 54 | @apply border-border; 55 | } 56 | body { 57 | @apply bg-background text-foreground; 58 | } 59 | } 60 | 61 | .hero-section::before { 62 | content: ""; 63 | position: absolute; 64 | top: 0; 65 | left: 0; 66 | width: 100%; 67 | height: 100%; 68 | background: radial-gradient(circle at top, rgba(138, 43, 226, 0.5), transparent 70%); 69 | z-index: -1; /* Ensure the background stays behind the content */ 70 | } 71 | -------------------------------------------------------------------------------- /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 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /src/components/Hero.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { Button } from "./ui/button"; 3 | import { buttonVariants } from "./ui/button"; 4 | import { GitHubLogoIcon } from "@radix-ui/react-icons"; 5 | 6 | export const Hero = () => { 7 | return ( 8 |
9 |
10 |
11 |
12 |

13 | 14 | Stripe 15 | {" "} 16 | Course with 17 |

{" "} 18 |

19 | 20 | Next.js 21 | {" "} 22 |

23 |
24 | {/* 25 | 26 | */} 27 | 28 |

29 | Stripe subscriptions are intimidating, but they don't have to be. Let's prove it. 30 |

31 | 32 |
33 | 34 | 35 | 43 | Github Repository 44 | 45 | 46 |
47 |
48 | 49 |
50 | 57 |
58 |
59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss" 2 | 3 | const config = { 4 | darkMode: ["class"], 5 | content: [ 6 | './pages/**/*.{ts,tsx}', 7 | './components/**/*.{ts,tsx}', 8 | './app/**/*.{ts,tsx}', 9 | './src/**/*.{ts,tsx}', 10 | ], 11 | prefix: "", 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: "2rem", 16 | screens: { 17 | "2xl": "1400px", 18 | }, 19 | }, 20 | extend: { 21 | colors: { 22 | border: "hsl(var(--border))", 23 | input: "hsl(var(--input))", 24 | ring: "hsl(var(--ring))", 25 | background: "hsl(var(--background))", 26 | foreground: "hsl(var(--foreground))", 27 | primary: { 28 | DEFAULT: "hsl(var(--primary))", 29 | foreground: "hsl(var(--primary-foreground))", 30 | }, 31 | secondary: { 32 | DEFAULT: "hsl(var(--secondary))", 33 | foreground: "hsl(var(--secondary-foreground))", 34 | }, 35 | destructive: { 36 | DEFAULT: "hsl(var(--destructive))", 37 | foreground: "hsl(var(--destructive-foreground))", 38 | }, 39 | muted: { 40 | DEFAULT: "hsl(var(--muted))", 41 | foreground: "hsl(var(--muted-foreground))", 42 | }, 43 | accent: { 44 | DEFAULT: "hsl(var(--accent))", 45 | foreground: "hsl(var(--accent-foreground))", 46 | }, 47 | popover: { 48 | DEFAULT: "hsl(var(--popover))", 49 | foreground: "hsl(var(--popover-foreground))", 50 | }, 51 | card: { 52 | DEFAULT: "hsl(var(--card))", 53 | foreground: "hsl(var(--card-foreground))", 54 | }, 55 | }, 56 | borderRadius: { 57 | lg: "var(--radius)", 58 | md: "calc(var(--radius) - 2px)", 59 | sm: "calc(var(--radius) - 4px)", 60 | }, 61 | keyframes: { 62 | "accordion-down": { 63 | from: { height: "0" }, 64 | to: { height: "var(--radix-accordion-content-height)" }, 65 | }, 66 | "accordion-up": { 67 | from: { height: "var(--radix-accordion-content-height)" }, 68 | to: { height: "0" }, 69 | }, 70 | }, 71 | animation: { 72 | "accordion-down": "accordion-down 0.2s ease-out", 73 | "accordion-up": "accordion-up 0.2s ease-out", 74 | }, 75 | }, 76 | }, 77 | plugins: [require("tailwindcss-animate")], 78 | } satisfies Config 79 | 80 | export default config -------------------------------------------------------------------------------- /src/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { NavigationMenu, NavigationMenuItem, NavigationMenuList } from "@/components/ui/navigation-menu"; 3 | 4 | import { LogOut } from "lucide-react"; 5 | import { ModeToggle } from "./ModeToggle"; 6 | import { buttonVariants } from "./ui/button"; 7 | import Link from "next/link"; 8 | import { useKindeBrowserClient } from "@kinde-oss/kinde-auth-nextjs"; 9 | import { useQuery } from "@tanstack/react-query"; 10 | import { isUserSubscribed } from "@/app/premium/actions"; 11 | 12 | interface RouteProps { 13 | href: string; 14 | label: string; 15 | } 16 | 17 | const routeList: RouteProps[] = [ 18 | { 19 | href: "/", 20 | label: "Home", 21 | }, 22 | { 23 | href: "#team", 24 | label: "Team", 25 | }, 26 | { 27 | href: "#testimonials", 28 | label: "Testimonials", 29 | }, 30 | ]; 31 | 32 | export const Navbar = () => { 33 | const { isAuthenticated } = useKindeBrowserClient(); 34 | 35 | const { data } = useQuery({ 36 | queryKey: ["isUserSubscribed"], 37 | queryFn: async () => isUserSubscribed(), 38 | }); 39 | 40 | const isSubscribed = data?.subscribed; 41 | 42 | return ( 43 |
48 | 49 | 50 | 51 | 52 | 53 | 🚀 Next Stripe 54 | 55 | 56 | 57 | 58 | 84 | 85 |
86 | {isAuthenticated && ( 87 | 92 | Logout 93 | 94 | 95 | )} 96 | 97 | {!isAuthenticated && ( 98 | 103 | Login 104 | 105 | )} 106 | 107 | {isAuthenticated && isSubscribed && ( 108 | 118 | Premium ✨ 119 | 120 | )} 121 | 122 | 123 |
124 |
125 |
126 |
127 | ); 128 | }; 129 | -------------------------------------------------------------------------------- /src/app/api/webhooks/stripe/route.ts: -------------------------------------------------------------------------------- 1 | import prisma from "@/db/prisma"; 2 | import { stripe } from "@/lib/stripe"; 3 | import Stripe from "stripe"; 4 | 5 | const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!; 6 | 7 | export async function POST(req: Request) { 8 | const body = await req.text(); 9 | 10 | const sig = req.headers.get("stripe-signature")!; 11 | let event: Stripe.Event; 12 | 13 | try { 14 | event = stripe.webhooks.constructEvent(body, sig, WEBHOOK_SECRET); 15 | } catch (err: any) { 16 | console.error("Webhook signature verification failed.", err.message); 17 | return new Response(`Webhook Error: ${err.message}`, { status: 400 }); 18 | } 19 | 20 | // Handle the event 21 | try { 22 | switch (event.type) { 23 | case "checkout.session.completed": 24 | const session = await stripe.checkout.sessions.retrieve( 25 | (event.data.object as Stripe.Checkout.Session).id, 26 | { 27 | expand: ["line_items"], 28 | } 29 | ); 30 | const customerId = session.customer as string; 31 | const customerDetails = session.customer_details; 32 | 33 | if (customerDetails?.email) { 34 | const user = await prisma.user.findUnique({ where: { email: customerDetails.email } }); 35 | if (!user) throw new Error("User not found"); 36 | 37 | if (!user.customerId) { 38 | await prisma.user.update({ 39 | where: { id: user.id }, 40 | data: { customerId }, 41 | }); 42 | } 43 | 44 | const lineItems = session.line_items?.data || []; 45 | 46 | for (const item of lineItems) { 47 | const priceId = item.price?.id; 48 | const isSubscription = item.price?.type === "recurring"; 49 | 50 | if (isSubscription) { 51 | let endDate = new Date(); 52 | if (priceId === process.env.STRIPE_YEARLY_PRICE_ID!) { 53 | endDate.setFullYear(endDate.getFullYear() + 1); // 1 year from now 54 | } else if (priceId === process.env.STRIPE_MONTHLY_PRICE_ID!) { 55 | endDate.setMonth(endDate.getMonth() + 1); // 1 month from now 56 | } else { 57 | throw new Error("Invalid priceId"); 58 | } 59 | // it is gonna create the subscription if it does not exist already, but if it exists it will update it 60 | await prisma.subscription.upsert({ 61 | where: { userId: user.id! }, 62 | create: { 63 | userId: user.id, 64 | startDate: new Date(), 65 | endDate: endDate, 66 | plan: "premium", 67 | period: priceId === process.env.STRIPE_YEARLY_PRICE_ID! ? "yearly" : "monthly", 68 | }, 69 | update: { 70 | plan: "premium", 71 | period: priceId === process.env.STRIPE_YEARLY_PRICE_ID! ? "yearly" : "monthly", 72 | startDate: new Date(), 73 | endDate: endDate, 74 | }, 75 | }); 76 | 77 | await prisma.user.update({ 78 | where: { id: user.id }, 79 | data: { plan: "premium" }, 80 | }); 81 | } else { 82 | // one_time_purchase 83 | } 84 | } 85 | } 86 | break; 87 | case "customer.subscription.deleted": { 88 | const subscription = await stripe.subscriptions.retrieve((event.data.object as Stripe.Subscription).id); 89 | const user = await prisma.user.findUnique({ 90 | where: { customerId: subscription.customer as string }, 91 | }); 92 | if (user) { 93 | await prisma.user.update({ 94 | where: { id: user.id }, 95 | data: { plan: "free" }, 96 | }); 97 | } else { 98 | console.error("User not found for the subscription deleted event."); 99 | throw new Error("User not found for the subscription deleted event."); 100 | } 101 | 102 | break; 103 | } 104 | 105 | default: 106 | console.log(`Unhandled event type ${event.type}`); 107 | } 108 | } catch (error) { 109 | console.error("Error handling event", error); 110 | return new Response("Webhook Error", { status: 400 }); 111 | } 112 | 113 | return new Response("Webhook received", { status: 200 }); 114 | } 115 | -------------------------------------------------------------------------------- /src/components/Pricing.tsx: -------------------------------------------------------------------------------- 1 | import { Badge } from "@/components/ui/badge"; 2 | import { buttonVariants } from "@/components/ui/button"; 3 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; 4 | import { Check } from "lucide-react"; 5 | import Link from "next/link"; 6 | import PaymentLink from "./PaymentLink"; 7 | 8 | enum PopularPlanType { 9 | NO = 0, 10 | YES = 1, 11 | } 12 | 13 | interface PricingProps { 14 | title: string; 15 | popular: PopularPlanType; 16 | price: number; 17 | description: string; 18 | buttonText: string; 19 | benefitList: string[]; 20 | href: string; 21 | billing: string; 22 | paymentLink?: string; 23 | } 24 | 25 | const pricingList: PricingProps[] = [ 26 | { 27 | title: "Free", 28 | popular: 0, 29 | price: 0, 30 | description: "Lorem ipsum dolor sit, amet ipsum consectetur adipisicing elit.", 31 | buttonText: "Get Started", 32 | benefitList: ["1 Team member", "2 GB Storage", "Upto 4 pages", "Community support", "lorem ipsum dolor"], 33 | href: "/api/auth/login", 34 | billing: "/month", 35 | }, 36 | { 37 | title: "Premium", 38 | popular: 1, 39 | price: 10, 40 | description: "Lorem ipsum dolor sit, amet ipsum consectetur adipisicing elit.", 41 | buttonText: "Buy Now", 42 | benefitList: ["4 Team member", "4 GB Storage", "Upto 6 pages", "Priority support", "lorem ipsum dolor"], 43 | href: "/api/auth/login", 44 | paymentLink: process.env.STRIPE_MONTHLY_PLAN_LINK, 45 | billing: "/month", 46 | }, 47 | { 48 | title: "Enterprise", 49 | popular: 0, 50 | price: 99, 51 | description: "Lorem ipsum dolor sit, amet ipsum consectetur adipisicing elit.", 52 | buttonText: "Buy Now", 53 | benefitList: ["10 Team member", "8 GB Storage", "Upto 10 pages", "Priority support", "lorem ipsum dolor"], 54 | href: "/api/auth/login", 55 | paymentLink: process.env.STRIPE_YEARLY_PLAN_LINK, 56 | billing: "/year", 57 | }, 58 | ]; 59 | 60 | export const Pricing = () => { 61 | return ( 62 |
63 |

64 | Get 65 | 66 | {" "} 67 | Unlimited{" "} 68 | 69 | Access 70 |

71 |

72 | Lorem ipsum dolor sit amet consectetur adipisicing elit. Alias reiciendis. 73 |

74 |
75 | {pricingList.map((pricing: PricingProps) => ( 76 | 84 | 85 | 86 | {pricing.title} 87 | {pricing.popular === PopularPlanType.YES ? ( 88 | 89 | Most popular 90 | 91 | ) : null} 92 | 93 |
94 | ${pricing.price} 95 | {pricing.billing} 96 |
97 | 98 | {pricing.description} 99 |
100 | 101 | 102 | 107 | 108 | 109 |
110 | 111 | 112 |
113 | {pricing.benefitList.map((benefit: string) => ( 114 | 115 |

{benefit}

116 |
117 | ))} 118 |
119 |
120 |
121 | ))} 122 |
123 |
124 | ); 125 | }; 126 | -------------------------------------------------------------------------------- /src/components/ui/navigation-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" 3 | import { cva } from "class-variance-authority" 4 | import { ChevronDown } from "lucide-react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const NavigationMenu = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 20 | {children} 21 | 22 | 23 | )) 24 | NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName 25 | 26 | const NavigationMenuList = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, ...props }, ref) => ( 30 | 38 | )) 39 | NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName 40 | 41 | const NavigationMenuItem = NavigationMenuPrimitive.Item 42 | 43 | const navigationMenuTriggerStyle = cva( 44 | "group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50" 45 | ) 46 | 47 | const NavigationMenuTrigger = React.forwardRef< 48 | React.ElementRef, 49 | React.ComponentPropsWithoutRef 50 | >(({ className, children, ...props }, ref) => ( 51 | 56 | {children}{" "} 57 | 62 | )) 63 | NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName 64 | 65 | const NavigationMenuContent = React.forwardRef< 66 | React.ElementRef, 67 | React.ComponentPropsWithoutRef 68 | >(({ className, ...props }, ref) => ( 69 | 77 | )) 78 | NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName 79 | 80 | const NavigationMenuLink = NavigationMenuPrimitive.Link 81 | 82 | const NavigationMenuViewport = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 |
87 | 95 |
96 | )) 97 | NavigationMenuViewport.displayName = 98 | NavigationMenuPrimitive.Viewport.displayName 99 | 100 | const NavigationMenuIndicator = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, ...props }, ref) => ( 104 | 112 |
113 | 114 | )) 115 | NavigationMenuIndicator.displayName = 116 | NavigationMenuPrimitive.Indicator.displayName 117 | 118 | export { 119 | navigationMenuTriggerStyle, 120 | NavigationMenu, 121 | NavigationMenuList, 122 | NavigationMenuItem, 123 | NavigationMenuContent, 124 | NavigationMenuTrigger, 125 | NavigationMenuLink, 126 | NavigationMenuIndicator, 127 | NavigationMenuViewport, 128 | } 129 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 73 | 74 | )) 75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 76 | 77 | const DropdownMenuItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef & { 80 | inset?: boolean 81 | } 82 | >(({ className, inset, ...props }, ref) => ( 83 | 92 | )) 93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 94 | 95 | const DropdownMenuCheckboxItem = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, children, checked, ...props }, ref) => ( 99 | 108 | 109 | 110 | 111 | 112 | 113 | {children} 114 | 115 | )) 116 | DropdownMenuCheckboxItem.displayName = 117 | DropdownMenuPrimitive.CheckboxItem.displayName 118 | 119 | const DropdownMenuRadioItem = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, children, ...props }, ref) => ( 123 | 131 | 132 | 133 | 134 | 135 | 136 | {children} 137 | 138 | )) 139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 140 | 141 | const DropdownMenuLabel = React.forwardRef< 142 | React.ElementRef, 143 | React.ComponentPropsWithoutRef & { 144 | inset?: boolean 145 | } 146 | >(({ className, inset, ...props }, ref) => ( 147 | 156 | )) 157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 158 | 159 | const DropdownMenuSeparator = React.forwardRef< 160 | React.ElementRef, 161 | React.ComponentPropsWithoutRef 162 | >(({ className, ...props }, ref) => ( 163 | 168 | )) 169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 170 | 171 | const DropdownMenuShortcut = ({ 172 | className, 173 | ...props 174 | }: React.HTMLAttributes) => { 175 | return ( 176 | 180 | ) 181 | } 182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 183 | 184 | export { 185 | DropdownMenu, 186 | DropdownMenuTrigger, 187 | DropdownMenuContent, 188 | DropdownMenuItem, 189 | DropdownMenuCheckboxItem, 190 | DropdownMenuRadioItem, 191 | DropdownMenuLabel, 192 | DropdownMenuSeparator, 193 | DropdownMenuShortcut, 194 | DropdownMenuGroup, 195 | DropdownMenuPortal, 196 | DropdownMenuSub, 197 | DropdownMenuSubContent, 198 | DropdownMenuSubTrigger, 199 | DropdownMenuRadioGroup, 200 | } 201 | --------------------------------------------------------------------------------