├── .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 |
21 | 22 | 23 | My Orders 24 | 25 | Enter your email and we will email you your order history and 26 | download links 27 | 28 | 29 | 30 |
31 | 32 | 33 | {data.error &&
{data.error}
} 34 |
35 |
36 | 37 | {data.message ?

{data.message}

: } 38 |
39 |
40 |
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 | {product.name} 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 | 169 | 170 | 171 | Checkout 172 | 173 | {errorMessage &&
{errorMessage}
} 174 | {coupon != null && discountCode == null && ( 175 |
Invalid discount code
176 | )} 177 |
178 |
179 | 180 | 181 |
182 | setEmail(e.value.email)} 184 | /> 185 |
186 |
187 | 188 |
189 | 197 | 207 | {discountCode != null && ( 208 |
209 | {formatDiscountCode(discountCode)} discount 210 |
211 | )} 212 |
213 |
214 |
215 | 216 | 225 | 226 |
227 | 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 | {product.name} 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 |
29 |
30 | 31 | 32 | {error.code &&
{error.code}
} 33 |
34 |
35 |
36 | 37 | 42 |
43 | 47 | 48 |
49 |
50 | 51 | 52 |
53 |
54 | {error.discountType && ( 55 |
{error.discountType}
56 | )} 57 |
58 |
59 | 60 | 66 | {error.discountAmount && ( 67 |
{error.discountAmount}
68 | )} 69 |
70 |
71 |
72 | 73 | 74 |
75 | Leave blank for infinite uses 76 |
77 | {error.limit &&
{error.limit}
} 78 |
79 |
80 | 81 | 88 |
89 | Leave blank for no expiration 90 |
91 | {error.expiresAt && ( 92 |
{error.expiresAt}
93 | )} 94 |
95 |
96 | 97 | {error.allProducts && ( 98 |
{error.allProducts}
99 | )} 100 | {error.productIds && ( 101 |
{error.productIds}
102 | )} 103 |
104 | setAllProducts(e === true)} 109 | /> 110 | 111 |
112 | {products.map(product => ( 113 |
114 | 120 | 121 |
122 | ))} 123 |
124 | 125 | 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 |
25 |
26 | 27 | 34 | {error.name &&
{error.name}
} 35 |
36 |
37 | 38 | setPriceInCents(Number(e.target.value) || undefined)} 45 | /> 46 |
47 | {formatCurrency((priceInCents || 0) / 100)} 48 |
49 | {error.priceInCents && ( 50 |
{error.priceInCents}
51 | )} 52 |
53 |
54 | 55 |