├── .eslintrc.json ├── app ├── favicon.ico ├── (auth) │ ├── (routes) │ │ ├── sign-in │ │ │ └── [[...sign-in]] │ │ │ │ └── page.tsx │ │ └── sign-up │ │ │ └── [[...sign-up]] │ │ │ └── page.tsx │ └── layout.tsx ├── (root) │ ├── (routes) │ │ └── page.tsx │ └── layout.tsx ├── (dashboard) │ └── [storeId] │ │ ├── (routes) │ │ ├── sizes │ │ │ ├── [sizeId] │ │ │ │ ├── page.tsx │ │ │ │ └── components │ │ │ │ │ └── size-form.tsx │ │ │ ├── components │ │ │ │ ├── columns.tsx │ │ │ │ ├── client.tsx │ │ │ │ └── cell-action.tsx │ │ │ └── page.tsx │ │ ├── colors │ │ │ ├── [colorId] │ │ │ │ ├── page.tsx │ │ │ │ └── components │ │ │ │ │ └── color-form.tsx │ │ │ ├── page.tsx │ │ │ └── components │ │ │ │ ├── columns.tsx │ │ │ │ ├── client.tsx │ │ │ │ └── cell-action.tsx │ │ ├── billboards │ │ │ ├── components │ │ │ │ ├── columns.tsx │ │ │ │ ├── client.tsx │ │ │ │ └── cell-action.tsx │ │ │ ├── [billboardId] │ │ │ │ ├── page.tsx │ │ │ │ └── components │ │ │ │ │ └── billboard-form.tsx │ │ │ └── page.tsx │ │ ├── orders │ │ │ ├── components │ │ │ │ ├── client.tsx │ │ │ │ └── columns.tsx │ │ │ └── page.tsx │ │ ├── categories │ │ │ ├── components │ │ │ │ ├── columns.tsx │ │ │ │ ├── client.tsx │ │ │ │ └── cell-action.tsx │ │ │ ├── [categoryId] │ │ │ │ ├── page.tsx │ │ │ │ └── components │ │ │ │ │ └── category-form.tsx │ │ │ └── page.tsx │ │ ├── settings │ │ │ ├── page.tsx │ │ │ └── components │ │ │ │ └── settings-form.tsx │ │ ├── products │ │ │ ├── [productId] │ │ │ │ ├── page.tsx │ │ │ │ └── components │ │ │ │ │ └── product-form.tsx │ │ │ ├── page.tsx │ │ │ └── components │ │ │ │ ├── client.tsx │ │ │ │ ├── columns.tsx │ │ │ │ └── cell-action.tsx │ │ └── page.tsx │ │ └── layout.tsx ├── api │ ├── stores │ │ ├── route.ts │ │ └── [storeId] │ │ │ └── route.ts │ ├── webhook │ │ └── route.ts │ └── [storeId] │ │ ├── sizes │ │ ├── route.ts │ │ └── [sizeId] │ │ │ └── route.ts │ │ ├── colors │ │ ├── route.ts │ │ └── [colorId] │ │ │ └── route.ts │ │ ├── billboards │ │ ├── route.ts │ │ └── [billboardId] │ │ │ └── route.ts │ │ ├── categories │ │ ├── route.ts │ │ └── [categoryId] │ │ │ └── route.ts │ │ ├── checkout │ │ └── route.ts │ │ └── products │ │ ├── route.ts │ │ └── [productId] │ │ └── route.ts ├── layout.tsx └── globals.css ├── postcss.config.js ├── providers ├── toast-provider.tsx ├── theme-provider.tsx └── modal-provider.tsx ├── lib ├── stripe.ts ├── prismadb.ts └── utils.ts ├── next.config.js ├── middleware.ts ├── actions ├── get-sales-count.ts ├── get-stock-count.ts ├── get-total-revenue.ts └── get-graph-revenue.ts ├── hooks ├── use-store-modal.tsx └── use-origin.tsx ├── components.json ├── components ├── ui │ ├── heading.tsx │ ├── label.tsx │ ├── separator.tsx │ ├── input.tsx │ ├── modal.tsx │ ├── checkbox.tsx │ ├── badge.tsx │ ├── api-list.tsx │ ├── popover.tsx │ ├── api-alert.tsx │ ├── alert.tsx │ ├── button.tsx │ ├── card.tsx │ ├── image-upload.tsx │ ├── table.tsx │ ├── data-table.tsx │ ├── dialog.tsx │ ├── select.tsx │ ├── form.tsx │ ├── command.tsx │ └── dropdown-menu.tsx ├── overview.tsx ├── navbar.tsx ├── modals │ ├── alert-modal.tsx │ └── store-modal.tsx ├── theme-toggle.tsx ├── main-nav.tsx └── store-switcher.tsx ├── .gitignore ├── public ├── vercel.svg └── next.svg ├── tsconfig.json ├── package.json ├── tailwind.config.js ├── README.md └── prisma └── schema.prisma /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evanch98/ecommerce-admin-nextjs/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /app/(auth)/(routes)/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(auth)/(routes)/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /providers/toast-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Toaster } from "react-hot-toast"; 4 | 5 | export const ToastProvider = () => { 6 | return ; 7 | }; 8 | -------------------------------------------------------------------------------- /lib/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe"; 2 | 3 | export const stripe = new Stripe(process.env.STRIPE_API_KEY!, { 4 | apiVersion: "2023-08-16", 5 | typescript: true, 6 | }); 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | domains: ["res.cloudinary.com"], 5 | }, 6 | }; 7 | 8 | module.exports = nextConfig; 9 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function AuthLayout({ 2 | children, 3 | }: { 4 | children: React.ReactNode; 5 | }) { 6 | return ( 7 |
8 | {children} 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { authMiddleware } from "@clerk/nextjs"; 2 | 3 | // to protect all routes 4 | export default authMiddleware({ 5 | publicRoutes: ["/api/:path*"], 6 | }); 7 | 8 | export const config = { 9 | matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"], 10 | }; 11 | -------------------------------------------------------------------------------- /actions/get-sales-count.ts: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | 3 | export const getSalesCount = async (storedId: string) => { 4 | const salesCount = await prismadb.order.count({ 5 | where: { 6 | storeId: storedId, 7 | isPaid: true, 8 | }, 9 | }); 10 | 11 | return salesCount; 12 | }; 13 | -------------------------------------------------------------------------------- /lib/prismadb.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | declare global { 4 | var prisma: PrismaClient | undefined; 5 | } 6 | 7 | const prismadb = globalThis.prisma || new PrismaClient(); 8 | if (process.env.NODE_ENV !== "production") globalThis.prisma = prismadb; 9 | 10 | export default prismadb; 11 | -------------------------------------------------------------------------------- /actions/get-stock-count.ts: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | 3 | export const getStockCount = async (storedId: string) => { 4 | const stockCount = await prismadb.product.count({ 5 | where: { 6 | storeId: storedId, 7 | isArchived: false, 8 | }, 9 | }); 10 | 11 | return stockCount; 12 | }; 13 | -------------------------------------------------------------------------------- /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 | 8 | export const formatter = new Intl.NumberFormat("en-US", { 9 | style: "currency", 10 | currency: "USD", 11 | }); 12 | -------------------------------------------------------------------------------- /hooks/use-store-modal.tsx: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | interface useStoreModalStore { 4 | isOpen: boolean; 5 | onOpen: () => void; 6 | onClose: () => void; 7 | } 8 | 9 | export const useStoreModal = create((set) => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }), 13 | })); 14 | -------------------------------------------------------------------------------- /providers/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes/dist/types"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /components/ui/heading.tsx: -------------------------------------------------------------------------------- 1 | interface HeadingProps { 2 | title: string; 3 | description: string; 4 | } 5 | 6 | export const Heading: React.FC = ({ title, description }) => { 7 | return ( 8 |
9 |

{title}

10 |

{description}

