diff --git a/README.md b/README.md index d270d69c..01ef77cb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

Full-Stack E-Commerce Platform

Built using Typescript with Next.js, Prisma ORM and TailwindCSS.

-Storefront +Storefront · Admin Panel
@@ -100,3 +100,110 @@ Follow the deployment guides for [Vercel](https://create.t3.gg/en/deployment/ver This project is MIT-licensed and is free to use and modify for your own projects. Check the [LICENSE](./LICENSE) file for details. Created by [Amirhossein Mohammadi](https://github.com/sesto-dev). + + + + +## 📘 Developer Assessment Additions (Harold) + +### Introduction +This project was developed as part of my Developer Assessment Exam. The goal of the assessment is to extend the existing next-prisma-tailwind-ecommerce application by implementing new features that enhance both the storefront and the admin panel. + +The assessment focuses on three major enhancements: + +### 1. Rebuilt Product Page Filter + +#### Objective +Allow users to filter products by text, price, category, brand, and order. + +#### Implementation Details +1) The filtering interface was redesigned to improve usability. Instead of placing all filters directly on the page—which can appear cluttered—secondary filters such as categories, brand, and price range were moved into a popover component. Users typically arrive intending to browse products immediately, so the search bar remains the primary, always-visible control. To address the drawback of hidden filters, a number badge was added to the filter button showing how many filters are currently active. + +2) New filtering inputs were implemented for Sort By, Category, Price Range, and Brand. Dispatch was used to batch state updates to prevent excessive re-fetches, particularly during text search. + +3) Pagination was added to avoid loading all products at once, improving performance and reducing layout shifts. + +#### Testing +- Filtering across multiple categories, search queries, brand selection, and minimum/maximum price was validated. +- Responsiveness was checked to ensure proper layout behavior across different screen sizes. +- The page was tested on multiple devices to confirm consistent performance and functionality. +- Product listings now update dynamically based on user selected filters without requiring a full page refresh. + +### 2. Admin Reporting Page + +#### Objective +The reporting page provides administrators with tools to view and analyze order data, including: +- Order summaries grouped by date to track sales over time. +- Top‑selling products based on overall sales volume. +- Filter options for date range, product categories, and brand. + +#### Implementation Details +1. Added filter inputs for Category, Brand, Date From, and Date To, including validation that prevents Date From being later than Date To, and vice versa. +2. Implemented a Prisma query that fetches orders based on active filters and groups them by date. +3. Added pagination to improve performance and ensure efficient retrieval of large datasets. +4. Developed a flexible top‑selling product function. The current configuration displays the top 5 products, but the limit can be easily changed. +5. Added a summary card showing total revenue and total order count, dynamically updated based on filters. +6. Designed the UI in Figma and implemented it in the Admin panel, including a copy‑to‑clipboard feature for quick access to order IDs. + +#### Testing +1. **Filter Functionality** + - Verified that filtering by Category limits results to products within the selected category. + - Confirmed that Brand filtering shows only orders containing matching products. + - Validated date range logic so Date From cannot exceed Date To. + - Confirmed combined filters (e.g., Category + Date Range) produce accurate results. + +2. **Order Grouping** + - Ensured orders are grouped correctly by date. + - Cross‑checked totals against sample seed data. + +3. **Pagination** + - Verified smooth navigation between pages. + +4. **Top‑Selling Products** + - Confirmed the Top 5 products display accurately. + - Adjusted limits (Top 3, Top 10) to validate flexibility. + +5. **Summary Cards** + - Confirmed Total Revenue calculations are accurate. + - Verified Total Order Count updates with filters. + +6. **UI & Responsiveness** + - Matched the final UI to the Figma design. + - Tested responsiveness across desktop, tablet, and mobile. + - Confirmed the order ID copy feature works reliably. + +### 3. Extended Prisma Database Model & Cross‑Sell Functionality +- Updated the Prisma `Product` model to include an optional `crossSellProducts` field enabling product‑to‑product relationships. +- Storefront now displays cross‑sell product suggestions on product detail pages. +- The cart UI has been enhanced to show dynamic cross‑sell suggestions along with improved, user‑friendly feedback and notifications. + + + +### Extend Prisma DB Model & Cross-Sell Functionality + +#### Objective +Extended the Prisma Product model by adding an optional `crossSellProducts` field to support linking related products. This allows the storefront to display cross-sell suggestions on product detail and cart pages, paired with improved cart feedback to strengthen product discovery. + +#### Implementation Details +1. **Schema Update** + - Modified the Product model to include a self-relation many-to-many field. + - Added two relational fields, `crossSellProducts` and `crossSellOf`, enabling bidirectional product linking. + +2. **Seed Update** + - Updated the seed script to include products with cross-sell relationships. + - Ensured multiple products can be linked without affecting existing product data. + +3. **API Integration** + - Implemented an API endpoint that retrieves all `crossSellProducts` for the provided product IDs. + +4. **Displaying Suggested Products** + - Added a product card carousel on the product page to show cross-sell items. + - Added another carousel in the cart to highlight related suggestions for items currently in the cart. + +5. **Frontend Enhancements** + - Displayed each product’s stock count; items marked `isAvailable = false` are shown as Out of Stock and cannot be added to the cart. + - Improved Add to Cart flow by allowing users to enter quantity before adding, reducing repeated requests. + - Added a popup on the cart icon to acknowledge successful additions. + - After adding a product, a redirect button with a badge appears, showing how many items were added. + - Added quantity validation based on available stock. + - Enhanced the cart page to allow quantity edits in a single update action rather than incremental changes. diff --git a/apps/admin/bun.lockb b/apps/admin/bun.lockb index bb505b93..45d65b3c 100644 Binary files a/apps/admin/bun.lockb and b/apps/admin/bun.lockb differ diff --git a/apps/admin/package.json b/apps/admin/package.json index 37024013..0792886a 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -16,6 +16,9 @@ "db:seed": "npx prisma db seed", "db:studio": "npx prisma studio" }, + "prisma": { + "seed": "bun run prisma/seed.ts" + }, "dependencies": { "@hookform/resolvers": "^3.9.0", "@persepolis/mail": "^1.0.9", @@ -32,10 +35,10 @@ "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", - "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", @@ -51,6 +54,7 @@ "next-themes": "^0.3.0", "nodemailer": "^6.9.15", "react": "18.3.1", + "react-day-picker": "^9.11.1", "react-dom": "18.3.1", "react-hook-form": "^7.53.0", "react-hot-toast": "^2.4.1", diff --git a/apps/admin/prisma/schema.prisma b/apps/admin/prisma/schema.prisma index 453393d4..d1070584 100644 --- a/apps/admin/prisma/schema.prisma +++ b/apps/admin/prisma/schema.prisma @@ -123,6 +123,10 @@ model Product { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + crossSellProducts Product[] @relation("CrossSell") + crossSellOf Product[] @relation("CrossSell") + + @@index([brandId]) } diff --git a/apps/admin/prisma/seed.ts b/apps/admin/prisma/seed.ts index b90aa004..183c1174 100644 --- a/apps/admin/prisma/seed.ts +++ b/apps/admin/prisma/seed.ts @@ -267,6 +267,39 @@ async function main() { console.error('Could not create products...') } + try { + console.log('Assigning random cross-sell products...') + + for (const product of createdProducts) { + // filter out the product itself + const otherProducts = createdProducts.filter( + (p) => p.id !== product.id + ) + + // choose a random number from 1 to 5 (or less if fewer products exist) + const maxCrossSells = Math.min(5, otherProducts.length) + const randomCount = Math.floor(Math.random() * maxCrossSells) + 1 + + // shuffle + slice based on randomCount + const randomCrossSells = otherProducts + .sort(() => 0.5 - Math.random()) + .slice(0, randomCount) + + await prisma.product.update({ + where: { id: product.id }, + data: { + crossSellProducts: { + connect: randomCrossSells.map((p) => ({ id: p.id })), + }, + }, + }) + } + + console.log('Random cross-sell assignment done!') + } catch (error) { + console.error('Could not assign cross-sell products...', error) + } + try { await prisma.author.create({ data: { diff --git a/apps/admin/src/app/(dashboard)/(routes)/products/[productId]/components/product-form.tsx b/apps/admin/src/app/(dashboard)/(routes)/products/[productId]/components/product-form.tsx index 967cf171..e6a0cf57 100644 --- a/apps/admin/src/app/(dashboard)/(routes)/products/[productId]/components/product-form.tsx +++ b/apps/admin/src/app/(dashboard)/(routes)/products/[productId]/components/product-form.tsx @@ -28,7 +28,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { Category } from '@prisma/client' import { Trash } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' -import { useState } from 'react' +import React, { useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'react-hot-toast' import * as z from 'zod' diff --git a/apps/admin/src/app/(dashboard)/(routes)/reports/components/chart-component.tsx b/apps/admin/src/app/(dashboard)/(routes)/reports/components/chart-component.tsx new file mode 100644 index 00000000..4f3a9ed0 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/(routes)/reports/components/chart-component.tsx @@ -0,0 +1,78 @@ +import { Card, CardContent, CardHeader } from '@/components/ui/card' +import { Separator } from '@/components/ui/separator' +import { format } from 'date-fns' +import { ChevronsRight, Coins, ScrollText } from 'lucide-react' +import React from 'react' + +export default function ChartComponent({ + orderCount = 0, + startDate, + endDate, + totalRevenue, +}: { + orderCount: number + startDate: string + endDate: string + totalRevenue: number +}) { + return ( + + {(startDate || endDate) && ( + <> + +
+

Start Date:

+

+ {startDate + ? format(new Date(startDate), 'MMM dd, yyyy') + : 'All Time'} +

+
+ +
+

End Date:

+

+ {endDate + ? format(new Date(endDate), 'MMM dd, yyyy') + : 'Present'} +

+
+
+ + + )} + + } + /> + } + /> + +
+ ) +} + +function TitleValueCard({ + title, + value, + icon, +}: { + title: string + value: string + icon: React.ReactNode +}) { + return ( +
+
+ {icon} +

{title}

+
+

{value}

+
+ ) +} diff --git a/apps/admin/src/app/(dashboard)/(routes)/reports/components/copy-to-clipboard-component.tsx b/apps/admin/src/app/(dashboard)/(routes)/reports/components/copy-to-clipboard-component.tsx new file mode 100644 index 00000000..60759294 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/(routes)/reports/components/copy-to-clipboard-component.tsx @@ -0,0 +1,18 @@ +'use client' + +import { Copy } from 'lucide-react' +import React from 'react' +import toast from 'react-hot-toast' + +export default function CopyToClipboardComponent({ text }: { text: string }) { + const handleCopy = () => { + navigator.clipboard.writeText(text) + toast.success('Copied to clipboard!') + } + return ( + + ) +} diff --git a/apps/admin/src/app/(dashboard)/(routes)/reports/components/filter-button.tsx b/apps/admin/src/app/(dashboard)/(routes)/reports/components/filter-button.tsx new file mode 100644 index 00000000..e423b2ea --- /dev/null +++ b/apps/admin/src/app/(dashboard)/(routes)/reports/components/filter-button.tsx @@ -0,0 +1,106 @@ +'use client' + +// components +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { useUpdateQueryParam } from '@/hooks/use-update-query-param' +// utils +import { Sliders } from 'lucide-react' +import { useMemo, useState } from 'react' +import toast from 'react-hot-toast' + +import { BrandCombobox, CategoriesCombobox, DateRange } from './options' + +export function FilterButton({ + brand, + category, + categories, + brands, + startDate, + endDate, +}) { + const [open, setOpen] = useState(false) + const { updateMany } = useUpdateQueryParam() + + const filterCount = useMemo(() => { + let count = 0 + if (brand) count += 1 + if (category) { + const categoryList = category.split('+').filter(Boolean) + count += categoryList.length + } + if (startDate) count += 1 + if (endDate) count += 1 + return count + }, [brand, category, startDate, endDate]) + + return ( + { + setOpen(!open) + }} + > + + + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ ) +} diff --git a/apps/admin/src/app/(dashboard)/(routes)/reports/components/options.tsx b/apps/admin/src/app/(dashboard)/(routes)/reports/components/options.tsx new file mode 100644 index 00000000..dace076f --- /dev/null +++ b/apps/admin/src/app/(dashboard)/(routes)/reports/components/options.tsx @@ -0,0 +1,310 @@ +'use client' + +// Components +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { DatePickerComponent } from '@/components/ui/date-picker' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { useDebounce } from '@/hooks/use-debounce' +import { useUpdateQueryParam } from '@/hooks/use-update-query-param' +// utils +import { cn, isVariableValid } from '@/lib/utils' +import { slugify } from '@persepolis/slugify' +import { format } from 'date-fns' +// Icons +import { Check, ChevronsUpDown } from 'lucide-react' +import React, { useEffect } from 'react' + + +export function CategoriesCombobox({ categories, initialCategory }) { + const [open, setOpen] = React.useState(false) + const [value, setValue] = React.useState([]) + const { updateMany } = useUpdateQueryParam() + + const valueDebounce = useDebounce(value, 500) + + const firstMount = React.useRef(true) + const initialValueRef = React.useRef(value) + + useEffect(() => { + if (firstMount.current) { + firstMount.current = false + initialValueRef.current = valueDebounce + return + } + + // Only update if the value has actually changed + if ( + JSON.stringify(initialValueRef.current) !== + JSON.stringify(valueDebounce) + ) { + updateMany({ + category: valueDebounce.join('+'), + page: '1', + }) + initialValueRef.current = valueDebounce + } + }, [valueDebounce, updateMany]) + + function getCategoryTitle() { + const selectedCategories = categories.filter((category) => + value.includes(slugify(category.title)) + ) + return selectedCategories.map((cat) => cat.title).join(', ') + } + + function toggleListItem(slug: string) { + let updatedList = [...value] + if (updatedList.includes(slug)) { + updatedList = updatedList.filter((item) => item !== slug) + } else { + updatedList.push(slug) + } + setValue(updatedList) + } + + useEffect(() => { + if (!initialCategory) return + + const initialSlugs = initialCategory + .split('+') + .map((title) => slugify(title)) + + setValue(initialSlugs) + }, [initialCategory]) + + return ( + + + + + + + + + No category found. + {categories.map((category) => { + const slug = slugify(category.title) + const isSelected = value.includes(slug) + return ( + { + toggleListItem(slug) + }} + className={`flex align-start content-start hover:opacity-100 cursor-pointer ${isSelected ? 'opacity-100' : 'opacity-50'}`} + > + + {category.title} + + ) + })} + + + + + ) +} + +export function BrandCombobox({ brands, initialValue }) { + const [open, setOpen] = React.useState(false) + const [value, setValue] = React.useState(initialValue) + + const { updateMany } = useUpdateQueryParam() + const debounceValue = useDebounce(value, 500) + + const firstMount = React.useRef(true) + const initialValueRef = React.useRef(value) + + useEffect(() => { + if (firstMount.current) { + firstMount.current = false + initialValueRef.current = debounceValue + return + } + + // Only update if the value has actually changed + if (initialValueRef.current !== debounceValue) { + updateMany({ + brand: debounceValue, + page: '1', + }) + initialValueRef.current = debounceValue + } + }, [debounceValue, updateMany]) + + function getBrandTitle() { + for (const brand of brands) { + if (brand.id === value) return brand.title + } + } + + return ( + <> + + + + + + + + + No brand found. + + + {brands.map((brand) => { + const isSelected = value === brand.id + return ( + { + if (value !== brand.id) { + setValue(brand.id) + } else { + setValue('') + } + + setOpen(false) + }} + className={`flex align-start content-start hover:opacity-100 cursor-pointer ${isSelected ? 'opacity-100' : 'opacity-50'}`} + > + + {brand.title} + + ) + })} + + + + + + + ) +} + +export function DateRange({ from, to }) { + const [fromValue, setFromValue] = React.useState(from) + const [toValue, setToValue] = React.useState(to) + + const { updateMany } = useUpdateQueryParam() + const debouncedFromValue = useDebounce(fromValue, 500) + const debouncedToValue = useDebounce(toValue, 500) + + const firstMountFrom = React.useRef(true) + const firstMountTo = React.useRef(true) + const initialFromRef = React.useRef(fromValue) + const initialToRef = React.useRef(toValue) + + useEffect(() => { + if (firstMountFrom.current) { + firstMountFrom.current = false + initialFromRef.current = debouncedFromValue + return + } + + if (initialFromRef.current !== debouncedFromValue) { + if (debouncedFromValue && isVariableValid(debouncedFromValue)) { + const formattedFromDate = format( + new Date(debouncedFromValue), + 'yyyy-MM-dd' + ) + updateMany({ + startDate: formattedFromDate, + page: '1', + }) + } else { + updateMany({ + startDate: '', + page: '1', + }) + } + initialFromRef.current = debouncedFromValue + } + }, [debouncedFromValue, updateMany]) + + useEffect(() => { + if (firstMountTo.current) { + firstMountTo.current = false + initialToRef.current = debouncedToValue + return + } + + if (initialToRef.current !== debouncedToValue) { + if (debouncedToValue && isVariableValid(debouncedToValue)) { + const formattedToDate = format( + new Date(debouncedToValue), + 'yyyy-MM-dd' + ) + updateMany({ + endDate: formattedToDate, + page: '1', + }) + } else { + updateMany({ + endDate: '', + page: '1', + }) + } + initialToRef.current = debouncedToValue + } + }, [debouncedToValue, updateMany]) + + return ( +
+ setFromValue(date)} + maxDate={toValue} + /> +
-
+ setToValue(date)} + minDate={fromValue} + /> +
+ ) +} diff --git a/apps/admin/src/app/(dashboard)/(routes)/reports/components/popover-item-list-component.tsx b/apps/admin/src/app/(dashboard)/(routes)/reports/components/popover-item-list-component.tsx new file mode 100644 index 00000000..e86595d7 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/(routes)/reports/components/popover-item-list-component.tsx @@ -0,0 +1,31 @@ +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import React from 'react' + +export default function PopoverItemListComponent({ items }) { + const text = items + .map((item) => `${item.count} x ${item.product.title}`) + .join(', ') + + return ( + + +

{text}

+
+ + +
    + {items.map((item, index) => ( +
  • +

    {item.product.title}

    +

    x{item.count}

    +
  • + ))} +
+
+
+ ) +} diff --git a/apps/admin/src/app/(dashboard)/(routes)/reports/components/report-component.tsx b/apps/admin/src/app/(dashboard)/(routes)/reports/components/report-component.tsx new file mode 100644 index 00000000..757c0ae2 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/(routes)/reports/components/report-component.tsx @@ -0,0 +1,147 @@ +import { Card, CardContent } from '@/components/ui/card' +import { PaginationComponent } from '@/components/ui/pagination' +import { Order } from '@/types/index' +import { format } from 'date-fns' +import { CalendarFold } from 'lucide-react' +import React from 'react' + +import CopyToClipboard from './copy-to-clipboard-component' +import PopoverItemListComponent from './popover-item-list-component' + +export default function ReportComponent({ data, pagination }) { + const groupedOrders = React.useMemo(() => { + return data.reduce( + (acc, order) => { + // Convert ISO date → YYYY-MM-DD + const date = new Date(order.createdAt).toISOString().split('T')[0] + + if (!acc[date]) { + acc[date] = [] + } + + acc[date].push(order) + return acc + }, + {} as Record + ) + }, [data]) + + return ( +
+ {Object.entries(groupedOrders).map( + ([date, orders]: [string, Order[]]) => ( +
+
+
+ +

+ {format(new Date(date), 'MMM dd, yyyy')} +

+
+
+

+ Total Order{orders.length !== 1 ? 's' : ''} :{' '} + {orders.length} +

+

+ Revenue : $ + {orders + .reduce((acc, order) => acc + order.total, 0) + .toFixed(2)} +

+
+
+ + {orders.map((order) => ( + + ))} +
+ ) + )} + +
+ ) +} + +function OrderItemComponent({ order }: { order: Order }) { + return ( + + +
+
+
+ Order # {order.number} +
+ +
+
+

ID : {order.id}

+ +
+
+ + + + +

+ {format(new Date(order.createdAt), 'hh:mm a')} +

+
+ +

${order.payable.toFixed(2)}

+
+
+
+ ) +} + +function LabelValueComponent({ + label, + children, + classname, +}: { + label: string + children: React.ReactNode + classname?: string +}) { + return ( +
+ {label} + {children} +
+ ) +} + +function ChipStatusComponent({ status }: { status: string }) { + const statusColors: Record = { + Processing: 'bg-[#FFA629]', + Shipped: 'bg-blue-500', + Delivered: 'bg-green-500', + ReturnProcessing: 'bg-yellow-600', + ReturnCompleted: 'bg-gray-500', + Cancelled: 'bg-red-500', + RefundProcessing: 'bg-orange-500', + RefundCompleted: 'bg-purple-500', + Denied: 'bg-red-700', + } + return ( + + {status} + + ) +} diff --git a/apps/admin/src/app/(dashboard)/(routes)/reports/components/status-chip.tsx b/apps/admin/src/app/(dashboard)/(routes)/reports/components/status-chip.tsx new file mode 100644 index 00000000..3d873520 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/(routes)/reports/components/status-chip.tsx @@ -0,0 +1,5 @@ +import React from 'react' + +export default function StatusChip() { + return
status-card
+} diff --git a/apps/admin/src/app/(dashboard)/(routes)/reports/components/table.tsx b/apps/admin/src/app/(dashboard)/(routes)/reports/components/table.tsx new file mode 100644 index 00000000..ae15b22b --- /dev/null +++ b/apps/admin/src/app/(dashboard)/(routes)/reports/components/table.tsx @@ -0,0 +1,105 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { DataTable } from '@/components/ui/data-table' +import { ColumnDef } from '@tanstack/react-table' +import { + CheckIcon, + ClockIcon, + DownloadCloudIcon, + EyeIcon, + XIcon, +} from 'lucide-react' +import Link from 'next/link' + + +interface ReportsTableProps { + data: ReportColumn[] +} + +export const ReportsTable: React.FC = ({ data }) => { + return +} + +export type ReportStatus = 'completed' | 'pending' | 'failed' + +export type ReportColumn = { + id: string + title: string + createdAt: string // ISO date + generatedBy: string + rows: number + sizeKb?: number + status: ReportStatus +} + +export const reportColumns: ColumnDef[] = [ + { + accessorKey: 'title', + header: 'Title', + }, + { + accessorKey: 'createdAt', + header: 'Created', + cell: (props) => { + const value = props.getValue() as string + return new Date(value).toLocaleString() + }, + }, + { + accessorKey: 'generatedBy', + header: 'Generated By', + }, + { + accessorKey: 'rows', + header: 'Rows', + }, + { + accessorKey: 'sizeKb', + header: 'Size (KB)', + cell: (props) => { + const v = props.getValue() as number | undefined + return v == null ? '-' : v.toLocaleString() + }, + }, + { + accessorKey: 'status', + header: 'Status', + cell: (props) => { + const status = props.getValue() as ReportStatus + if (status === 'completed') + return + if (status === 'pending') + return + return + }, + }, + { + id: 'actions', + cell: ({ row }) => { + const id = row.original.id + return ( +
+ + + + + + +
+ ) + }, + }, +] diff --git a/apps/admin/src/app/(dashboard)/(routes)/reports/components/top-products-component.tsx b/apps/admin/src/app/(dashboard)/(routes)/reports/components/top-products-component.tsx new file mode 100644 index 00000000..7d0637b7 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/(routes)/reports/components/top-products-component.tsx @@ -0,0 +1,45 @@ +'use client' + +import { Card, CardContent, CardHeader } from '@/components/ui/card' +import { Order, ProductInfo } from '@/types/index' +import '@/types/index' +import Image from 'next/image' +import React from 'react' + +export default function TopProductsComponent({ topProducts }) { + React.useEffect(() => { + console.log('Top Products:', topProducts) + }, [topProducts]) + return ( + + +
Top 5 Products
+
+ + {topProducts.map((product, index) => ( +
+ {index + 1}. + {product.product.images[0] && ( +
+ {product.product.title} +
+ )} +
+

{product.product.title}

+

Units Sold: {product.quantity}

+
+
+ ))} +
+
+ ) +} diff --git a/apps/admin/src/app/(dashboard)/(routes)/reports/page.tsx b/apps/admin/src/app/(dashboard)/(routes)/reports/page.tsx new file mode 100644 index 00000000..10835bbc --- /dev/null +++ b/apps/admin/src/app/(dashboard)/(routes)/reports/page.tsx @@ -0,0 +1,264 @@ +import { Heading } from '@/components/ui/heading' +import { PaginationComponent } from '@/components/ui/pagination' +import { Separator } from '@/components/ui/separator' +import prisma from '@/lib/prisma' +import React from 'react' + +import ChartComponent from './components/chart-component' +import { FilterButton } from './components/filter-button' +import ReportComponent from './components/report-component' +import TopProductsComponent from './components/top-products-component' + +export default async function page({ searchParams }) { + const { startDate, endDate, category, brand, page } = searchParams ?? null + + const brandList = await prisma.brand.findMany({ + include: { + products: true, + }, + }) + + const categoriesLists = await prisma.category.findMany({ + include: { + products: true, + }, + }) + + const { orders, pagination, totalRevenue } = await getPaginatedOrders({ + brand, + category, + startDate, + endDate, + page: page ? parseInt(page) : 1, + }) + + const topProducts = await getTopProducts({ + brand, + category, + startDate, + endDate, + topN: 5, + }) + + return ( +
+
+ + +
+ +
+ + +
+ + +
+
+
+ ) +} + +/** + * @description Fetches paginated orders from the database based on optional filters such as brand, category, and date range. + * @param {string} [params.brand] - Optional brand ID to filter orders. + * @param {string} [params.category] - Optional category string (can include multiple categories separated by '+') to filter orders. + * @param {string} [params.startDate] - Optional start date for the date range filter. + * @param {string} [params.endDate] - Optional end date for the date range filter. + * @param {number} [params.page=1] - The page number for pagination. + * @param {number} [params.pageSize=2] - The number of orders to fetch per page. + * @example + * getPaginatedOrders({ + * brand: 'brandId123', + * category: 'Electronics+Gadgets', + * startDate: '2024-01-01', + * endDate: '2024-01-31', + * page: 2, + * pageSize: 10 + * }) + * @author Harold + * @returns {Promise<{ orders: Order[]; pagination: { totalCount: number; totalPages: number; currentPage: number; pageSize: number } }>} - An object containing the paginated orders and pagination details. + */ +export async function getPaginatedOrders({ + brand, + category, + startDate, + endDate, + page = 1, + pageSize = 8, +}: { + brand?: string + category?: string + startDate?: string + endDate?: string + page?: number + pageSize?: number +}) { + const where: any = { + ...(brand || category + ? { + orderItems: { + some: { + product: { + ...(brand && { brand: { id: brand } }), + ...(category && { + categories: { + some: { + OR: category.split('+').map((c) => ({ + title: { + contains: c, + mode: 'insensitive' as const, + }, + })), + }, + }, + }), + }, + }, + }, + } + : {}), + ...(startDate || endDate + ? { + createdAt: { + ...(startDate && { gte: new Date(startDate) }), + ...(endDate && { lte: new Date(endDate) }), + }, + } + : {}), + } + + // Count orders for pagination + const totalCount = await prisma.order.count({ where }) + const totalPages = Math.ceil(totalCount / pageSize) + + // Fetch paginated orders + const orders = await prisma.order.findMany({ + where, + include: { + orderItems: { + include: { + product: { include: { categories: true } }, + }, + }, + user: true, + }, + skip: (page - 1) * pageSize, + take: pageSize, + }) + + // Compute revenue using payable (final amount customer paid) + const revenueOrders = await prisma.order.findMany({ + where, + select: { payable: true }, + }) + + const totalRevenue = revenueOrders.reduce( + (sum, o) => sum + (o.payable ?? 0), + 0 + ) + + return { + orders, + pagination: { + totalCount, + totalPages, + currentPage: page, + pageSize, + }, + totalRevenue, + } +} + +/** + * @description Fetches the top N products based on total quantity sold within a specified date range and optional brand/category filters. + * @param {string} [params.brand] - Optional brand ID to filter products. + * @param {string} [params.category] - Optional category string (can include multiple categories separated by '+') to filter products. + * @param {string} [params.startDate] - Optional start date for the date range filter. + * @param {string} [params.endDate] - Optional end date for the date range filter. + * @param {number} [params.topN=5] - The number of top products to retrieve. + * + * @example + * getTopProducts({ + * brand: 'brandId123', + * category: 'Electronics+Gadgets', + * startDate: '2024-01-01', + * endDate: '2024-01-31', + * topN: 10 + * }) + * + * @author Harold + * @returns {Promise>} - An array of top products with their total quantities sold. + */ +export async function getTopProducts({ + brand, + category, + startDate, + endDate, + topN = 5, +}) { + const where = { + order: { + is: { + createdAt: { + gte: startDate ? new Date(startDate) : undefined, + lte: endDate ? new Date(endDate) : undefined, + }, + }, + }, + product: { + brand: brand ? { id: brand } : undefined, + categories: category + ? { + some: { + OR: category.split('+').map((c) => ({ + title: { + contains: c, + mode: 'insensitive' as const, + }, + })), + }, + } + : undefined, + }, + } + + // Fetch order items based on filters + const items = await prisma.orderItem.findMany({ + where, + include: { + product: { + include: { categories: true, brand: true }, + }, + }, + }) + + const totals: Record = {} + + // Calculate total quantities + for (const item of items) { + if (!totals[item.productId]) { + totals[item.productId] = { quantity: 0, product: item.product } + } + totals[item.productId].quantity += item.count + } + + return Object.values(totals) + .sort((a, b) => b.quantity - a.quantity) + .slice(0, topN) +} diff --git a/apps/admin/src/app/(dashboard)/layout.tsx b/apps/admin/src/app/(dashboard)/layout.tsx index 6a649877..75be6496 100644 --- a/apps/admin/src/app/(dashboard)/layout.tsx +++ b/apps/admin/src/app/(dashboard)/layout.tsx @@ -6,11 +6,11 @@ export default async function DashboardLayout({ children: React.ReactNode }) { return ( - <> +
-
+
{children}
- +
) } diff --git a/apps/admin/src/app/api/auth/otp/email/try/route.ts b/apps/admin/src/app/api/auth/otp/email/login/route.ts similarity index 75% rename from apps/admin/src/app/api/auth/otp/email/try/route.ts rename to apps/admin/src/app/api/auth/otp/email/login/route.ts index 6b32f102..83887a0f 100644 --- a/apps/admin/src/app/api/auth/otp/email/try/route.ts +++ b/apps/admin/src/app/api/auth/otp/email/login/route.ts @@ -16,12 +16,26 @@ export async function POST(req: NextRequest) { const { email } = await req.json() if (isEmailValid(email)) { - await prisma.owner.update({ - where: { email }, - data: { - OTP, + const existingUser = await prisma.owner.findUnique({ + where: { + email, }, }) + if (existingUser) { + await prisma.owner.update({ + where: { email }, + data: { + OTP, + }, + }) + } else { + await prisma.owner.create({ + data: { + email, + OTP, + }, + }) + } await sendMail({ name: config.name, diff --git a/apps/admin/src/app/api/brands/[brandId]/route.ts b/apps/admin/src/app/api/brands/[brandId]/route.ts index f10f3a8f..1e56452c 100644 --- a/apps/admin/src/app/api/brands/[brandId]/route.ts +++ b/apps/admin/src/app/api/brands/[brandId]/route.ts @@ -71,7 +71,6 @@ export async function PATCH( const body = await req.json() const { title, description } = body - console.log('PATCH', body) if (!title && !description) { return new NextResponse( diff --git a/apps/admin/src/app/globals.css b/apps/admin/src/app/globals.css index a2ad806d..719d4d3e 100644 --- a/apps/admin/src/app/globals.css +++ b/apps/admin/src/app/globals.css @@ -3,137 +3,90 @@ @tailwind utilities; @layer base { - :root { - --background: - 0 0% 100%; - --foreground: - 0 0% 3.9%; - --card: - 0 0% 100%; - --card-foreground: - 0 0% 3.9%; - --popover: - 0 0% 100%; - --popover-foreground: - 0 0% 3.9%; - --primary: - 0 0% 9%; - --primary-foreground: - 0 0% 98%; - --secondary: - 0 0% 96.1%; - --secondary-foreground: - 0 0% 9%; - --muted: - 0 0% 96.1%; - --muted-foreground: - 0 0% 45.1%; - --accent: - 0 0% 96.1%; - --accent-foreground: - 0 0% 9%; - --destructive: - 0 84.2% 60.2%; - --destructive-foreground: - 0 0% 98%; - --border: - 0 0% 89.8%; - --input: - 0 0% 89.8%; - --ring: - 0 0% 3.9%; - --radius: - 0.5rem; - --chart-1: - 12 76% 61%; - --chart-2: - 173 58% 39%; - --chart-3: - 197 37% 24%; - --chart-4: - 43 74% 66%; - --chart-5: - 27 87% 67%; - } + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-primary: 0 0% 13%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --radius: 0.5rem; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + } - .dark { - --background: - 0 0% 3.9%; - --foreground: - 0 0% 98%; - --card: - 0 0% 3.9%; - --card-foreground: - 0 0% 98%; - --popover: - 0 0% 3.9%; - --popover-foreground: - 0 0% 98%; - --primary: - 0 0% 98%; - --primary-foreground: - 0 0% 9%; - --secondary: - 0 0% 14.9%; - --secondary-foreground: - 0 0% 98%; - --muted: - 0 0% 14.9%; - --muted-foreground: - 0 0% 63.9%; - --accent: - 0 0% 14.9%; - --accent-foreground: - 0 0% 98%; - --destructive: - 0 62.8% 30.6%; - --destructive-foreground: - 0 0% 98%; - --border: - 0 0% 14.9%; - --input: - 0 0% 14.9%; - --ring: - 0 0% 83.1%; - --chart-1: - 220 70% 50%; - --chart-2: - 160 60% 45%; - --chart-3: - 30 80% 55%; - --chart-4: - 280 65% 60%; - --chart-5: - 340 75% 55%; - } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-primary: 0 0% 14.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } } @layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - font-feature-settings: - "rlig" 1, - "calt" 1; - } + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + font-feature-settings: + 'rlig' 1, + 'calt' 1; + } } @layer utilities { - .step { - counter-increment: step; - } + .step { + counter-increment: step; + } - .step:before { - @apply absolute w-9 h-9 bg-muted rounded-full font-mono font-medium text-center text-base inline-flex items-center justify-center -indent-px border-4 border-background; - @apply ml-[-50px] mt-[-4px]; - content: counter(step); - } + .step:before { + @apply bg-muted border-background absolute inline-flex h-9 w-9 items-center justify-center rounded-full border-4 text-center -indent-px font-mono text-base font-medium; + @apply ml-[-50px] mt-[-4px]; + content: counter(step); + } } @media (max-width: 640px) { - .container { - @apply px-4; - } + .container { + @apply px-4; + } } diff --git a/apps/admin/src/app/login/components/user-auth-form.tsx b/apps/admin/src/app/login/components/user-auth-form.tsx index 96bd55be..dc4d1184 100644 --- a/apps/admin/src/app/login/components/user-auth-form.tsx +++ b/apps/admin/src/app/login/components/user-auth-form.tsx @@ -121,14 +121,7 @@ function TryComponents({ isLoading, setIsLoading, setFetchedOTP }) { async function onSubmitEmail() { try { setIsLoading(true) - - if (!process.env.JWT_SECRET_KEY) { - console.error('JWT secret key is missing') - setIsLoading(false) - return - } - - const response = await fetch('/api/auth/otp/email/try', { + const response = await fetch('/api/auth/otp/email/login', { method: 'POST', body: JSON.stringify({ email }), cache: 'no-store', diff --git a/apps/admin/src/components/main-nav.tsx b/apps/admin/src/components/main-nav.tsx index bd87cb6e..70a7658e 100644 --- a/apps/admin/src/components/main-nav.tsx +++ b/apps/admin/src/components/main-nav.tsx @@ -31,6 +31,11 @@ export function MainNav({ label: 'Orders', active: pathname.includes(`/orders`), }, + { + href: `/reports`, + label: 'Reports', + active: pathname.includes(`/reports`), + }, { href: `/payments`, label: 'Payments', diff --git a/apps/admin/src/components/navbar.tsx b/apps/admin/src/components/navbar.tsx index 3ae472e6..fddb4da3 100644 --- a/apps/admin/src/components/navbar.tsx +++ b/apps/admin/src/components/navbar.tsx @@ -11,7 +11,7 @@ export default async function Navbar() { ADMIN - +
diff --git a/apps/admin/src/components/theme-toggle.tsx b/apps/admin/src/components/theme-toggle.tsx index dc1ffdc7..64e1ac72 100644 --- a/apps/admin/src/components/theme-toggle.tsx +++ b/apps/admin/src/components/theme-toggle.tsx @@ -7,17 +7,30 @@ import * as React from 'react' export function ThemeToggle() { const { resolvedTheme, setTheme } = useTheme() + const [mounted, setMounted] = React.useState(false) + + React.useEffect(() => { + setMounted(true) + }, []) + + const isDark = resolvedTheme === 'dark' return ( ) diff --git a/apps/admin/src/components/ui/button.tsx b/apps/admin/src/components/ui/button.tsx index ac8e0c9a..65d4fcd9 100644 --- a/apps/admin/src/components/ui/button.tsx +++ b/apps/admin/src/components/ui/button.tsx @@ -5,25 +5,26 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "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", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90", + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { - default: "h-10 px-4 py-2", - sm: "h-9 rounded-md px-3", - lg: "h-11 rounded-md px-8", - icon: "h-10 w-10", + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", }, }, defaultVariants: { diff --git a/apps/admin/src/components/ui/calendar.tsx b/apps/admin/src/components/ui/calendar.tsx new file mode 100644 index 00000000..a623682d --- /dev/null +++ b/apps/admin/src/components/ui/calendar.tsx @@ -0,0 +1,213 @@ +"use client" + +import * as React from "react" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" +import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "bg-popover absolute inset-0 opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" + ? "text-sm" + : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-[--cell-size] select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-muted-foreground select-none text-[0.8rem]", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md", + defaultClassNames.day + ), + range_start: cn( + "bg-accent rounded-l-md", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end), + today: cn( + "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + + + + { + if (!d) { + onChange?.(undefined) + return + } + if (!isWithinRange(d, min, max)) return + + onChange?.(d) + setOpen(false) + }} + /> + + +
+
+ ) +} diff --git a/apps/admin/src/components/ui/pagination.tsx b/apps/admin/src/components/ui/pagination.tsx new file mode 100644 index 00000000..eff7f690 --- /dev/null +++ b/apps/admin/src/components/ui/pagination.tsx @@ -0,0 +1,192 @@ +'use client' + +import { ButtonProps, buttonVariants } from '@/components/ui/button' +import { useUpdateQueryParam } from '@/hooks/use-update-query-param' +import { cn } from '@/lib/utils' +import { + ChevronLeftIcon, + ChevronRightIcon, + DotsHorizontalIcon, +} from '@radix-ui/react-icons' +import * as React from 'react' + +const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => ( +