27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/app/api/stripe/webhooks/route.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/server/db";
2 | import { IncomingMessage } from "http";
3 | import { buffer } from "micro";
4 | import Stripe from "stripe";
5 |
6 | const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
7 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
8 | apiVersion: "2023-10-16",
9 | });
10 |
11 | type StripeMetadata = {
12 | clerkEmailAddress: string;
13 | clerkFullName: string;
14 | clerkId: string;
15 | };
16 |
17 | // remember to add your_url/api/stripe/webhooks to the stripe dashboard
18 |
19 | export async function POST(req: Request) {
20 | const body = await req.text();
21 | const sig = req.headers.get("stripe-signature") as string;
22 | let event: Stripe.Event;
23 |
24 | try {
25 | if (!sig || !webhookSecret) return;
26 | event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
27 | } catch (err: any) {
28 | console.log(`❌ Error message: ${err.message}`);
29 | return new Response(`Webhook Error: ${err.message}`, { status: 400 });
30 | }
31 |
32 | if (event.type === "checkout.session.completed") {
33 | const paymentIntent = event.data.object as Stripe.Checkout.Session;
34 | console.log(
35 | `🔔 Stripe PaymentIntent status 💸: ${paymentIntent.payment_status}`,
36 | );
37 | const { clerkId, clerkFullName, clerkEmailAddress } =
38 | paymentIntent.metadata as StripeMetadata;
39 |
40 | db.user.update({
41 | where: { id: clerkId },
42 | data: {
43 | isPremium: true,
44 | PremiumUntil: new Date(
45 | new Date().setMonth(new Date().getMonth() + 1),
46 | ).toISOString(),
47 | },
48 | });
49 | } else
50 | console.warn(`💸 Stripe Webhook : Unhandled event type: ${event.type}`);
51 |
52 | return new Response(JSON.stringify({ received: true }));
53 | }
54 |
55 | // export
56 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "t3-app-router-clerk",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "build": "next build",
7 | "db:push": "prisma db push",
8 | "db:studio": "prisma studio",
9 | "dev": "next dev",
10 | "postinstall": "prisma generate",
11 | "lint": "next lint",
12 | "start": "next start"
13 | },
14 | "dependencies": {
15 | "@clerk/nextjs": "^4.27.1",
16 | "@prisma/client": "^5.1.1",
17 | "@stripe/stripe-js": "^2.2.0",
18 | "@t3-oss/env-nextjs": "^0.7.0",
19 | "@tanstack/react-query": "^4.32.6",
20 | "@trpc/client": "^10.37.1",
21 | "@trpc/next": "^10.37.1",
22 | "@trpc/react-query": "^10.37.1",
23 | "@trpc/server": "^10.37.1",
24 | "clsx": "^2.0.0",
25 | "framer-motion": "^10.16.5",
26 | "micro": "^10.0.1",
27 | "next": "^14.0.0",
28 | "next-themes": "^0.2.1",
29 | "react": "18.2.0",
30 | "react-dom": "18.2.0",
31 | "react-wrap-balancer": "^1.1.0",
32 | "stripe": "^14.5.0",
33 | "superjson": "^1.13.1",
34 | "svix": "^1.14.0",
35 | "tailwind-merge": "^2.0.0",
36 | "tailwindcss-animate": "^1.0.7",
37 | "zod": "^3.22.4",
38 | "zod-prisma": "^0.5.4"
39 | },
40 | "devDependencies": {
41 | "@types/eslint": "^8.44.2",
42 | "@types/node": "^18.16.0",
43 | "@types/react": "^18.2.33",
44 | "@types/react-dom": "^18.2.14",
45 | "@typescript-eslint/eslint-plugin": "^6.3.0",
46 | "@typescript-eslint/parser": "^6.3.0",
47 | "autoprefixer": "^10.4.14",
48 | "eslint": "^8.47.0",
49 | "eslint-config-next": "^14.0.0",
50 | "postcss": "^8.4.27",
51 | "prettier": "^3.0.0",
52 | "prettier-plugin-tailwindcss": "^0.5.1",
53 | "prisma": "^5.1.1",
54 | "tailwindcss": "^3.3.3",
55 | "typescript": "^5.1.6"
56 | },
57 | "ct3aMetadata": {
58 | "initVersion": "7.23.2"
59 | },
60 | "packageManager": "npm@10.2.0"
61 | }
62 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 222.2 84% 4.9%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 222.2 84% 4.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 222.2 84% 4.9%;
15 |
16 | --primary: 222.2 47.4% 11.2%;
17 | --primary-foreground: 210 40% 98%;
18 |
19 | --secondary: 210 40% 96.1%;
20 | --secondary-foreground: 222.2 47.4% 11.2%;
21 |
22 | --muted: 210 40% 96.1%;
23 | --muted-foreground: 215.4 16.3% 46.9%;
24 |
25 | --accent: 210 40% 96.1%;
26 | --accent-foreground: 222.2 47.4% 11.2%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 210 40% 98%;
30 |
31 | --border: 214.3 31.8% 91.4%;
32 | --input: 214.3 31.8% 91.4%;
33 | --ring: 222.2 84% 4.9%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 222.2 84% 4.9%;
40 | --foreground: 210 40% 98%;
41 |
42 | --card: 222.2 84% 4.9%;
43 | --card-foreground: 210 40% 98%;
44 |
45 | --popover: 222.2 84% 4.9%;
46 | --popover-foreground: 210 40% 98%;
47 |
48 | --primary: 210 40% 98%;
49 | --primary-foreground: 222.2 47.4% 11.2%;
50 |
51 | --secondary: 217.2 32.6% 17.5%;
52 | --secondary-foreground: 210 40% 98%;
53 |
54 | --muted: 217.2 32.6% 17.5%;
55 | --muted-foreground: 215 20.2% 65.1%;
56 |
57 | --accent: 217.2 32.6% 17.5%;
58 | --accent-foreground: 210 40% 98%;
59 |
60 | --destructive: 0 62.8% 30.6%;
61 | --destructive-foreground: 210 40% 98%;
62 |
63 | --border: 217.2 32.6% 17.5%;
64 | --input: 217.2 32.6% 17.5%;
65 | --ring: 212.7 26.8% 83.9%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 | body {
74 | @apply bg-background text-foreground;
75 | }
76 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: "2rem",
14 | screens: {
15 | "2xl": "1400px",
16 | },
17 | },
18 | extend: {
19 | colors: {
20 | border: "hsl(var(--border))",
21 | input: "hsl(var(--input))",
22 | ring: "hsl(var(--ring))",
23 | background: "hsl(var(--background))",
24 | foreground: "hsl(var(--foreground))",
25 | primary: {
26 | DEFAULT: "hsl(var(--primary))",
27 | foreground: "hsl(var(--primary-foreground))",
28 | },
29 | secondary: {
30 | DEFAULT: "hsl(var(--secondary))",
31 | foreground: "hsl(var(--secondary-foreground))",
32 | },
33 | destructive: {
34 | DEFAULT: "hsl(var(--destructive))",
35 | foreground: "hsl(var(--destructive-foreground))",
36 | },
37 | muted: {
38 | DEFAULT: "hsl(var(--muted))",
39 | foreground: "hsl(var(--muted-foreground))",
40 | },
41 | accent: {
42 | DEFAULT: "hsl(var(--accent))",
43 | foreground: "hsl(var(--accent-foreground))",
44 | },
45 | popover: {
46 | DEFAULT: "hsl(var(--popover))",
47 | foreground: "hsl(var(--popover-foreground))",
48 | },
49 | card: {
50 | DEFAULT: "hsl(var(--card))",
51 | foreground: "hsl(var(--card-foreground))",
52 | },
53 | },
54 | borderRadius: {
55 | lg: "var(--radius)",
56 | md: "calc(var(--radius) - 2px)",
57 | sm: "calc(var(--radius) - 4px)",
58 | },
59 | keyframes: {
60 | "accordion-down": {
61 | from: { height: 0 },
62 | to: { height: "var(--radix-accordion-content-height)" },
63 | },
64 | "accordion-up": {
65 | from: { height: "var(--radix-accordion-content-height)" },
66 | to: { height: 0 },
67 | },
68 | },
69 | animation: {
70 | "accordion-down": "accordion-down 0.2s ease-out",
71 | "accordion-up": "accordion-up 0.2s ease-out",
72 | },
73 | },
74 | },
75 | plugins: [require("tailwindcss-animate")],
76 | };
77 |
--------------------------------------------------------------------------------
/src/env.mjs:
--------------------------------------------------------------------------------
1 | import { createEnv } from "@t3-oss/env-nextjs";
2 | import { z } from "zod";
3 |
4 | export const env = createEnv({
5 | /**
6 | * Specify your server-side environment variables schema here. This way you can ensure the app
7 | * isn't built with invalid env vars.
8 | */
9 | server: {
10 | DATABASE_URL: z
11 | .string()
12 | .url()
13 | .refine(
14 | (str) => !str.includes("YOUR_MYSQL_URL_HERE"),
15 | "You forgot to change the default URL",
16 | ),
17 | NODE_ENV: z
18 | .enum(["development", "test", "production"])
19 | .default("development"),
20 | CLERK_SECRET_KEY: z.string(),
21 | WEBHOOK_SECRET: z.string(),
22 | STRIPE_SECRET_KEY: z.string(),
23 | STRIPE_WEBHOOK_SECRET: z.string(),
24 | },
25 |
26 | /**
27 | * Specify your client-side environment variables schema here. This way you can ensure the app
28 | * isn't built with invalid env vars. To expose them to the client, prefix them with
29 | * `NEXT_PUBLIC_`.
30 | */
31 | client: {
32 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string(),
33 | NEXT_PUBLIC_CLERK_SIGN_IN_URL: z.string(),
34 | NEXT_PUBLIC_CLERK_SIGN_UP_URL: z.string(),
35 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string(),
36 | NEXT_PUBLIC_WEBSITE_URL: z.string(),
37 | },
38 |
39 | /**
40 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
41 | * middlewares) or client-side so we need to destruct manually.
42 | */
43 | runtimeEnv: {
44 | DATABASE_URL: process.env.DATABASE_URL,
45 | NODE_ENV: process.env.NODE_ENV,
46 | CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
47 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
48 | process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
49 | WEBHOOK_SECRET: process.env.WEBHOOK_SECRET,
50 | NEXT_PUBLIC_CLERK_SIGN_IN_URL: process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL,
51 | NEXT_PUBLIC_CLERK_SIGN_UP_URL: process.env.NEXT_PUBLIC_CLERK_SIGN_UP_URL,
52 | STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
53 | STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
54 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:
55 | process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
56 | NEXT_PUBLIC_WEBSITE_URL: process.env.NEXT_PUBLIC_WEBSITE_URL,
57 |
58 | // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
59 | },
60 | /**
61 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
62 | * useful for Docker builds.
63 | */
64 | skipValidation: !!process.env.SKIP_ENV_VALIDATION,
65 | /**
66 | * Makes it so that empty strings are treated as undefined.
67 | * `SOME_VAR: z.string()` and `SOME_VAR=''` will throw an error.
68 | */
69 | emptyStringAsUndefined: true,
70 | });
71 |
--------------------------------------------------------------------------------
/src/server/api/routers/stripe.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import Stripe from "stripe";
3 | import {
4 | createTRPCRouter,
5 | publicProcedure,
6 | protectedProcedure,
7 | } from "@/server/api/trpc";
8 | import { currentUser } from "@clerk/nextjs";
9 | import { TRPCError } from "@trpc/server";
10 |
11 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
12 | apiVersion: "2023-10-16",
13 | });
14 |
15 | /*
16 | In This file you can add another procedure like this:
17 | Remove Subscription -> andrà a rimuovere la sottoscrizione dell'utente ma l'utente rimarrà premium fino alla fine del periodo
18 | ecc...
19 | */
20 |
21 | export const stripeRouter = createTRPCRouter({
22 | getCheckoutSession: protectedProcedure
23 | .input(z.object({ productId: z.string() }))
24 | .mutation(async ({ ctx, input }) => {
25 | const user = await currentUser();
26 |
27 | if (!user) {
28 | throw new TRPCError({
29 | code: "UNAUTHORIZED",
30 | message: "You are not signed in.",
31 | });
32 | }
33 |
34 | const userData = await ctx.db.user.findUnique({
35 | where: {
36 | id: ctx.session.userId,
37 | },
38 | select: {
39 | stripeCustomerId: true,
40 | },
41 | });
42 |
43 | if (!ctx.session.userId || !userData?.stripeCustomerId) {
44 | throw new TRPCError({
45 | code: "UNAUTHORIZED",
46 | message:
47 | "You are not signed in or you don't have a Stripe account, contact support.",
48 | });
49 | }
50 |
51 | const plan = await stripe.plans.create({
52 | amount: 2000,
53 | currency: "eur",
54 | interval: "month",
55 | product: input.productId,
56 | });
57 |
58 | const checkoutSession = await stripe.checkout.sessions.create({
59 | payment_method_types: ["card"],
60 | billing_address_collection: "required",
61 | line_items: [
62 | {
63 | price: plan.id,
64 | quantity: 1,
65 | },
66 | ],
67 | mode: "subscription",
68 | customer: userData.stripeCustomerId,
69 | success_url:
70 | process.env.NEXT_PUBLIC_WEBSITE_URL +
71 | `?session_id={CHECKOUT_SESSION_ID}`,
72 | cancel_url: process.env.NEXT_PUBLIC_WEBSITE_URL,
73 | metadata: {
74 | clerkId: user.id,
75 | clerkEmailAddress: user.emailAddresses[0]!.emailAddress,
76 | clerkFullName: user.firstName + " " + user.lastName ?? "",
77 | },
78 | });
79 |
80 | if (checkoutSession) {
81 | return checkoutSession;
82 | }
83 |
84 | throw new TRPCError({
85 | code: "INTERNAL_SERVER_ERROR",
86 | message: "Something went wrong, contact support.",
87 | });
88 | }),
89 |
90 | getUserSubscription: protectedProcedure
91 | .input(z.object({}))
92 | .query(async ({ ctx }) => {
93 | const userData = await ctx.db.user.findUnique({
94 | where: {
95 | id: ctx.session.userId,
96 | },
97 | select: {
98 | stripeCustomerId: true,
99 | isPremium: true,
100 | PremiumUntil: true,
101 | },
102 | });
103 |
104 | if (!ctx.session.userId || !userData?.stripeCustomerId) {
105 | throw new TRPCError({
106 | code: "UNAUTHORIZED",
107 | message:
108 | "You are not signed in or you don't have a Stripe account, contact support.",
109 | });
110 | }
111 |
112 | const subscriptions = await stripe.subscriptions.list({
113 | customer: userData.stripeCustomerId,
114 | });
115 |
116 | for (const subscription of subscriptions.data) {
117 | if (
118 | subscription.status === "active" ||
119 | subscription.status === "trialing" ||
120 | subscription.status === "past_due"
121 | ) {
122 | return {
123 | isPremium: true,
124 | PremiumUntil: subscription.current_period_end,
125 | };
126 | }
127 | }
128 |
129 | return {
130 | isPremium: false,
131 | PremiumUntil: null,
132 | };
133 | }),
134 | });
135 |
--------------------------------------------------------------------------------
/src/server/api/trpc.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
3 | * 1. You want to modify request context (see Part 1).
4 | * 2. You want to create a new middleware or type of procedure (see Part 3).
5 | *
6 | * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will
7 | * need to use are documented accordingly near the end.
8 | */
9 | import { initTRPC } from "@trpc/server";
10 | import { type NextRequest } from "next/server";
11 | import superjson from "superjson";
12 | import { ZodError } from "zod";
13 |
14 | import { db } from "@/server/db";
15 | import { decodeJwt, type Session } from "@clerk/nextjs/server";
16 | import { clerkClient } from "@clerk/nextjs";
17 |
18 | /**
19 | * 1. CONTEXT
20 | *
21 | * This section defines the "contexts" that are available in the backend API.
22 | *
23 | * These allow you to access things when processing a request, like the database, the session, etc.
24 | */
25 |
26 | interface CreateContextOptions {
27 | headers: Headers;
28 | session: Session | null;
29 | }
30 |
31 | /**
32 | * This helper generates the "internals" for a tRPC context. If you need to use it, you can export
33 | * it from here.
34 | *
35 | * Examples of things you may need it for:
36 | * - testing, so we don't have to mock Next.js' req/res
37 | * - tRPC's `createSSGHelpers`, where we don't have req/res
38 | *
39 | * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts
40 | */
41 | export const createInnerTRPCContext = (opts: CreateContextOptions) => {
42 | return {
43 | headers: opts.headers,
44 | db,
45 | session: opts.session,
46 | };
47 | };
48 |
49 | /**
50 | * This is the actual context you will use in your router. It will be used to process every request
51 | * that goes through your tRPC endpoint.
52 | *
53 | * @see https://trpc.io/docs/context
54 | */
55 | export const createTRPCContext = async (opts: { req: NextRequest }) => {
56 | const sessionToken = opts.req.cookies.get("__session")?.value ?? "";
57 |
58 | try {
59 | // Decode the JWT to get the session ID
60 | const decodedJwt = decodeJwt(sessionToken);
61 |
62 | // Verify the session with Clerk to get the session object
63 | const verifiedSession = await clerkClient.sessions.verifySession(
64 | decodedJwt.payload.sid,
65 | sessionToken,
66 | );
67 |
68 | // If the session is valid, return a context with the session
69 | return createInnerTRPCContext({
70 | headers: opts.req.headers,
71 | session: verifiedSession,
72 | });
73 | } catch (error) {
74 | console.log(error);
75 | }
76 |
77 | // If the session is invalid, return a context with no session
78 | return createInnerTRPCContext({
79 | headers: opts.req.headers,
80 | session: null,
81 | });
82 | };
83 |
84 | /**
85 | * 2. INITIALIZATION
86 | *
87 | * This is where the tRPC API is initialized, connecting the context and transformer. We also parse
88 | * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
89 | * errors on the backend.
90 | */
91 |
92 | const t = initTRPC.context().create({
93 | transformer: superjson,
94 | errorFormatter({ shape, error }) {
95 | return {
96 | ...shape,
97 | data: {
98 | ...shape.data,
99 | zodError:
100 | error.cause instanceof ZodError ? error.cause.flatten() : null,
101 | },
102 | };
103 | },
104 | });
105 |
106 | const isAuthed = t.middleware(async ({ ctx, next }) => {
107 | if (!ctx.session) {
108 | throw new Error("UNAUTHORIZED");
109 | }
110 |
111 | return next({
112 | ctx: {
113 | session: ctx.session,
114 | },
115 | });
116 | });
117 |
118 | /**
119 | * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
120 | *
121 | * These are the pieces you use to build your tRPC API. You should import these a lot in the
122 | * "/src/server/api/routers" directory.
123 | */
124 |
125 | /**
126 | * This is how you create new routers and sub-routers in your tRPC API.
127 | *
128 | * @see https://trpc.io/docs/router
129 | */
130 | export const createTRPCRouter = t.router;
131 |
132 | /**
133 | * Public (unauthenticated) procedure
134 | *
135 | * This is the base piece you use to build new queries and mutations on your tRPC API. It does not
136 | * guarantee that a user querying is authorized, but you can still access user session data if they
137 | * are logged in.
138 | */
139 | export const publicProcedure = t.procedure;
140 |
141 | export const protectedProcedure = t.procedure.use(isAuthed);
142 |
--------------------------------------------------------------------------------
/src/app/api/clerk/route.ts:
--------------------------------------------------------------------------------
1 | import { Webhook } from "svix";
2 | import { headers } from "next/headers";
3 | import { WebhookEvent } from "@clerk/nextjs/server";
4 | import { db } from "@/server/db";
5 | import Stripe from "stripe";
6 |
7 | // remember to add your_url/api/clerk/ to the clerk dashboard
8 |
9 | export async function POST(req: Request) {
10 | // You can find this in the Clerk Dashboard -> Webhooks -> choose the webhook
11 | const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
12 |
13 | if (!WEBHOOK_SECRET) {
14 | throw new Error(
15 | "Please add WEBHOOK_SECRET from Clerk Dashboard to .env or .env.local",
16 | );
17 | }
18 |
19 | // Get the headers
20 | const headerPayload = headers();
21 | const svix_id = headerPayload.get("svix-id");
22 | const svix_timestamp = headerPayload.get("svix-timestamp");
23 | const svix_signature = headerPayload.get("svix-signature");
24 |
25 | // If there are no headers, error out
26 | if (!svix_id || !svix_timestamp || !svix_signature) {
27 | return new Response("Error occured -- no svix headers", {
28 | status: 400,
29 | });
30 | }
31 |
32 | // Get the body
33 | const payload = await req.json();
34 | const body = JSON.stringify(payload);
35 |
36 | // Create a new Svix instance with your secret.
37 | const wh = new Webhook(WEBHOOK_SECRET);
38 |
39 | let evt: WebhookEvent;
40 |
41 | // Verify the payload with the headers
42 | try {
43 | evt = wh.verify(body, {
44 | "svix-id": svix_id,
45 | "svix-timestamp": svix_timestamp,
46 | "svix-signature": svix_signature,
47 | }) as WebhookEvent;
48 | } catch (err) {
49 | console.error("Error verifying webhook:", err);
50 | return new Response("Error occured", {
51 | status: 400,
52 | });
53 | }
54 |
55 | const eventType = evt.type;
56 |
57 | switch (eventType) {
58 | case "user.created":
59 | const { id } = evt.data;
60 | let {
61 | id: userId,
62 | first_name,
63 | last_name,
64 | image_url,
65 | email_addresses,
66 | } = payload.data;
67 |
68 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
69 | apiVersion: "2023-10-16",
70 | });
71 |
72 | const stripe_customer = await stripe.customers
73 | .create({
74 | email: email_addresses[0].email_address,
75 | name: `${first_name} ${last_name}`,
76 | metadata: {
77 | clerkId: userId,
78 | },
79 | })
80 | .then((customer) => {
81 | return customer;
82 | });
83 |
84 | let user = db.user
85 | .create({
86 | data: {
87 | id: userId,
88 | firstName: first_name ?? " ",
89 | lastName: last_name ?? " ",
90 | profileImageUrl: image_url,
91 | email: email_addresses[0].email_address,
92 | stripeCustomerId: stripe_customer.id,
93 | },
94 | })
95 | .then((user) => {
96 | console.log(user);
97 | });
98 |
99 | console.log(`Webhook with and ID of ${id} and type of ${eventType}`);
100 | console.log(
101 | `User with an ID of ${userId} and name of ${first_name} ${last_name}`,
102 | );
103 | break;
104 |
105 | case "user.updated":
106 | const { id: u_id } = evt.data;
107 |
108 | let {
109 | id: u_userId,
110 | first_name: u_first_name,
111 | last_name: u_last_name,
112 | image_url: u_profile_image_url,
113 | email_addresses: u_email_addresse,
114 | } = payload.data;
115 |
116 | let u_user = await db.user.update({
117 | where: {
118 | id: u_userId,
119 | },
120 | data: {
121 | firstName: u_first_name,
122 | lastName: u_last_name,
123 | profileImageUrl: u_profile_image_url,
124 | email: u_email_addresse[0].email_address,
125 | },
126 | });
127 |
128 | console.log(`Webhook with and ID of ${u_id} and type of ${eventType}`);
129 | break;
130 |
131 | case "user.deleted":
132 | const { id: d_id } = evt.data;
133 |
134 | let { id: d_userId } = payload.data;
135 |
136 | let d_user = await db.user.delete({
137 | where: {
138 | id: d_userId,
139 | },
140 | });
141 | console.log(`Webhook with and ID of ${d_id} and type of ${eventType}`);
142 | console.log(`User with an ID of ${d_userId} was deleted`);
143 | break;
144 |
145 | default:
146 | console.log(
147 | `Webhook with and ID of [NotValued] and type of ${eventType}`,
148 | );
149 | return new Response("Error occured", {
150 | status: 400,
151 | });
152 | }
153 |
154 | return new Response("", { status: 200 });
155 | }
156 |
--------------------------------------------------------------------------------
/src/app/(marketing)/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { CreatePost } from "@/app/_components/create-post";
3 | import { api } from "@/trpc/react";
4 | import {
5 | SignInButton,
6 | SignOutButton,
7 | currentUser,
8 | useUser,
9 | } from "@clerk/nextjs";
10 | import Link from "next/link";
11 | import Balancer from "react-wrap-balancer";
12 | import getStripe from "@/lib/getStripe";
13 | import { Animated_div, Animated_h1, Animated_p } from "@/lib/animated";
14 | import Stripe from "stripe";
15 | import { cn } from "@/lib/utils";
16 | import LoadingComponent from "../_components/loading";
17 |
18 | export default function Home() {
19 | const { mutate: getCheckoutSession } =
20 | api.stripe.getCheckoutSession.useMutation({
21 | onSuccess: async (session: Stripe.Response) => {
22 | const stripe = await getStripe();
23 | const { error } = await stripe!.redirectToCheckout({
24 | sessionId: session.id,
25 | });
26 | },
27 | });
28 |
29 | const { user, isLoaded } = useUser();
30 | if (!isLoaded) {
31 | return (
32 |
33 |
34 |
35 | );
36 | }
37 |
38 | const { data: premiumData, isLoading } = {
39 | data: { isPremium: false },
40 | isLoading: false,
41 | };
42 |
43 | /*
44 |
45 | i hard coded the premium because
46 | i can't do the stripe integration on a public page
47 |
48 | you need to work like that:
49 |
50 | const { data: premiumData, isLoading } = api.stripe.getUserSubscription.useQuery({});
51 |
52 | and then you can check if the user is premium or not
53 |
54 | */
55 |
56 | if (isLoading) {
57 | return (
58 |
59 |
60 |
61 | );
62 | }
63 |
64 | return (
65 |
66 |
67 |
74 | T3 Template App
75 |
76 |
83 |
84 | This is a boilerplate for a fullstack{" "}
85 | Next.js app i have done
86 | implementing different template around the web. I have done This
87 | because every time i try to start a new project i have to do the
88 | same thing over and over again (and every time something decide to
89 | don't work...). I promise to keep this updated and to add new stuff
90 |
91 |
92 |
93 | {user && !isLoading ? (
94 |
95 |
101 | Logged in as {user.fullName} and you are{" "}
102 |
107 | {premiumData?.isPremium ? "Premium" : "Not Premium"}
108 |
109 |
110 |
116 |
117 |
120 |
121 |
136 |
137 |