11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /hooks/use-origin.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useOrigin = () => { 4 | const [mounted, setMounted] = useState(false); 5 | 6 | const origin = 7 | typeof window !== "undefined" && window.location.origin 8 | ? window.location.origin 9 | : ""; 10 | 11 | useEffect(() => { 12 | setMounted(true); 13 | }, []); 14 | 15 | if (!mounted) { 16 | return ""; 17 | } 18 | 19 | return origin; 20 | }; 21 | -------------------------------------------------------------------------------- /app/(root)/(routes)/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useStoreModal } from "@/hooks/use-store-modal"; 4 | import { useEffect } from "react"; 5 | 6 | const SetupPage = () => { 7 | const onOpen = useStoreModal((state) => state.onOpen); 8 | const isOpen = useStoreModal((state) => state.isOpen); 9 | 10 | useEffect(() => { 11 | if (!isOpen) { 12 | onOpen(); 13 | } 14 | }, [isOpen, onOpen]); 15 | return null; 16 | }; 17 | 18 | export default SetupPage; 19 | -------------------------------------------------------------------------------- /providers/modal-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { StoreModal } from "@/components/modals/store-modal"; 4 | import { useEffect, useState } from "react"; 5 | 6 | export const ModalProvider = () => { 7 | // to prevent the hydration error 8 | const [isMounted, setIsMounted] = useState(false); 9 | 10 | useEffect(() => { 11 | setIsMounted(true); 12 | }, []); 13 | 14 | if (!isMounted) { 15 | return null; 16 | } 17 | 18 | return ( 19 | <> 20 | 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /.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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/sizes/[sizeId]/page.tsx: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { SizeForm } from "./components/size-form"; 3 | 4 | const SizePage = async ({ 5 | params, 6 | }: { 7 | params: { sizeId: string }; 8 | }) => { 9 | const size = await prismadb.size.findUnique({ 10 | where: { 11 | id: params.sizeId, 12 | }, 13 | }); 14 | 15 | return ( 16 |
17 |
18 | 19 |
20 |
21 | ); 22 | }; 23 | 24 | export default SizePage; 25 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/colors/[colorId]/page.tsx: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { ColorForm } from "./components/color-form"; 3 | 4 | const ColorPage = async ({ 5 | params, 6 | }: { 7 | params: { colorId: string }; 8 | }) => { 9 | const color = await prismadb.color.findUnique({ 10 | where: { 11 | id: params.colorId, 12 | }, 13 | }); 14 | 15 | return ( 16 |
17 |
18 | 19 |
20 |
21 | ); 22 | }; 23 | 24 | export default ColorPage; 25 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/billboards/components/columns.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ColumnDef } from "@tanstack/react-table"; 4 | import { CellAction } from "./cell-action"; 5 | 6 | export type BillboardColumn = { 7 | id: string; 8 | label: string; 9 | createdAt: string; 10 | }; 11 | 12 | export const columns: ColumnDef[] = [ 13 | { 14 | accessorKey: "label", 15 | header: "Label", 16 | }, 17 | { 18 | accessorKey: "createdAt", 19 | header: "Created At", 20 | }, 21 | { 22 | id: "actions", 23 | cell: ({ row }) => , 24 | }, 25 | ]; 26 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/billboards/[billboardId]/page.tsx: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { BillboardForm } from "./components/billboard-form"; 3 | 4 | const BillboardPage = async ({ 5 | params, 6 | }: { 7 | params: { billboardId: string }; 8 | }) => { 9 | const billboard = await prismadb.billboard.findUnique({ 10 | where: { 11 | id: params.billboardId, 12 | }, 13 | }); 14 | 15 | return ( 16 |
17 |
18 | 19 |
20 |
21 | ); 22 | }; 23 | 24 | export default BillboardPage; 25 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/sizes/components/columns.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ColumnDef } from "@tanstack/react-table"; 4 | import { CellAction } from "./cell-action"; 5 | 6 | export type SizeColumn = { 7 | id: string; 8 | name: string; 9 | value: string; 10 | createdAt: string; 11 | }; 12 | 13 | export const columns: ColumnDef[] = [ 14 | { 15 | accessorKey: "name", 16 | header: "Name", 17 | }, 18 | { 19 | accessorKey: "value", 20 | header: "Value", 21 | }, 22 | { 23 | accessorKey: "createdAt", 24 | header: "Created At", 25 | }, 26 | { 27 | id: "actions", 28 | cell: ({ row }) => , 29 | }, 30 | ]; 31 | -------------------------------------------------------------------------------- /app/(root)/layout.tsx: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { auth } from "@clerk/nextjs"; 3 | import { redirect } from "next/navigation"; 4 | 5 | export default async function SetupLayout({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) { 10 | const { userId } = auth(); 11 | 12 | if (!userId) { 13 | redirect("/sign-in"); 14 | } 15 | 16 | // check if the user has already created a store 17 | const store = await prismadb.store.findFirst({ 18 | where: { 19 | userId, 20 | }, 21 | }); 22 | 23 | // if yes, redirect to the user to the Dashboard 24 | if (store) { 25 | redirect(`/${store.id}`); 26 | } 27 | 28 | // otherwise 29 | return <>{children}; 30 | } 31 | -------------------------------------------------------------------------------- /actions/get-total-revenue.ts: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | 3 | export const getTotalRevenue = async (storedId: string) => { 4 | const paidOrders = await prismadb.order.findMany({ 5 | where: { 6 | storeId: storedId, 7 | isPaid: true, 8 | }, 9 | include: { 10 | orderItems: { 11 | include: { 12 | product: true, 13 | }, 14 | }, 15 | }, 16 | }); 17 | 18 | const totalRevenue = paidOrders.reduce((total, order) => { 19 | const orderTotal = order.orderItems.reduce((orderSum, item) => { 20 | return orderSum + item.product.price.toNumber(); 21 | }, 0); 22 | 23 | return total + orderTotal; 24 | }, 0); 25 | 26 | return totalRevenue; 27 | }; 28 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/orders/components/client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Heading } from "@/components/ui/heading"; 4 | import { Separator } from "@/components/ui/separator"; 5 | import { OrderColumn, columns } from "./columns"; 6 | import { DataTable } from "@/components/ui/data-table"; 7 | 8 | interface OrderClientProps { 9 | data: OrderColumn[]; 10 | } 11 | 12 | export const OrderClient: React.FC = ({ data }) => { 13 | return ( 14 | <> 15 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/categories/components/columns.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ColumnDef } from "@tanstack/react-table"; 4 | import { CellAction } from "./cell-action"; 5 | 6 | export type CategoryColumn = { 7 | id: string; 8 | name: string; 9 | billboardLabel: string; 10 | createdAt: string; 11 | }; 12 | 13 | export const columns: ColumnDef[] = [ 14 | { 15 | accessorKey: "name", 16 | header: "Name", 17 | }, 18 | { 19 | accessorKey: "billboard", 20 | header: "Billboard", 21 | cell: ({ row }) => row.original.billboardLabel, 22 | }, 23 | { 24 | accessorKey: "createdAt", 25 | header: "Date", 26 | }, 27 | { 28 | id: "actions", 29 | cell: ({ row }) => , 30 | }, 31 | ]; 32 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/categories/[categoryId]/page.tsx: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { CategoryForm } from "./components/category-form"; 3 | 4 | const CategoryPage = async ({ 5 | params, 6 | }: { 7 | params: { categoryId: string; storeId: string }; 8 | }) => { 9 | const category = await prismadb.category.findUnique({ 10 | where: { 11 | id: params.categoryId, 12 | }, 13 | }); 14 | 15 | const billboards = await prismadb.billboard.findMany({ 16 | where: { 17 | storeId: params.storeId, 18 | }, 19 | }); 20 | 21 | return ( 22 |
23 |
24 | 25 |
26 |
27 | ); 28 | }; 29 | 30 | export default CategoryPage; 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/api/stores/route.ts: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { auth } from "@clerk/nextjs"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function POST(req: Request) { 6 | try { 7 | const { userId } = auth(); 8 | const body = await req.json(); 9 | 10 | const { name } = body; 11 | 12 | if (!userId) { 13 | return new NextResponse("Unauthorized", { status: 401 }); 14 | } 15 | 16 | if (!name) { 17 | return new NextResponse("Name is required", { status: 400 }); 18 | } 19 | 20 | const store = await prismadb.store.create({ 21 | data: { 22 | name, 23 | userId, 24 | }, 25 | }); 26 | 27 | return NextResponse.json(store); 28 | } catch (error) { 29 | console.log("[STORES_POST]", error); 30 | return new NextResponse("Internal error", { status: 500 }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/orders/components/columns.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ColumnDef } from "@tanstack/react-table"; 4 | 5 | export type OrderColumn = { 6 | id: string; 7 | phone: string; 8 | address: string; 9 | isPaid: boolean; 10 | totalPrice: string; 11 | products: string; 12 | createdAt: string; 13 | }; 14 | 15 | export const columns: ColumnDef[] = [ 16 | { 17 | accessorKey: "products", 18 | header: "Products", 19 | }, 20 | { 21 | accessorKey: "phone", 22 | header: "Phone", 23 | }, 24 | { 25 | accessorKey: "address", 26 | header: "Address", 27 | }, 28 | { 29 | accessorKey: "totalPrice", 30 | header: "Total price", 31 | }, 32 | { 33 | accessorKey: "isPaid", 34 | header: "Paid", 35 | }, 36 | { 37 | accessorKey: "createdAt", 38 | header: "Created At", 39 | }, 40 | ]; 41 | -------------------------------------------------------------------------------- /components/overview.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts"; 4 | 5 | interface OverviewProps { 6 | data: any[]; 7 | } 8 | 9 | export const Overview: React.FC = ({ data }) => { 10 | return ( 11 | 12 | 13 | 20 | `$${value}`} 26 | /> 27 | 28 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/sizes/page.tsx: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { SizeClient } from "./components/client"; 3 | import { SizeColumn } from "./components/columns"; 4 | import { format } from "date-fns"; 5 | 6 | const SizesPage = async ({ params }: { params: { storeId: string } }) => { 7 | const sizes = await prismadb.size.findMany({ 8 | where: { 9 | storeId: params.storeId, 10 | }, 11 | orderBy: { 12 | createdAt: "desc", 13 | }, 14 | }); 15 | 16 | const formattedSizes: SizeColumn[] = sizes.map((item) => ({ 17 | id: item.id, 18 | name: item.name, 19 | value: item.value, 20 | createdAt: format(item.createdAt, "MMMM do, yyyy"), 21 | })); 22 | 23 | return ( 24 |
25 |
26 | 27 |
28 |
29 | ); 30 | }; 31 | 32 | export default SizesPage; 33 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { auth } from "@clerk/nextjs"; 3 | import { redirect } from "next/navigation"; 4 | import { SettingsForm } from "./components/settings-form"; 5 | 6 | interface SettingsPageProps { 7 | params: { 8 | storeId: string; 9 | }; 10 | } 11 | 12 | const SettingsPage: React.FC = async ({ params }) => { 13 | const { userId } = auth(); 14 | 15 | if (!userId) { 16 | redirect("/sign-in"); 17 | } 18 | 19 | const store = await prismadb.store.findFirst({ 20 | where: { 21 | id: params.storeId, 22 | userId, 23 | }, 24 | }); 25 | 26 | if (!store) { 27 | redirect("/"); 28 | } 29 | return ( 30 |
31 |
32 | 33 |
34 |
35 | ); 36 | }; 37 | 38 | export default SettingsPage; 39 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/colors/page.tsx: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { ColorClient } from "./components/client"; 3 | import { ColorColumn } from "./components/columns"; 4 | import { format } from "date-fns"; 5 | 6 | const ColorsPage = async ({ params }: { params: { storeId: string } }) => { 7 | const colors = await prismadb.color.findMany({ 8 | where: { 9 | storeId: params.storeId, 10 | }, 11 | orderBy: { 12 | createdAt: "desc", 13 | }, 14 | }); 15 | 16 | const formattedColors: ColorColumn[] = colors.map((item) => ({ 17 | id: item.id, 18 | name: item.name, 19 | value: item.value, 20 | createdAt: format(item.createdAt, "MMMM do, yyyy"), 21 | })); 22 | 23 | return ( 24 |
25 |
26 | 27 |
28 |
29 | ); 30 | }; 31 | 32 | export default ColorsPage; 33 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/billboards/page.tsx: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { BillboardClient } from "./components/client"; 3 | import { BillboardColumn } from "./components/columns"; 4 | import { format } from "date-fns"; 5 | 6 | const BillboardsPage = async ({ params }: { params: { storeId: string } }) => { 7 | const billboards = await prismadb.billboard.findMany({ 8 | where: { 9 | storeId: params.storeId, 10 | }, 11 | orderBy: { 12 | createdAt: "desc", 13 | }, 14 | }); 15 | 16 | const formattedBillboard: BillboardColumn[] = billboards.map((item) => ({ 17 | id: item.id, 18 | label: item.label, 19 | createdAt: format(item.createdAt, "MMMM do, yyyy"), 20 | })); 21 | 22 | return ( 23 |
24 |
25 | 26 |
27 |
28 | ); 29 | }; 30 | 31 | export default BillboardsPage; 32 | -------------------------------------------------------------------------------- /components/ui/modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogDescription, 7 | DialogHeader, 8 | DialogTitle, 9 | } from "@/components/ui/dialog"; 10 | 11 | interface ModalProps { 12 | title: string; 13 | description: string; 14 | isOpen: boolean; 15 | onClose: () => void; 16 | children?: React.ReactNode; 17 | } 18 | 19 | export const Modal: React.FC = ({ 20 | title, 21 | description, 22 | isOpen, 23 | onClose, 24 | children, 25 | }) => { 26 | const onChange = (open: boolean) => { 27 | if (!open) { 28 | onClose(); 29 | } 30 | }; 31 | 32 | return ( 33 | 34 | 35 | 36 | {title} 37 | {description} 38 |
{children}
39 |
40 |
41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/colors/components/columns.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ColumnDef } from "@tanstack/react-table"; 4 | import { CellAction } from "./cell-action"; 5 | 6 | export type ColorColumn = { 7 | id: string; 8 | name: string; 9 | value: string; 10 | createdAt: string; 11 | }; 12 | 13 | export const columns: ColumnDef[] = [ 14 | { 15 | accessorKey: "name", 16 | header: "Name", 17 | }, 18 | { 19 | accessorKey: "value", 20 | header: "Value", 21 | cell: ({ row }) => ( 22 |
23 | {row.original.value} 24 |
28 |
29 | ), 30 | }, 31 | { 32 | accessorKey: "createdAt", 33 | header: "Created At", 34 | }, 35 | { 36 | id: "actions", 37 | cell: ({ row }) => , 38 | }, 39 | ]; 40 | -------------------------------------------------------------------------------- /components/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { UserButton, auth } from "@clerk/nextjs"; 2 | import { MainNav } from "@/components/main-nav"; 3 | import StoreSwitcher from "@/components/store-switcher"; 4 | import { redirect } from "next/navigation"; 5 | import prismadb from "@/lib/prismadb"; 6 | import { ThemeToggle } from "./theme-toggle"; 7 | 8 | const Navbar = async () => { 9 | const { userId } = auth(); 10 | if (!userId) { 11 | redirect("/sign-in"); 12 | } 13 | 14 | const stores = await prismadb.store.findMany({ 15 | where: { 16 | userId, 17 | }, 18 | }); 19 | return ( 20 |
21 |
22 | 23 | 24 |
25 | 26 | 27 |
28 |
29 |
30 | ); 31 | }; 32 | 33 | export default Navbar; 34 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import type { Metadata } from "next"; 3 | import { Inter } from "next/font/google"; 4 | import { ClerkProvider } from "@clerk/nextjs"; 5 | import { ModalProvider } from "@/providers/modal-provider"; 6 | import { ToastProvider } from "@/providers/toast-provider"; 7 | import { ThemeProvider } from "@/providers/theme-provider"; 8 | 9 | const inter = Inter({ subsets: ["latin"] }); 10 | 11 | export const metadata: Metadata = { 12 | title: "Admin Dashboard", 13 | description: "Admin Dashboard", 14 | }; 15 | 16 | export default function RootLayout({ 17 | children, 18 | }: { 19 | children: React.ReactNode; 20 | }) { 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | {children} 29 | 30 | 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/categories/page.tsx: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { CategoryClient } from "./components/client"; 3 | import { CategoryColumn } from "./components/columns"; 4 | import { format } from "date-fns"; 5 | 6 | const CategoriesPage = async ({ params }: { params: { storeId: string } }) => { 7 | const categories = await prismadb.category.findMany({ 8 | where: { 9 | storeId: params.storeId, 10 | }, 11 | include: { 12 | billboard: true, 13 | }, 14 | orderBy: { 15 | createdAt: "desc", 16 | }, 17 | }); 18 | 19 | const formattedCategories: CategoryColumn[] = categories.map((item) => ({ 20 | id: item.id, 21 | name: item.name, 22 | billboardLabel: item.billboard.label, 23 | createdAt: format(item.createdAt, "MMMM do, yyyy"), 24 | })); 25 | 26 | return ( 27 |
28 |
29 | 30 |
31 |
32 | ); 33 | }; 34 | 35 | export default CategoriesPage; 36 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/layout.tsx: -------------------------------------------------------------------------------- 1 | import Navbar from "@/components/navbar"; 2 | import prismadb from "@/lib/prismadb"; 3 | import { auth } from "@clerk/nextjs"; 4 | import { redirect } from "next/navigation"; 5 | 6 | export default async function DashboardLayout({ 7 | children, 8 | params, 9 | }: { 10 | children: React.ReactNode; 11 | params: { storeId: string }; 12 | }) { 13 | const { userId } = auth(); // get the current user 14 | 15 | // if the userId is null, it means the user has not signed in 16 | // redirect to the sign-in page 17 | if (!userId) { 18 | redirect("/sign-in"); 19 | } 20 | 21 | // find the store in the database 22 | // the store id will be provided in the params 23 | const store = await prismadb.store.findFirst({ 24 | where: { 25 | id: params.storeId, 26 | userId, 27 | }, 28 | }); 29 | 30 | // if by any chance, the store does not exist, redirect to the index page 31 | if (!store) { 32 | redirect("/"); 33 | } 34 | 35 | return ( 36 | <> 37 | 38 | {children} 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /components/modals/alert-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { Modal } from "@/components/ui/modal"; 5 | import { Button } from "@/components/ui/button"; 6 | 7 | interface AlertModalProps { 8 | isOpen: boolean; 9 | onClose: () => void; 10 | onConfirm: () => void; 11 | loading: boolean; 12 | } 13 | 14 | export const AlertModal: React.FC = ({ 15 | isOpen, 16 | onClose, 17 | onConfirm, 18 | loading, 19 | }) => { 20 | const [isMounted, setIsMounted] = useState(false); 21 | 22 | useEffect(() => { 23 | setIsMounted(true); 24 | }, []); 25 | 26 | if (!isMounted) { 27 | return null; 28 | } 29 | 30 | return ( 31 | 37 |
38 | 41 | 44 |
45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/products/[productId]/page.tsx: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { ProductForm } from "./components/product-form"; 3 | 4 | const ProductPage = async ({ 5 | params, 6 | }: { 7 | params: { productId: string; storedId: string }; 8 | }) => { 9 | const product = await prismadb.product.findUnique({ 10 | where: { 11 | id: params.productId, 12 | }, 13 | include: { 14 | images: true, 15 | }, 16 | }); 17 | 18 | const categories = await prismadb.category.findMany({ 19 | where: { 20 | storeId: params.storedId, 21 | }, 22 | }); 23 | 24 | const sizes = await prismadb.size.findMany({ 25 | where: { 26 | storeId: params.storedId, 27 | }, 28 | }); 29 | 30 | const colors = await prismadb.color.findMany({ 31 | where: { 32 | storeId: params.storedId, 33 | }, 34 | }); 35 | 36 | return ( 37 |
38 |
39 | 45 |
46 |
47 | ); 48 | }; 49 | 50 | export default ProductPage; 51 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/products/page.tsx: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { ProductClient } from "./components/client"; 3 | import { ProductColumn } from "./components/columns"; 4 | import { format } from "date-fns"; 5 | import { formatter } from "@/lib/utils"; 6 | 7 | const ProductsPage = async ({ params }: { params: { storeId: string } }) => { 8 | const products = await prismadb.product.findMany({ 9 | where: { 10 | storeId: params.storeId, 11 | }, 12 | include: { 13 | category: true, 14 | size: true, 15 | color: true, 16 | }, 17 | orderBy: { 18 | createdAt: "desc", 19 | }, 20 | }); 21 | 22 | const formattedProducts: ProductColumn[] = products.map((item) => ({ 23 | id: item.id, 24 | name: item.name, 25 | isFeatured: item.isFeatured, 26 | isArchived: item.isArchived, 27 | price: formatter.format(item.price.toNumber()), 28 | category: item.category.name, 29 | size: item.size.name, 30 | color: item.color.value, 31 | createdAt: format(item.createdAt, "MMMM do, yyyy"), 32 | })); 33 | 34 | return ( 35 |
36 |
37 | 38 |
39 |
40 | ); 41 | }; 42 | 43 | export default ProductsPage; 44 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/ui/api-list.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useOrigin } from "@/hooks/use-origin"; 4 | import { useParams } from "next/navigation"; 5 | import { ApiAlert } from "@/components/ui/api-alert"; 6 | 7 | interface ApiListProps { 8 | entityName: string; 9 | entityIdName: string; 10 | } 11 | 12 | export const ApiList: React.FC = ({ 13 | entityName, 14 | entityIdName, 15 | }) => { 16 | const params = useParams(); 17 | const origin = useOrigin(); 18 | 19 | const baseUrl = `${origin}/api/${params.storeId}`; 20 | 21 | return ( 22 | <> 23 | 28 | 33 | 38 | 43 | 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent } 32 | -------------------------------------------------------------------------------- /components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Moon, Sun } from "lucide-react"; 5 | import { useTheme } from "next-themes"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu"; 14 | 15 | export function ThemeToggle() { 16 | const { setTheme } = useTheme(); 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | Light 30 | 31 | setTheme("dark")}> 32 | Dark 33 | 34 | setTheme("system")}> 35 | System 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/sizes/components/client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Heading } from "@/components/ui/heading"; 5 | import { Separator } from "@/components/ui/separator"; 6 | import { Plus } from "lucide-react"; 7 | import { useParams, useRouter } from "next/navigation"; 8 | import { SizeColumn, columns } from "./columns"; 9 | import { DataTable } from "@/components/ui/data-table"; 10 | import { ApiList } from "@/components/ui/api-list"; 11 | 12 | interface SizeClientProps { 13 | data: SizeColumn[]; 14 | } 15 | 16 | export const SizeClient: React.FC = ({ data }) => { 17 | const router = useRouter(); 18 | const params = useParams(); 19 | 20 | return ( 21 | <> 22 |
23 | 27 | 31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/colors/components/client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Heading } from "@/components/ui/heading"; 5 | import { Separator } from "@/components/ui/separator"; 6 | import { Plus } from "lucide-react"; 7 | import { useParams, useRouter } from "next/navigation"; 8 | import { ColorColumn, columns } from "./columns"; 9 | import { DataTable } from "@/components/ui/data-table"; 10 | import { ApiList } from "@/components/ui/api-list"; 11 | 12 | interface ColorClientProps { 13 | data: ColorColumn[]; 14 | } 15 | 16 | export const ColorClient: React.FC = ({ data }) => { 17 | const router = useRouter(); 18 | const params = useParams(); 19 | 20 | return ( 21 | <> 22 |
23 | 27 | 31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/orders/page.tsx: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { OrderClient } from "./components/client"; 3 | import { OrderColumn } from "./components/columns"; 4 | import { format } from "date-fns"; 5 | import { formatter } from "@/lib/utils"; 6 | 7 | const OrdersPage = async ({ params }: { params: { storeId: string } }) => { 8 | const orders = await prismadb.order.findMany({ 9 | where: { 10 | storeId: params.storeId, 11 | }, 12 | orderBy: { 13 | createdAt: "desc", 14 | }, 15 | include: { 16 | orderItems: { 17 | include: { 18 | product: true, 19 | }, 20 | }, 21 | }, 22 | }); 23 | 24 | const formattedOrders: OrderColumn[] = orders.map((item) => ({ 25 | id: item.id, 26 | phone: item.phone, 27 | address: item.address, 28 | products: item.orderItems 29 | .map((orderItem) => orderItem.product.name) 30 | .join(", "), 31 | totalPrice: formatter.format( 32 | item.orderItems.reduce((total, item) => { 33 | return total + Number(item.product.price); 34 | }, 0) 35 | ), 36 | isPaid: item.isPaid, 37 | createdAt: format(item.createdAt, "MMMM do, yyyy"), 38 | })); 39 | 40 | return ( 41 |
42 |
43 | 44 |
45 |
46 | ); 47 | }; 48 | 49 | export default OrdersPage; 50 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/products/components/client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Heading } from "@/components/ui/heading"; 5 | import { Separator } from "@/components/ui/separator"; 6 | import { Plus } from "lucide-react"; 7 | import { useParams, useRouter } from "next/navigation"; 8 | import { ProductColumn, columns } from "./columns"; 9 | import { DataTable } from "@/components/ui/data-table"; 10 | import { ApiList } from "@/components/ui/api-list"; 11 | 12 | interface ProductClientProps { 13 | data: ProductColumn[]; 14 | } 15 | 16 | export const ProductClient: React.FC = ({ data }) => { 17 | const router = useRouter(); 18 | const params = useParams(); 19 | 20 | return ( 21 | <> 22 |
23 | 27 | 33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/categories/components/client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Heading } from "@/components/ui/heading"; 5 | import { Separator } from "@/components/ui/separator"; 6 | import { Plus } from "lucide-react"; 7 | import { useParams, useRouter } from "next/navigation"; 8 | import { CategoryColumn, columns } from "./columns"; 9 | import { DataTable } from "@/components/ui/data-table"; 10 | import { ApiList } from "@/components/ui/api-list"; 11 | 12 | interface CategoryClientProps { 13 | data: CategoryColumn[]; 14 | } 15 | 16 | export const CategoryClient: React.FC = ({ data }) => { 17 | const router = useRouter(); 18 | const params = useParams(); 19 | 20 | return ( 21 | <> 22 |
23 | 27 | 33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/billboards/components/client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Heading } from "@/components/ui/heading"; 5 | import { Separator } from "@/components/ui/separator"; 6 | import { Plus } from "lucide-react"; 7 | import { useParams, useRouter } from "next/navigation"; 8 | import { BillboardColumn, columns } from "./columns"; 9 | import { DataTable } from "@/components/ui/data-table"; 10 | import { ApiList } from "@/components/ui/api-list"; 11 | 12 | interface BillboardClientProps { 13 | data: BillboardColumn[]; 14 | } 15 | 16 | export const BillboardClient: React.FC = ({ data }) => { 17 | const router = useRouter(); 18 | const params = useParams(); 19 | 20 | return ( 21 | <> 22 |
23 | 27 | 33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/products/components/columns.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ColumnDef } from "@tanstack/react-table"; 4 | import { CellAction } from "./cell-action"; 5 | 6 | export type ProductColumn = { 7 | id: string; 8 | name: string; 9 | price: string; 10 | size: string; 11 | category: string; 12 | color: string; 13 | isFeatured: boolean; 14 | isArchived: boolean; 15 | createdAt: string; 16 | }; 17 | 18 | export const columns: ColumnDef[] = [ 19 | { 20 | accessorKey: "name", 21 | header: "Name", 22 | }, 23 | { 24 | accessorKey: "isArchived", 25 | header: "Archived", 26 | }, 27 | { 28 | accessorKey: "isFeatured", 29 | header: "Featured", 30 | }, 31 | { 32 | accessorKey: "price", 33 | header: "Price", 34 | }, 35 | { 36 | accessorKey: "category", 37 | header: "Category", 38 | }, 39 | { 40 | accessorKey: "size", 41 | header: "Size", 42 | }, 43 | { 44 | accessorKey: "color", 45 | header: "Color", 46 | cell: ({ row }) => ( 47 |
48 | {row.original.color} 49 |
53 |
54 | ), 55 | }, 56 | { 57 | accessorKey: "createdAt", 58 | header: "Created At", 59 | }, 60 | { 61 | id: "actions", 62 | cell: ({ row }) => , 63 | }, 64 | ]; 65 | -------------------------------------------------------------------------------- /actions/get-graph-revenue.ts: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | 3 | interface GraphData { 4 | name: string; 5 | total: number; 6 | } 7 | 8 | export const getGraphRevenue = async (storedId: string) => { 9 | const paidOrders = await prismadb.order.findMany({ 10 | where: { 11 | storeId: storedId, 12 | isPaid: true, 13 | }, 14 | include: { 15 | orderItems: { 16 | include: { 17 | product: true, 18 | }, 19 | }, 20 | }, 21 | }); 22 | 23 | const monthlyRevenue: { [key: number]: number } = {}; 24 | 25 | for (const order of paidOrders) { 26 | const month = order.createdAt.getMonth(); 27 | let revenueForOrder = 0; 28 | 29 | for (const item of order.orderItems) { 30 | revenueForOrder += item.product.price.toNumber(); 31 | } 32 | 33 | monthlyRevenue[month] = (monthlyRevenue[month] || 0) + revenueForOrder; 34 | } 35 | 36 | const graphData: GraphData[] = [ 37 | { name: "Jan", total: 0 }, 38 | { name: "Feb", total: 0 }, 39 | { name: "Mar", total: 0 }, 40 | { name: "Apr", total: 0 }, 41 | { name: "May", total: 0 }, 42 | { name: "Jun", total: 0 }, 43 | { name: "Jul", total: 0 }, 44 | { name: "Aug", total: 0 }, 45 | { name: "Sep", total: 0 }, 46 | { name: "Oct", total: 0 }, 47 | { name: "Nov", total: 0 }, 48 | { name: "Dec", total: 0 }, 49 | ]; 50 | 51 | for (const month in monthlyRevenue) { 52 | graphData[parseInt(month)].total = monthlyRevenue[parseInt(month)]; 53 | } 54 | 55 | return graphData; 56 | }; 57 | -------------------------------------------------------------------------------- /components/ui/api-alert.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 4 | import { Badge, BadgeProps } from "@/components/ui/badge"; 5 | import { Button } from "@/components/ui/button"; 6 | import { CopyIcon, Server } from "lucide-react"; 7 | import toast from "react-hot-toast"; 8 | 9 | interface ApiAlertProps { 10 | title: string; 11 | description: string; 12 | variant: "public" | "admin"; 13 | } 14 | 15 | const textMap: Record = { 16 | public: "Public", 17 | admin: "Admin", 18 | }; 19 | 20 | const variantMap: Record = { 21 | public: "secondary", 22 | admin: "destructive", 23 | }; 24 | 25 | export const ApiAlert: React.FC = ({ 26 | title, 27 | description, 28 | variant = "public", 29 | }) => { 30 | const onCopy = () => { 31 | navigator.clipboard.writeText(description); 32 | toast.success("API Route copied to the clipboard."); 33 | }; 34 | return ( 35 | 36 | 37 | 38 | {title} 39 | {textMap[variant]} 40 | 41 | 42 | 43 | {description} 44 | 45 | 48 | 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ecommerce-admin", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "postinstall": "prisma generate" 11 | }, 12 | "dependencies": { 13 | "@clerk/nextjs": "^4.23.1", 14 | "@hookform/resolvers": "^3.1.1", 15 | "@prisma/client": "^5.0.0", 16 | "@radix-ui/react-checkbox": "^1.0.4", 17 | "@radix-ui/react-dialog": "^1.0.4", 18 | "@radix-ui/react-dropdown-menu": "^2.0.5", 19 | "@radix-ui/react-label": "^2.0.2", 20 | "@radix-ui/react-popover": "^1.0.6", 21 | "@radix-ui/react-select": "^1.2.2", 22 | "@radix-ui/react-separator": "^1.0.3", 23 | "@radix-ui/react-slot": "^1.0.2", 24 | "@tanstack/react-table": "^8.9.3", 25 | "@types/node": "20.4.5", 26 | "@types/react": "18.2.16", 27 | "@types/react-dom": "18.2.7", 28 | "autoprefixer": "10.4.14", 29 | "axios": "^1.4.0", 30 | "class-variance-authority": "^0.7.0", 31 | "clsx": "^2.0.0", 32 | "cmdk": "^0.2.0", 33 | "date-fns": "^2.30.0", 34 | "eslint": "8.45.0", 35 | "eslint-config-next": "13.4.12", 36 | "lucide-react": "^0.263.1", 37 | "next": "13.4.12", 38 | "next-cloudinary": "^4.16.3", 39 | "next-themes": "^0.2.1", 40 | "postcss": "8.4.27", 41 | "react": "18.2.0", 42 | "react-dom": "18.2.0", 43 | "react-hook-form": "^7.45.2", 44 | "react-hot-toast": "^2.4.1", 45 | "recharts": "^2.7.3", 46 | "stripe": "^13.2.0", 47 | "tailwind-merge": "^1.14.0", 48 | "tailwindcss": "3.3.3", 49 | "tailwindcss-animate": "^1.0.6", 50 | "typescript": "5.1.6", 51 | "zod": "^3.21.4", 52 | "zustand": "^4.3.9" 53 | }, 54 | "devDependencies": { 55 | "prisma": "^5.0.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /app/api/stores/[storeId]/route.ts: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { auth } from "@clerk/nextjs"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function PATCH( 6 | req: Request, 7 | { params }: { params: { storeId: string } } 8 | ) { 9 | try { 10 | const { userId } = auth(); 11 | const body = await req.json(); 12 | 13 | const { name } = body; 14 | 15 | if (!userId) { 16 | return new NextResponse("Unauthenticated", { status: 401 }); 17 | } 18 | 19 | if (!name) { 20 | return new NextResponse("Name is required", { status: 400 }); 21 | } 22 | 23 | if (!params.storeId) { 24 | return new NextResponse("Store id is required", { status: 400 }); 25 | } 26 | 27 | const store = await prismadb.store.updateMany({ 28 | where: { 29 | id: params.storeId, 30 | userId, 31 | }, 32 | data: { 33 | name, 34 | }, 35 | }); 36 | return NextResponse.json(store); 37 | } catch (error) { 38 | console.log("[STORE_PATCH]", error); 39 | return new NextResponse("Internal error", { status: 500 }); 40 | } 41 | } 42 | 43 | export async function DELETE( 44 | req: Request, 45 | { params }: { params: { storeId: string } } 46 | ) { 47 | try { 48 | const { userId } = auth(); 49 | 50 | if (!userId) { 51 | return new NextResponse("Unauthenticated", { status: 401 }); 52 | } 53 | 54 | if (!params.storeId) { 55 | return new NextResponse("Store id is required", { status: 400 }); 56 | } 57 | 58 | const store = await prismadb.store.deleteMany({ 59 | where: { 60 | id: params.storeId, 61 | userId, 62 | }, 63 | }); 64 | return NextResponse.json(store); 65 | } catch (error) { 66 | console.log("[STORE_DELETE]", error); 67 | return new NextResponse("Internal error", { status: 500 }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/api/webhook/route.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe"; 2 | import { headers } from "next/headers"; 3 | import { NextResponse } from "next/server"; 4 | import { stripe } from "@/lib/stripe"; 5 | import prismadb from "@/lib/prismadb"; 6 | 7 | export async function POST(req: Request) { 8 | const body = await req.text(); 9 | const signature = headers().get("Stripe-Signature") as string; 10 | 11 | let event: Stripe.Event; 12 | 13 | try { 14 | event = stripe.webhooks.constructEvent( 15 | body, 16 | signature, 17 | process.env.STRIPE_WEBHOOK_SECRET! 18 | ); 19 | } catch (error: any) { 20 | return new NextResponse(`Webhook Error: ${error.message}`, { status: 400 }); 21 | } 22 | 23 | const session = event.data.object as Stripe.Checkout.Session; 24 | const address = session?.customer_details?.address; 25 | 26 | const addressComponents = [ 27 | address?.line1, 28 | address?.line2, 29 | address?.city, 30 | address?.state, 31 | address?.postal_code, 32 | address?.country, 33 | ]; 34 | 35 | const addressString = addressComponents.filter((c) => c !== null).join(", "); 36 | 37 | if (event.type === "checkout.session.completed") { 38 | const order = await prismadb.order.update({ 39 | where: { 40 | id: session?.metadata?.orderId, 41 | }, 42 | data: { 43 | isPaid: true, 44 | address: addressString, 45 | phone: session?.customer_details?.phone || "", 46 | }, 47 | include: { 48 | orderItems: true, 49 | }, 50 | }); 51 | 52 | const productIds = order.orderItems.map((orderItem) => orderItem.productId); 53 | 54 | await prismadb.product.updateMany({ 55 | where: { 56 | id: { 57 | in: [...productIds], 58 | }, 59 | }, 60 | data: { 61 | isArchived: true, 62 | }, 63 | }); 64 | } 65 | 66 | return new NextResponse(null, { status: 200 }); 67 | } 68 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | :root { 8 | height: 100%; 9 | } 10 | 11 | @layer base { 12 | :root { 13 | --background: 0 0% 100%; 14 | --foreground: 222.2 84% 4.9%; 15 | 16 | --muted: 210 40% 96.1%; 17 | --muted-foreground: 215.4 16.3% 46.9%; 18 | 19 | --popover: 0 0% 100%; 20 | --popover-foreground: 222.2 84% 4.9%; 21 | 22 | --card: 0 0% 100%; 23 | --card-foreground: 222.2 84% 4.9%; 24 | 25 | --border: 214.3 31.8% 91.4%; 26 | --input: 214.3 31.8% 91.4%; 27 | 28 | --primary: 222.2 47.4% 11.2%; 29 | --primary-foreground: 210 40% 98%; 30 | 31 | --secondary: 210 40% 96.1%; 32 | --secondary-foreground: 222.2 47.4% 11.2%; 33 | 34 | --accent: 210 40% 96.1%; 35 | --accent-foreground: 222.2 47.4% 11.2%; 36 | 37 | --destructive: 0 84.2% 60.2%; 38 | --destructive-foreground: 210 40% 98%; 39 | 40 | --ring: 215 20.2% 65.1%; 41 | 42 | --radius: 0.5rem; 43 | } 44 | 45 | .dark { 46 | --background: 222.2 84% 4.9%; 47 | --foreground: 210 40% 98%; 48 | 49 | --muted: 217.2 32.6% 17.5%; 50 | --muted-foreground: 215 20.2% 65.1%; 51 | 52 | --popover: 222.2 84% 4.9%; 53 | --popover-foreground: 210 40% 98%; 54 | 55 | --card: 222.2 84% 4.9%; 56 | --card-foreground: 210 40% 98%; 57 | 58 | --border: 217.2 32.6% 17.5%; 59 | --input: 217.2 32.6% 17.5%; 60 | 61 | --primary: 210 40% 98%; 62 | --primary-foreground: 222.2 47.4% 11.2%; 63 | 64 | --secondary: 217.2 32.6% 17.5%; 65 | --secondary-foreground: 210 40% 98%; 66 | 67 | --accent: 217.2 32.6% 17.5%; 68 | --accent-foreground: 210 40% 98%; 69 | 70 | --destructive: 0 62.8% 30.6%; 71 | --destructive-foreground: 0 85.7% 97.3%; 72 | 73 | --ring: 217.2 32.6% 17.5%; 74 | } 75 | } 76 | 77 | @layer base { 78 | * { 79 | @apply border-border; 80 | } 81 | body { 82 | @apply bg-background text-foreground; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /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 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 | -------------------------------------------------------------------------------- /app/api/[storeId]/sizes/route.ts: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { auth } from "@clerk/nextjs"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function POST( 6 | req: Request, 7 | { params }: { params: { storeId: string } } 8 | ) { 9 | try { 10 | const { userId } = auth(); 11 | const body = await req.json(); 12 | 13 | const { name, value } = body; 14 | 15 | if (!userId) { 16 | return new NextResponse("Unauthenticated", { status: 401 }); 17 | } 18 | 19 | if (!name) { 20 | return new NextResponse("Name is required", { status: 400 }); 21 | } 22 | 23 | if (!value) { 24 | return new NextResponse("Value is required", { status: 400 }); 25 | } 26 | 27 | if (!params.storeId) { 28 | return new NextResponse("Store id is required", { status: 400 }); 29 | } 30 | 31 | const storeByUserId = await prismadb.store.findFirst({ 32 | where: { 33 | id: params.storeId, 34 | userId, 35 | }, 36 | }); 37 | 38 | if (!storeByUserId) { 39 | return new NextResponse("Unauthorized", { status: 403 }); 40 | } 41 | 42 | const size = await prismadb.size.create({ 43 | data: { 44 | name, 45 | value, 46 | storeId: params.storeId, 47 | }, 48 | }); 49 | 50 | return NextResponse.json(size); 51 | } catch (error) { 52 | console.log("[SIZES_POST]", error); 53 | return new NextResponse("Internal error", { status: 500 }); 54 | } 55 | } 56 | 57 | export async function GET( 58 | req: Request, 59 | { params }: { params: { storeId: string } } 60 | ) { 61 | try { 62 | if (!params.storeId) { 63 | return new NextResponse("Store id is required", { status: 400 }); 64 | } 65 | 66 | const sizes = await prismadb.size.findMany({ 67 | where: { 68 | storeId: params.storeId, 69 | }, 70 | }); 71 | 72 | return NextResponse.json(sizes); 73 | } catch (error) { 74 | console.log("[SIZES_GET]", error); 75 | return new NextResponse("Internal error", { status: 500 }); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/api/[storeId]/colors/route.ts: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { auth } from "@clerk/nextjs"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function POST( 6 | req: Request, 7 | { params }: { params: { storeId: string } } 8 | ) { 9 | try { 10 | const { userId } = auth(); 11 | const body = await req.json(); 12 | 13 | const { name, value } = body; 14 | 15 | if (!userId) { 16 | return new NextResponse("Unauthenticated", { status: 401 }); 17 | } 18 | 19 | if (!name) { 20 | return new NextResponse("Name is required", { status: 400 }); 21 | } 22 | 23 | if (!value) { 24 | return new NextResponse("Value is required", { status: 400 }); 25 | } 26 | 27 | if (!params.storeId) { 28 | return new NextResponse("Store id is required", { status: 400 }); 29 | } 30 | 31 | const storeByUserId = await prismadb.store.findFirst({ 32 | where: { 33 | id: params.storeId, 34 | userId, 35 | }, 36 | }); 37 | 38 | if (!storeByUserId) { 39 | return new NextResponse("Unauthorized", { status: 403 }); 40 | } 41 | 42 | const color = await prismadb.color.create({ 43 | data: { 44 | name, 45 | value, 46 | storeId: params.storeId, 47 | }, 48 | }); 49 | 50 | return NextResponse.json(color); 51 | } catch (error) { 52 | console.log("[COLORS_POST]", error); 53 | return new NextResponse("Internal error", { status: 500 }); 54 | } 55 | } 56 | 57 | export async function GET( 58 | req: Request, 59 | { params }: { params: { storeId: string } } 60 | ) { 61 | try { 62 | if (!params.storeId) { 63 | return new NextResponse("Store id is required", { status: 400 }); 64 | } 65 | 66 | const colors = await prismadb.color.findMany({ 67 | where: { 68 | storeId: params.storeId, 69 | }, 70 | }); 71 | 72 | return NextResponse.json(colors); 73 | } catch (error) { 74 | console.log("[COLORS_GET]", error); 75 | return new NextResponse("Internal error", { status: 500 }); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/api/[storeId]/billboards/route.ts: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { auth } from "@clerk/nextjs"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function POST( 6 | req: Request, 7 | { params }: { params: { storeId: string } } 8 | ) { 9 | try { 10 | const { userId } = auth(); 11 | const body = await req.json(); 12 | 13 | const { label, imageUrl } = body; 14 | 15 | if (!userId) { 16 | return new NextResponse("Unauthenticated", { status: 401 }); 17 | } 18 | 19 | if (!label) { 20 | return new NextResponse("Label is required", { status: 400 }); 21 | } 22 | 23 | if (!imageUrl) { 24 | return new NextResponse("Image URL is required", { status: 400 }); 25 | } 26 | 27 | if (!params.storeId) { 28 | return new NextResponse("Store id is required", { status: 400 }); 29 | } 30 | 31 | const storeByUserId = await prismadb.store.findFirst({ 32 | where: { 33 | id: params.storeId, 34 | userId, 35 | }, 36 | }); 37 | 38 | if (!storeByUserId) { 39 | return new NextResponse("Unauthorized", { status: 403 }); 40 | } 41 | 42 | const billboard = await prismadb.billboard.create({ 43 | data: { 44 | label, 45 | imageUrl, 46 | storeId: params.storeId, 47 | }, 48 | }); 49 | 50 | return NextResponse.json(billboard); 51 | } catch (error) { 52 | console.log("[BILLBOARDS_POST]", error); 53 | return new NextResponse("Internal error", { status: 500 }); 54 | } 55 | } 56 | 57 | export async function GET( 58 | req: Request, 59 | { params }: { params: { storeId: string } } 60 | ) { 61 | try { 62 | if (!params.storeId) { 63 | return new NextResponse("Store id is required", { status: 400 }); 64 | } 65 | 66 | const billboards = await prismadb.billboard.findMany({ 67 | where: { 68 | storeId: params.storeId, 69 | }, 70 | }); 71 | 72 | return NextResponse.json(billboards); 73 | } catch (error) { 74 | console.log("[BILLBOARDS_GET]", error); 75 | return new NextResponse("Internal error", { status: 500 }); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/api/[storeId]/categories/route.ts: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { auth } from "@clerk/nextjs"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function POST( 6 | req: Request, 7 | { params }: { params: { storeId: string } } 8 | ) { 9 | try { 10 | const { userId } = auth(); 11 | const body = await req.json(); 12 | 13 | const { name, billboardId } = body; 14 | 15 | if (!userId) { 16 | return new NextResponse("Unauthenticated", { status: 401 }); 17 | } 18 | 19 | if (!name) { 20 | return new NextResponse("Name is required", { status: 400 }); 21 | } 22 | 23 | if (!billboardId) { 24 | return new NextResponse("billboard id is required", { status: 400 }); 25 | } 26 | 27 | if (!params.storeId) { 28 | return new NextResponse("Store id is required", { status: 400 }); 29 | } 30 | 31 | const storeByUserId = await prismadb.store.findFirst({ 32 | where: { 33 | id: params.storeId, 34 | userId, 35 | }, 36 | }); 37 | 38 | if (!storeByUserId) { 39 | return new NextResponse("Unauthorized", { status: 403 }); 40 | } 41 | 42 | const category = await prismadb.category.create({ 43 | data: { 44 | name, 45 | billboardId, 46 | storeId: params.storeId, 47 | }, 48 | }); 49 | 50 | return NextResponse.json(category); 51 | } catch (error) { 52 | console.log("[CATEGORIES_POST]", error); 53 | return new NextResponse("Internal error", { status: 500 }); 54 | } 55 | } 56 | 57 | export async function GET( 58 | req: Request, 59 | { params }: { params: { storeId: string } } 60 | ) { 61 | try { 62 | if (!params.storeId) { 63 | return new NextResponse("Store id is required", { status: 400 }); 64 | } 65 | 66 | const categories = await prismadb.category.findMany({ 67 | where: { 68 | storeId: params.storeId, 69 | }, 70 | }); 71 | 72 | return NextResponse.json(categories); 73 | } catch (error) { 74 | console.log("[CATEGORIES_GET]", error); 75 | return new NextResponse("Internal error", { status: 500 }); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /components/main-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import Link from "next/link"; 5 | import { useParams, usePathname } from "next/navigation"; 6 | 7 | export function MainNav({ 8 | className, 9 | ...props 10 | }: React.HTMLAttributes) { 11 | const pathname = usePathname(); 12 | const params = useParams(); 13 | 14 | const routes = [ 15 | { 16 | href: `/${params.storeId}`, 17 | label: "Overview", 18 | active: pathname === `/${params.storeId}`, 19 | }, 20 | { 21 | href: `/${params.storeId}/billboards`, 22 | label: "Billboards", 23 | active: pathname === `/${params.storeId}/billboards`, 24 | }, 25 | { 26 | href: `/${params.storeId}/categories`, 27 | label: "Categories", 28 | active: pathname === `/${params.storeId}/categories`, 29 | }, 30 | { 31 | href: `/${params.storeId}/sizes`, 32 | label: "Sizes", 33 | active: pathname === `/${params.storeId}/sizes`, 34 | }, 35 | { 36 | href: `/${params.storeId}/colors`, 37 | label: "Colors", 38 | active: pathname === `/${params.storeId}/colors`, 39 | }, 40 | { 41 | href: `/${params.storeId}/products`, 42 | label: "Products", 43 | active: pathname === `/${params.storeId}/products`, 44 | }, 45 | { 46 | href: `/${params.storeId}/orders`, 47 | label: "Orders", 48 | active: pathname === `/${params.storeId}/orders`, 49 | }, 50 | { 51 | href: `/${params.storeId}/settings`, 52 | label: "Settings", 53 | active: pathname === `/${params.storeId}/settings`, 54 | }, 55 | ]; 56 | return ( 57 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /components/ui/image-upload.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { CldUploadWidget } from "next-cloudinary"; 6 | import { ImagePlus, Trash } from "lucide-react"; 7 | import Image from "next/image"; 8 | 9 | interface ImageUploadProps { 10 | disabled?: boolean; 11 | onChange: (value: string) => void; 12 | onRemove: (value: string) => void; 13 | value: string[]; 14 | } 15 | 16 | const ImageUpload: React.FC = ({ 17 | disabled, 18 | onChange, 19 | onRemove, 20 | value, 21 | }) => { 22 | const [isMounted, setIsMounted] = useState(false); 23 | 24 | useEffect(() => { 25 | setIsMounted(true); 26 | }, []); 27 | 28 | const onUpload = (result: any) => { 29 | onChange(result.info.secure_url); 30 | }; 31 | 32 | if (!isMounted) { 33 | return null; 34 | } 35 | 36 | return ( 37 |
38 |
39 | {value.map((url) => ( 40 |
44 |
45 | 53 |
54 | Image 55 |
56 | ))} 57 |
58 | 59 | {({ open }) => { 60 | const onClick = () => { 61 | open(); 62 | }; 63 | 64 | return ( 65 | 74 | ); 75 | }} 76 | 77 |
78 | ); 79 | }; 80 | 81 | export default ImageUpload; 82 | -------------------------------------------------------------------------------- /app/api/[storeId]/checkout/route.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe"; 2 | import { NextResponse } from "next/server"; 3 | import { stripe } from "@/lib/stripe"; 4 | import prismadb from "@/lib/prismadb"; 5 | 6 | const corsHeaders = { 7 | "Access-Control-Allow-Origin": "*", 8 | "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", 9 | "Access-Control-Allow-Headers": "Content-Type, Authorization", 10 | }; 11 | 12 | export async function OPTIONS() { 13 | return NextResponse.json({}, { headers: corsHeaders }); 14 | } 15 | 16 | export async function POST( 17 | req: Request, 18 | { params }: { params: { storeId: string } } 19 | ) { 20 | const { productIds } = await req.json(); 21 | 22 | if (!productIds || productIds.length === 0) { 23 | return new NextResponse("Product ids are required", { status: 400 }); 24 | } 25 | 26 | const products = await prismadb.product.findMany({ 27 | where: { 28 | id: { 29 | in: productIds, 30 | }, 31 | }, 32 | }); 33 | 34 | const line_items: Stripe.Checkout.SessionCreateParams.LineItem[] = []; 35 | 36 | products.forEach((product) => { 37 | line_items.push({ 38 | quantity: 1, 39 | price_data: { 40 | currency: "USD", 41 | product_data: { 42 | name: product.name, 43 | }, 44 | unit_amount: product.price.toNumber() * 100, 45 | }, 46 | }); 47 | }); 48 | 49 | const order = await prismadb.order.create({ 50 | data: { 51 | storeId: params.storeId, 52 | isPaid: false, 53 | orderItems: { 54 | create: productIds.map((productId: string) => ({ 55 | product: { 56 | connect: { 57 | id: productId, 58 | }, 59 | }, 60 | })), 61 | }, 62 | }, 63 | }); 64 | 65 | const session = await stripe.checkout.sessions.create({ 66 | line_items, 67 | mode: "payment", 68 | billing_address_collection: "required", 69 | phone_number_collection: { 70 | enabled: true, 71 | }, 72 | success_url: `${process.env.FRONTEND_STORE_URL}/cart?success=1`, 73 | cancel_url: `${process.env.FRONTEND_STORE_URL}/cart?canceled=1`, 74 | metadata: { 75 | orderId: order.id, 76 | }, 77 | }); 78 | 79 | return NextResponse.json({ url: session.url }, { headers: corsHeaders }); 80 | } 81 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: [ 5 | './pages/**/*.{ts,tsx}', 6 | './components/**/*.{ts,tsx}', 7 | './app/**/*.{ts,tsx}', 8 | './src/**/*.{ts,tsx}', 9 | ], 10 | theme: { 11 | container: { 12 | center: true, 13 | padding: "2rem", 14 | screens: { 15 | "2xl": "1400px", 16 | }, 17 | }, 18 | extend: { 19 | colors: { 20 | border: "hsl(var(--border))", 21 | input: "hsl(var(--input))", 22 | ring: "hsl(var(--ring))", 23 | background: "hsl(var(--background))", 24 | foreground: "hsl(var(--foreground))", 25 | primary: { 26 | DEFAULT: "hsl(var(--primary))", 27 | foreground: "hsl(var(--primary-foreground))", 28 | }, 29 | secondary: { 30 | DEFAULT: "hsl(var(--secondary))", 31 | foreground: "hsl(var(--secondary-foreground))", 32 | }, 33 | destructive: { 34 | DEFAULT: "hsl(var(--destructive))", 35 | foreground: "hsl(var(--destructive-foreground))", 36 | }, 37 | muted: { 38 | DEFAULT: "hsl(var(--muted))", 39 | foreground: "hsl(var(--muted-foreground))", 40 | }, 41 | accent: { 42 | DEFAULT: "hsl(var(--accent))", 43 | foreground: "hsl(var(--accent-foreground))", 44 | }, 45 | popover: { 46 | DEFAULT: "hsl(var(--popover))", 47 | foreground: "hsl(var(--popover-foreground))", 48 | }, 49 | card: { 50 | DEFAULT: "hsl(var(--card))", 51 | foreground: "hsl(var(--card-foreground))", 52 | }, 53 | }, 54 | borderRadius: { 55 | lg: "var(--radius)", 56 | md: "calc(var(--radius) - 2px)", 57 | sm: "calc(var(--radius) - 4px)", 58 | }, 59 | keyframes: { 60 | "accordion-down": { 61 | from: { height: 0 }, 62 | to: { height: "var(--radix-accordion-content-height)" }, 63 | }, 64 | "accordion-up": { 65 | from: { height: "var(--radix-accordion-content-height)" }, 66 | to: { height: 0 }, 67 | }, 68 | }, 69 | animation: { 70 | "accordion-down": "accordion-down 0.2s ease-out", 71 | "accordion-up": "accordion-up 0.2s ease-out", 72 | }, 73 | }, 74 | }, 75 | plugins: [require("tailwindcss-animate")], 76 | } -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/colors/components/cell-action.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuItem, 7 | DropdownMenuLabel, 8 | DropdownMenuTrigger, 9 | } from "@/components/ui/dropdown-menu"; 10 | import { ColorColumn } from "./columns"; 11 | import { Button } from "@/components/ui/button"; 12 | import { Copy, Edit, MoreHorizontal, Trash } from "lucide-react"; 13 | import toast from "react-hot-toast"; 14 | import { useParams, useRouter } from "next/navigation"; 15 | import { useState } from "react"; 16 | import axios from "axios"; 17 | import { AlertModal } from "@/components/modals/alert-modal"; 18 | 19 | interface CellActionProps { 20 | data: ColorColumn; 21 | } 22 | 23 | export const CellAction: React.FC = ({ data }) => { 24 | const router = useRouter(); 25 | const params = useParams(); 26 | const [loading, setLoading] = useState(false); 27 | const [open, setOpen] = useState(false); 28 | 29 | const onCopy = (id: string) => { 30 | navigator.clipboard.writeText(id); 31 | toast.success("Color Id copied to the clipboard."); 32 | }; 33 | 34 | // delete the color 35 | const onDelete = async () => { 36 | try { 37 | setLoading(true); 38 | await axios.delete(`/api/${params.storeId}/colors/${data.id}`); 39 | router.refresh(); 40 | toast.success("Color deleted"); 41 | } catch (error) { 42 | toast.error("Make sure you removed all products using this color first."); 43 | } finally { 44 | setLoading(false); 45 | setOpen(false); 46 | } 47 | }; 48 | 49 | return ( 50 | <> 51 | setOpen(false)} 54 | onConfirm={onDelete} 55 | loading={loading} 56 | /> 57 | 58 | 59 | 63 | 64 | 65 | Actions 66 | onCopy(data.id)}> 67 | 68 | Copy Id 69 | 70 | router.push(`/${params.storeId}/colors/${data.id}`)} 72 | > 73 | 74 | Update 75 | 76 | setOpen(true)}> 77 | 78 | Delete 79 | 80 | 81 | 82 | 83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/products/components/cell-action.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuItem, 7 | DropdownMenuLabel, 8 | DropdownMenuTrigger, 9 | } from "@/components/ui/dropdown-menu"; 10 | import { ProductColumn } from "./columns"; 11 | import { Button } from "@/components/ui/button"; 12 | import { Copy, Edit, MoreHorizontal, Trash } from "lucide-react"; 13 | import toast from "react-hot-toast"; 14 | import { useParams, useRouter } from "next/navigation"; 15 | import { useState } from "react"; 16 | import axios from "axios"; 17 | import { AlertModal } from "@/components/modals/alert-modal"; 18 | 19 | interface CellActionProps { 20 | data: ProductColumn; 21 | } 22 | 23 | export const CellAction: React.FC = ({ data }) => { 24 | const router = useRouter(); 25 | const params = useParams(); 26 | const [loading, setLoading] = useState(false); 27 | const [open, setOpen] = useState(false); 28 | 29 | const onCopy = (id: string) => { 30 | navigator.clipboard.writeText(id); 31 | toast.success("Product Id copied to the clipboard."); 32 | }; 33 | 34 | // delete the billboard 35 | const onDelete = async () => { 36 | try { 37 | setLoading(true); 38 | await axios.delete(`/api/${params.storeId}/products/${data.id}`); 39 | router.refresh(); 40 | toast.success("Product deleted"); 41 | } catch (error) { 42 | toast.error("Something went wrong."); 43 | } finally { 44 | setLoading(false); 45 | setOpen(false); 46 | } 47 | }; 48 | 49 | return ( 50 | <> 51 | setOpen(false)} 54 | onConfirm={onDelete} 55 | loading={loading} 56 | /> 57 | 58 | 59 | 63 | 64 | 65 | Actions 66 | onCopy(data.id)}> 67 | 68 | Copy Id 69 | 70 | 72 | router.push(`/${params.storeId}/products/${data.id}`) 73 | } 74 | > 75 | 76 | Update 77 | 78 | setOpen(true)}> 79 | 80 | Delete 81 | 82 | 83 | 84 | 85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/sizes/components/cell-action.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuItem, 7 | DropdownMenuLabel, 8 | DropdownMenuTrigger, 9 | } from "@/components/ui/dropdown-menu"; 10 | import { SizeColumn } from "./columns"; 11 | import { Button } from "@/components/ui/button"; 12 | import { Copy, Edit, MoreHorizontal, Trash } from "lucide-react"; 13 | import toast from "react-hot-toast"; 14 | import { useParams, useRouter } from "next/navigation"; 15 | import { useState } from "react"; 16 | import axios from "axios"; 17 | import { AlertModal } from "@/components/modals/alert-modal"; 18 | 19 | interface CellActionProps { 20 | data: SizeColumn; 21 | } 22 | 23 | export const CellAction: React.FC = ({ data }) => { 24 | const router = useRouter(); 25 | const params = useParams(); 26 | const [loading, setLoading] = useState(false); 27 | const [open, setOpen] = useState(false); 28 | 29 | const onCopy = (id: string) => { 30 | navigator.clipboard.writeText(id); 31 | toast.success("Size Id copied to the clipboard."); 32 | }; 33 | 34 | // delete the size 35 | const onDelete = async () => { 36 | try { 37 | setLoading(true); 38 | await axios.delete(`/api/${params.storeId}/sizes/${data.id}`); 39 | router.refresh(); 40 | toast.success("Size deleted"); 41 | } catch (error) { 42 | toast.error( 43 | "Make sure you removed all products using this size first." 44 | ); 45 | } finally { 46 | setLoading(false); 47 | setOpen(false); 48 | } 49 | }; 50 | 51 | return ( 52 | <> 53 | setOpen(false)} 56 | onConfirm={onDelete} 57 | loading={loading} 58 | /> 59 | 60 | 61 | 65 | 66 | 67 | Actions 68 | onCopy(data.id)}> 69 | 70 | Copy Id 71 | 72 | 74 | router.push(`/${params.storeId}/sizes/${data.id}`) 75 | } 76 | > 77 | 78 | Update 79 | 80 | setOpen(true)}> 81 | 82 | Delete 83 | 84 | 85 | 86 | 87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/categories/components/cell-action.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuItem, 7 | DropdownMenuLabel, 8 | DropdownMenuTrigger, 9 | } from "@/components/ui/dropdown-menu"; 10 | import { CategoryColumn } from "./columns"; 11 | import { Button } from "@/components/ui/button"; 12 | import { Copy, Edit, MoreHorizontal, Trash } from "lucide-react"; 13 | import toast from "react-hot-toast"; 14 | import { useParams, useRouter } from "next/navigation"; 15 | import { useState } from "react"; 16 | import axios from "axios"; 17 | import { AlertModal } from "@/components/modals/alert-modal"; 18 | 19 | interface CellActionProps { 20 | data: CategoryColumn; 21 | } 22 | 23 | export const CellAction: React.FC = ({ data }) => { 24 | const router = useRouter(); 25 | const params = useParams(); 26 | const [loading, setLoading] = useState(false); 27 | const [open, setOpen] = useState(false); 28 | 29 | const onCopy = (id: string) => { 30 | navigator.clipboard.writeText(id); 31 | toast.success("Category Id copied to the clipboard."); 32 | }; 33 | 34 | // delete the billboard 35 | const onDelete = async () => { 36 | try { 37 | setLoading(true); 38 | await axios.delete(`/api/${params.storeId}/categories/${data.id}`); 39 | router.refresh(); 40 | toast.success("Category deleted"); 41 | } catch (error) { 42 | toast.error( 43 | "Make sure you removed all products using this category first." 44 | ); 45 | } finally { 46 | setLoading(false); 47 | setOpen(false); 48 | } 49 | }; 50 | 51 | return ( 52 | <> 53 | setOpen(false)} 56 | onConfirm={onDelete} 57 | loading={loading} 58 | /> 59 | 60 | 61 | 65 | 66 | 67 | Actions 68 | onCopy(data.id)}> 69 | 70 | Copy Id 71 | 72 | 74 | router.push(`/${params.storeId}/categories/${data.id}`) 75 | } 76 | > 77 | 78 | Update 79 | 80 | setOpen(true)}> 81 | 82 | Delete 83 | 84 | 85 | 86 | 87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/billboards/components/cell-action.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuItem, 7 | DropdownMenuLabel, 8 | DropdownMenuTrigger, 9 | } from "@/components/ui/dropdown-menu"; 10 | import { BillboardColumn } from "./columns"; 11 | import { Button } from "@/components/ui/button"; 12 | import { Copy, Edit, MoreHorizontal, Trash } from "lucide-react"; 13 | import toast from "react-hot-toast"; 14 | import { useParams, useRouter } from "next/navigation"; 15 | import { useState } from "react"; 16 | import axios from "axios"; 17 | import { AlertModal } from "@/components/modals/alert-modal"; 18 | 19 | interface CellActionProps { 20 | data: BillboardColumn; 21 | } 22 | 23 | export const CellAction: React.FC = ({ data }) => { 24 | const router = useRouter(); 25 | const params = useParams(); 26 | const [loading, setLoading] = useState(false); 27 | const [open, setOpen] = useState(false); 28 | 29 | const onCopy = (id: string) => { 30 | navigator.clipboard.writeText(id); 31 | toast.success("Billboard Id copied to the clipboard."); 32 | }; 33 | 34 | // delete the billboard 35 | const onDelete = async () => { 36 | try { 37 | setLoading(true); 38 | await axios.delete(`/api/${params.storeId}/billboards/${data.id}`); 39 | router.refresh(); 40 | toast.success("Billboard deleted"); 41 | } catch (error) { 42 | toast.error( 43 | "Make sure you removed all categories using this billboard first." 44 | ); 45 | } finally { 46 | setLoading(false); 47 | setOpen(false); 48 | } 49 | }; 50 | 51 | return ( 52 | <> 53 | setOpen(false)} 56 | onConfirm={onDelete} 57 | loading={loading} 58 | /> 59 | 60 | 61 | 65 | 66 | 67 | Actions 68 | onCopy(data.id)}> 69 | 70 | Copy Id 71 | 72 | 74 | router.push(`/${params.storeId}/billboards/${data.id}`) 75 | } 76 | > 77 | 78 | Update 79 | 80 | setOpen(true)}> 81 | 82 | Delete 83 | 84 | 85 | 86 | 87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /components/modals/store-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as z from "zod"; 4 | import axios from "axios"; 5 | import { useStoreModal } from "@/hooks/use-store-modal"; 6 | import { Modal } from "@/components/ui/modal"; 7 | import { useForm } from "react-hook-form"; 8 | import { zodResolver } from "@hookform/resolvers/zod"; 9 | import { 10 | Form, 11 | FormControl, 12 | FormField, 13 | FormItem, 14 | FormLabel, 15 | FormMessage, 16 | } from "@/components/ui/form"; 17 | import { Input } from "@/components/ui/input"; 18 | import { Button } from "@/components/ui/button"; 19 | import { useState } from "react"; 20 | import toast from "react-hot-toast"; 21 | 22 | const formSchema = z.object({ 23 | name: z.string().min(1), 24 | }); 25 | 26 | export const StoreModal = () => { 27 | const storeModal = useStoreModal(); 28 | 29 | const [loading, setLoading] = useState(false); 30 | const form = useForm>({ 31 | resolver: zodResolver(formSchema), 32 | defaultValues: { 33 | name: "", 34 | }, 35 | }); 36 | 37 | const onSubmit = async (values: z.infer) => { 38 | // Create Store 39 | try { 40 | setLoading(true); 41 | 42 | const response = await axios.post("/api/stores", values); 43 | 44 | window.location.assign(`/${response.data.id}`); // this approach will refresh the whole page so that we don't need to worry about database not being ready 45 | } catch (error) { 46 | toast.error("Something went wrong."); 47 | } finally { 48 | setLoading(false); 49 | } 50 | }; 51 | 52 | return ( 53 | 59 |
60 |
61 |
62 | 63 | ( 67 | 68 | Name 69 | 70 | 75 | 76 | 77 | 78 | )} 79 | /> 80 |
81 | 88 | 91 |
92 | 93 | 94 |
95 |
96 |
97 | ); 98 | }; 99 | -------------------------------------------------------------------------------- /app/api/[storeId]/sizes/[sizeId]/route.ts: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { auth } from "@clerk/nextjs"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function GET( 6 | req: Request, 7 | { params }: { params: { sizeId: string } } 8 | ) { 9 | try { 10 | if (!params.sizeId) { 11 | return new NextResponse("Size id is required", { status: 400 }); 12 | } 13 | 14 | const size = await prismadb.size.findUnique({ 15 | where: { 16 | id: params.sizeId, 17 | }, 18 | }); 19 | return NextResponse.json(size); 20 | } catch (error) { 21 | console.log("SIZE_GET", error); 22 | return new NextResponse("Internal error", { status: 500 }); 23 | } 24 | } 25 | 26 | export async function PATCH( 27 | req: Request, 28 | { params }: { params: { sizeId: string; storeId: string } } 29 | ) { 30 | try { 31 | const { userId } = auth(); 32 | const body = await req.json(); 33 | 34 | const { name, value } = body; 35 | 36 | if (!userId) { 37 | return new NextResponse("Unauthenticated", { status: 401 }); 38 | } 39 | 40 | if (!name) { 41 | return new NextResponse("Name is required", { status: 400 }); 42 | } 43 | 44 | if (!value) { 45 | return new NextResponse("Value is required", { status: 400 }); 46 | } 47 | 48 | if (!params.sizeId) { 49 | return new NextResponse("Size id is required", { status: 400 }); 50 | } 51 | 52 | const storeByUserId = await prismadb.store.findFirst({ 53 | where: { 54 | id: params.storeId, 55 | userId, 56 | }, 57 | }); 58 | 59 | if (!storeByUserId) { 60 | return new NextResponse("Unauthorized", { status: 403 }); 61 | } 62 | 63 | const size = await prismadb.size.updateMany({ 64 | where: { 65 | id: params.sizeId, 66 | }, 67 | data: { 68 | name, 69 | value, 70 | }, 71 | }); 72 | 73 | return NextResponse.json(size); 74 | } catch (error) { 75 | console.log("[SIZE_PATCH]", error); 76 | return new NextResponse("Internal error", { status: 500 }); 77 | } 78 | } 79 | 80 | export async function DELETE( 81 | req: Request, 82 | { params }: { params: { sizeId: string; storeId: string } } 83 | ) { 84 | try { 85 | const { userId } = auth(); 86 | if (!userId) { 87 | return new NextResponse("Unauthenticated", { status: 401 }); 88 | } 89 | 90 | if (!params.sizeId) { 91 | return new NextResponse("Size id is required", { status: 400 }); 92 | } 93 | 94 | const storeByUserId = await prismadb.store.findFirst({ 95 | where: { 96 | id: params.storeId, 97 | userId, 98 | }, 99 | }); 100 | 101 | if (!storeByUserId) { 102 | return new NextResponse("Unauthorized", { status: 403 }); 103 | } 104 | 105 | const size = await prismadb.size.deleteMany({ 106 | where: { 107 | id: params.sizeId, 108 | }, 109 | }); 110 | return NextResponse.json(size); 111 | } catch (error) { 112 | console.log("SIZE_DELETE", error); 113 | return new NextResponse("Internal error", { status: 500 }); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/page.tsx: -------------------------------------------------------------------------------- 1 | import { getGraphRevenue } from "@/actions/get-graph-revenue"; 2 | import { getSalesCount } from "@/actions/get-sales-count"; 3 | import { getStockCount } from "@/actions/get-stock-count"; 4 | import { getTotalRevenue } from "@/actions/get-total-revenue"; 5 | import { Overview } from "@/components/overview"; 6 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 7 | import { Heading } from "@/components/ui/heading"; 8 | import { Separator } from "@/components/ui/separator"; 9 | import { formatter } from "@/lib/utils"; 10 | import { CreditCard, DollarSign, Package } from "lucide-react"; 11 | 12 | interface DashboardPageProps { 13 | params: { storeId: string }; 14 | } 15 | 16 | const DashboardPage: React.FC = async ({ params }) => { 17 | const totalRevenue = await getTotalRevenue(params.storeId); 18 | const salesCount = await getSalesCount(params.storeId); 19 | const stockCount = await getStockCount(params.storeId); 20 | const graphRevenue = await getGraphRevenue(params.storeId); 21 | 22 | return ( 23 |
24 |
25 | 26 | 27 |
28 | 29 | 30 | 31 | Total Revenue 32 | 33 | 34 | 35 | 36 |
37 | {formatter.format(totalRevenue)} 38 |
39 |
40 |
41 | 42 | 43 | Sales 44 | 45 | 46 | 47 |
+{salesCount}
48 |
49 |
50 | 51 | 52 | 53 | Products in Stock 54 | 55 | 56 | 57 | 58 |
{stockCount}
59 |
60 |
61 |
62 | 63 | 64 | Overview 65 | 66 | 67 | 68 | 69 | 70 |
71 |
72 | ); 73 | }; 74 | 75 | export default DashboardPage; 76 | -------------------------------------------------------------------------------- /app/api/[storeId]/colors/[colorId]/route.ts: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { auth } from "@clerk/nextjs"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function GET( 6 | req: Request, 7 | { params }: { params: { colorId: string } } 8 | ) { 9 | try { 10 | if (!params.colorId) { 11 | return new NextResponse("Color id is required", { status: 400 }); 12 | } 13 | 14 | const color = await prismadb.color.findUnique({ 15 | where: { 16 | id: params.colorId, 17 | }, 18 | }); 19 | return NextResponse.json(color); 20 | } catch (error) { 21 | console.log("COLOR_GET", error); 22 | return new NextResponse("Internal error", { status: 500 }); 23 | } 24 | } 25 | 26 | export async function PATCH( 27 | req: Request, 28 | { params }: { params: { colorId: string; storeId: string } } 29 | ) { 30 | try { 31 | const { userId } = auth(); 32 | const body = await req.json(); 33 | 34 | const { name, value } = body; 35 | 36 | if (!userId) { 37 | return new NextResponse("Unauthenticated", { status: 401 }); 38 | } 39 | 40 | if (!name) { 41 | return new NextResponse("Name is required", { status: 400 }); 42 | } 43 | 44 | if (!value) { 45 | return new NextResponse("Value is required", { status: 400 }); 46 | } 47 | 48 | if (!params.colorId) { 49 | return new NextResponse("Color id is required", { status: 400 }); 50 | } 51 | 52 | const storeByUserId = await prismadb.store.findFirst({ 53 | where: { 54 | id: params.storeId, 55 | userId, 56 | }, 57 | }); 58 | 59 | if (!storeByUserId) { 60 | return new NextResponse("Unauthorized", { status: 403 }); 61 | } 62 | 63 | const color = await prismadb.color.updateMany({ 64 | where: { 65 | id: params.colorId, 66 | }, 67 | data: { 68 | name, 69 | value, 70 | }, 71 | }); 72 | 73 | return NextResponse.json(color); 74 | } catch (error) { 75 | console.log("[COLOR_PATCH]", error); 76 | return new NextResponse("Internal error", { status: 500 }); 77 | } 78 | } 79 | 80 | export async function DELETE( 81 | req: Request, 82 | { params }: { params: { colorId: string; storeId: string } } 83 | ) { 84 | try { 85 | const { userId } = auth(); 86 | if (!userId) { 87 | return new NextResponse("Unauthenticated", { status: 401 }); 88 | } 89 | 90 | if (!params.colorId) { 91 | return new NextResponse("Color id is required", { status: 400 }); 92 | } 93 | 94 | const storeByUserId = await prismadb.store.findFirst({ 95 | where: { 96 | id: params.storeId, 97 | userId, 98 | }, 99 | }); 100 | 101 | if (!storeByUserId) { 102 | return new NextResponse("Unauthorized", { status: 403 }); 103 | } 104 | 105 | const color = await prismadb.color.deleteMany({ 106 | where: { 107 | id: params.colorId, 108 | }, 109 | }); 110 | return NextResponse.json(color); 111 | } catch (error) { 112 | console.log("COLOR_DELETE", error); 113 | return new NextResponse("Internal error", { status: 500 }); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /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 |
10 | 15 | 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 | 48 | )) 49 | TableFooter.displayName = "TableFooter" 50 | 51 | const TableRow = React.forwardRef< 52 | HTMLTableRowElement, 53 | React.HTMLAttributes 54 | >(({ className, ...props }, ref) => ( 55 | 63 | )) 64 | TableRow.displayName = "TableRow" 65 | 66 | const TableHead = React.forwardRef< 67 | HTMLTableCellElement, 68 | React.ThHTMLAttributes 69 | >(({ className, ...props }, ref) => ( 70 |
78 | )) 79 | TableHead.displayName = "TableHead" 80 | 81 | const TableCell = React.forwardRef< 82 | HTMLTableCellElement, 83 | React.TdHTMLAttributes 84 | >(({ className, ...props }, ref) => ( 85 | 90 | )) 91 | TableCell.displayName = "TableCell" 92 | 93 | const TableCaption = React.forwardRef< 94 | HTMLTableCaptionElement, 95 | React.HTMLAttributes 96 | >(({ className, ...props }, ref) => ( 97 |
102 | )) 103 | TableCaption.displayName = "TableCaption" 104 | 105 | export { 106 | Table, 107 | TableHeader, 108 | TableBody, 109 | TableFooter, 110 | TableHead, 111 | TableRow, 112 | TableCell, 113 | TableCaption, 114 | } 115 | -------------------------------------------------------------------------------- /app/api/[storeId]/billboards/[billboardId]/route.ts: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { auth } from "@clerk/nextjs"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function GET( 6 | req: Request, 7 | { params }: { params: { billboardId: string } } 8 | ) { 9 | try { 10 | if (!params.billboardId) { 11 | return new NextResponse("Billboard id is required", { status: 400 }); 12 | } 13 | 14 | const billboard = await prismadb.billboard.findUnique({ 15 | where: { 16 | id: params.billboardId, 17 | }, 18 | }); 19 | return NextResponse.json(billboard); 20 | } catch (error) { 21 | console.log("BILLBOARD_GET", error); 22 | return new NextResponse("Internal error", { status: 500 }); 23 | } 24 | } 25 | 26 | export async function PATCH( 27 | req: Request, 28 | { params }: { params: { billboardId: string; storeId: string } } 29 | ) { 30 | try { 31 | const { userId } = auth(); 32 | const body = await req.json(); 33 | 34 | const { label, imageUrl } = body; 35 | 36 | if (!userId) { 37 | return new NextResponse("Unauthenticated", { status: 401 }); 38 | } 39 | 40 | if (!label) { 41 | return new NextResponse("Label is required", { status: 400 }); 42 | } 43 | 44 | if (!imageUrl) { 45 | return new NextResponse("Image URL is required", { status: 400 }); 46 | } 47 | 48 | if (!params.billboardId) { 49 | return new NextResponse("Billboard id is required", { status: 400 }); 50 | } 51 | 52 | const storeByUserId = await prismadb.store.findFirst({ 53 | where: { 54 | id: params.storeId, 55 | userId, 56 | }, 57 | }); 58 | 59 | if (!storeByUserId) { 60 | return new NextResponse("Unauthorized", { status: 403 }); 61 | } 62 | 63 | const billboard = await prismadb.billboard.updateMany({ 64 | where: { 65 | id: params.billboardId, 66 | }, 67 | data: { 68 | label, 69 | imageUrl, 70 | }, 71 | }); 72 | 73 | return NextResponse.json(billboard); 74 | } catch (error) { 75 | console.log("[BILLBOARD_PATCH]", error); 76 | return new NextResponse("Internal error", { status: 500 }); 77 | } 78 | } 79 | 80 | export async function DELETE( 81 | req: Request, 82 | { params }: { params: { billboardId: string; storeId: string } } 83 | ) { 84 | try { 85 | const { userId } = auth(); 86 | if (!userId) { 87 | return new NextResponse("Unauthenticated", { status: 401 }); 88 | } 89 | 90 | if (!params.billboardId) { 91 | return new NextResponse("Billboard id is required", { status: 400 }); 92 | } 93 | 94 | const storeByUserId = await prismadb.store.findFirst({ 95 | where: { 96 | id: params.storeId, 97 | userId, 98 | }, 99 | }); 100 | 101 | if (!storeByUserId) { 102 | return new NextResponse("Unauthorized", { status: 403 }); 103 | } 104 | 105 | const billboard = await prismadb.billboard.deleteMany({ 106 | where: { 107 | id: params.billboardId, 108 | }, 109 | }); 110 | return NextResponse.json(billboard); 111 | } catch (error) { 112 | console.log("BILLBOARD_DELETE", error); 113 | return new NextResponse("Internal error", { status: 500 }); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /app/api/[storeId]/categories/[categoryId]/route.ts: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { auth } from "@clerk/nextjs"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function GET( 6 | req: Request, 7 | { params }: { params: { categoryId: string } } 8 | ) { 9 | try { 10 | if (!params.categoryId) { 11 | return new NextResponse("Category id is required", { status: 400 }); 12 | } 13 | 14 | const category = await prismadb.category.findUnique({ 15 | where: { 16 | id: params.categoryId, 17 | }, 18 | include: { 19 | billboard: true, 20 | }, 21 | }); 22 | return NextResponse.json(category); 23 | } catch (error) { 24 | console.log("CATEGORY_GET", error); 25 | return new NextResponse("Internal error", { status: 500 }); 26 | } 27 | } 28 | 29 | export async function PATCH( 30 | req: Request, 31 | { params }: { params: { categoryId: string; storeId: string } } 32 | ) { 33 | try { 34 | const { userId } = auth(); 35 | const body = await req.json(); 36 | 37 | const { name, billboardId } = body; 38 | 39 | if (!userId) { 40 | return new NextResponse("Unauthenticated", { status: 401 }); 41 | } 42 | 43 | if (!name) { 44 | return new NextResponse("Name is required", { status: 400 }); 45 | } 46 | 47 | if (!billboardId) { 48 | return new NextResponse("Billboard id is required", { status: 400 }); 49 | } 50 | 51 | if (!params.categoryId) { 52 | return new NextResponse("Category id is required", { status: 400 }); 53 | } 54 | 55 | const storeByUserId = await prismadb.store.findFirst({ 56 | where: { 57 | id: params.storeId, 58 | userId, 59 | }, 60 | }); 61 | 62 | if (!storeByUserId) { 63 | return new NextResponse("Unauthorized", { status: 403 }); 64 | } 65 | 66 | const category = await prismadb.category.updateMany({ 67 | where: { 68 | id: params.categoryId, 69 | }, 70 | data: { 71 | name, 72 | billboardId, 73 | }, 74 | }); 75 | 76 | return NextResponse.json(category); 77 | } catch (error) { 78 | console.log("[CATEGORY_PATCH]", error); 79 | return new NextResponse("Internal error", { status: 500 }); 80 | } 81 | } 82 | 83 | export async function DELETE( 84 | req: Request, 85 | { params }: { params: { categoryId: string; storeId: string } } 86 | ) { 87 | try { 88 | const { userId } = auth(); 89 | if (!userId) { 90 | return new NextResponse("Unauthenticated", { status: 401 }); 91 | } 92 | 93 | if (!params.categoryId) { 94 | return new NextResponse("Category id is required", { status: 400 }); 95 | } 96 | 97 | const storeByUserId = await prismadb.store.findFirst({ 98 | where: { 99 | id: params.storeId, 100 | userId, 101 | }, 102 | }); 103 | 104 | if (!storeByUserId) { 105 | return new NextResponse("Unauthorized", { status: 403 }); 106 | } 107 | 108 | const category = await prismadb.category.deleteMany({ 109 | where: { 110 | id: params.categoryId, 111 | }, 112 | }); 113 | return NextResponse.json(category); 114 | } catch (error) { 115 | console.log("CATEGORY_DELETE", error); 116 | return new NextResponse("Internal error", { status: 500 }); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /app/api/[storeId]/products/route.ts: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { auth } from "@clerk/nextjs"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function POST( 6 | req: Request, 7 | { params }: { params: { storeId: string } } 8 | ) { 9 | try { 10 | const { userId } = auth(); 11 | const body = await req.json(); 12 | 13 | const { 14 | name, 15 | price, 16 | categoryId, 17 | colorId, 18 | sizeId, 19 | images, 20 | isFeatured, 21 | isArchived, 22 | } = body; 23 | 24 | if (!userId) { 25 | return new NextResponse("Unauthenticated", { status: 401 }); 26 | } 27 | 28 | if (!name) { 29 | return new NextResponse("Name is required", { status: 400 }); 30 | } 31 | 32 | if (!price) { 33 | return new NextResponse("Price is required", { status: 400 }); 34 | } 35 | 36 | if (!categoryId) { 37 | return new NextResponse("Category id is required", { status: 400 }); 38 | } 39 | 40 | if (!colorId) { 41 | return new NextResponse("Color id is required", { status: 400 }); 42 | } 43 | 44 | if (!sizeId) { 45 | return new NextResponse("Size id is required", { status: 400 }); 46 | } 47 | 48 | if (!images || !images.length) { 49 | return new NextResponse("Images are required", { status: 400 }); 50 | } 51 | 52 | if (!params.storeId) { 53 | return new NextResponse("Store id is required", { status: 400 }); 54 | } 55 | 56 | const storeByUserId = await prismadb.store.findFirst({ 57 | where: { 58 | id: params.storeId, 59 | userId, 60 | }, 61 | }); 62 | 63 | if (!storeByUserId) { 64 | return new NextResponse("Unauthorized", { status: 403 }); 65 | } 66 | 67 | const product = await prismadb.product.create({ 68 | data: { 69 | name, 70 | price, 71 | categoryId, 72 | colorId, 73 | sizeId, 74 | images: { 75 | createMany: { 76 | data: [...images.map((image: { url: string }) => image)], 77 | }, 78 | }, 79 | isFeatured, 80 | isArchived, 81 | storeId: params.storeId, 82 | }, 83 | }); 84 | 85 | return NextResponse.json(product); 86 | } catch (error) { 87 | console.log("[PRODUCTS_POST]", error); 88 | return new NextResponse("Internal error", { status: 500 }); 89 | } 90 | } 91 | 92 | export async function GET( 93 | req: Request, 94 | { params }: { params: { storeId: string } } 95 | ) { 96 | try { 97 | const { searchParams } = new URL(req.url); 98 | const categoryId = searchParams.get("categoryId") || undefined; 99 | const colorId = searchParams.get("colorId") || undefined; 100 | const sizeId = searchParams.get("sizeId") || undefined; 101 | const isFeatured = searchParams.get("isFeatured"); 102 | 103 | if (!params.storeId) { 104 | return new NextResponse("Store id is required", { status: 400 }); 105 | } 106 | 107 | const products = await prismadb.product.findMany({ 108 | where: { 109 | storeId: params.storeId, 110 | categoryId, 111 | colorId, 112 | sizeId, 113 | isFeatured: isFeatured ? true : undefined, 114 | isArchived: false, 115 | }, 116 | include: { 117 | images: true, 118 | category: true, 119 | color: true, 120 | size: true, 121 | }, 122 | orderBy: { 123 | createdAt: "desc", 124 | }, 125 | }); 126 | 127 | return NextResponse.json(products); 128 | } catch (error) { 129 | console.log("[PRODUCTS_GET]", error); 130 | return new NextResponse("Internal error", { status: 500 }); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /components/store-switcher.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Popover, 5 | PopoverContent, 6 | PopoverTrigger, 7 | } from "@/components/ui/popover"; 8 | import { useStoreModal } from "@/hooks/use-store-modal"; 9 | import { Store } from "@prisma/client"; 10 | import { useParams, useRouter } from "next/navigation"; 11 | import { useState } from "react"; 12 | import { Button } from "@/components/ui/button"; 13 | import { 14 | Check, 15 | ChevronsUpDown, 16 | PlusCircle, 17 | Store as StoreIcon, 18 | } from "lucide-react"; 19 | import { cn } from "@/lib/utils"; 20 | import { 21 | Command, 22 | CommandEmpty, 23 | CommandGroup, 24 | CommandInput, 25 | CommandItem, 26 | CommandList, 27 | CommandSeparator, 28 | } from "@/components/ui/command"; 29 | 30 | type PopoverTriggerProps = React.ComponentPropsWithoutRef< 31 | typeof PopoverTrigger 32 | >; 33 | 34 | interface StoreSwitcherProps extends PopoverTriggerProps { 35 | items: Store[]; 36 | } 37 | 38 | export default function StoreSwitcher({ 39 | className, 40 | items = [], 41 | }: StoreSwitcherProps) { 42 | const storeModal = useStoreModal(); 43 | const params = useParams(); 44 | const router = useRouter(); 45 | const [open, setOpen] = useState(false); 46 | 47 | const formattedItems = items.map((item) => ({ 48 | label: item.name, 49 | value: item.id, 50 | })); 51 | 52 | const currenStore = formattedItems.find( 53 | (item) => item.value === params.storeId 54 | ); 55 | 56 | const onStoreSelect = (store: { value: string; label: string }) => { 57 | setOpen(false); 58 | router.push(`/${store.value}`); 59 | }; 60 | return ( 61 | 62 | 63 | 75 | 76 | 77 | 78 | 79 | 80 | No store found. 81 | 82 | {formattedItems.map((store) => ( 83 | onStoreSelect(store)} 86 | className="text-sm" 87 | > 88 | 89 | {store.label} 90 | 98 | 99 | ))} 100 | 101 | 102 | 103 | 104 | 105 | { 107 | setOpen(false); 108 | storeModal.onOpen(); 109 | }} 110 | > 111 | 112 | Create Store 113 | 114 | 115 | 116 | 117 | 118 | 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ecommerce-admin-nextjs 2 | 3 | > The demo website no longer works due to the PlanetScale retiring the free plan. 4 | 5 | Start Date: 26th July, 2023. 6 | 7 | # Description 8 | 9 | The E-Commerce Admin Dashboard with Stripe Integration is a robust web application designed to streamline and enhance the management of an e-commerce website. Developed using cutting-edge technologies like Next JS, TypeScript, Tailwind CSS, Shadcn UI, Planet Scale, Prisma, zustand, clerk auth, Cloudinary, and Stripe, this comprehensive dashboard provides administrators with a powerful toolkit to oversee and optimize various aspects of the online store. 10 | 11 | # Key Features 12 | 13 | - The admin dashboard allows the website's administrators to create, update, and delete store information. 14 | - Admins can efficiently manage the billboards or banners that appear on the website's homepage. They can upload, update, and remove promotional banners to attract customers and highlight ongoing offers or sales. 15 | - With the admin dashboard, managing product categories, sizes, and colors becomes a breeze. Administrators can add, edit, or delete categories, sizes, and colors, enabling the website to efficiently organize and display products. 16 | - To enable smooth communication between the front-end web application and the back-end, the dashboard incorporates API routes. This ensures that product information is seamlessly fetched and displayed on the customer-facing website. 17 | - The admin dashboard utilizes Clerk auth for secure authentication, ensuring that only authorized personnel can access and manage sensitive information. 18 | - With Planet Scale and Prisma integration, the application benefits from a robust and scalable database, capable of handling growing product inventories and increasing website traffic. 19 | - The heart of any e-commerce website lies in its product management capabilities. The admin dashboard empowers administrators to add new products, update existing ones, archive out-of-stock products, mark products as featured products, and remove products that are no longer available. They can set product details such as name, category, price, images, and other product-specific attributes. 20 | - Efficient order management is crucial for any e-commerce website. The dashboard provides comprehensive tools to view, process, and manage incoming orders. 21 | - Seamlessly integrate Stripe as the payment gateway, allowing customers to make secure and convenient online payments. Admins can view transaction history and ensure a seamless checkout experience for shoppers. 22 | 23 | # Testing the Project 24 | 25 | To test the project, download the zip file and open it in the editor of your choice. 26 | Then, run `npm install` in the terminal to install the dependencies. 27 | 28 | ```bash 29 | npm install 30 | ``` 31 | 32 | After installing the required dependencies, you will have to create a `.env` file at the root of your project folder. 33 | The required environment variables are as follows. 34 | 35 | ```text 36 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 37 | CLERK_SECRET_KEY= 38 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in 39 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up 40 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/ 41 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/ 42 | DATABASE_URL= 43 | NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME= 44 | STRIPE_API_KEY= 45 | FRONTEND_STORE_URL= 46 | STRIPE_WEBHOOK_SECRET= 47 | ``` 48 | 49 | Follow the steps from the official documentation for [Clerk](https://clerk.com/docs/quickstarts/nextjs), [Planet Scale](https://planetscale.com/docs/tutorials/connect-nextjs-app), [Cloudinary](https://cloudinary.com/documentation/how_to_integrate_cloudinary) and [Stripe](https://stripe.com/docs/development) to get the values for the environment variable. 50 | After completing the above steps, run `npm run dev` to test the project. 51 | 52 | ```bash 53 | npm run dev 54 | ``` 55 | 56 | If you do not want to go through all the troubles, you can always test the [demo website](https://ecommerce-admin-nextjs-mu.vercel.app/) for this project. 57 | -------------------------------------------------------------------------------- /components/ui/data-table.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Input } from "@/components/ui/input"; 5 | import { 6 | ColumnDef, 7 | ColumnFiltersState, 8 | flexRender, 9 | getCoreRowModel, 10 | getFilteredRowModel, 11 | getPaginationRowModel, 12 | useReactTable, 13 | } from "@tanstack/react-table"; 14 | 15 | import { 16 | Table, 17 | TableBody, 18 | TableCell, 19 | TableHead, 20 | TableHeader, 21 | TableRow, 22 | } from "@/components/ui/table"; 23 | import { useState } from "react"; 24 | 25 | interface DataTableProps { 26 | columns: ColumnDef[]; 27 | data: TData[]; 28 | searchKey: string; 29 | } 30 | 31 | export function DataTable({ 32 | columns, 33 | data, 34 | searchKey, 35 | }: DataTableProps) { 36 | const [columnFilters, setColumnFilters] = useState([]); 37 | 38 | const table = useReactTable({ 39 | data, 40 | columns, 41 | getCoreRowModel: getCoreRowModel(), 42 | getPaginationRowModel: getPaginationRowModel(), 43 | onColumnFiltersChange: setColumnFilters, 44 | getFilteredRowModel: getFilteredRowModel(), 45 | state: { 46 | columnFilters, 47 | }, 48 | }); 49 | 50 | return ( 51 |
52 |
53 | 57 | table.getColumn(searchKey)?.setFilterValue(event.target.value) 58 | } 59 | className="max-w-sm" 60 | /> 61 |
62 |
63 | 64 | 65 | {table.getHeaderGroups().map((headerGroup) => ( 66 | 67 | {headerGroup.headers.map((header) => { 68 | return ( 69 | 70 | {header.isPlaceholder 71 | ? null 72 | : flexRender( 73 | header.column.columnDef.header, 74 | header.getContext() 75 | )} 76 | 77 | ); 78 | })} 79 | 80 | ))} 81 | 82 | 83 | {table.getRowModel().rows?.length ? ( 84 | table.getRowModel().rows.map((row) => ( 85 | 89 | {row.getVisibleCells().map((cell) => ( 90 | 91 | {flexRender( 92 | cell.column.columnDef.cell, 93 | cell.getContext() 94 | )} 95 | 96 | ))} 97 | 98 | )) 99 | ) : ( 100 | 101 | 105 | No results. 106 | 107 | 108 | )} 109 | 110 |
111 |
112 |
113 | 121 | 129 |
130 |
131 | ); 132 | } 133 | -------------------------------------------------------------------------------- /app/(dashboard)/[storeId]/(routes)/settings/components/settings-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as z from "zod"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Heading } from "@/components/ui/heading"; 6 | import { Separator } from "@/components/ui/separator"; 7 | import { Store } from "@prisma/client"; 8 | import { Trash } from "lucide-react"; 9 | import { useForm } from "react-hook-form"; 10 | import { zodResolver } from "@hookform/resolvers/zod"; 11 | import { useState } from "react"; 12 | import { 13 | Form, 14 | FormControl, 15 | FormField, 16 | FormItem, 17 | FormLabel, 18 | FormMessage, 19 | } from "@/components/ui/form"; 20 | import { Input } from "@/components/ui/input"; 21 | import toast from "react-hot-toast"; 22 | import axios from "axios"; 23 | import { useParams, useRouter } from "next/navigation"; 24 | import { AlertModal } from "@/components/modals/alert-modal"; 25 | import { ApiAlert } from "@/components/ui/api-alert"; 26 | import { useOrigin } from "@/hooks/use-origin"; 27 | 28 | interface SettingsFormProps { 29 | initialData: Store; 30 | } 31 | 32 | const formSchema = z.object({ 33 | name: z.string().min(1), 34 | }); 35 | 36 | type settingsFormValues = z.infer; 37 | 38 | export const SettingsForm: React.FC = ({ initialData }) => { 39 | const params = useParams(); 40 | const router = useRouter(); 41 | const origin = useOrigin(); 42 | 43 | const [open, setOpen] = useState(false); 44 | const [loading, setLoading] = useState(false); 45 | 46 | const form = useForm({ 47 | resolver: zodResolver(formSchema), 48 | defaultValues: initialData, 49 | }); 50 | 51 | // update the store 52 | const onSubmit = async (data: settingsFormValues) => { 53 | try { 54 | setLoading(true); 55 | await axios.patch(`/api/stores/${params.storeId}`, data); 56 | router.refresh(); 57 | toast.success("Store updated."); 58 | } catch (error) { 59 | toast.error("Something went wrong."); 60 | } finally { 61 | setLoading(false); 62 | } 63 | }; 64 | 65 | // delete the store 66 | const onDelete = async () => { 67 | try { 68 | setLoading(true); 69 | await axios.delete(`/api/stores/${params.storeId}`); 70 | router.refresh(); 71 | router.push("/"); 72 | toast.success("Store deleted"); 73 | } catch (error) { 74 | toast.error("Make sure you removed all products and categories first."); 75 | } finally { 76 | setLoading(false); 77 | setOpen(false); 78 | } 79 | }; 80 | 81 | return ( 82 | <> 83 | setOpen(false)} 86 | onConfirm={onDelete} 87 | loading={loading} 88 | /> 89 |
90 | 91 | 99 |
100 | 101 |
102 | 106 |
107 | ( 111 | 112 | Name 113 | 114 | 119 | 120 | 121 | 122 | )} 123 | /> 124 |
125 | 128 |
129 | 130 | 131 | 136 | 137 | ); 138 | }; 139 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = ({ 14 | className, 15 | ...props 16 | }: DialogPrimitive.DialogPortalProps) => ( 17 | 18 | ) 19 | DialogPortal.displayName = DialogPrimitive.Portal.displayName 20 | 21 | const DialogOverlay = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 33 | )) 34 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 35 | 36 | const DialogContent = React.forwardRef< 37 | React.ElementRef, 38 | React.ComponentPropsWithoutRef 39 | >(({ className, children, ...props }, ref) => ( 40 | 41 | 42 | 50 | {children} 51 | 52 | 53 | Close 54 | 55 | 56 | 57 | )) 58 | DialogContent.displayName = DialogPrimitive.Content.displayName 59 | 60 | const DialogHeader = ({ 61 | className, 62 | ...props 63 | }: React.HTMLAttributes) => ( 64 |
71 | ) 72 | DialogHeader.displayName = "DialogHeader" 73 | 74 | const DialogFooter = ({ 75 | className, 76 | ...props 77 | }: React.HTMLAttributes) => ( 78 |
85 | ) 86 | DialogFooter.displayName = "DialogFooter" 87 | 88 | const DialogTitle = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 100 | )) 101 | DialogTitle.displayName = DialogPrimitive.Title.displayName 102 | 103 | const DialogDescription = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 112 | )) 113 | DialogDescription.displayName = DialogPrimitive.Description.displayName 114 | 115 | export { 116 | Dialog, 117 | DialogTrigger, 118 | DialogContent, 119 | DialogHeader, 120 | DialogFooter, 121 | DialogTitle, 122 | DialogDescription, 123 | } 124 | -------------------------------------------------------------------------------- /app/api/[storeId]/products/[productId]/route.ts: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb"; 2 | import { auth } from "@clerk/nextjs"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function GET( 6 | req: Request, 7 | { params }: { params: { productId: string } } 8 | ) { 9 | try { 10 | if (!params.productId) { 11 | return new NextResponse("Product id is required", { status: 400 }); 12 | } 13 | 14 | const product = await prismadb.product.findUnique({ 15 | where: { 16 | id: params.productId, 17 | }, 18 | include: { 19 | images: true, 20 | category: true, 21 | size: true, 22 | color: true, 23 | }, 24 | }); 25 | return NextResponse.json(product); 26 | } catch (error) { 27 | console.log("PRODUCT_GET", error); 28 | return new NextResponse("Internal error", { status: 500 }); 29 | } 30 | } 31 | 32 | export async function PATCH( 33 | req: Request, 34 | { params }: { params: { productId: string; storeId: string } } 35 | ) { 36 | try { 37 | const { userId } = auth(); 38 | const body = await req.json(); 39 | 40 | const { 41 | name, 42 | price, 43 | categoryId, 44 | colorId, 45 | sizeId, 46 | images, 47 | isFeatured, 48 | isArchived, 49 | } = body; 50 | 51 | if (!userId) { 52 | return new NextResponse("Unauthenticated", { status: 401 }); 53 | } 54 | 55 | if (!name) { 56 | return new NextResponse("Name is required", { status: 400 }); 57 | } 58 | 59 | if (!price) { 60 | return new NextResponse("Price is required", { status: 400 }); 61 | } 62 | 63 | if (!categoryId) { 64 | return new NextResponse("Category id is required", { status: 400 }); 65 | } 66 | 67 | if (!colorId) { 68 | return new NextResponse("Color id is required", { status: 400 }); 69 | } 70 | 71 | if (!sizeId) { 72 | return new NextResponse("Size id is required", { status: 400 }); 73 | } 74 | 75 | if (!images || !images.length) { 76 | return new NextResponse("Images are required", { status: 400 }); 77 | } 78 | 79 | if (!params.productId) { 80 | return new NextResponse("Product id is required", { status: 400 }); 81 | } 82 | 83 | const storeByUserId = await prismadb.store.findFirst({ 84 | where: { 85 | id: params.storeId, 86 | userId, 87 | }, 88 | }); 89 | 90 | if (!storeByUserId) { 91 | return new NextResponse("Unauthorized", { status: 403 }); 92 | } 93 | 94 | await prismadb.product.update({ 95 | where: { 96 | id: params.productId, 97 | }, 98 | data: { 99 | name, 100 | price, 101 | categoryId, 102 | colorId, 103 | sizeId, 104 | images: { 105 | deleteMany: {}, 106 | }, 107 | isFeatured, 108 | isArchived, 109 | }, 110 | }); 111 | 112 | const product = await prismadb.product.update({ 113 | where: { 114 | id: params.productId, 115 | }, 116 | data: { 117 | images: { 118 | createMany: { 119 | data: [...images.map((image: { url: string }) => image)], 120 | }, 121 | }, 122 | }, 123 | }); 124 | 125 | return NextResponse.json(product); 126 | } catch (error) { 127 | console.log("[PRODUCT_PATCH]", error); 128 | return new NextResponse("Internal error", { status: 500 }); 129 | } 130 | } 131 | 132 | export async function DELETE( 133 | req: Request, 134 | { params }: { params: { productId: string; storeId: string } } 135 | ) { 136 | try { 137 | const { userId } = auth(); 138 | if (!userId) { 139 | return new NextResponse("Unauthenticated", { status: 401 }); 140 | } 141 | 142 | if (!params.productId) { 143 | return new NextResponse("Product id is required", { status: 400 }); 144 | } 145 | 146 | const storeByUserId = await prismadb.store.findFirst({ 147 | where: { 148 | id: params.storeId, 149 | userId, 150 | }, 151 | }); 152 | 153 | if (!storeByUserId) { 154 | return new NextResponse("Unauthorized", { status: 403 }); 155 | } 156 | 157 | const product = await prismadb.product.deleteMany({ 158 | where: { 159 | id: params.productId, 160 | }, 161 | }); 162 | return NextResponse.json(product); 163 | } catch (error) { 164 | console.log("PRODUCT_DELETE", error); 165 | return new NextResponse("Internal error", { status: 500 }); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /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 = "mysql" 10 | url = env("DATABASE_URL") 11 | relationMode = "prisma" 12 | } 13 | 14 | model Store { 15 | id String @id @default(uuid()) 16 | name String 17 | userId String 18 | billboards Billboard[] @relation("StoreToBillboard") 19 | categories Category[] @relation("StoreToCategory") 20 | sizes Size[] @relation("StoreToSize") 21 | colors Color[] @relation("StoreToColor") 22 | products Product[] @relation("StoreToProduct") 23 | orders Order[] @relation("StoreToOrder") 24 | createdAt DateTime @default(now()) 25 | updatedAt DateTime @updatedAt 26 | } 27 | 28 | model Billboard { 29 | id String @id @default(uuid()) 30 | storeId String 31 | store Store @relation("StoreToBillboard", fields: [storeId], references: [id]) 32 | label String 33 | imageUrl String 34 | categories Category[] 35 | createdAt DateTime @default(now()) 36 | updatedAt DateTime @updatedAt 37 | 38 | @@index([storeId]) 39 | } 40 | 41 | model Category { 42 | id String @id @default(uuid()) 43 | storeId String 44 | store Store @relation("StoreToCategory", fields: [storeId], references: [id]) 45 | billboardId String 46 | billboard Billboard @relation(fields: [billboardId], references: [id]) 47 | products Product[] @relation("CategoryToProduct") 48 | name String 49 | createdAt DateTime @default(now()) 50 | updatedAt DateTime @updatedAt 51 | 52 | @@index([storeId]) 53 | @@index([billboardId]) 54 | } 55 | 56 | model Size { 57 | id String @id @default(uuid()) 58 | storeId String 59 | store Store @relation("StoreToSize", fields: [storeId], references: [id]) 60 | name String 61 | value String 62 | products Product[] 63 | createdAt DateTime @default(now()) 64 | updatedAt DateTime @updatedAt 65 | 66 | @@index([storeId]) 67 | } 68 | 69 | model Color { 70 | id String @id @default(uuid()) 71 | storeId String 72 | store Store @relation("StoreToColor", fields: [storeId], references: [id]) 73 | name String 74 | value String 75 | products Product[] 76 | createdAt DateTime @default(now()) 77 | updatedAt DateTime @updatedAt 78 | 79 | @@index([storeId]) 80 | } 81 | 82 | model Product { 83 | id String @id @default(uuid()) 84 | storeId String 85 | store Store @relation("StoreToProduct", fields: [storeId], references: [id]) 86 | categoryId String 87 | category Category @relation("CategoryToProduct", fields: [categoryId], references: [id]) 88 | name String 89 | price Decimal 90 | isFeatured Boolean @default(false) 91 | isArchived Boolean @default(false) 92 | sizeId String 93 | size Size @relation(fields: [sizeId], references: [id]) 94 | colorId String 95 | color Color @relation(fields: [colorId], references: [id]) 96 | images Image[] 97 | OrderItems OrderItem[] 98 | createdAt DateTime @default(now()) 99 | updatedAt DateTime @updatedAt 100 | 101 | @@index([storeId]) 102 | @@index([categoryId]) 103 | @@index([sizeId]) 104 | @@index([colorId]) 105 | } 106 | 107 | model Image { 108 | id String @id @default(uuid()) 109 | productId String 110 | product Product @relation(fields: [productId], references: [id], onDelete: Cascade) 111 | url String 112 | createdAt DateTime @default(now()) 113 | updatedAt DateTime @updatedAt 114 | 115 | @@index([productId]) 116 | } 117 | 118 | model Order { 119 | id String @id @default(uuid()) 120 | storeId String 121 | store Store @relation("StoreToOrder", fields: [storeId], references: [id]) 122 | orderItems OrderItem[] 123 | isPaid Boolean @default(false) 124 | phone String @default("") 125 | address String @default("") 126 | createdAt DateTime @default(now()) 127 | updatedAt DateTime @updatedAt 128 | 129 | @@index([storeId]) 130 | } 131 | 132 | model OrderItem { 133 | id String @id @default(uuid()) 134 | orderId String 135 | order Order @relation(fields: [orderId], references: [id]) 136 | productId String 137 | product Product @relation(fields: [productId], references: [id]) 138 | 139 | @@index([orderId]) 140 | @@index([productId]) 141 | } 142 | -------------------------------------------------------------------------------- /components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SelectPrimitive from "@radix-ui/react-select" 5 | import { Check, ChevronDown } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Select = SelectPrimitive.Root 10 | 11 | const SelectGroup = SelectPrimitive.Group 12 | 13 | const SelectValue = SelectPrimitive.Value 14 | 15 | const SelectTrigger = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, children, ...props }, ref) => ( 19 | 27 | {children} 28 | 29 | 30 | 31 | 32 | )) 33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 34 | 35 | const SelectContent = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, children, position = "popper", ...props }, ref) => ( 39 | 40 | 51 | 58 | {children} 59 | 60 | 61 | 62 | )) 63 | SelectContent.displayName = SelectPrimitive.Content.displayName 64 | 65 | const SelectLabel = React.forwardRef< 66 | React.ElementRef, 67 | React.ComponentPropsWithoutRef 68 | >(({ className, ...props }, ref) => ( 69 | 74 | )) 75 | SelectLabel.displayName = SelectPrimitive.Label.displayName 76 | 77 | const SelectItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef 80 | >(({ className, children, ...props }, ref) => ( 81 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | {children} 96 | 97 | )) 98 | SelectItem.displayName = SelectPrimitive.Item.displayName 99 | 100 | const SelectSeparator = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, ...props }, ref) => ( 104 | 109 | )) 110 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 111 | 112 | export { 113 | Select, 114 | SelectGroup, 115 | SelectValue, 116 | SelectTrigger, 117 | SelectContent, 118 | SelectLabel, 119 | SelectItem, 120 | SelectSeparator, 121 | } 122 | -------------------------------------------------------------------------------- /components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form" 12 | 13 | import { cn } from "@/lib/utils" 14 | import { Label } from "@/components/ui/label" 15 | 16 | const Form = FormProvider 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { 22 | name: TName 23 | } 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue 27 | ) 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext) 44 | const itemContext = React.useContext(FormItemContext) 45 | const { getFieldState, formState } = useFormContext() 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState) 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within ") 51 | } 52 | 53 | const { id } = itemContext 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | } 63 | } 64 | 65 | type FormItemContextValue = { 66 | id: string 67 | } 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue 71 | ) 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
82 | 83 | ) 84 | }) 85 | FormItem.displayName = "FormItem" 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField() 92 | 93 | return ( 94 |