├── .eslintrc.json
├── .gitignore
├── LICENSE
├── README.md
├── components.json
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.js
├── prisma
├── migrations
│ ├── 20240308133406_init
│ │ └── migration.sql
│ ├── 20240308152924_fix_error
│ │ └── migration.sql
│ └── migration_lock.toml
└── schema.prisma
├── src
├── actions
│ └── orders.tsx
├── app
│ ├── (customerFacing)
│ │ ├── layout.tsx
│ │ ├── orders
│ │ │ └── page.tsx
│ │ ├── page.tsx
│ │ ├── products
│ │ │ ├── [id]
│ │ │ │ └── purchase
│ │ │ │ │ ├── _components
│ │ │ │ │ └── CheckoutForm.tsx
│ │ │ │ │ └── page.tsx
│ │ │ ├── download
│ │ │ │ ├── [downloadVerificationId]
│ │ │ │ │ └── route.ts
│ │ │ │ └── expired
│ │ │ │ │ └── page.tsx
│ │ │ └── page.tsx
│ │ └── stripe
│ │ │ └── purchase-success
│ │ │ └── page.tsx
│ ├── admin
│ │ ├── _actions
│ │ │ ├── discountCodes.ts
│ │ │ ├── orders.ts
│ │ │ ├── products.ts
│ │ │ └── users.ts
│ │ ├── _components
│ │ │ ├── ChartCard.tsx
│ │ │ ├── PageHeader.tsx
│ │ │ └── charts
│ │ │ │ ├── OrdersByDayChart.tsx
│ │ │ │ ├── RevenueByProductChart.tsx
│ │ │ │ └── UsersByDayChart.tsx
│ │ ├── discount-codes
│ │ │ ├── _components
│ │ │ │ ├── DiscountCodeActions.tsx
│ │ │ │ └── DiscountCodeForm.tsx
│ │ │ ├── new
│ │ │ │ └── page.tsx
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── loading.tsx
│ │ ├── orders
│ │ │ ├── _components
│ │ │ │ └── OrderActions.tsx
│ │ │ └── page.tsx
│ │ ├── page.tsx
│ │ ├── products
│ │ │ ├── [id]
│ │ │ │ ├── download
│ │ │ │ │ └── route.ts
│ │ │ │ └── edit
│ │ │ │ │ └── page.tsx
│ │ │ ├── _components
│ │ │ │ ├── ProductActions.tsx
│ │ │ │ └── ProductForm.tsx
│ │ │ ├── new
│ │ │ │ └── page.tsx
│ │ │ └── page.tsx
│ │ └── users
│ │ │ ├── _components
│ │ │ └── UserActions.tsx
│ │ │ └── page.tsx
│ ├── globals.css
│ ├── layout.tsx
│ └── webhooks
│ │ └── stripe
│ │ └── route.tsx
├── components
│ ├── Nav.tsx
│ ├── ProductCard.tsx
│ ├── page.tsx
│ └── ui
│ │ ├── button.tsx
│ │ ├── calendar.tsx
│ │ ├── card.tsx
│ │ ├── checkbox.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── radio-group.tsx
│ │ ├── table.tsx
│ │ └── textarea.tsx
├── db
│ └── db.ts
├── email
│ ├── OrderHistory.tsx
│ ├── PurchaseReceipt.tsx
│ └── components
│ │ └── OrderInformation.tsx
├── lib
│ ├── cache.ts
│ ├── discountCodeHelpers.ts
│ ├── formatters.ts
│ ├── isValidPassword.ts
│ ├── rangeOptions.ts
│ └── utils.ts
└── middleware.ts
├── tailwind.config.ts
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.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 | /products
38 | /public/products
39 | .env
40 | /prisma/dev.db
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 WebDevSimplified
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
37 |
--------------------------------------------------------------------------------
/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": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-js-ecommerce-mvp",
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 | "email": "cp .env ./node_modules/react-email && email dev --dir src/email --port 3001"
11 | },
12 | "dependencies": {
13 | "@prisma/client": "^5.10.2",
14 | "@radix-ui/react-checkbox": "^1.0.4",
15 | "@radix-ui/react-dropdown-menu": "^2.0.6",
16 | "@radix-ui/react-label": "^2.0.2",
17 | "@radix-ui/react-radio-group": "^1.1.3",
18 | "@radix-ui/react-slot": "^1.0.2",
19 | "@react-email/components": "^0.0.15",
20 | "@stripe/react-stripe-js": "^2.5.1",
21 | "@stripe/stripe-js": "^3.0.6",
22 | "class-variance-authority": "^0.7.0",
23 | "clsx": "^2.1.0",
24 | "date-fns": "^3.5.0",
25 | "lucide-react": "^0.341.0",
26 | "next": "14.1.0",
27 | "react": "^18",
28 | "react-day-picker": "^8.10.0",
29 | "react-dom": "^18",
30 | "react-email": "^2.1.0",
31 | "recharts": "^2.12.2",
32 | "resend": "^3.2.0",
33 | "stripe": "^14.18.0",
34 | "tailwind-merge": "^2.2.1",
35 | "tailwindcss-animate": "^1.0.7",
36 | "zod": "^3.22.4"
37 | },
38 | "devDependencies": {
39 | "@types/node": "^20",
40 | "@types/react": "^18",
41 | "@types/react-dom": "^18",
42 | "autoprefixer": "^10.0.1",
43 | "eslint": "^8",
44 | "eslint-config-next": "14.1.0",
45 | "postcss": "^8",
46 | "prisma": "^5.10.2",
47 | "tailwindcss": "^3.3.0",
48 | "ts-node": "^10.9.2",
49 | "typescript": "^5"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/prisma/migrations/20240308133406_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateEnum
2 | CREATE TYPE "DiscountCodeType" AS ENUM ('PERCENTAGE', 'FIXED');
3 |
4 | -- CreateTable
5 | CREATE TABLE "Product" (
6 | "id" TEXT NOT NULL,
7 | "name" TEXT NOT NULL,
8 | "priceInCents" INTEGER NOT NULL,
9 | "filePath" TEXT NOT NULL,
10 | "imagePath" TEXT NOT NULL,
11 | "description" TEXT NOT NULL,
12 | "isAvailableForPurchase" BOOLEAN NOT NULL DEFAULT true,
13 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
14 | "updatedAt" TIMESTAMP(3) NOT NULL,
15 |
16 | CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
17 | );
18 |
19 | -- CreateTable
20 | CREATE TABLE "User" (
21 | "id" TEXT NOT NULL,
22 | "email" TEXT NOT NULL,
23 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
24 | "updatedAt" TIMESTAMP(3) NOT NULL,
25 |
26 | CONSTRAINT "User_pkey" PRIMARY KEY ("id")
27 | );
28 |
29 | -- CreateTable
30 | CREATE TABLE "Order" (
31 | "id" TEXT NOT NULL,
32 | "pricePaidInCents" INTEGER NOT NULL,
33 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
34 | "updatedAt" TIMESTAMP(3) NOT NULL,
35 | "userId" TEXT NOT NULL,
36 | "productId" TEXT NOT NULL,
37 | "discountCodeId" TEXT NOT NULL,
38 |
39 | CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
40 | );
41 |
42 | -- CreateTable
43 | CREATE TABLE "DownloadVerification" (
44 | "id" TEXT NOT NULL,
45 | "expiresAt" TIMESTAMP(3) NOT NULL,
46 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
47 | "productId" TEXT NOT NULL,
48 |
49 | CONSTRAINT "DownloadVerification_pkey" PRIMARY KEY ("id")
50 | );
51 |
52 | -- CreateTable
53 | CREATE TABLE "DiscountCode" (
54 | "id" TEXT NOT NULL,
55 | "code" TEXT NOT NULL,
56 | "discountAmount" INTEGER NOT NULL,
57 | "discountType" "DiscountCodeType" NOT NULL,
58 | "uses" INTEGER NOT NULL DEFAULT 0,
59 | "isActive" BOOLEAN NOT NULL DEFAULT true,
60 | "allProducts" BOOLEAN NOT NULL DEFAULT false,
61 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
62 | "limit" INTEGER,
63 | "expiresAt" TIMESTAMP(3),
64 |
65 | CONSTRAINT "DiscountCode_pkey" PRIMARY KEY ("id")
66 | );
67 |
68 | -- CreateTable
69 | CREATE TABLE "_DiscountCodeToProduct" (
70 | "A" TEXT NOT NULL,
71 | "B" TEXT NOT NULL
72 | );
73 |
74 | -- CreateIndex
75 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
76 |
77 | -- CreateIndex
78 | CREATE UNIQUE INDEX "DiscountCode_code_key" ON "DiscountCode"("code");
79 |
80 | -- CreateIndex
81 | CREATE UNIQUE INDEX "_DiscountCodeToProduct_AB_unique" ON "_DiscountCodeToProduct"("A", "B");
82 |
83 | -- CreateIndex
84 | CREATE INDEX "_DiscountCodeToProduct_B_index" ON "_DiscountCodeToProduct"("B");
85 |
86 | -- AddForeignKey
87 | ALTER TABLE "Order" ADD CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
88 |
89 | -- AddForeignKey
90 | ALTER TABLE "Order" ADD CONSTRAINT "Order_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
91 |
92 | -- AddForeignKey
93 | ALTER TABLE "Order" ADD CONSTRAINT "Order_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
94 |
95 | -- AddForeignKey
96 | ALTER TABLE "DownloadVerification" ADD CONSTRAINT "DownloadVerification_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
97 |
98 | -- AddForeignKey
99 | ALTER TABLE "_DiscountCodeToProduct" ADD CONSTRAINT "_DiscountCodeToProduct_A_fkey" FOREIGN KEY ("A") REFERENCES "DiscountCode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
100 |
101 | -- AddForeignKey
102 | ALTER TABLE "_DiscountCodeToProduct" ADD CONSTRAINT "_DiscountCodeToProduct_B_fkey" FOREIGN KEY ("B") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
103 |
--------------------------------------------------------------------------------
/prisma/migrations/20240308152924_fix_error/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "Order" ALTER COLUMN "discountCodeId" DROP NOT NULL;
3 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | }
7 |
8 | datasource db {
9 | provider = "postgresql"
10 | url = env("DATABASE_URL")
11 | }
12 |
13 | model Product {
14 | id String @id @default(uuid())
15 | name String
16 | priceInCents Int
17 | filePath String
18 | imagePath String
19 | description String
20 | isAvailableForPurchase Boolean @default(true)
21 | createdAt DateTime @default(now())
22 | updatedAt DateTime @updatedAt
23 | orders Order[]
24 | downloadVerifications DownloadVerification[]
25 | discountCodes DiscountCode[]
26 | }
27 |
28 | model User {
29 | id String @id @default(uuid())
30 | email String @unique
31 | createdAt DateTime @default(now())
32 | updatedAt DateTime @updatedAt
33 | orders Order[]
34 | }
35 |
36 | model Order {
37 | id String @id @default(uuid())
38 | pricePaidInCents Int
39 | createdAt DateTime @default(now())
40 | updatedAt DateTime @updatedAt
41 |
42 | userId String
43 | productId String
44 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
45 | product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
46 | discountCodeId String?
47 | discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id], onDelete: Restrict)
48 | }
49 |
50 | model DownloadVerification {
51 | id String @id @default(uuid())
52 | expiresAt DateTime
53 | createdAt DateTime @default(now())
54 | productId String
55 | product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
56 | }
57 |
58 | model DiscountCode {
59 | id String @id @default(uuid())
60 | code String @unique
61 | discountAmount Int
62 | discountType DiscountCodeType
63 | uses Int @default(0)
64 | isActive Boolean @default(true)
65 | allProducts Boolean @default(false)
66 | createdAt DateTime @default(now())
67 | limit Int?
68 | expiresAt DateTime?
69 |
70 | products Product[]
71 | orders Order[]
72 | }
73 |
74 | enum DiscountCodeType {
75 | PERCENTAGE
76 | FIXED
77 | }
78 |
--------------------------------------------------------------------------------
/src/actions/orders.tsx:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import db from "@/db/db"
4 | import OrderHistoryEmail from "@/email/OrderHistory"
5 | import {
6 | getDiscountedAmount,
7 | usableDiscountCodeWhere,
8 | } from "@/lib/discountCodeHelpers"
9 | import { Resend } from "resend"
10 | import Stripe from "stripe"
11 | import { z } from "zod"
12 |
13 | const emailSchema = z.string().email()
14 | const resend = new Resend(process.env.RESEND_API_KEY as string)
15 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)
16 |
17 | export async function emailOrderHistory(
18 | prevState: unknown,
19 | formData: FormData
20 | ): Promise<{ message?: string; error?: string }> {
21 | const result = emailSchema.safeParse(formData.get("email"))
22 |
23 | if (result.success === false) {
24 | return { error: "Invalid email address" }
25 | }
26 |
27 | const user = await db.user.findUnique({
28 | where: { email: result.data },
29 | select: {
30 | email: true,
31 | orders: {
32 | select: {
33 | pricePaidInCents: true,
34 | id: true,
35 | createdAt: true,
36 | product: {
37 | select: {
38 | id: true,
39 | name: true,
40 | imagePath: true,
41 | description: true,
42 | },
43 | },
44 | },
45 | },
46 | },
47 | })
48 |
49 | if (user == null) {
50 | return {
51 | message:
52 | "Check your email to view your order history and download your products.",
53 | }
54 | }
55 |
56 | const orders = user.orders.map(async order => {
57 | return {
58 | ...order,
59 | downloadVerificationId: (
60 | await db.downloadVerification.create({
61 | data: {
62 | expiresAt: new Date(Date.now() + 24 * 1000 * 60 * 60),
63 | productId: order.product.id,
64 | },
65 | })
66 | ).id,
67 | }
68 | })
69 |
70 | const data = await resend.emails.send({
71 | from: `Support <${process.env.SENDER_EMAIL}>`,
72 | to: user.email,
73 | subject: "Order History",
74 | react: ,
75 | })
76 |
77 | if (data.error) {
78 | return { error: "There was an error sending your email. Please try again." }
79 | }
80 |
81 | return {
82 | message:
83 | "Check your email to view your order history and download your products.",
84 | }
85 | }
86 |
87 | export async function createPaymentIntent(
88 | email: string,
89 | productId: string,
90 | discountCodeId?: string
91 | ) {
92 | const product = await db.product.findUnique({ where: { id: productId } })
93 | if (product == null) return { error: "Unexpected Error" }
94 |
95 | const discountCode =
96 | discountCodeId == null
97 | ? null
98 | : await db.discountCode.findUnique({
99 | where: { id: discountCodeId, ...usableDiscountCodeWhere(product.id) },
100 | })
101 |
102 | if (discountCode == null && discountCodeId != null) {
103 | return { error: "Coupon has expired" }
104 | }
105 |
106 | const existingOrder = await db.order.findFirst({
107 | where: { user: { email }, productId },
108 | select: { id: true },
109 | })
110 |
111 | if (existingOrder != null) {
112 | return {
113 | error:
114 | "You have already purchased this product. Try downloading it from the My Orders page",
115 | }
116 | }
117 |
118 | const amount =
119 | discountCode == null
120 | ? product.priceInCents
121 | : getDiscountedAmount(discountCode, product.priceInCents)
122 |
123 | const paymentIntent = await stripe.paymentIntents.create({
124 | amount,
125 | currency: "USD",
126 | metadata: {
127 | productId: product.id,
128 | discountCodeId: discountCode?.id || null,
129 | },
130 | })
131 |
132 | if (paymentIntent.client_secret == null) {
133 | return { error: "Unknown error" }
134 | }
135 |
136 | return { clientSecret: paymentIntent.client_secret }
137 | }
138 |
--------------------------------------------------------------------------------
/src/app/(customerFacing)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Nav, NavLink } from "@/components/Nav"
2 |
3 | export const dynamic = "force-dynamic"
4 |
5 | export default function Layout({
6 | children,
7 | }: Readonly<{
8 | children: React.ReactNode
9 | }>) {
10 | return (
11 | <>
12 |
17 |
{children}
18 | >
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/(customerFacing)/orders/page.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { emailOrderHistory } from "@/actions/orders"
4 | import { Button } from "@/components/ui/button"
5 | import {
6 | Card,
7 | CardContent,
8 | CardDescription,
9 | CardFooter,
10 | CardHeader,
11 | CardTitle,
12 | } from "@/components/ui/card"
13 | import { Input } from "@/components/ui/input"
14 | import { Label } from "@/components/ui/label"
15 | import { useFormState, useFormStatus } from "react-dom"
16 |
17 | export default function MyOrdersPage() {
18 | const [data, action] = useFormState(emailOrderHistory, {})
19 | return (
20 |
41 | )
42 | }
43 |
44 | function SubmitButton() {
45 | const { pending } = useFormStatus()
46 |
47 | return (
48 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/src/app/(customerFacing)/page.tsx:
--------------------------------------------------------------------------------
1 | import { ProductCard, ProductCardSkeleton } from "@/components/ProductCard"
2 | import { Button } from "@/components/ui/button"
3 | import db from "@/db/db"
4 | import { cache } from "@/lib/cache"
5 | import { Product } from "@prisma/client"
6 | import { ArrowRight } from "lucide-react"
7 | import Link from "next/link"
8 | import { Suspense } from "react"
9 |
10 | const getMostPopularProducts = cache(
11 | () => {
12 | return db.product.findMany({
13 | where: { isAvailableForPurchase: true },
14 | orderBy: { orders: { _count: "desc" } },
15 | take: 6,
16 | })
17 | },
18 | ["/", "getMostPopularProducts"],
19 | { revalidate: 60 * 60 * 24 }
20 | )
21 |
22 | const getNewestProducts = cache(() => {
23 | return db.product.findMany({
24 | where: { isAvailableForPurchase: true },
25 | orderBy: { createdAt: "desc" },
26 | take: 6,
27 | })
28 | }, ["/", "getNewestProducts"])
29 |
30 | export default function HomePage() {
31 | return (
32 |
33 |
37 |
38 |
39 | )
40 | }
41 |
42 | type ProductGridSectionProps = {
43 | title: string
44 | productsFetcher: () => Promise
45 | }
46 |
47 | function ProductGridSection({
48 | productsFetcher,
49 | title,
50 | }: ProductGridSectionProps) {
51 | return (
52 |
53 |
54 |
{title}
55 |
61 |
62 |
63 |
66 |
67 |
68 |
69 | >
70 | }
71 | >
72 |
73 |
74 |
75 |
76 | )
77 | }
78 |
79 | async function ProductSuspense({
80 | productsFetcher,
81 | }: {
82 | productsFetcher: () => Promise
83 | }) {
84 | return (await productsFetcher()).map(product => (
85 |
86 | ))
87 | }
88 |
--------------------------------------------------------------------------------
/src/app/(customerFacing)/products/[id]/purchase/_components/CheckoutForm.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { createPaymentIntent } from "@/actions/orders"
4 | import { Button } from "@/components/ui/button"
5 | import {
6 | Card,
7 | CardContent,
8 | CardDescription,
9 | CardFooter,
10 | CardHeader,
11 | CardTitle,
12 | } from "@/components/ui/card"
13 | import { Input } from "@/components/ui/input"
14 | import { Label } from "@/components/ui/label"
15 | import { getDiscountedAmount } from "@/lib/discountCodeHelpers"
16 | import { formatCurrency, formatDiscountCode } from "@/lib/formatters"
17 | import { DiscountCodeType } from "@prisma/client"
18 | import {
19 | Elements,
20 | LinkAuthenticationElement,
21 | PaymentElement,
22 | useElements,
23 | useStripe,
24 | } from "@stripe/react-stripe-js"
25 | import { loadStripe } from "@stripe/stripe-js"
26 | import Image from "next/image"
27 | import { usePathname, useRouter, useSearchParams } from "next/navigation"
28 | import { FormEvent, useRef, useState } from "react"
29 |
30 | type CheckoutFormProps = {
31 | product: {
32 | id: string
33 | imagePath: string
34 | name: string
35 | priceInCents: number
36 | description: string
37 | }
38 | discountCode?: {
39 | id: string
40 | discountAmount: number
41 | discountType: DiscountCodeType
42 | }
43 | }
44 |
45 | const stripePromise = loadStripe(
46 | process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY as string
47 | )
48 |
49 | export function CheckoutForm({ product, discountCode }: CheckoutFormProps) {
50 | const amount =
51 | discountCode == null
52 | ? product.priceInCents
53 | : getDiscountedAmount(discountCode, product.priceInCents)
54 | const isDiscounted = amount !== product.priceInCents
55 |
56 | return (
57 |
58 |
59 |
60 |
66 |
67 |
68 |
69 |
74 | {formatCurrency(product.priceInCents / 100)}
75 |
76 | {isDiscounted && (
77 |
{formatCurrency(amount / 100)}
78 | )}
79 |
80 |
{product.name}
81 |
82 | {product.description}
83 |
84 |
85 |
86 |
90 |
95 |
96 |
97 | )
98 | }
99 |
100 | function Form({
101 | priceInCents,
102 | productId,
103 | discountCode,
104 | }: {
105 | priceInCents: number
106 | productId: string
107 | discountCode?: {
108 | id: string
109 | discountAmount: number
110 | discountType: DiscountCodeType
111 | }
112 | }) {
113 | const stripe = useStripe()
114 | const elements = useElements()
115 | const [isLoading, setIsLoading] = useState(false)
116 | const [errorMessage, setErrorMessage] = useState()
117 | const [email, setEmail] = useState()
118 | const discountCodeRef = useRef(null)
119 | const searchParams = useSearchParams()
120 | const router = useRouter()
121 | const pathname = usePathname()
122 | const coupon = searchParams.get("coupon")
123 |
124 | async function handleSubmit(e: FormEvent) {
125 | e.preventDefault()
126 |
127 | if (stripe == null || elements == null || email == null) return
128 |
129 | setIsLoading(true)
130 |
131 | const formSubmit = await elements.submit()
132 | if (formSubmit.error != null) {
133 | setErrorMessage(formSubmit.error.message)
134 | setIsLoading(false)
135 | return
136 | }
137 |
138 | const paymentIntent = await createPaymentIntent(
139 | email,
140 | productId,
141 | discountCode?.id
142 | )
143 | if (paymentIntent.error != null) {
144 | setErrorMessage(paymentIntent.error)
145 | setIsLoading(false)
146 | return
147 | }
148 |
149 | stripe
150 | .confirmPayment({
151 | elements,
152 | clientSecret: paymentIntent.clientSecret,
153 | confirmParams: {
154 | return_url: `${process.env.NEXT_PUBLIC_SERVER_URL}/stripe/purchase-success`,
155 | },
156 | })
157 | .then(({ error }) => {
158 | if (error.type === "card_error" || error.type === "validation_error") {
159 | setErrorMessage(error.message)
160 | } else {
161 | setErrorMessage("An unknown error occurred")
162 | }
163 | })
164 | .finally(() => setIsLoading(false))
165 | }
166 |
167 | return (
168 |
228 | )
229 | }
230 |
--------------------------------------------------------------------------------
/src/app/(customerFacing)/products/[id]/purchase/page.tsx:
--------------------------------------------------------------------------------
1 | import db from "@/db/db"
2 | import { notFound } from "next/navigation"
3 | import Stripe from "stripe"
4 | import { CheckoutForm } from "./_components/CheckoutForm"
5 | import { usableDiscountCodeWhere } from "@/lib/discountCodeHelpers"
6 |
7 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)
8 |
9 | export default async function PurchasePage({
10 | params: { id },
11 | searchParams: { coupon },
12 | }: {
13 | params: { id: string }
14 | searchParams: { coupon?: string }
15 | }) {
16 | const product = await db.product.findUnique({ where: { id } })
17 | if (product == null) return notFound()
18 |
19 | const discountCode =
20 | coupon == null ? undefined : await getDiscountCode(coupon, product.id)
21 |
22 | return (
23 |
24 | )
25 | }
26 |
27 | function getDiscountCode(coupon: string, productId: string) {
28 | return db.discountCode.findUnique({
29 | select: { id: true, discountAmount: true, discountType: true },
30 | where: { ...usableDiscountCodeWhere, code: coupon },
31 | })
32 | }
33 |
--------------------------------------------------------------------------------
/src/app/(customerFacing)/products/download/[downloadVerificationId]/route.ts:
--------------------------------------------------------------------------------
1 | import db from "@/db/db"
2 | import { NextRequest, NextResponse } from "next/server"
3 | import fs from "fs/promises"
4 |
5 | export async function GET(
6 | req: NextRequest,
7 | {
8 | params: { downloadVerificationId },
9 | }: { params: { downloadVerificationId: string } }
10 | ) {
11 | const data = await db.downloadVerification.findUnique({
12 | where: { id: downloadVerificationId, expiresAt: { gt: new Date() } },
13 | select: { product: { select: { filePath: true, name: true } } },
14 | })
15 |
16 | if (data == null) {
17 | return NextResponse.redirect(new URL("/products/download/expired", req.url))
18 | }
19 |
20 | const { size } = await fs.stat(data.product.filePath)
21 | const file = await fs.readFile(data.product.filePath)
22 | const extension = data.product.filePath.split(".").pop()
23 |
24 | return new NextResponse(file, {
25 | headers: {
26 | "Content-Disposition": `attachment; filename="${data.product.name}.${extension}"`,
27 | "Content-Length": size.toString(),
28 | },
29 | })
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/(customerFacing)/products/download/expired/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button"
2 | import Link from "next/link"
3 |
4 | export default function Expired() {
5 | return (
6 | <>
7 | Download link expired
8 |
11 | >
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/src/app/(customerFacing)/products/page.tsx:
--------------------------------------------------------------------------------
1 | import { ProductCard, ProductCardSkeleton } from "@/components/ProductCard"
2 | import db from "@/db/db"
3 | import { cache } from "@/lib/cache"
4 | import { Suspense } from "react"
5 |
6 | const getProducts = cache(() => {
7 | return db.product.findMany({
8 | where: { isAvailableForPurchase: true },
9 | orderBy: { name: "asc" },
10 | })
11 | }, ["/products", "getProducts"])
12 |
13 | export default function ProductsPage() {
14 | return (
15 |
16 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | >
26 | }
27 | >
28 |
29 |
30 |
31 | )
32 | }
33 |
34 | async function ProductsSuspense() {
35 | const products = await getProducts()
36 |
37 | return products.map(product => )
38 | }
39 |
--------------------------------------------------------------------------------
/src/app/(customerFacing)/stripe/purchase-success/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button"
2 | import db from "@/db/db"
3 | import Image from "next/image"
4 | import Link from "next/link"
5 | import { notFound } from "next/navigation"
6 | import Stripe from "stripe"
7 |
8 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)
9 |
10 | export default async function SuccessPage({
11 | searchParams,
12 | }: {
13 | searchParams: { payment_intent: string }
14 | }) {
15 | const paymentIntent = await stripe.paymentIntents.retrieve(
16 | searchParams.payment_intent
17 | )
18 | if (paymentIntent.metadata.productId == null) return notFound()
19 |
20 | const product = await db.product.findUnique({
21 | where: { id: paymentIntent.metadata.productId },
22 | })
23 | if (product == null) return notFound()
24 |
25 | const isSuccess = paymentIntent.status === "succeeded"
26 |
27 | return (
28 |
29 |
30 | {isSuccess ? "Success!" : "Error!"}
31 |
32 |
33 |
34 |
40 |
41 |
42 |
{product.name}
43 |
44 | {product.description}
45 |
46 |
59 |
60 |
61 |
62 | )
63 | }
64 |
65 | async function createDownloadVerification(productId: string) {
66 | return (
67 | await db.downloadVerification.create({
68 | data: {
69 | productId,
70 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
71 | },
72 | })
73 | ).id
74 | }
75 |
--------------------------------------------------------------------------------
/src/app/admin/_actions/discountCodes.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import db from "@/db/db"
4 | import { DiscountCodeType } from "@prisma/client"
5 | import { notFound, redirect } from "next/navigation"
6 | import { z } from "zod"
7 |
8 | const addSchema = z
9 | .object({
10 | code: z.string().min(1),
11 | discountAmount: z.coerce.number().int().min(1),
12 | discountType: z.nativeEnum(DiscountCodeType),
13 | allProducts: z.coerce.boolean(),
14 | productIds: z.array(z.string()).optional(),
15 | expiresAt: z.preprocess(
16 | value => (value === "" ? undefined : value),
17 | z.coerce.date().min(new Date()).optional()
18 | ),
19 | limit: z.preprocess(
20 | value => (value === "" ? undefined : value),
21 | z.coerce.number().int().min(1).optional()
22 | ),
23 | })
24 | .refine(
25 | data =>
26 | data.discountAmount <= 100 ||
27 | data.discountType !== DiscountCodeType.PERCENTAGE,
28 | {
29 | message: "Percentage discount must be less than or equal to 100",
30 | path: ["discountAmount"],
31 | }
32 | )
33 | .refine(data => !data.allProducts || data.productIds == null, {
34 | message: "Cannot select products when all products is selected",
35 | path: ["productIds"],
36 | })
37 | .refine(data => data.allProducts || data.productIds != null, {
38 | message: "Must select products when all products is not selected",
39 | path: ["productIds"],
40 | })
41 |
42 | export async function addDiscountCode(prevState: unknown, formData: FormData) {
43 | const productIds = formData.getAll("productIds")
44 | const result = addSchema.safeParse({
45 | ...Object.fromEntries(formData.entries()),
46 | productIds: productIds.length > 0 ? productIds : undefined,
47 | })
48 |
49 | if (result.success === false) return result.error.formErrors.fieldErrors
50 |
51 | const data = result.data
52 |
53 | await db.discountCode.create({
54 | data: {
55 | code: data.code,
56 | discountAmount: data.discountAmount,
57 | discountType: data.discountType,
58 | allProducts: data.allProducts,
59 | products:
60 | data.productIds != null
61 | ? { connect: data.productIds.map(id => ({ id })) }
62 | : undefined,
63 | expiresAt: data.expiresAt,
64 | limit: data.limit,
65 | },
66 | })
67 |
68 | redirect("/admin/discount-codes")
69 | }
70 |
71 | export async function toggleDiscountCodeActive(id: string, isActive: boolean) {
72 | await db.discountCode.update({ where: { id }, data: { isActive } })
73 | }
74 |
75 | export async function deleteDiscountCode(id: string) {
76 | const discountCode = await db.discountCode.delete({ where: { id } })
77 |
78 | if (discountCode == null) return notFound()
79 |
80 | return discountCode
81 | }
82 |
--------------------------------------------------------------------------------
/src/app/admin/_actions/orders.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import db from "@/db/db"
4 | import { notFound } from "next/navigation"
5 |
6 | export async function deleteOrder(id: string) {
7 | const order = await db.order.delete({
8 | where: { id },
9 | })
10 |
11 | if (order == null) return notFound()
12 |
13 | return order
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/admin/_actions/products.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import db from "@/db/db"
4 | import { z } from "zod"
5 | import fs from "fs/promises"
6 | import { notFound, redirect } from "next/navigation"
7 | import { revalidatePath } from "next/cache"
8 |
9 | const fileSchema = z.instanceof(File, { message: "Required" })
10 | const imageSchema = fileSchema.refine(
11 | file => file.size === 0 || file.type.startsWith("image/")
12 | )
13 |
14 | const addSchema = z.object({
15 | name: z.string().min(1),
16 | description: z.string().min(1),
17 | priceInCents: z.coerce.number().int().min(1),
18 | file: fileSchema.refine(file => file.size > 0, "Required"),
19 | image: imageSchema.refine(file => file.size > 0, "Required"),
20 | })
21 |
22 | export async function addProduct(prevState: unknown, formData: FormData) {
23 | const result = addSchema.safeParse(Object.fromEntries(formData.entries()))
24 | if (result.success === false) {
25 | return result.error.formErrors.fieldErrors
26 | }
27 |
28 | const data = result.data
29 |
30 | await fs.mkdir("products", { recursive: true })
31 | const filePath = `products/${crypto.randomUUID()}-${data.file.name}`
32 | await fs.writeFile(filePath, Buffer.from(await data.file.arrayBuffer()))
33 |
34 | await fs.mkdir("public/products", { recursive: true })
35 | const imagePath = `/products/${crypto.randomUUID()}-${data.image.name}`
36 | await fs.writeFile(
37 | `public${imagePath}`,
38 | Buffer.from(await data.image.arrayBuffer())
39 | )
40 |
41 | await db.product.create({
42 | data: {
43 | isAvailableForPurchase: false,
44 | name: data.name,
45 | description: data.description,
46 | priceInCents: data.priceInCents,
47 | filePath,
48 | imagePath,
49 | },
50 | })
51 |
52 | revalidatePath("/")
53 | revalidatePath("/products")
54 |
55 | redirect("/admin/products")
56 | }
57 |
58 | const editSchema = addSchema.extend({
59 | file: fileSchema.optional(),
60 | image: imageSchema.optional(),
61 | })
62 |
63 | export async function updateProduct(
64 | id: string,
65 | prevState: unknown,
66 | formData: FormData
67 | ) {
68 | const result = editSchema.safeParse(Object.fromEntries(formData.entries()))
69 | if (result.success === false) {
70 | return result.error.formErrors.fieldErrors
71 | }
72 |
73 | const data = result.data
74 | const product = await db.product.findUnique({ where: { id } })
75 |
76 | if (product == null) return notFound()
77 |
78 | let filePath = product.filePath
79 | if (data.file != null && data.file.size > 0) {
80 | await fs.unlink(product.filePath)
81 | filePath = `products/${crypto.randomUUID()}-${data.file.name}`
82 | await fs.writeFile(filePath, Buffer.from(await data.file.arrayBuffer()))
83 | }
84 |
85 | let imagePath = product.imagePath
86 | if (data.image != null && data.image.size > 0) {
87 | await fs.unlink(`public${product.imagePath}`)
88 | imagePath = `/products/${crypto.randomUUID()}-${data.image.name}`
89 | await fs.writeFile(
90 | `public${imagePath}`,
91 | Buffer.from(await data.image.arrayBuffer())
92 | )
93 | }
94 |
95 | await db.product.update({
96 | where: { id },
97 | data: {
98 | name: data.name,
99 | description: data.description,
100 | priceInCents: data.priceInCents,
101 | filePath,
102 | imagePath,
103 | },
104 | })
105 |
106 | revalidatePath("/")
107 | revalidatePath("/products")
108 |
109 | redirect("/admin/products")
110 | }
111 |
112 | export async function toggleProductAvailability(
113 | id: string,
114 | isAvailableForPurchase: boolean
115 | ) {
116 | await db.product.update({ where: { id }, data: { isAvailableForPurchase } })
117 |
118 | revalidatePath("/")
119 | revalidatePath("/products")
120 | }
121 |
122 | export async function deleteProduct(id: string) {
123 | const product = await db.product.delete({ where: { id } })
124 |
125 | if (product == null) return notFound()
126 |
127 | await fs.unlink(product.filePath)
128 | await fs.unlink(`public${product.imagePath}`)
129 |
130 | revalidatePath("/")
131 | revalidatePath("/products")
132 | }
133 |
--------------------------------------------------------------------------------
/src/app/admin/_actions/users.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import db from "@/db/db"
4 | import { notFound } from "next/navigation"
5 |
6 | export async function deleteUser(id: string) {
7 | const user = await db.user.delete({
8 | where: { id },
9 | })
10 |
11 | if (user == null) return notFound()
12 |
13 | return user
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/admin/_components/ChartCard.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Button } from "@/components/ui/button"
4 | import { Calendar } from "@/components/ui/calendar"
5 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
6 | import {
7 | DropdownMenu,
8 | DropdownMenuContent,
9 | DropdownMenuItem,
10 | DropdownMenuSeparator,
11 | DropdownMenuSub,
12 | DropdownMenuSubContent,
13 | DropdownMenuSubTrigger,
14 | DropdownMenuTrigger,
15 | } from "@/components/ui/dropdown-menu"
16 | import { RANGE_OPTIONS } from "@/lib/rangeOptions"
17 | import { subDays } from "date-fns"
18 | import { usePathname, useRouter, useSearchParams } from "next/navigation"
19 | import { ReactNode, useState } from "react"
20 | import { DateRange } from "react-day-picker"
21 | import { date } from "zod"
22 |
23 | type ChartCardProps = {
24 | title: string
25 | queryKey: string
26 | selectedRangeLabel: string
27 | children: ReactNode
28 | }
29 |
30 | export function ChartCard({
31 | title,
32 | children,
33 | queryKey,
34 | selectedRangeLabel,
35 | }: ChartCardProps) {
36 | const searchParams = useSearchParams()
37 | const router = useRouter()
38 | const pathname = usePathname()
39 | const [dateRange, setDateRange] = useState({
40 | from: subDays(new Date(), 29),
41 | to: new Date(),
42 | })
43 |
44 | function setRange(range: keyof typeof RANGE_OPTIONS | DateRange) {
45 | const params = new URLSearchParams(searchParams)
46 | if (typeof range === "string") {
47 | params.set(queryKey, range)
48 | params.delete(`${queryKey}From`)
49 | params.delete(`${queryKey}To`)
50 | } else {
51 | if (range.from == null || range.to == null) return
52 | params.delete(queryKey)
53 | params.set(`${queryKey}From`, range.from.toISOString())
54 | params.set(`${queryKey}To`, range.to.toISOString())
55 | }
56 | router.push(`${pathname}?${params.toString()}`, { scroll: false })
57 | }
58 |
59 | return (
60 |
61 |
62 |
63 |
{title}
64 |
65 |
66 |
67 |
68 |
69 | {Object.entries(RANGE_OPTIONS).map(([key, value]) => (
70 | setRange(key as keyof typeof RANGE_OPTIONS)}
72 | key={key}
73 | >
74 | {value.label}
75 |
76 | ))}
77 |
78 |
79 | Custom
80 |
81 |
82 |
90 |
91 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 | {children}
110 |
111 | )
112 | }
113 |
--------------------------------------------------------------------------------
/src/app/admin/_components/PageHeader.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react"
2 |
3 | export function PageHeader({ children }: { children: ReactNode }) {
4 | return {children}
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/admin/_components/charts/OrdersByDayChart.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { formatCurrency } from "@/lib/formatters"
4 | import {
5 | CartesianGrid,
6 | Line,
7 | LineChart,
8 | ResponsiveContainer,
9 | Tooltip,
10 | XAxis,
11 | YAxis,
12 | } from "recharts"
13 |
14 | type OrdersByDayChartProps = {
15 | data: {
16 | date: string
17 | totalSales: number
18 | }[]
19 | }
20 |
21 | export function OrdersByDayChart({ data }: OrdersByDayChartProps) {
22 | return (
23 |
24 |
25 |
26 |
27 | formatCurrency(tick)}
29 | stroke="hsl(var(--primary))"
30 | />
31 | formatCurrency(value as number)} />
32 |
39 |
40 |
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/admin/_components/charts/RevenueByProductChart.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { formatCurrency, formatNumber } from "@/lib/formatters"
4 | import {
5 | Bar,
6 | BarChart,
7 | CartesianGrid,
8 | Line,
9 | LineChart,
10 | Pie,
11 | PieChart,
12 | ResponsiveContainer,
13 | Tooltip,
14 | XAxis,
15 | YAxis,
16 | } from "recharts"
17 |
18 | type RevenueByProductChartProps = {
19 | data: {
20 | name: string
21 | revenue: number
22 | }[]
23 | }
24 |
25 | export function RevenueByProductChart({ data }: RevenueByProductChartProps) {
26 | return (
27 |
28 |
29 | formatCurrency(value as number)}
32 | />
33 | item.name}
36 | dataKey="revenue"
37 | nameKey="name"
38 | fill="hsl(var(--primary))"
39 | />
40 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/src/app/admin/_components/charts/UsersByDayChart.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { formatNumber } from "@/lib/formatters"
4 | import {
5 | Bar,
6 | BarChart,
7 | CartesianGrid,
8 | Line,
9 | LineChart,
10 | ResponsiveContainer,
11 | Tooltip,
12 | XAxis,
13 | YAxis,
14 | } from "recharts"
15 |
16 | type UsersByDayChartProps = {
17 | data: {
18 | date: string
19 | totalUsers: number
20 | }[]
21 | }
22 |
23 | export function UsersByDayChart({ data }: UsersByDayChartProps) {
24 | return (
25 |
26 |
27 |
28 |
29 | formatNumber(tick)}
31 | stroke="hsl(var(--primary))"
32 | />
33 | formatNumber(value as number)}
36 | />
37 |
42 |
43 |
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/src/app/admin/discount-codes/_components/DiscountCodeActions.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
4 | import { useTransition } from "react"
5 | import { useRouter } from "next/navigation"
6 | import {
7 | deleteDiscountCode,
8 | toggleDiscountCodeActive,
9 | } from "../../_actions/discountCodes"
10 |
11 | export function ActiveToggleDropdownItem({
12 | id,
13 | isActive,
14 | }: {
15 | id: string
16 | isActive: boolean
17 | }) {
18 | const [isPending, startTransition] = useTransition()
19 | const router = useRouter()
20 | return (
21 | {
24 | startTransition(async () => {
25 | await toggleDiscountCodeActive(id, !isActive)
26 | router.refresh()
27 | })
28 | }}
29 | >
30 | {isActive ? "Deactivate" : "Activate"}
31 |
32 | )
33 | }
34 |
35 | export function DeleteDropdownItem({
36 | id,
37 | disabled,
38 | }: {
39 | id: string
40 | disabled: boolean
41 | }) {
42 | const [isPending, startTransition] = useTransition()
43 | const router = useRouter()
44 | return (
45 | {
49 | startTransition(async () => {
50 | await deleteDiscountCode(id)
51 | router.refresh()
52 | })
53 | }}
54 | >
55 | Delete
56 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/src/app/admin/discount-codes/_components/DiscountCodeForm.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Button } from "@/components/ui/button"
4 | import { Input } from "@/components/ui/input"
5 | import { Label } from "@/components/ui/label"
6 | import { Textarea } from "@/components/ui/textarea"
7 | import { formatCurrency } from "@/lib/formatters"
8 | import { useState } from "react"
9 | import { addProduct, updateProduct } from "../../_actions/products"
10 | import { useFormState, useFormStatus } from "react-dom"
11 | import Image from "next/image"
12 | import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
13 | import { DiscountCodeType } from "@prisma/client"
14 | import { addDiscountCode } from "../../_actions/discountCodes"
15 | import { Checkbox } from "@/components/ui/checkbox"
16 |
17 | export function DiscountCodeForm({
18 | products,
19 | }: {
20 | products: { name: string; id: string }[]
21 | }) {
22 | const [error, action] = useFormState(addDiscountCode, {})
23 | const [allProducts, setAllProducts] = useState(true)
24 | const today = new Date()
25 | today.setMinutes(today.getMinutes() - today.getTimezoneOffset())
26 |
27 | return (
28 |
126 | )
127 | }
128 |
129 | function SubmitButton() {
130 | const { pending } = useFormStatus()
131 |
132 | return (
133 |
136 | )
137 | }
138 |
--------------------------------------------------------------------------------
/src/app/admin/discount-codes/new/page.tsx:
--------------------------------------------------------------------------------
1 | import db from "@/db/db"
2 | import { PageHeader } from "../../_components/PageHeader"
3 | import { DiscountCodeForm } from "../_components/DiscountCodeForm"
4 |
5 | export default async function NewDiscountCodePage() {
6 | const products = await db.product.findMany({
7 | select: { id: true, name: true },
8 | orderBy: { name: "asc" },
9 | })
10 |
11 | return (
12 | <>
13 | Add Product
14 |
15 | >
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/admin/discount-codes/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button"
2 | import { PageHeader } from "../_components/PageHeader"
3 | import Link from "next/link"
4 | import {
5 | Table,
6 | TableBody,
7 | TableCell,
8 | TableHead,
9 | TableHeader,
10 | TableRow,
11 | } from "@/components/ui/table"
12 | import {
13 | CheckCircle2,
14 | Globe,
15 | Infinity,
16 | Minus,
17 | MoreVertical,
18 | XCircle,
19 | } from "lucide-react"
20 | import {
21 | DropdownMenu,
22 | DropdownMenuContent,
23 | DropdownMenuItem,
24 | DropdownMenuSeparator,
25 | DropdownMenuTrigger,
26 | } from "@/components/ui/dropdown-menu"
27 | import db from "@/db/db"
28 | import { Prisma } from "@prisma/client"
29 | import {
30 | formatDateTime,
31 | formatDiscountCode,
32 | formatNumber,
33 | } from "@/lib/formatters"
34 | import {
35 | ActiveToggleDropdownItem,
36 | DeleteDropdownItem,
37 | } from "./_components/DiscountCodeActions"
38 |
39 | const WHERE_EXPIRED: Prisma.DiscountCodeWhereInput = {
40 | OR: [
41 | { limit: { not: null, lte: db.discountCode.fields.uses } },
42 | { expiresAt: { not: null, lte: new Date() } },
43 | ],
44 | }
45 |
46 | const SELECT_FIELDS: Prisma.DiscountCodeSelect = {
47 | id: true,
48 | allProducts: true,
49 | code: true,
50 | discountAmount: true,
51 | discountType: true,
52 | expiresAt: true,
53 | limit: true,
54 | uses: true,
55 | isActive: true,
56 | products: { select: { name: true } },
57 | _count: { select: { orders: true } },
58 | }
59 |
60 | function getExpiredDiscountCodes() {
61 | return db.discountCode.findMany({
62 | select: SELECT_FIELDS,
63 | where: WHERE_EXPIRED,
64 | orderBy: { createdAt: "asc" },
65 | })
66 | }
67 |
68 | function getUnexpiredDiscountCodes() {
69 | return db.discountCode.findMany({
70 | select: SELECT_FIELDS,
71 | where: { NOT: WHERE_EXPIRED },
72 | orderBy: { createdAt: "asc" },
73 | })
74 | }
75 |
76 | export default async function DiscountCodesPage() {
77 | const [expiredDiscountCodes, unexpiredDiscountCodes] = await Promise.all([
78 | getExpiredDiscountCodes(),
79 | getUnexpiredDiscountCodes(),
80 | ])
81 |
82 | return (
83 | <>
84 |
85 |
Coupons
86 |
89 |
90 |
94 |
95 |
96 |
Expired Coupons
97 |
98 |
99 | >
100 | )
101 | }
102 |
103 | type DiscountCodesTableProps = {
104 | discountCodes: Awaited>
105 | isInactive?: boolean
106 | canDeactivate?: boolean
107 | }
108 |
109 | function DiscountCodesTable({
110 | discountCodes,
111 | isInactive = false,
112 | canDeactivate = false,
113 | }: DiscountCodesTableProps) {
114 | return (
115 |
116 |
117 |
118 |
119 | Is Active
120 |
121 | Code
122 | Discount
123 | Expires
124 | Remaining Uses
125 | Orders
126 | Products
127 |
128 | Actions
129 |
130 |
131 |
132 |
133 | {discountCodes.map(discountCode => (
134 |
135 |
136 | {discountCode.isActive && !isInactive ? (
137 | <>
138 | Active
139 |
140 | >
141 | ) : (
142 | <>
143 | Inactive
144 |
145 | >
146 | )}
147 |
148 | {discountCode.code}
149 | {formatDiscountCode(discountCode)}
150 |
151 | {discountCode.expiresAt == null ? (
152 |
153 | ) : (
154 | formatDateTime(discountCode.expiresAt)
155 | )}
156 |
157 |
158 | {discountCode.limit == null ? (
159 |
160 | ) : (
161 | formatNumber(discountCode.limit - discountCode.uses)
162 | )}
163 |
164 | {formatNumber(discountCode._count.orders)}
165 |
166 | {discountCode.allProducts ? (
167 |
168 | ) : (
169 | discountCode.products.map(p => p.name).join(", ")
170 | )}
171 |
172 |
173 |
174 |
175 |
176 | Actions
177 |
178 |
179 | {canDeactivate && (
180 | <>
181 |
185 |
186 | >
187 | )}
188 | 0}
191 | />
192 |
193 |
194 |
195 |
196 | ))}
197 |
198 |
199 | )
200 | }
201 |
--------------------------------------------------------------------------------
/src/app/admin/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Nav, NavLink } from "@/components/Nav"
2 |
3 | export const dynamic = "force-dynamic"
4 |
5 | export default function AdminLayout({
6 | children,
7 | }: Readonly<{
8 | children: React.ReactNode
9 | }>) {
10 | return (
11 | <>
12 |
19 | {children}
20 | >
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/src/app/admin/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Loader2 } from "lucide-react"
2 |
3 | export default function AdminLoading() {
4 | return (
5 |
6 |
7 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/src/app/admin/orders/_components/OrderActions.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
4 | import { useTransition } from "react"
5 | import { deleteOrder } from "../../_actions/orders"
6 | import { useRouter } from "next/navigation"
7 |
8 | export function DeleteDropDownItem({ id }: { id: string }) {
9 | const [isPending, startTransition] = useTransition()
10 | const router = useRouter()
11 |
12 | return (
13 |
17 | startTransition(async () => {
18 | await deleteOrder(id)
19 | router.refresh()
20 | })
21 | }
22 | >
23 | Delete
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/admin/orders/page.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Table,
3 | TableBody,
4 | TableCell,
5 | TableHead,
6 | TableHeader,
7 | TableRow,
8 | } from "@/components/ui/table"
9 | import db from "@/db/db"
10 | import { formatCurrency } from "@/lib/formatters"
11 | import { PageHeader } from "../_components/PageHeader"
12 | import {
13 | DropdownMenu,
14 | DropdownMenuContent,
15 | DropdownMenuItem,
16 | DropdownMenuTrigger,
17 | } from "@/components/ui/dropdown-menu"
18 | import { Minus, MoreVertical } from "lucide-react"
19 | import { DeleteDropDownItem } from "./_components/OrderActions"
20 |
21 | function getOrders() {
22 | return db.order.findMany({
23 | select: {
24 | id: true,
25 | pricePaidInCents: true,
26 | product: { select: { name: true } },
27 | user: { select: { email: true } },
28 | discountCode: { select: { code: true } },
29 | },
30 | orderBy: { createdAt: "desc" },
31 | })
32 | }
33 |
34 | export default function OrdersPage() {
35 | return (
36 | <>
37 | Sales
38 |
39 | >
40 | )
41 | }
42 |
43 | async function OrdersTable() {
44 | const orders = await getOrders()
45 |
46 | if (orders.length === 0) return No sales found
47 |
48 | return (
49 |
50 |
51 |
52 | Product
53 | Customer
54 | Price Paid
55 | Coupon
56 |
57 | Actions
58 |
59 |
60 |
61 |
62 | {orders.map(order => (
63 |
64 | {order.product.name}
65 | {order.user.email}
66 |
67 | {formatCurrency(order.pricePaidInCents / 100)}
68 |
69 |
70 | {order.discountCode == null ? : order.discountCode.code}
71 |
72 |
73 |
74 |
75 |
76 | Actions
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | ))}
85 |
86 |
87 | )
88 | }
89 |
--------------------------------------------------------------------------------
/src/app/admin/page.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | CardContent,
4 | CardDescription,
5 | CardHeader,
6 | CardTitle,
7 | } from "@/components/ui/card"
8 | import db from "@/db/db"
9 | import { formatCurrency, formatDate, formatNumber } from "@/lib/formatters"
10 | import { OrdersByDayChart } from "./_components/charts/OrdersByDayChart"
11 | import { Prisma } from "@prisma/client"
12 | import {
13 | differenceInDays,
14 | differenceInMonths,
15 | differenceInWeeks,
16 | eachDayOfInterval,
17 | eachMonthOfInterval,
18 | eachWeekOfInterval,
19 | eachYearOfInterval,
20 | endOfWeek,
21 | interval,
22 | max,
23 | min,
24 | startOfDay,
25 | startOfWeek,
26 | subDays,
27 | } from "date-fns"
28 | import { Search } from "lucide-react"
29 | import { ReactNode } from "react"
30 | import { UsersByDayChart } from "./_components/charts/UsersByDayChart"
31 | import { RevenueByProductChart } from "./_components/charts/RevenueByProductChart"
32 | import {
33 | DropdownMenu,
34 | DropdownMenuContent,
35 | DropdownMenuItem,
36 | DropdownMenuTrigger,
37 | } from "@/components/ui/dropdown-menu"
38 | import { Button } from "@/components/ui/button"
39 | import { RANGE_OPTIONS, getRangeOption } from "@/lib/rangeOptions"
40 | import { ChartCard } from "./_components/ChartCard"
41 |
42 | async function getSalesData(
43 | createdAfter: Date | null,
44 | createdBefore: Date | null
45 | ) {
46 | const createdAtQuery: Prisma.OrderWhereInput["createdAt"] = {}
47 | if (createdAfter) createdAtQuery.gte = createdAfter
48 | if (createdBefore) createdAtQuery.lte = createdBefore
49 |
50 | const [data, chartData] = await Promise.all([
51 | db.order.aggregate({
52 | _sum: { pricePaidInCents: true },
53 | _count: true,
54 | }),
55 | db.order.findMany({
56 | select: { createdAt: true, pricePaidInCents: true },
57 | where: { createdAt: createdAtQuery },
58 | orderBy: { createdAt: "asc" },
59 | }),
60 | ])
61 |
62 | const { array, format } = getChartDateArray(
63 | createdAfter || startOfDay(chartData[0].createdAt),
64 | createdBefore || new Date()
65 | )
66 |
67 | const dayArray = array.map(date => {
68 | return {
69 | date: format(date),
70 | totalSales: 0,
71 | }
72 | })
73 |
74 | return {
75 | chartData: chartData.reduce((data, order) => {
76 | const formattedDate = format(order.createdAt)
77 | const entry = dayArray.find(day => day.date === formattedDate)
78 | if (entry == null) return data
79 | entry.totalSales += order.pricePaidInCents / 100
80 | return data
81 | }, dayArray),
82 | amount: (data._sum.pricePaidInCents || 0) / 100,
83 | numberOfSales: data._count,
84 | }
85 | }
86 |
87 | async function getUserData(
88 | createdAfter: Date | null,
89 | createdBefore: Date | null
90 | ) {
91 | const createdAtQuery: Prisma.UserWhereInput["createdAt"] = {}
92 | if (createdAfter) createdAtQuery.gte = createdAfter
93 | if (createdBefore) createdAtQuery.lte = createdBefore
94 |
95 | const [userCount, orderData, chartData] = await Promise.all([
96 | db.user.count(),
97 | db.order.aggregate({
98 | _sum: { pricePaidInCents: true },
99 | }),
100 | db.user.findMany({
101 | select: { createdAt: true },
102 | where: { createdAt: createdAtQuery },
103 | orderBy: { createdAt: "asc" },
104 | }),
105 | ])
106 |
107 | const { array, format } = getChartDateArray(
108 | createdAfter || startOfDay(chartData[0].createdAt),
109 | createdBefore || new Date()
110 | )
111 |
112 | const dayArray = array.map(date => {
113 | return {
114 | date: format(date),
115 | totalUsers: 0,
116 | }
117 | })
118 |
119 | return {
120 | chartData: chartData.reduce((data, user) => {
121 | const formattedDate = format(user.createdAt)
122 | const entry = dayArray.find(day => day.date === formattedDate)
123 | if (entry == null) return data
124 | entry.totalUsers += 1
125 | return data
126 | }, dayArray),
127 | userCount,
128 | averageValuePerUser:
129 | userCount === 0
130 | ? 0
131 | : (orderData._sum.pricePaidInCents || 0) / userCount / 100,
132 | }
133 | }
134 |
135 | async function getProductData(
136 | createdAfter: Date | null,
137 | createdBefore: Date | null
138 | ) {
139 | const createdAtQuery: Prisma.OrderWhereInput["createdAt"] = {}
140 | if (createdAfter) createdAtQuery.gte = createdAfter
141 | if (createdBefore) createdAtQuery.lte = createdBefore
142 |
143 | const [activeCount, inactiveCount, chartData] = await Promise.all([
144 | db.product.count({ where: { isAvailableForPurchase: true } }),
145 | db.product.count({ where: { isAvailableForPurchase: false } }),
146 | db.product.findMany({
147 | select: {
148 | name: true,
149 | orders: {
150 | select: { pricePaidInCents: true },
151 | where: { createdAt: createdAtQuery },
152 | },
153 | },
154 | }),
155 | ])
156 |
157 | return {
158 | chartData: chartData
159 | .map(product => {
160 | return {
161 | name: product.name,
162 | revenue: product.orders.reduce((sum, order) => {
163 | return sum + order.pricePaidInCents / 100
164 | }, 0),
165 | }
166 | })
167 | .filter(product => product.revenue > 0),
168 | activeCount,
169 | inactiveCount,
170 | }
171 | }
172 |
173 | export default async function AdminDashboard({
174 | searchParams: {
175 | totalSalesRange,
176 | totalSalesRangeFrom,
177 | totalSalesRangeTo,
178 | newCustomersRange,
179 | newCustomersRangeFrom,
180 | newCustomersRangeTo,
181 | revenueByProductRange,
182 | revenueByProductRangeFrom,
183 | revenueByProductRangeTo,
184 | },
185 | }: {
186 | searchParams: {
187 | totalSalesRange?: string
188 | totalSalesRangeFrom?: string
189 | totalSalesRangeTo?: string
190 | newCustomersRange?: string
191 | newCustomersRangeFrom?: string
192 | newCustomersRangeTo?: string
193 | revenueByProductRange?: string
194 | revenueByProductRangeFrom?: string
195 | revenueByProductRangeTo?: string
196 | }
197 | }) {
198 | const totalSalesRangeOption =
199 | getRangeOption(totalSalesRange, totalSalesRangeFrom, totalSalesRangeTo) ||
200 | RANGE_OPTIONS.last_7_days
201 | const newCustomersRangeOption =
202 | getRangeOption(
203 | newCustomersRange,
204 | newCustomersRangeFrom,
205 | newCustomersRangeTo
206 | ) || RANGE_OPTIONS.last_7_days
207 | const revenueByProductRangeOption =
208 | getRangeOption(
209 | revenueByProductRange,
210 | revenueByProductRangeFrom,
211 | revenueByProductRangeTo
212 | ) || RANGE_OPTIONS.all_time
213 |
214 | const [salesData, userData, productData] = await Promise.all([
215 | getSalesData(
216 | totalSalesRangeOption.startDate,
217 | totalSalesRangeOption.endDate
218 | ),
219 | getUserData(
220 | newCustomersRangeOption.startDate,
221 | newCustomersRangeOption.endDate
222 | ),
223 | getProductData(
224 | revenueByProductRangeOption.startDate,
225 | revenueByProductRangeOption.endDate
226 | ),
227 | ])
228 |
229 | return (
230 | <>
231 |
232 |
237 |
244 |
249 |
250 |
251 |
256 |
257 |
258 |
263 |
264 |
265 |
270 |
271 |
272 |
273 | >
274 | )
275 | }
276 |
277 | type DashboardCardProps = {
278 | title: string
279 | subtitle: string
280 | body: string
281 | }
282 |
283 | function DashboardCard({ title, subtitle, body }: DashboardCardProps) {
284 | return (
285 |
286 |
287 | {title}
288 | {subtitle}
289 |
290 |
291 | {body}
292 |
293 |
294 | )
295 | }
296 |
297 | function getChartDateArray(startDate: Date, endDate: Date = new Date()) {
298 | const days = differenceInDays(endDate, startDate)
299 | if (days < 30) {
300 | return {
301 | array: eachDayOfInterval(interval(startDate, endDate)),
302 | format: formatDate,
303 | }
304 | }
305 |
306 | const weeks = differenceInWeeks(endDate, startDate)
307 | if (weeks < 30) {
308 | return {
309 | array: eachWeekOfInterval(interval(startDate, endDate)),
310 | format: (date: Date) => {
311 | const start = max([startOfWeek(date), startDate])
312 | const end = min([endOfWeek(date), endDate])
313 |
314 | return `${formatDate(start)} - ${formatDate(end)}`
315 | },
316 | }
317 | }
318 |
319 | const months = differenceInMonths(endDate, startDate)
320 | if (months < 30) {
321 | return {
322 | array: eachMonthOfInterval(interval(startDate, endDate)),
323 | format: new Intl.DateTimeFormat("en", { month: "long", year: "numeric" })
324 | .format,
325 | }
326 | }
327 |
328 | return {
329 | array: eachYearOfInterval(interval(startDate, endDate)),
330 | format: new Intl.DateTimeFormat("en", { year: "numeric" }).format,
331 | }
332 | }
333 |
--------------------------------------------------------------------------------
/src/app/admin/products/[id]/download/route.ts:
--------------------------------------------------------------------------------
1 | import db from "@/db/db"
2 | import { notFound } from "next/navigation"
3 | import { NextRequest, NextResponse } from "next/server"
4 | import fs from "fs/promises"
5 |
6 | export async function GET(
7 | req: NextRequest,
8 | { params: { id } }: { params: { id: string } }
9 | ) {
10 | const product = await db.product.findUnique({
11 | where: { id },
12 | select: { filePath: true, name: true },
13 | })
14 |
15 | if (product == null) return notFound()
16 |
17 | const { size } = await fs.stat(product.filePath)
18 | const file = await fs.readFile(product.filePath)
19 | const extension = product.filePath.split(".").pop()
20 |
21 | return new NextResponse(file, {
22 | headers: {
23 | "Content-Disposition": `attachment; filename="${product.name}.${extension}"`,
24 | "Content-Length": size.toString(),
25 | },
26 | })
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/admin/products/[id]/edit/page.tsx:
--------------------------------------------------------------------------------
1 | import db from "@/db/db"
2 | import { PageHeader } from "../../../_components/PageHeader"
3 | import { ProductForm } from "../../_components/ProductForm"
4 |
5 | export default async function EditProductPage({
6 | params: { id },
7 | }: {
8 | params: { id: string }
9 | }) {
10 | const product = await db.product.findUnique({ where: { id } })
11 |
12 | return (
13 | <>
14 | Edit Product
15 |
16 | >
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/app/admin/products/_components/ProductActions.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
4 | import { useTransition } from "react"
5 | import {
6 | deleteProduct,
7 | toggleProductAvailability,
8 | } from "../../_actions/products"
9 | import { useRouter } from "next/navigation"
10 |
11 | export function ActiveToggleDropdownItem({
12 | id,
13 | isAvailableForPurchase,
14 | }: {
15 | id: string
16 | isAvailableForPurchase: boolean
17 | }) {
18 | const [isPending, startTransition] = useTransition()
19 | const router = useRouter()
20 | return (
21 | {
24 | startTransition(async () => {
25 | await toggleProductAvailability(id, !isAvailableForPurchase)
26 | router.refresh()
27 | })
28 | }}
29 | >
30 | {isAvailableForPurchase ? "Deactivate" : "Activate"}
31 |
32 | )
33 | }
34 |
35 | export function DeleteDropdownItem({
36 | id,
37 | disabled,
38 | }: {
39 | id: string
40 | disabled: boolean
41 | }) {
42 | const [isPending, startTransition] = useTransition()
43 | const router = useRouter()
44 | return (
45 | {
49 | startTransition(async () => {
50 | await deleteProduct(id)
51 | router.refresh()
52 | })
53 | }}
54 | >
55 | Delete
56 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/src/app/admin/products/_components/ProductForm.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Button } from "@/components/ui/button"
4 | import { Input } from "@/components/ui/input"
5 | import { Label } from "@/components/ui/label"
6 | import { Textarea } from "@/components/ui/textarea"
7 | import { formatCurrency } from "@/lib/formatters"
8 | import { useState } from "react"
9 | import { addProduct, updateProduct } from "../../_actions/products"
10 | import { useFormState, useFormStatus } from "react-dom"
11 | import { Product } from "@prisma/client"
12 | import Image from "next/image"
13 |
14 | export function ProductForm({ product }: { product?: Product | null }) {
15 | const [error, action] = useFormState(
16 | product == null ? addProduct : updateProduct.bind(null, product.id),
17 | {}
18 | )
19 | const [priceInCents, setPriceInCents] = useState(
20 | product?.priceInCents
21 | )
22 |
23 | return (
24 |
88 | )
89 | }
90 |
91 | function SubmitButton() {
92 | const { pending } = useFormStatus()
93 |
94 | return (
95 |
98 | )
99 | }
100 |
--------------------------------------------------------------------------------
/src/app/admin/products/new/page.tsx:
--------------------------------------------------------------------------------
1 | import { PageHeader } from "../../_components/PageHeader"
2 | import { ProductForm } from "../_components/ProductForm"
3 |
4 | export default function NewProductPage() {
5 | return (
6 | <>
7 | Add Product
8 |
9 | >
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/admin/products/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button"
2 | import { PageHeader } from "../_components/PageHeader"
3 | import Link from "next/link"
4 | import {
5 | Table,
6 | TableBody,
7 | TableCell,
8 | TableHead,
9 | TableHeader,
10 | TableRow,
11 | } from "@/components/ui/table"
12 | import db from "@/db/db"
13 | import { CheckCircle2, MoreVertical, XCircle } from "lucide-react"
14 | import { formatCurrency, formatNumber } from "@/lib/formatters"
15 | import {
16 | DropdownMenu,
17 | DropdownMenuContent,
18 | DropdownMenuItem,
19 | DropdownMenuSeparator,
20 | DropdownMenuTrigger,
21 | } from "@/components/ui/dropdown-menu"
22 | import {
23 | ActiveToggleDropdownItem,
24 | DeleteDropdownItem,
25 | } from "./_components/ProductActions"
26 |
27 | export default function AdminProductsPage() {
28 | return (
29 | <>
30 |
31 |
Products
32 |
35 |
36 |
37 | >
38 | )
39 | }
40 |
41 | async function ProductsTable() {
42 | const products = await db.product.findMany({
43 | select: {
44 | id: true,
45 | name: true,
46 | priceInCents: true,
47 | isAvailableForPurchase: true,
48 | _count: { select: { orders: true } },
49 | },
50 | orderBy: { name: "asc" },
51 | })
52 |
53 | if (products.length === 0) return No products found
54 |
55 | return (
56 |
57 |
58 |
59 |
60 | Available For Purchase
61 |
62 | Name
63 | Price
64 | Orders
65 |
66 | Actions
67 |
68 |
69 |
70 |
71 | {products.map(product => (
72 |
73 |
74 | {product.isAvailableForPurchase ? (
75 | <>
76 | Available
77 |
78 | >
79 | ) : (
80 | <>
81 | Unavailable
82 |
83 | >
84 | )}
85 |
86 | {product.name}
87 | {formatCurrency(product.priceInCents / 100)}
88 | {formatNumber(product._count.orders)}
89 |
90 |
91 |
92 |
93 | Actions
94 |
95 |
96 |
97 |
98 | Download
99 |
100 |
101 |
102 |
103 | Edit
104 |
105 |
106 |
110 |
111 | 0}
114 | />
115 |
116 |
117 |
118 |
119 | ))}
120 |
121 |
122 | )
123 | }
124 |
--------------------------------------------------------------------------------
/src/app/admin/users/_components/UserActions.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
4 | import { useTransition } from "react"
5 | import { deleteUser } from "../../_actions/users"
6 | import { useRouter } from "next/navigation"
7 |
8 | export function DeleteDropDownItem({ id }: { id: string }) {
9 | const [isPending, startTransition] = useTransition()
10 | const router = useRouter()
11 |
12 | return (
13 |
17 | startTransition(async () => {
18 | await deleteUser(id)
19 | router.refresh()
20 | })
21 | }
22 | >
23 | Delete
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/admin/users/page.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Table,
3 | TableBody,
4 | TableCell,
5 | TableHead,
6 | TableHeader,
7 | TableRow,
8 | } from "@/components/ui/table"
9 | import db from "@/db/db"
10 | import { formatCurrency, formatNumber } from "@/lib/formatters"
11 | import { PageHeader } from "../_components/PageHeader"
12 | import {
13 | DropdownMenu,
14 | DropdownMenuContent,
15 | DropdownMenuTrigger,
16 | } from "@/components/ui/dropdown-menu"
17 | import { MoreVertical } from "lucide-react"
18 | import { DeleteDropDownItem } from "./_components/UserActions"
19 |
20 | function getUsers() {
21 | return db.user.findMany({
22 | select: {
23 | id: true,
24 | email: true,
25 | orders: { select: { pricePaidInCents: true } },
26 | },
27 | orderBy: { createdAt: "desc" },
28 | })
29 | }
30 |
31 | export default function UsersPage() {
32 | return (
33 | <>
34 | Customers
35 |
36 | >
37 | )
38 | }
39 |
40 | async function UsersTable() {
41 | const users = await getUsers()
42 |
43 | if (users.length === 0) return No customers found
44 |
45 | return (
46 |
47 |
48 |
49 | Email
50 | Orders
51 | Value
52 |
53 | Actions
54 |
55 |
56 |
57 |
58 | {users.map(user => (
59 |
60 | {user.email}
61 | {formatNumber(user.orders.length)}
62 |
63 | {formatCurrency(
64 | user.orders.reduce((sum, o) => o.pricePaidInCents + sum, 0) /
65 | 100
66 | )}
67 |
68 |
69 |
70 |
71 |
72 | Actions
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 | ))}
81 |
82 |
83 | )
84 | }
85 |
--------------------------------------------------------------------------------
/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: 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/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next"
2 | import { Inter } from "next/font/google"
3 | import "./globals.css"
4 | import { cn } from "@/lib/utils"
5 |
6 | const inter = Inter({ subsets: ["latin"], variable: "--font-sans" })
7 |
8 | export const metadata: Metadata = {
9 | title: "Create Next App",
10 | description: "Generated by create next app",
11 | }
12 |
13 | export default function RootLayout({
14 | children,
15 | }: Readonly<{
16 | children: React.ReactNode
17 | }>) {
18 | return (
19 |
20 |
26 | {children}
27 |
28 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/webhooks/stripe/route.tsx:
--------------------------------------------------------------------------------
1 | import db from "@/db/db"
2 | import { NextRequest, NextResponse } from "next/server"
3 | import Stripe from "stripe"
4 | import { Resend } from "resend"
5 | import PurchaseReceiptEmail from "@/email/PurchaseReceipt"
6 |
7 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)
8 | const resend = new Resend(process.env.RESEND_API_KEY as string)
9 |
10 | export async function POST(req: NextRequest) {
11 | const event = await stripe.webhooks.constructEvent(
12 | await req.text(),
13 | req.headers.get("stripe-signature") as string,
14 | process.env.STRIPE_WEBHOOK_SECRET as string
15 | )
16 |
17 | if (event.type === "charge.succeeded") {
18 | const charge = event.data.object
19 | const productId = charge.metadata.productId
20 | const discountCodeId = charge.metadata.discountCodeId
21 | const email = charge.billing_details.email
22 | const pricePaidInCents = charge.amount
23 |
24 | const product = await db.product.findUnique({ where: { id: productId } })
25 | if (product == null || email == null) {
26 | return new NextResponse("Bad Request", { status: 400 })
27 | }
28 |
29 | const userFields = {
30 | email,
31 | orders: { create: { productId, pricePaidInCents, discountCodeId } },
32 | }
33 | const {
34 | orders: [order],
35 | } = await db.user.upsert({
36 | where: { email },
37 | create: userFields,
38 | update: userFields,
39 | select: { orders: { orderBy: { createdAt: "desc" }, take: 1 } },
40 | })
41 |
42 | const downloadVerification = await db.downloadVerification.create({
43 | data: {
44 | productId,
45 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
46 | },
47 | })
48 |
49 | if (discountCodeId != null) {
50 | await db.discountCode.update({
51 | where: { id: discountCodeId },
52 | data: { uses: { increment: 1 } },
53 | })
54 | }
55 |
56 | await resend.emails.send({
57 | from: `Support <${process.env.SENDER_EMAIL}>`,
58 | to: email,
59 | subject: "Order Confirmation",
60 | react: (
61 |
66 | ),
67 | })
68 | }
69 |
70 | return new NextResponse()
71 | }
72 |
--------------------------------------------------------------------------------
/src/components/Nav.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { cn } from "@/lib/utils"
4 | import Link from "next/link"
5 | import { usePathname } from "next/navigation"
6 | import { ComponentProps, ReactNode } from "react"
7 |
8 | export function Nav({ children }: { children: ReactNode }) {
9 | return (
10 |
13 | )
14 | }
15 |
16 | export function NavLink(props: Omit, "className">) {
17 | const pathname = usePathname()
18 | return (
19 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/ProductCard.tsx:
--------------------------------------------------------------------------------
1 | import { formatCurrency } from "@/lib/formatters"
2 | import {
3 | Card,
4 | CardContent,
5 | CardDescription,
6 | CardFooter,
7 | CardHeader,
8 | CardTitle,
9 | } from "./ui/card"
10 | import { Button } from "./ui/button"
11 | import Link from "next/link"
12 | import Image from "next/image"
13 |
14 | type ProductCardProps = {
15 | id: string
16 | name: string
17 | priceInCents: number
18 | description: string
19 | imagePath: string
20 | }
21 |
22 | export function ProductCard({
23 | id,
24 | name,
25 | priceInCents,
26 | description,
27 | imagePath,
28 | }: ProductCardProps) {
29 | return (
30 |
31 |
32 |
33 |
34 |
35 | {name}
36 | {formatCurrency(priceInCents / 100)}
37 |
38 |
39 | {description}
40 |
41 |
42 |
45 |
46 |
47 | )
48 | }
49 |
50 | export function ProductCardSkeleton() {
51 | return (
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | )
72 | }
73 |
--------------------------------------------------------------------------------
/src/components/page.tsx:
--------------------------------------------------------------------------------
1 | export default function Home() {
2 | return Hi
3 | }
4 |
--------------------------------------------------------------------------------
/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/ui/calendar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { ChevronLeft, ChevronRight } from "lucide-react"
5 | import { DayPicker } from "react-day-picker"
6 |
7 | import { cn } from "@/lib/utils"
8 | import { buttonVariants } from "@/components/ui/button"
9 |
10 | export type CalendarProps = React.ComponentProps
11 |
12 | function Calendar({
13 | className,
14 | classNames,
15 | showOutsideDays = true,
16 | ...props
17 | }: CalendarProps) {
18 | return (
19 | ,
58 | IconRight: ({ ...props }) => ,
59 | }}
60 | {...props}
61 | />
62 | )
63 | }
64 | Calendar.displayName = "Calendar"
65 |
66 | export { Calendar }
67 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
5 | import { Check } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Checkbox = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 |
24 |
25 |
26 |
27 | ))
28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
29 |
30 | export { Checkbox }
31 |
--------------------------------------------------------------------------------
/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 | import { cva } from "class-variance-authority"
9 |
10 | const DropdownMenu = DropdownMenuPrimitive.Root
11 |
12 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
13 |
14 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
15 |
16 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
17 |
18 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
19 |
20 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
21 |
22 | const DropdownMenuSubTrigger = React.forwardRef<
23 | React.ElementRef,
24 | React.ComponentPropsWithoutRef & {
25 | inset?: boolean
26 | }
27 | >(({ className, inset, children, ...props }, ref) => (
28 |
37 | {children}
38 |
39 |
40 | ))
41 | DropdownMenuSubTrigger.displayName =
42 | DropdownMenuPrimitive.SubTrigger.displayName
43 |
44 | const DropdownMenuSubContent = React.forwardRef<
45 | React.ElementRef,
46 | React.ComponentPropsWithoutRef
47 | >(({ className, ...props }, ref) => (
48 |
56 | ))
57 | DropdownMenuSubContent.displayName =
58 | DropdownMenuPrimitive.SubContent.displayName
59 |
60 | const DropdownMenuContent = React.forwardRef<
61 | React.ElementRef,
62 | React.ComponentPropsWithoutRef
63 | >(({ className, sideOffset = 4, ...props }, ref) => (
64 |
65 |
74 |
75 | ))
76 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
77 |
78 | const dropdownMenuItemVariants = cva(
79 | "relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
80 | {
81 | variants: {
82 | variant: {
83 | default: "focus:bg-accent focus:text-accent-foreground0",
84 | destructive:
85 | "focus:bg-destructive focus:text-destructive-foreground text-destructive",
86 | },
87 | },
88 | defaultVariants: {
89 | variant: "default",
90 | },
91 | }
92 | )
93 |
94 | const DropdownMenuItem = React.forwardRef<
95 | React.ElementRef,
96 | React.ComponentPropsWithoutRef & {
97 | inset?: boolean
98 | variant?: "default" | "destructive"
99 | }
100 | >(({ className, inset, variant, ...props }, ref) => (
101 |
109 | ))
110 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
111 |
112 | const DropdownMenuCheckboxItem = React.forwardRef<
113 | React.ElementRef,
114 | React.ComponentPropsWithoutRef
115 | >(({ className, children, checked, ...props }, ref) => (
116 |
125 |
126 |
127 |
128 |
129 |
130 | {children}
131 |
132 | ))
133 | DropdownMenuCheckboxItem.displayName =
134 | DropdownMenuPrimitive.CheckboxItem.displayName
135 |
136 | const DropdownMenuRadioItem = React.forwardRef<
137 | React.ElementRef,
138 | React.ComponentPropsWithoutRef
139 | >(({ className, children, ...props }, ref) => (
140 |
148 |
149 |
150 |
151 |
152 |
153 | {children}
154 |
155 | ))
156 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
157 |
158 | const DropdownMenuLabel = React.forwardRef<
159 | React.ElementRef,
160 | React.ComponentPropsWithoutRef & {
161 | inset?: boolean
162 | }
163 | >(({ className, inset, ...props }, ref) => (
164 |
173 | ))
174 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
175 |
176 | const DropdownMenuSeparator = React.forwardRef<
177 | React.ElementRef,
178 | React.ComponentPropsWithoutRef
179 | >(({ className, ...props }, ref) => (
180 |
185 | ))
186 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
187 |
188 | const DropdownMenuShortcut = ({
189 | className,
190 | ...props
191 | }: React.HTMLAttributes) => {
192 | return (
193 |
197 | )
198 | }
199 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
200 |
201 | export {
202 | DropdownMenu,
203 | DropdownMenuTrigger,
204 | DropdownMenuContent,
205 | DropdownMenuItem,
206 | DropdownMenuCheckboxItem,
207 | DropdownMenuRadioItem,
208 | DropdownMenuLabel,
209 | DropdownMenuSeparator,
210 | DropdownMenuShortcut,
211 | DropdownMenuGroup,
212 | DropdownMenuPortal,
213 | DropdownMenuSub,
214 | DropdownMenuSubContent,
215 | DropdownMenuSubTrigger,
216 | DropdownMenuRadioGroup,
217 | }
218 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/src/components/ui/radio-group.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
5 | import { Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const RadioGroup = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => {
13 | return (
14 |
19 | )
20 | })
21 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
22 |
23 | const RadioGroupItem = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => {
27 | return (
28 |
36 |
37 |
38 |
39 |
40 | )
41 | })
42 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
43 |
44 | export { RadioGroup, RadioGroupItem }
45 |
--------------------------------------------------------------------------------
/src/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Table = React.forwardRef<
6 | HTMLTableElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
16 | ))
17 | Table.displayName = "Table"
18 |
19 | const TableHeader = React.forwardRef<
20 | HTMLTableSectionElement,
21 | React.HTMLAttributes
22 | >(({ className, ...props }, ref) => (
23 |
24 | ))
25 | TableHeader.displayName = "TableHeader"
26 |
27 | const TableBody = React.forwardRef<
28 | HTMLTableSectionElement,
29 | React.HTMLAttributes
30 | >(({ className, ...props }, ref) => (
31 |
36 | ))
37 | TableBody.displayName = "TableBody"
38 |
39 | const TableFooter = React.forwardRef<
40 | HTMLTableSectionElement,
41 | React.HTMLAttributes
42 | >(({ className, ...props }, ref) => (
43 | tr]:last:border-b-0",
47 | className
48 | )}
49 | {...props}
50 | />
51 | ))
52 | TableFooter.displayName = "TableFooter"
53 |
54 | const TableRow = React.forwardRef<
55 | HTMLTableRowElement,
56 | React.HTMLAttributes
57 | >(({ className, ...props }, ref) => (
58 |
66 | ))
67 | TableRow.displayName = "TableRow"
68 |
69 | const TableHead = React.forwardRef<
70 | HTMLTableCellElement,
71 | React.ThHTMLAttributes
72 | >(({ className, ...props }, ref) => (
73 | |
81 | ))
82 | TableHead.displayName = "TableHead"
83 |
84 | const TableCell = React.forwardRef<
85 | HTMLTableCellElement,
86 | React.TdHTMLAttributes
87 | >(({ className, ...props }, ref) => (
88 | |
93 | ))
94 | TableCell.displayName = "TableCell"
95 |
96 | const TableCaption = React.forwardRef<
97 | HTMLTableCaptionElement,
98 | React.HTMLAttributes
99 | >(({ className, ...props }, ref) => (
100 |
105 | ))
106 | TableCaption.displayName = "TableCaption"
107 |
108 | export {
109 | Table,
110 | TableHeader,
111 | TableBody,
112 | TableFooter,
113 | TableHead,
114 | TableRow,
115 | TableCell,
116 | TableCaption,
117 | }
118 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/src/db/db.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client"
2 |
3 | const prismaClientSingleton = () => {
4 | return new PrismaClient()
5 | }
6 |
7 | declare global {
8 | var prisma: undefined | ReturnType
9 | }
10 |
11 | const db = globalThis.prisma ?? prismaClientSingleton()
12 |
13 | export default db
14 |
15 | if (process.env.NODE_ENV !== "production") globalThis.prisma = db
16 |
--------------------------------------------------------------------------------
/src/email/OrderHistory.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Container,
4 | Head,
5 | Heading,
6 | Hr,
7 | Html,
8 | Preview,
9 | Tailwind,
10 | } from "@react-email/components"
11 | import { OrderInformation } from "./components/OrderInformation"
12 | import React from "react"
13 |
14 | type OrderHistoryEmailProps = {
15 | orders: {
16 | id: string
17 | pricePaidInCents: number
18 | createdAt: Date
19 | downloadVerificationId: string
20 | product: {
21 | name: string
22 | imagePath: string
23 | description: string
24 | }
25 | }[]
26 | }
27 |
28 | OrderHistoryEmail.PreviewProps = {
29 | orders: [
30 | {
31 | id: crypto.randomUUID(),
32 | createdAt: new Date(),
33 | pricePaidInCents: 10000,
34 | downloadVerificationId: crypto.randomUUID(),
35 | product: {
36 | name: "Product name",
37 | description: "Some description",
38 | imagePath:
39 | "/products/5aba7442-e4a5-4d2e-bfa7-5bd358cdad64-02 - What Is Next.js.jpg",
40 | },
41 | },
42 | {
43 | id: crypto.randomUUID(),
44 | createdAt: new Date(),
45 | pricePaidInCents: 2000,
46 | downloadVerificationId: crypto.randomUUID(),
47 | product: {
48 | name: "Product name 2",
49 | description: "Some other desc",
50 | imagePath:
51 | "/products/db3035a5-e762-41b0-996f-d54ec730bc9c-01 - Course Introduction.jpg",
52 | },
53 | },
54 | ],
55 | } satisfies OrderHistoryEmailProps
56 |
57 | export default function OrderHistoryEmail({ orders }: OrderHistoryEmailProps) {
58 | return (
59 |
60 | Order History & Downloads
61 |
62 |
63 |
64 |
65 | Order History
66 | {orders.map((order, index) => (
67 |
68 |
73 | {index < orders.length - 1 &&
}
74 |
75 | ))}
76 |
77 |
78 |
79 |
80 | )
81 | }
82 |
--------------------------------------------------------------------------------
/src/email/PurchaseReceipt.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Container,
4 | Head,
5 | Heading,
6 | Html,
7 | Preview,
8 | Tailwind,
9 | } from "@react-email/components"
10 | import { OrderInformation } from "./components/OrderInformation"
11 |
12 | type PurchaseReceiptEmailProps = {
13 | product: {
14 | name: string
15 | imagePath: string
16 | description: string
17 | }
18 | order: { id: string; createdAt: Date; pricePaidInCents: number }
19 | downloadVerificationId: string
20 | }
21 |
22 | PurchaseReceiptEmail.PreviewProps = {
23 | product: {
24 | name: "Product name",
25 | description: "Some description",
26 | imagePath:
27 | "/products/5aba7442-e4a5-4d2e-bfa7-5bd358cdad64-02 - What Is Next.js.jpg",
28 | },
29 | order: {
30 | id: crypto.randomUUID(),
31 | createdAt: new Date(),
32 | pricePaidInCents: 10000,
33 | },
34 | downloadVerificationId: crypto.randomUUID(),
35 | } satisfies PurchaseReceiptEmailProps
36 |
37 | export default function PurchaseReceiptEmail({
38 | product,
39 | order,
40 | downloadVerificationId,
41 | }: PurchaseReceiptEmailProps) {
42 | return (
43 |
44 | Download {product.name} and view receipt
45 |
46 |
47 |
48 |
49 | Purchase Receipt
50 |
55 |
56 |
57 |
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/src/email/components/OrderInformation.tsx:
--------------------------------------------------------------------------------
1 | import { formatCurrency } from "@/lib/formatters"
2 | import {
3 | Button,
4 | Column,
5 | Img,
6 | Row,
7 | Section,
8 | Text,
9 | } from "@react-email/components"
10 |
11 | type OrderInformationProps = {
12 | order: { id: string; createdAt: Date; pricePaidInCents: number }
13 | product: { imagePath: string; name: string; description: string }
14 | downloadVerificationId: string
15 | }
16 |
17 | const dateFormatter = new Intl.DateTimeFormat("en", { dateStyle: "medium" })
18 |
19 | export function OrderInformation({
20 | order,
21 | product,
22 | downloadVerificationId,
23 | }: OrderInformationProps) {
24 | return (
25 | <>
26 |
27 |
28 |
29 |
30 | Order ID
31 |
32 | {order.id}
33 |
34 |
35 |
36 | Purchased On
37 |
38 |
39 | {dateFormatter.format(order.createdAt)}
40 |
41 |
42 |
43 |
44 | Price Paid
45 |
46 |
47 | {formatCurrency(order.pricePaidInCents / 100)}
48 |
49 |
50 |
51 |
52 |
53 |
58 |
59 |
60 | {product.name}
61 |
62 |
63 |
69 |
70 |
71 |
72 |
73 | {product.description}
74 |
75 |
76 |
77 | >
78 | )
79 | }
80 |
--------------------------------------------------------------------------------
/src/lib/cache.ts:
--------------------------------------------------------------------------------
1 | import { unstable_cache as nextCache } from "next/cache"
2 | import { cache as reactCache } from "react"
3 |
4 | type Callback = (...args: any[]) => Promise
5 | export function cache(
6 | cb: T,
7 | keyParts: string[],
8 | options: { revalidate?: number | false; tags?: string[] } = {}
9 | ) {
10 | return nextCache(reactCache(cb), keyParts, options)
11 | }
12 |
--------------------------------------------------------------------------------
/src/lib/discountCodeHelpers.ts:
--------------------------------------------------------------------------------
1 | import db from "@/db/db"
2 | import { DiscountCodeType, Prisma } from "@prisma/client"
3 |
4 | export function usableDiscountCodeWhere(productId: string) {
5 | return {
6 | isActive: true,
7 | AND: [
8 | {
9 | OR: [{ allProducts: true }, { products: { some: { id: productId } } }],
10 | },
11 | { OR: [{ limit: null }, { limit: { gt: db.discountCode.fields.uses } }] },
12 | { OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }] },
13 | ],
14 | } satisfies Prisma.DiscountCodeWhereInput
15 | }
16 |
17 | export function getDiscountedAmount(
18 | discountCode: { discountAmount: number; discountType: DiscountCodeType },
19 | priceInCents: number
20 | ) {
21 | switch (discountCode.discountType) {
22 | case "PERCENTAGE":
23 | return Math.max(
24 | 1,
25 | Math.ceil(
26 | priceInCents - (priceInCents * discountCode.discountAmount) / 100
27 | )
28 | )
29 | case "FIXED":
30 | return Math.max(
31 | 1,
32 | Math.ceil(priceInCents - discountCode.discountAmount * 100)
33 | )
34 | default:
35 | throw new Error(
36 | `Invalid discount type ${discountCode.discountType satisfies never}`
37 | )
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/lib/formatters.ts:
--------------------------------------------------------------------------------
1 | import { DiscountCodeType } from "@prisma/client"
2 |
3 | const CURRENCY_FORMATTER = new Intl.NumberFormat("en-US", {
4 | currency: "USD",
5 | style: "currency",
6 | minimumFractionDigits: 0,
7 | })
8 |
9 | export function formatCurrency(amount: number) {
10 | return CURRENCY_FORMATTER.format(amount)
11 | }
12 |
13 | const NUMBER_FORMATTER = new Intl.NumberFormat("en-US")
14 |
15 | export function formatNumber(number: number) {
16 | return NUMBER_FORMATTER.format(number)
17 | }
18 |
19 | const PERCENT_FORMATTER = new Intl.NumberFormat("en-US", { style: "percent" })
20 |
21 | export function formatDiscountCode({
22 | discountAmount,
23 | discountType,
24 | }: {
25 | discountAmount: number
26 | discountType: DiscountCodeType
27 | }) {
28 | switch (discountType) {
29 | case "PERCENTAGE":
30 | return PERCENT_FORMATTER.format(discountAmount / 100)
31 | case "FIXED":
32 | return formatCurrency(discountAmount)
33 | default:
34 | throw new Error(
35 | `Invalid discount code type ${discountType satisfies never}`
36 | )
37 | }
38 | }
39 |
40 | const DATE_TIME_FORMATTER = new Intl.DateTimeFormat("en", {
41 | dateStyle: "medium",
42 | timeStyle: "short",
43 | })
44 |
45 | export function formatDateTime(date: Date) {
46 | return DATE_TIME_FORMATTER.format(date)
47 | }
48 |
49 | const DATE_FORMATTER = new Intl.DateTimeFormat("en", {
50 | dateStyle: "medium",
51 | })
52 |
53 | export function formatDate(date: Date) {
54 | return DATE_FORMATTER.format(date)
55 | }
56 |
--------------------------------------------------------------------------------
/src/lib/isValidPassword.ts:
--------------------------------------------------------------------------------
1 | export async function isValidPassword(
2 | password: string,
3 | hashedPassword: string
4 | ) {
5 | return (await hashPassword(password)) === hashedPassword
6 | }
7 |
8 | async function hashPassword(password: string) {
9 | const arrayBuffer = await crypto.subtle.digest(
10 | "SHA-512",
11 | new TextEncoder().encode(password)
12 | )
13 |
14 | return Buffer.from(arrayBuffer).toString("base64")
15 | }
16 |
--------------------------------------------------------------------------------
/src/lib/rangeOptions.ts:
--------------------------------------------------------------------------------
1 | import { isValid, startOfDay, subDays } from "date-fns"
2 | import { formatDate } from "./formatters"
3 |
4 | export const RANGE_OPTIONS = {
5 | last_7_days: {
6 | label: "Last 7 Days",
7 | startDate: startOfDay(subDays(new Date(), 6)),
8 | endDate: null,
9 | },
10 | last_30_days: {
11 | label: "Last 30 Days",
12 | startDate: startOfDay(subDays(new Date(), 29)),
13 | endDate: null,
14 | },
15 | last_90_days: {
16 | label: "Last 90 Days",
17 | startDate: startOfDay(subDays(new Date(), 89)),
18 | endDate: null,
19 | },
20 | last_365_days: {
21 | label: "Last 365 Days",
22 | startDate: startOfDay(subDays(new Date(), 364)),
23 | endDate: null,
24 | },
25 | all_time: {
26 | label: "All Time",
27 | startDate: null,
28 | endDate: null,
29 | },
30 | }
31 |
32 | export function getRangeOption(range?: string, from?: string, to?: string) {
33 | if (range == null) {
34 | const startDate = new Date(from || "")
35 | const endDate = new Date(to || "")
36 | if (!isValid(startDate) || !isValid(endDate)) return
37 |
38 | return {
39 | label: `${formatDate(startDate)} - ${formatDate(endDate)}`,
40 | startDate,
41 | endDate,
42 | }
43 | }
44 | return RANGE_OPTIONS[range as keyof typeof RANGE_OPTIONS]
45 | }
46 |
--------------------------------------------------------------------------------
/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/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server"
2 | import { isValidPassword } from "./lib/isValidPassword"
3 |
4 | export async function middleware(req: NextRequest) {
5 | if ((await isAuthenticated(req)) === false) {
6 | return new NextResponse("Unauthorized", {
7 | status: 401,
8 | headers: { "WWW-Authenticate": "Basic" },
9 | })
10 | }
11 | }
12 |
13 | async function isAuthenticated(req: NextRequest) {
14 | const authHeader =
15 | req.headers.get("authorization") || req.headers.get("Authorization")
16 |
17 | if (authHeader == null) return false
18 |
19 | const [username, password] = Buffer.from(authHeader.split(" ")[1], "base64")
20 | .toString()
21 | .split(":")
22 |
23 | return (
24 | username === process.env.ADMIN_USERNAME &&
25 | (await isValidPassword(
26 | password,
27 | process.env.HASHED_ADMIN_PASSWORD as string
28 | ))
29 | )
30 | }
31 |
32 | export const config = {
33 | matcher: "/admin/:path*",
34 | }
35 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 | import { fontFamily } from "tailwindcss/defaultTheme"
3 |
4 | const config = {
5 | darkMode: ["class"],
6 | content: [
7 | "./pages/**/*.{ts,tsx}",
8 | "./components/**/*.{ts,tsx}",
9 | "./app/**/*.{ts,tsx}",
10 | "./src/**/*.{ts,tsx}",
11 | ],
12 | prefix: "",
13 | theme: {
14 | container: {
15 | center: true,
16 | padding: "2rem",
17 | screens: {
18 | "2xl": "1400px",
19 | },
20 | },
21 | extend: {
22 | fontFamily: {
23 | sans: ["var(--font-sans)", ...fontFamily.sans],
24 | },
25 | colors: {
26 | border: "hsl(var(--border))",
27 | input: "hsl(var(--input))",
28 | ring: "hsl(var(--ring))",
29 | background: "hsl(var(--background))",
30 | foreground: "hsl(var(--foreground))",
31 | primary: {
32 | DEFAULT: "hsl(var(--primary))",
33 | foreground: "hsl(var(--primary-foreground))",
34 | },
35 | secondary: {
36 | DEFAULT: "hsl(var(--secondary))",
37 | foreground: "hsl(var(--secondary-foreground))",
38 | },
39 | destructive: {
40 | DEFAULT: "hsl(var(--destructive))",
41 | foreground: "hsl(var(--destructive-foreground))",
42 | },
43 | muted: {
44 | DEFAULT: "hsl(var(--muted))",
45 | foreground: "hsl(var(--muted-foreground))",
46 | },
47 | accent: {
48 | DEFAULT: "hsl(var(--accent))",
49 | foreground: "hsl(var(--accent-foreground))",
50 | },
51 | popover: {
52 | DEFAULT: "hsl(var(--popover))",
53 | foreground: "hsl(var(--popover-foreground))",
54 | },
55 | card: {
56 | DEFAULT: "hsl(var(--card))",
57 | foreground: "hsl(var(--card-foreground))",
58 | },
59 | },
60 | borderRadius: {
61 | lg: "var(--radius)",
62 | md: "calc(var(--radius) - 2px)",
63 | sm: "calc(var(--radius) - 4px)",
64 | },
65 | keyframes: {
66 | "accordion-down": {
67 | from: { height: "0" },
68 | to: { height: "var(--radix-accordion-content-height)" },
69 | },
70 | "accordion-up": {
71 | from: { height: "var(--radix-accordion-content-height)" },
72 | to: { height: "0" },
73 | },
74 | },
75 | animation: {
76 | "accordion-down": "accordion-down 0.2s ease-out",
77 | "accordion-up": "accordion-up 0.2s ease-out",
78 | },
79 | },
80 | },
81 | plugins: [require("tailwindcss-animate")],
82 | } satisfies Config
83 |
84 | export default config
85 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------