diff --git a/README.md b/README.md index d270d69c..07006868 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ You can [read more about environment variables here](https://nextjs.org/docs/bas Clone the repository. ```bash -git clone https://github.com/sesto-dev/next-prisma-tailwind-ecommerce +git clone https://github.com/psyitama/next-prisma-tailwind-ecommerce.git ``` Navigate to each folder in the `apps` folder and and set the variables. @@ -75,7 +75,24 @@ Bring your database to life with pushing the database schema. bun run db:push ``` -```sh +Run the projects + +Storefront + +```bash +cd apps/storefront +``` + +```bash +bun run dev +``` +Admin + +```bash +cd apps/admin +``` + +```bash bun run dev ``` @@ -91,12 +108,59 @@ This project exposes a package.json script for accessing prisma via `bun run db: Make changes to your database by modifying `prisma/schema.prisma`. -## 🛸 How to Deploy the Project - -Follow the deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information. - -## 📄 License - -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). +## 1️⃣ Rebuild product filters on the storefront page +- Created `ProductSearchInput` with an `onChange` listener to search for products. Applied a debounce feature to prevent concurrent requests while the user has not finalized the keyword. +- Created `PriceInputFields` to filter the minimum and maximum price of the product list. Also created a corresponding button titled "Apply" to trigger the filtering. +- Noticed that the current `Categories` and `Brand` combo boxes are not fully functional. To fix this, I rewrote the UI component using the `Shadcn` documentation as reference. +- Added filter options to the `SortBy` filter to handle sorting of product titles in ascending and descending order. +- Updated the Prisma query to handle `search`, `minPrice`, `maxPrice`, `sort`, `isAvailable`, `brand`, and `category` search parameters. +- Ensured that the product data updates dynamically based on all selected filter options, without reloading the page. +- Commented out `AvailableToggle` as it was not included in part 1 of the assessment. + +## 2️⃣ Build an admin reports page with charts or tables +- Tried checking the login and encountered a bug where the `JWT_SECRET_KEY` was being checked on the login page, which is the `UserAuthForm` component rendered on the client (CSR). Removed that line of code and retried logging in, which redirected me to the OTP verification. +- Checked the OTP verification and found out I needed to set up an SMTP account in Google. Used an App Password to fill `MAIL_SMTP_SERVICE`, `MAIL_SMTP_PASS`, and `MAIL_SMTP_USER`. After that, I created a row in the Owner table using my personal email address to receive the OTP code. +- Created a Reports Page by adding `admin/reports` folders in the `(dashboard)/(routes)` path. +- After creating the ReportsPage, I added the link `main-nav /admin/report`. +- Added `'/admin/:path*'` in the `middleware.ts` config to prevent unauthorized access for users without administrator capabilities. +- Also noticed the same issue in the Storefront where the `Categories` and `Brand` combo boxes are not fully functional. To fix it, I rewrote `command.tsx` using the updated version from `Shadcn`. +- **Reports Overview - Orders: Line Chart** + - Display the order count grouped by date for visualization that the admin can use in reports. + - Prisma: To fetch the needed data, I used the `Order` table, grouping by `createdAt` while counting the IDs. + - Displaying: Looped through the results and formatted the date as `'yyyy-MM-dd'` for the chart. +- **Reports Overview - Top Selling Products: Table** + - Display products with the highest sales first, or by order count. Products with no sales are not listed. + - Prisma: To fetch the needed data, I used the `Product` table including the `Order` table and counted the order IDs for the Sales number. + - Displaying: Looped through the results and formatted them according to the table’s requirements. +- Made sure that any changes in the `DateRangePicker`, `Brand`, and `Category` combo box filter options dynamically update the Report Overview Products and Order data without reloading the page. + +## 3️⃣ Extend the Product model for cross-sell recommendations +- **3.1: Update Prisma DB Model to Support Cross-Sell Products** + + - Updated the Prisma migration to handle cross-related products by adding the necessary fields to the Product table. + +```prisma +crossSellProducts Product[] @relation("CrossSellRelation") +crossSellOf Product[] @relation("CrossSellRelation") +``` +- Run the updated migration using the following command: +```bash +npx prisma migrate dev --name add_cross_sell_products +``` +- Populated records by creating a `seed.ts` file and adding the script in `package.json`. This script seeds the database by linking existing products to their related cross-sell products using Prisma’s connect relation. +```package.json + "prisma": { + "seed": "tsx prisma/seed.ts" + } +``` +- Started the populating of `crossSellProducts` by running this command. +```bash +npx prisma db seed +``` +- **3.2: Enhance Frontend for Cross-Sell Products and Improved Cart +Feedback** +- I usually noticed on well-known e-commerce websites that related or cross-sell products are displayed at the bottom of the page. +- Implemented a toast notification after successfully adding a product to the cart using the `react-hot-toast` package. +- Created a `RelatedProducts` component to display the cross-related products of the selected product. +- Updated the Prisma query and used the `RelatedProducts` component to display the cross-related products below the Product Details and Cart page. +- While testing the adding/removing of quantity on the Cart page, I encountered and fixed a bug where, regardless of which product’s quantity was changed (first, second, or third), the last product in the list was always removed. diff --git a/apps/admin/.gitignore b/apps/admin/.gitignore index 4c629850..0f3f9ab5 100644 --- a/apps/admin/.gitignore +++ b/apps/admin/.gitignore @@ -1,2 +1,5 @@ /.next -/node_modules \ No newline at end of file +/node_modules + +package-lock.json +bun.lockb \ No newline at end of file diff --git a/apps/admin/bun.lockb b/apps/admin/bun.lockb deleted file mode 100644 index bb505b93..00000000 Binary files a/apps/admin/bun.lockb and /dev/null differ diff --git a/apps/admin/package.json b/apps/admin/package.json index 37024013..79c512d1 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -39,6 +39,8 @@ "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", + "@react-email/components": "^0.5.1", + "@react-email/render": "^1.2.1", "@tanstack/react-table": "^8.20.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -51,16 +53,19 @@ "next-themes": "^0.3.0", "nodemailer": "^6.9.15", "react": "18.3.1", + "react-day-picker": "^9.9.0", "react-dom": "18.3.1", "react-hook-form": "^7.53.0", "react-hot-toast": "^2.4.1", "recharts": "^2.13.0", + "ts-node": "^10.9.2", + "tsx": "^4.20.5", "zod": "^3.23.8", "zustand": "^4.5.5" }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^4.3.0", - "@types/node": "22.7.5", + "@types/node": "^24.3.0", "@types/react": "18.3.11", "@types/react-dom": "18.3.1", "autoprefixer": "10.4.20", @@ -73,6 +78,9 @@ "tailwind-merge": "^2.5.3", "tailwindcss": "3.4.13", "tailwindcss-animate": "^1.0.7", - "typescript": "5.6.3" + "typescript": "^5.9.2" + }, + "prisma": { + "seed": "tsx prisma/seed.ts" } } diff --git a/apps/admin/prisma/migrations/20250823171322_init/migration.sql b/apps/admin/prisma/migrations/20250823171322_init/migration.sql new file mode 100644 index 00000000..c0719bd1 --- /dev/null +++ b/apps/admin/prisma/migrations/20250823171322_init/migration.sql @@ -0,0 +1,502 @@ +-- CreateEnum +CREATE TYPE "OrderStatusEnum" AS ENUM ('Processing', 'Shipped', 'Delivered', 'ReturnProcessing', 'ReturnCompleted', 'Cancelled', 'RefundProcessing', 'RefundCompleted', 'Denied'); + +-- CreateEnum +CREATE TYPE "PaymentStatusEnum" AS ENUM ('Processing', 'Paid', 'Failed', 'Denied'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT, + "phone" TEXT, + "name" TEXT, + "birthday" TEXT, + "OTP" TEXT, + "emailUnsubscribeToken" TEXT, + "referralCode" TEXT, + "isBanned" BOOLEAN NOT NULL DEFAULT false, + "isEmailVerified" BOOLEAN NOT NULL DEFAULT false, + "isPhoneVerified" BOOLEAN NOT NULL DEFAULT false, + "isEmailSubscribed" BOOLEAN NOT NULL DEFAULT false, + "isPhoneSubscribed" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Cart" ( + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Cart_pkey" PRIMARY KEY ("userId") +); + +-- CreateTable +CREATE TABLE "CartItem" ( + "cartId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "count" INTEGER NOT NULL +); + +-- CreateTable +CREATE TABLE "Owner" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "phone" TEXT, + "name" TEXT, + "avatar" TEXT, + "OTP" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Owner_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Author" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "phone" TEXT, + "name" TEXT, + "avatar" TEXT, + "OTP" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Author_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Brand" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "logo" TEXT, + + CONSTRAINT "Brand_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Product" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "images" TEXT[], + "keywords" TEXT[], + "metadata" JSONB, + "price" DOUBLE PRECISION NOT NULL DEFAULT 100, + "discount" DOUBLE PRECISION NOT NULL DEFAULT 0, + "stock" INTEGER NOT NULL DEFAULT 0, + "isPhysical" BOOLEAN NOT NULL DEFAULT true, + "isAvailable" BOOLEAN NOT NULL DEFAULT false, + "isFeatured" BOOLEAN NOT NULL DEFAULT false, + "brandId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Product_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Category" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Category_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProductReview" ( + "id" TEXT NOT NULL, + "text" TEXT NOT NULL, + "rating" INTEGER NOT NULL, + "productId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProductReview_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Order" ( + "id" TEXT NOT NULL, + "number" SERIAL NOT NULL, + "status" "OrderStatusEnum" NOT NULL, + "total" DOUBLE PRECISION NOT NULL DEFAULT 100, + "shipping" DOUBLE PRECISION NOT NULL DEFAULT 100, + "payable" DOUBLE PRECISION NOT NULL DEFAULT 100, + "tax" DOUBLE PRECISION NOT NULL DEFAULT 100, + "discount" DOUBLE PRECISION NOT NULL DEFAULT 0, + "isPaid" BOOLEAN NOT NULL DEFAULT false, + "isCompleted" BOOLEAN NOT NULL DEFAULT false, + "discountCodeId" TEXT, + "addressId" TEXT, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Order_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OrderItem" ( + "orderId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "count" INTEGER NOT NULL, + "price" DOUBLE PRECISION NOT NULL, + "discount" DOUBLE PRECISION NOT NULL +); + +-- CreateTable +CREATE TABLE "Address" ( + "id" TEXT NOT NULL, + "country" TEXT NOT NULL DEFAULT 'IRI', + "address" TEXT NOT NULL, + "city" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "postalCode" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Address_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Notification" ( + "id" TEXT NOT NULL, + "content" TEXT NOT NULL, + "isRead" BOOLEAN NOT NULL DEFAULT false, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DiscountCode" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "stock" INTEGER NOT NULL DEFAULT 1, + "description" TEXT, + "percent" INTEGER NOT NULL, + "maxDiscountAmount" DOUBLE PRECISION NOT NULL DEFAULT 1, + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DiscountCode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Refund" ( + "id" TEXT NOT NULL, + "amount" DOUBLE PRECISION NOT NULL, + "reason" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Refund_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Payment" ( + "id" TEXT NOT NULL, + "number" SERIAL NOT NULL, + "status" "PaymentStatusEnum" NOT NULL, + "refId" TEXT NOT NULL, + "cardPan" TEXT, + "cardHash" TEXT, + "fee" DOUBLE PRECISION, + "isSuccessful" BOOLEAN NOT NULL DEFAULT false, + "payable" DOUBLE PRECISION NOT NULL, + "providerId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Payment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PaymentProvider" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "websiteUrl" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "PaymentProvider_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Error" ( + "id" TEXT NOT NULL, + "error" TEXT NOT NULL, + "userId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Error_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "File" ( + "id" TEXT NOT NULL, + "url" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "File_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Blog" ( + "slug" TEXT NOT NULL, + "title" TEXT NOT NULL, + "image" TEXT NOT NULL, + "description" TEXT NOT NULL, + "content" TEXT, + "categories" TEXT[], + "keywords" TEXT[], + "authorId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Blog_pkey" PRIMARY KEY ("slug") +); + +-- CreateTable +CREATE TABLE "Banner" ( + "id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "image" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Banner_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_Wishlist" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "_CategoryToProduct" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "_BannerToCategory" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_emailUnsubscribeToken_key" ON "User"("emailUnsubscribeToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_referralCode_key" ON "User"("referralCode"); + +-- CreateIndex +CREATE UNIQUE INDEX "CartItem_cartId_productId_key" ON "CartItem"("cartId", "productId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Owner_email_key" ON "Owner"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Owner_phone_key" ON "Owner"("phone"); + +-- CreateIndex +CREATE UNIQUE INDEX "Author_email_key" ON "Author"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Author_phone_key" ON "Author"("phone"); + +-- CreateIndex +CREATE UNIQUE INDEX "Brand_title_key" ON "Brand"("title"); + +-- CreateIndex +CREATE INDEX "Product_brandId_idx" ON "Product"("brandId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Category_title_key" ON "Category"("title"); + +-- CreateIndex +CREATE INDEX "ProductReview_userId_idx" ON "ProductReview"("userId"); + +-- CreateIndex +CREATE INDEX "ProductReview_productId_idx" ON "ProductReview"("productId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProductReview_productId_userId_key" ON "ProductReview"("productId", "userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Order_number_key" ON "Order"("number"); + +-- CreateIndex +CREATE INDEX "Order_userId_idx" ON "Order"("userId"); + +-- CreateIndex +CREATE INDEX "Order_addressId_idx" ON "Order"("addressId"); + +-- CreateIndex +CREATE INDEX "Order_discountCodeId_idx" ON "Order"("discountCodeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "OrderItem_orderId_productId_key" ON "OrderItem"("orderId", "productId"); + +-- CreateIndex +CREATE INDEX "Address_userId_idx" ON "Address"("userId"); + +-- CreateIndex +CREATE INDEX "Notification_userId_idx" ON "Notification"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DiscountCode_code_key" ON "DiscountCode"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "Refund_orderId_key" ON "Refund"("orderId"); + +-- CreateIndex +CREATE INDEX "Refund_orderId_idx" ON "Refund"("orderId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Payment_number_key" ON "Payment"("number"); + +-- CreateIndex +CREATE UNIQUE INDEX "Payment_refId_key" ON "Payment"("refId"); + +-- CreateIndex +CREATE INDEX "Payment_userId_idx" ON "Payment"("userId"); + +-- CreateIndex +CREATE INDEX "Payment_providerId_idx" ON "Payment"("providerId"); + +-- CreateIndex +CREATE INDEX "Payment_orderId_idx" ON "Payment"("orderId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PaymentProvider_title_key" ON "PaymentProvider"("title"); + +-- CreateIndex +CREATE INDEX "Error_userId_idx" ON "Error"("userId"); + +-- CreateIndex +CREATE INDEX "File_userId_idx" ON "File"("userId"); + +-- CreateIndex +CREATE INDEX "Blog_authorId_idx" ON "Blog"("authorId"); + +-- CreateIndex +CREATE UNIQUE INDEX "_Wishlist_AB_unique" ON "_Wishlist"("A", "B"); + +-- CreateIndex +CREATE INDEX "_Wishlist_B_index" ON "_Wishlist"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_CategoryToProduct_AB_unique" ON "_CategoryToProduct"("A", "B"); + +-- CreateIndex +CREATE INDEX "_CategoryToProduct_B_index" ON "_CategoryToProduct"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_BannerToCategory_AB_unique" ON "_BannerToCategory"("A", "B"); + +-- CreateIndex +CREATE INDEX "_BannerToCategory_B_index" ON "_BannerToCategory"("B"); + +-- AddForeignKey +ALTER TABLE "Cart" ADD CONSTRAINT "Cart_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CartItem" ADD CONSTRAINT "CartItem_cartId_fkey" FOREIGN KEY ("cartId") REFERENCES "Cart"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CartItem" ADD CONSTRAINT "CartItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductReview" ADD CONSTRAINT "ProductReview_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductReview" ADD CONSTRAINT "ProductReview_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Order" ADD CONSTRAINT "Order_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Order" ADD CONSTRAINT "Order_addressId_fkey" FOREIGN KEY ("addressId") REFERENCES "Address"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Order" ADD CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Address" ADD CONSTRAINT "Address_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Refund" ADD CONSTRAINT "Refund_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "PaymentProvider"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Error" ADD CONSTRAINT "Error_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "File" ADD CONSTRAINT "File_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Blog" ADD CONSTRAINT "Blog_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_Wishlist" ADD CONSTRAINT "_Wishlist_A_fkey" FOREIGN KEY ("A") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_Wishlist" ADD CONSTRAINT "_Wishlist_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_CategoryToProduct" ADD CONSTRAINT "_CategoryToProduct_A_fkey" FOREIGN KEY ("A") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_CategoryToProduct" ADD CONSTRAINT "_CategoryToProduct_B_fkey" FOREIGN KEY ("B") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_BannerToCategory" ADD CONSTRAINT "_BannerToCategory_A_fkey" FOREIGN KEY ("A") REFERENCES "Banner"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_BannerToCategory" ADD CONSTRAINT "_BannerToCategory_B_fkey" FOREIGN KEY ("B") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/admin/prisma/migrations/migration_lock.toml b/apps/admin/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..fbffa92c --- /dev/null +++ b/apps/admin/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/apps/admin/src/app/(dashboard)/(routes)/admin/reports/components/chart.tsx b/apps/admin/src/app/(dashboard)/(routes)/admin/reports/components/chart.tsx new file mode 100644 index 00000000..63cb83da --- /dev/null +++ b/apps/admin/src/app/(dashboard)/(routes)/admin/reports/components/chart.tsx @@ -0,0 +1,22 @@ +'use client' + +import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' + +interface OrderReportProps { + data: any[] +} + +export const OrderReportChart: React.FC = ({ data }) => { + return ( + + + + + + + + + + + ) +} diff --git a/apps/admin/src/app/(dashboard)/(routes)/admin/reports/components/options.tsx b/apps/admin/src/app/(dashboard)/(routes)/admin/reports/components/options.tsx new file mode 100644 index 00000000..4e7842e0 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/(routes)/admin/reports/components/options.tsx @@ -0,0 +1,277 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { Calendar } from '@/components/ui/calendar' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { slugify } from '@persepolis/slugify' +import { format } from 'date-fns' +import { Check, ChevronsUpDown } from 'lucide-react' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { useEffect, useState } from 'react' +import { DateRange } from 'react-day-picker' + +interface DateRangePickerProps { + readonly initialStartDate?: string + readonly initialEndDate?: string +} + +export function DateRangePicker({ + initialStartDate, + initialEndDate, +}: DateRangePickerProps) { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const [date, setDate] = useState({ + from: initialStartDate ? new Date(initialStartDate) : undefined, + to: initialEndDate ? new Date(initialEndDate) : undefined, + }) + + useEffect(() => { + const current = new URLSearchParams(Array.from(searchParams.entries())) + + if (date?.from) current.set('startDate', date.from.toISOString()) + else current.delete('startDate') + + if (date?.to) current.set('endDate', date.to.toISOString()) + else current.delete('endDate') + + const search = current.toString() + const query = search ? `?${search}` : '' + + router.replace(`${pathname}${query}`, { + scroll: false, + }) + }, [date]) + + return ( + + + + + + + + + ) +} + +interface CategoriesComboboxProps { + readonly categories: { title: string }[] + readonly initialCategory?: string +} + +export function CategoriesCombobox({ + categories, + initialCategory, +}: CategoriesComboboxProps) { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const [open, setOpen] = useState(false) + const [selected, setSelected] = useState([]) + + useEffect(() => { + if (!initialCategory) return + + const initialSlugs = initialCategory.split(',').map((slug) => slug.trim()) + setSelected(initialSlugs) + }, [initialCategory]) + + const toggleSelection = (slug: string) => { + const selectedCategories = selected.includes(slug) + ? selected.filter((s) => s !== slug) + : [...selected, slug] + + setSelected(selectedCategories) + + const current = new URLSearchParams(Array.from(searchParams.entries())) + + if (selectedCategories.length === 0) { + current.delete('category') + } else { + current.set('category', selectedCategories.join(',')) + } + + const search = current.toString() + const query = search ? `?${search}` : '' + router.replace(`${pathname}${query}`, { scroll: false }) + } + + const getDisplayedTitle = () => { + const matched = categories + .filter((cat) => selected.includes(slugify(cat.title))) + .map((cat) => cat.title) + + if (matched.length > 2) { + const [first, second, ...rest] = matched + return `${first}, ${second}, +${rest.length} other${rest.length > 1 ? 's' : ''}` + } + + return matched.join(', ') + } + + return ( + + + + + + + + + No category found. + + {categories.map((cat) => { + const slug = slugify(cat.title) + const isSelected = selected.includes(slug) + + return ( + toggleSelection(slug)} + > + + {cat.title} + + ) + })} + + + + + + ) +} + +interface BrandComboboxProps { + readonly brands: { title: string }[] + readonly initialBrand?: string +} + +export function BrandCombobox({ brands, initialBrand }: BrandComboboxProps) { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const [open, setOpen] = useState(false) + const [value, setValue] = useState('') + + function getBrandTitle() { + for (const brand of brands) { + if (slugify(brand.title) === slugify(value)) return brand.title + } + } + + useEffect(() => { + setValue(initialBrand) + }, [initialBrand]) + + return ( + + + + + + + + + No brand found. + + {brands.map((brand) => ( + { + const current = new URLSearchParams( + Array.from(searchParams.entries()) + ) + + if (currentValue === value) { + current.delete('brand') + setValue('') + } else { + current.set('brand', currentValue) + setValue(currentValue) + } + + // cast to string + const search = current.toString() + // or const query = `${'?'.repeat(search.length && 1)}${search}`; + const query = search ? `?${search}` : '' + + router.replace(`${pathname}${query}`, { + scroll: false, + }) + + setOpen(false) + }} + > + + {brand.title} + + ))} + + + + + + ) +} diff --git a/apps/admin/src/app/(dashboard)/(routes)/admin/reports/components/table.tsx b/apps/admin/src/app/(dashboard)/(routes)/admin/reports/components/table.tsx new file mode 100644 index 00000000..a7dea5c5 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/(routes)/admin/reports/components/table.tsx @@ -0,0 +1,66 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { DataTable } from '@/components/ui/data-table' +import { ColumnDef } from '@tanstack/react-table' +import { CheckIcon, EditIcon, XIcon } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' + +interface ProductsTableProps { + data: ProductColumn[] +} + +export const ProductsTable: React.FC = ({ data }) => { + const router = useRouter() + + return +} + +export type ProductColumn = { + id: string + title: string + price: string + discount: string + category: string + sales: number + isAvailable: boolean +} + +export const columns: ColumnDef[] = [ + { + accessorKey: 'title', + header: 'Title', + }, + { + accessorKey: 'price', + header: 'Price', + }, + { + accessorKey: 'discount', + header: 'Discount', + }, + { + accessorKey: 'category', + header: 'Category', + }, + { + accessorKey: 'sales', + header: 'Sales #', + }, + { + accessorKey: 'isAvailable', + header: 'Availability', + cell: (props) => (props.cell.getValue() ? : ), + }, + { + id: 'actions', + cell: ({ row }) => ( + + + + ), + }, +] diff --git a/apps/admin/src/app/(dashboard)/(routes)/admin/reports/page.tsx b/apps/admin/src/app/(dashboard)/(routes)/admin/reports/page.tsx new file mode 100644 index 00000000..b5ddc07c --- /dev/null +++ b/apps/admin/src/app/(dashboard)/(routes)/admin/reports/page.tsx @@ -0,0 +1,178 @@ +import { format } from "date-fns" + +import prisma from '@/lib/prisma' +import { formatter } from '@/lib/utils' + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Heading } from '@/components/ui/heading' +import { Separator } from '@/components/ui/separator' + +import { BrandCombobox, CategoriesCombobox, DateRangePicker } from "./components/options" +import { OrderReportChart } from './components/chart' +import { ProductColumn, ProductsTable } from './components/table' + +export default async function ReportsPage({ searchParams }) { + const { startDate, endDate, brand, category, page = 1 } = searchParams ?? null + + const filteredCategories = category ? + category + .split(',') + .map((cat) => cat.trim()) + : + undefined + + const brands = await prisma.brand.findMany() + const categories = await prisma.category.findMany() + + // CHART DATA: Order total grouped by date. + const orders = await prisma.order.groupBy({ + by: ['createdAt'], + _count: { + id: true, // count total orders + }, + where: { + createdAt: { + gte: startDate, + lte: endDate, + }, + orderItems: { + some: { + product: { + brand: { + title: { + contains: brand, + mode: 'insensitive' + } + }, + categories: { + some: { + title: { + in: filteredCategories, + mode: 'insensitive' + } + } + } + } + } + } + }, + }); + + const groupedOrders: Record = {}; + + // Aggregate orders by date using date-fns for formatting + orders.forEach(order => { + const date = format(order.createdAt, 'yyyy-MM-dd'); // format date + groupedOrders[date] = (groupedOrders[date] || 0) + order._count.id; + }); + + // Format the data for chart. + const ordersChartData = Object.entries(groupedOrders) + .map(([date, orderCount]) => ({ date, orderCount })) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + + + // TABLE DATA: Top Selling Products. + const products = await prisma.product.findMany({ + select: { + id: true, + title: true, + price: true, + discount: true, + isAvailable: true, + categories: { select: { title: true } }, + orders: { + where: { + order: { + createdAt: { gte: startDate, lte: endDate }, + }, + }, + select: { + orderId: true, + count: true, + order: { + select: { + createdAt: true + } + } + }, + }, + }, + where: { + orders: { + some: { + order: { + createdAt: { + gte: startDate, + lte: endDate, + } + } + } + }, + brand: { + title: { + contains: brand, + mode: 'insensitive' + } + }, + categories: { + some: { + title: { + in: filteredCategories, + mode: 'insensitive' + } + } + } + }, + orderBy: { + orders: { + _count: 'desc', + } + } + }) + + // Format the data for table. + const topSellingProducts: ProductColumn[] = products.map((product) => ({ + id: product.id, + title: product.title, + price: formatter.format(product.price), + discount: formatter.format(product.discount), + category: product.categories[0].title, + sales: product.orders.length, + isAvailable: product.isAvailable, + })) + + return ( +
+ + +
+ + + +
+ + + Orders + + + + + + + + Top Selling Products + + + + + +
+ ) +} 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..d1cb15d5 100644 --- a/apps/admin/src/app/login/components/user-auth-form.tsx +++ b/apps/admin/src/app/login/components/user-auth-form.tsx @@ -122,12 +122,6 @@ function TryComponents({ isLoading, setIsLoading, setFetchedOTP }) { 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', { method: 'POST', body: JSON.stringify({ email }), diff --git a/apps/admin/src/components/main-nav.tsx b/apps/admin/src/components/main-nav.tsx index bd87cb6e..fdafa621 100644 --- a/apps/admin/src/components/main-nav.tsx +++ b/apps/admin/src/components/main-nav.tsx @@ -51,6 +51,11 @@ export function MainNav({ label: 'Codes', active: pathname.includes(`/codes`), }, + { + href: `/admin/reports`, + label: 'Reports', + active: pathname.includes(`/admin/reports`), + }, ] return ( diff --git a/apps/admin/src/components/ui/calendar.tsx b/apps/admin/src/components/ui/calendar.tsx new file mode 100644 index 00000000..acfa6153 --- /dev/null +++ b/apps/admin/src/components/ui/calendar.tsx @@ -0,0 +1,221 @@ +'use client' + +import { Button, buttonVariants } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from 'lucide-react' +import * as React from 'react' +import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker' + +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 ( + - - - - - No category found. - - {categories.map((category) => ( - { - const current = new URLSearchParams( - Array.from(searchParams.entries()) - ) - - if (currentValue === value) { - current.delete('category') - setValue('') - } else { - current.set('category', currentValue) - setValue(currentValue) - } - - // cast to string - const search = current.toString() - // or const query = `${'?'.repeat(search.length && 1)}${search}`; - const query = search ? `?${search}` : '' - - router.replace(`${pathname}${query}`, { - scroll: false, - }) - - setOpen(false) - }} - > - - {category.title} - - ))} - - - + + + + + + + + No category found. + + {categories.map((cat) => { + const slug = slugify(cat.title) + const isSelected = selected.includes(slug) + + return ( + toggleSelection(slug)}> + + {cat.title} + + ) + })} + + + + ) } @@ -192,48 +207,50 @@ export function BrandCombobox({ brands, initialBrand }) { - No brand found. - - {brands.map((brand) => ( - { - const current = new URLSearchParams( - Array.from(searchParams.entries()) - ) - - if (currentValue === value) { - current.delete('brand') - setValue('') - } else { - current.set('brand', currentValue) - setValue(currentValue) - } - - // cast to string - const search = current.toString() - // or const query = `${'?'.repeat(search.length && 1)}${search}`; - const query = search ? `?${search}` : '' - - router.replace(`${pathname}${query}`, { - scroll: false, - }) - - setOpen(false) - }} - > - - {brand.title} - - ))} - + + No brand found. + + {brands.map((brand) => ( + { + const current = new URLSearchParams( + Array.from(searchParams.entries()) + ) + + if (currentValue === value) { + current.delete('brand') + setValue('') + } else { + current.set('brand', currentValue) + setValue(currentValue) + } + + // cast to string + const search = current.toString() + // or const query = `${'?'.repeat(search.length && 1)}${search}`; + const query = search ? `?${search}` : '' + + router.replace(`${pathname}${query}`, { + scroll: false, + }) + + setOpen(false) + }} + > + + {brand.title} + + ))} + + @@ -282,3 +299,121 @@ export function AvailableToggle({ initialData }) {
) } + +interface SearchProps { + readonly initialSearchQuery: string +} + +export function ProductSearchInput({ initialSearchQuery }: SearchProps) { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const [value, setValue] = React.useState('') + const [debouncedValue, setDebouncedValue] = React.useState('') + + useEffect(() => { + if (isVariableValid(initialSearchQuery)) { + setValue(initialSearchQuery) + } + }, [initialSearchQuery]) + + // Add delay while user search to avoid api request immediately + useEffect(() => { + const timeout = setTimeout(() => { + setDebouncedValue(value) + }, 500) + + return () => clearTimeout(timeout); + }, [value]) + + useEffect(() => { + const current = new URLSearchParams(Array.from(searchParams.entries())) + + if (debouncedValue) { + current.set('search', debouncedValue) + } else { + current.delete('search') + } + + // Only update the URL if it actually changed from the current one. + const newQuery = current.toString(); + const newUrl = `${pathname}${newQuery ? `?${newQuery}` : ""}`; + + if (newUrl !== `${pathname}?${searchParams.toString()}`) { + router.replace(newUrl, { scroll: false }); + } + + }, [debouncedValue]) + + return ( + setValue(e.target.value)} + className="w-full focus-visible:ring-0" + /> + ) +} + +interface PriceValueProps { + readonly initialMinPrice: number; + readonly initialMaxPrice: number; +} + +export function PriceInputFields({ initialMinPrice, initialMaxPrice }: PriceValueProps ) { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const [minPrice, setMinPrice] = React.useState(undefined) + const [maxPrice, setMaxPrice] = React.useState(undefined) + + useEffect(() => { + if (!isNaN(initialMinPrice)) setMinPrice(initialMinPrice) + if (!isNaN(initialMaxPrice)) setMaxPrice(initialMaxPrice) + }, [initialMinPrice, initialMaxPrice]) + + const handleApply = () => { + const current = new URLSearchParams(Array.from(searchParams.entries())) + + current.set('minPrice', minPrice.toString()) + current.set('maxPrice', maxPrice.toString()) + + const search = current.toString() + const query = search ? `?${search}` : '' + + router.replace(`${pathname}${query}`, { + scroll: false, + }) + } + + return ( +
+ setMinPrice(parseFloat(e.target.value))} + className="w-full focus-visible" + /> + + setMaxPrice(parseFloat(e.target.value))} + className="w-full focus-visible" + /> + + +
+ ) +} \ No newline at end of file diff --git a/apps/storefront/src/app/(store)/(routes)/products/page.tsx b/apps/storefront/src/app/(store)/(routes)/products/page.tsx index f494e595..3a1d226f 100644 --- a/apps/storefront/src/app/(store)/(routes)/products/page.tsx +++ b/apps/storefront/src/app/(store)/(routes)/products/page.tsx @@ -8,19 +8,36 @@ import { AvailableToggle, BrandCombobox, CategoriesCombobox, + ProductSearchInput, + PriceInputFields, SortBy, } from './components/options' export default async function Products({ searchParams }) { - const { sort, isAvailable, brand, category, page = 1 } = searchParams ?? null - + const { search, minPrice, maxPrice, sort, isAvailable, brand, category, page = 1 } = searchParams ?? null const orderBy = getOrderBy(sort) + const priceMin = parseFloat(minPrice); + const priceMax = parseFloat(maxPrice); + const priceFilter = !isNaN(priceMin) && !isNaN(priceMax) + ? { price: { gte: priceMin, lte: priceMax } } + : {}; + const filteredCategories = category ? + category + .split(',') + .map((cat) => cat.trim()) + : + undefined + const isTitleSort = sort === "title_asc" || sort === "title_desc" const brands = await prisma.brand.findMany() const categories = await prisma.category.findMany() const products = await prisma.product.findMany({ where: { - isAvailable: isAvailable == 'true' || sort ? true : undefined, + isAvailable: (isAvailable == 'true' || sort) && !isTitleSort ? true : undefined, + title: { + contains: search, + mode: 'insensitive', + }, brand: { title: { contains: brand, @@ -30,11 +47,12 @@ export default async function Products({ searchParams }) { categories: { some: { title: { - contains: category, + in: filteredCategories, mode: 'insensitive', }, }, }, + ...priceFilter }, orderBy, skip: (page - 1) * 12, @@ -51,14 +69,16 @@ export default async function Products({ searchParams }) { title="Products" description="Below is a list of products you have in your cart." /> -
- +
+ + - + + {/* */}
{isVariableValid(products) ? ( @@ -91,6 +111,16 @@ function getOrderBy(sort) { price: 'asc', } break + case 'title_asc': + orderBy = { + title: 'asc', + } + break + case 'title_desc': + orderBy = { + title: 'desc', + } + break default: orderBy = { diff --git a/apps/storefront/src/components/ui/command.tsx b/apps/storefront/src/components/ui/command.tsx index 40021027..af2d096a 100644 --- a/apps/storefront/src/components/ui/command.tsx +++ b/apps/storefront/src/components/ui/command.tsx @@ -1,158 +1,154 @@ -'use client' +"use client" + +import * as React from "react" +import { type DialogProps } from "@radix-ui/react-dialog" +import { SearchIcon } from "lucide-react" +import { Command as CommandPrimitive } from "cmdk" +import { cn } from "@/lib/utils" +import { Dialog, DialogContent } from "@/components/ui/dialog" -import * as React from 'react' -import { DialogProps } from '@radix-ui/react-dialog' -import { SearchIcon } from 'lucide-react' -import { Command as CommandPrimitive } from 'cmdk' -import { cn } from '@/lib/utils' -import { Dialog, DialogContent } from '@/components/ui/dialog' const Command = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )) Command.displayName = CommandPrimitive.displayName -interface CommandDialogProps extends DialogProps {} - -const CommandDialog = ({ children, ...props }: CommandDialogProps) => { - return ( - - - - {children} - - - - ) +const CommandDialog = ({ children, ...props }: DialogProps) => { + return ( + + + + {children} + + + + ) } const CommandInput = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( -
- - -
+
+ + +
)) CommandInput.displayName = CommandPrimitive.Input.displayName const CommandList = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )) CommandList.displayName = CommandPrimitive.List.displayName const CommandEmpty = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >((props, ref) => ( - + )) CommandEmpty.displayName = CommandPrimitive.Empty.displayName const CommandGroup = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )) CommandGroup.displayName = CommandPrimitive.Group.displayName const CommandSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )) CommandSeparator.displayName = CommandPrimitive.Separator.displayName const CommandItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )) CommandItem.displayName = CommandPrimitive.Item.displayName const CommandShortcut = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes) => { - return ( - - ) + return ( + + ) } -CommandShortcut.displayName = 'CommandShortcut' +CommandShortcut.displayName = "CommandShortcut" export { - Command, - CommandDialog, - CommandInput, - CommandList, - CommandEmpty, - CommandGroup, - CommandItem, - CommandShortcut, - CommandSeparator, -} + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} \ No newline at end of file