No products found.
;
27 | }
28 |
29 | return (
30 | = {};
17 | Object.entries(searchParams).forEach(([key, value]) => {
18 | if (key !== 'page' && value) {
19 | query[key] = value.toString();
20 | }
21 | });
22 | if (page > 1) {
23 | query.page = page.toString();
24 | }
25 | return {
26 | pathname: '/all',
27 | query,
28 | };
29 | };
30 |
31 | return (
32 |
33 |
34 | {currentPage > 1 && (
35 |
40 |
Previous
41 |
42 | )}
43 |
44 | {Array.from({ length: totalPages }, (_, i) => {
45 | return i + 1;
46 | }).map(page => {
47 | return (
48 |
49 |
57 | {page}
58 |
59 |
60 | );
61 | })}
62 |
63 | {currentPage < totalPages && (
64 |
69 |
Next
70 |
71 | )}
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/features/product/components/Reviews.tsx:
--------------------------------------------------------------------------------
1 | import { Star } from 'lucide-react';
2 | import Boundary from '@/components/internal/Boundary';
3 | import { getReviews } from '../product-queries';
4 |
5 | type Props = {
6 | productId: number;
7 | };
8 |
9 | export default async function Reviews({ productId }: Props) {
10 | 'use cache';
11 |
12 | const reviews = await getReviews(productId);
13 |
14 | return (
15 |
16 |
17 | {reviews.length === 0 ? (
18 |
No reviews yet for this product.
19 | ) : (
20 |
21 | {reviews.map(review => {
22 | return (
23 |
27 |
28 |
29 | {[...Array(5)].map((_, i) => {
30 | return (
31 |
36 | );
37 | })}
38 | {review.rating}/5
39 |
40 |
41 | {new Date().toLocaleDateString()}
42 |
43 |
44 | {review.comment && (
45 |
{review.comment}
46 | )}
47 |
48 | );
49 | })}
50 |
51 | )}
52 |
53 |
54 | );
55 | }
56 |
57 | export function ReviewsSkeleton() {
58 | return (
59 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/features/user/components/Discounts.tsx:
--------------------------------------------------------------------------------
1 | import { Percent } from 'lucide-react';
2 | import React from 'react';
3 |
4 | import Boundary from '@/components/internal/Boundary';
5 | import { getUserDiscounts } from '../user-queries';
6 |
7 | export default async function Discounts() {
8 | const discounts = await getUserDiscounts();
9 |
10 | if (discounts.length === 0) {
11 | return (
12 |
13 |
No discounts available
14 |
15 | );
16 | }
17 |
18 | return (
19 |
20 |
21 | {discounts.map(discount => {
22 | return (
23 |
27 |
28 |
29 |
30 |
{discount.code}
31 |
{discount.description}
32 |
33 |
34 |
35 |
36 | Expires: {discount.expiry.toLocaleDateString()}
37 |
38 |
39 |
40 | );
41 | })}
42 |
43 |
44 | );
45 | }
46 |
47 | export function DiscountsSkeleton() {
48 | return (
49 |
50 | {Array.from({ length: 3 }).map((_, index) => {
51 | return (
52 |
67 | );
68 | })}
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Next.js 16 Commerce
2 |
3 | A responsive and interactive e-commerce application built with Next.js 16 App Router, built with Prisma and TailwindCSS, utilizing `use cache` for performance optimization.
4 |
5 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
6 |
7 | ## Getting Started
8 |
9 | First, install the dependencies:
10 |
11 | ```bash
12 | npm install --force
13 | # or
14 | yarn install --force
15 | # or
16 | pnpm install --force
17 | ```
18 |
19 | Then, run the development server:
20 |
21 | ```bash
22 | npm run dev
23 | # or
24 | yarn dev
25 | # or
26 | pnpm dev
27 | ```
28 |
29 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
30 |
31 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
32 |
33 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
34 |
35 | ## Prisma Setup
36 |
37 | You need decide between prisma local development with `sqlite` or a real database with for example `postgresql` or `sqlserver`. Define it in the `schema.prisma` file.
38 |
39 | Consider adding a `.env` file to the root of the project and use the environment variables inside `schema.prisma` with `env("DATABASE_URL")`, refer to `.env.sample`.
40 |
41 | When using sqlite, initialize the database with:
42 |
43 | ```bash
44 | npm run prisma.push
45 | ```
46 |
47 | Seed prisma/seed.ts for initial data:
48 |
49 | ```sh
50 | npm run prisma.seed
51 | ```
52 |
53 | To view your data in the database, you can run:
54 |
55 | ```bash
56 | npm run prisma.studio
57 | ```
58 |
59 | When using a real database with for example postgresql or sqlserver, you need to migrate the database schema with:
60 |
61 | ```bash
62 | npm run prisma.migrate
63 | ```
64 |
65 | ## Learn More
66 |
67 | To learn more about Next.js, take a look at the following resources:
68 |
69 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
70 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
71 |
72 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
73 |
74 | ## Deploy on Vercel
75 |
76 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
77 |
78 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
79 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 |
3 | @theme {
4 | --color-gray: #9ca3af;
5 | --color-divider-dark: #303030;
6 | --color-divider: #d1d5db;
7 | --color-card-dark: #1a1a1a;
8 | --color-card: #f5f5f5;
9 | --color-section: #111111;
10 |
11 | --color-primary: #2457fa;
12 | --color-primary-dark: #1d47cf;
13 | --color-primary-darker: #142f8b;
14 | --color-accent: #1b50ff;
15 | --color-accent-hover: #1340d1;
16 | --color-accent-fade: #214fff33;
17 |
18 | --color-success: #0fa958;
19 | --color-danger: #e54848;
20 | --color-warning: #f5a524;
21 |
22 | --font-mono: var(--font-geist-mono);
23 | --font-sans: var(--font-geist-sans);
24 | --tracking-tight: -0.02em;
25 | --tracking-wide: 0.08em;
26 | --breakpoint-3xl: 120rem;
27 | --breakpoint-4xl: 140rem;
28 | }
29 |
30 | @layer base {
31 | body {
32 | @apply bg-white font-sans text-black antialiased dark:bg-black dark:text-white selection:bg-accent selection:text-white;
33 | }
34 |
35 | h1 {
36 | @apply font-sans text-[clamp(2.75rem,5vw,4.25rem)] font-semibold leading-[0.95] tracking-tight text-black dark:text-white;
37 | }
38 |
39 | h2 {
40 | @apply font-sans text-[clamp(1.75rem,3.2vw,2.75rem)] font-medium leading-tight tracking-tight text-black dark:text-white;
41 | }
42 |
43 | h3 {
44 | @apply font-sans text-xl md:text-2xl font-medium tracking-tight text-black dark:text-white;
45 | }
46 |
47 | input {
48 | @apply w-full border border-divider bg-white px-3 py-2 text-sm text-black placeholder-gray focus:border-accent focus:ring-1 focus:ring-accent focus:outline-none dark:border-divider-dark dark:bg-black dark:text-white dark:placeholder-gray;
49 | }
50 |
51 | label {
52 | @apply mb-2 block text-sm font-medium text-gray dark:text-gray;
53 | }
54 |
55 | a {
56 | @apply font-medium uppercase text-accent no-underline transition-colors hover:text-accent-hover;
57 | }
58 | }
59 |
60 | .skeleton-animation {
61 | background: rgba(150, 150, 150, 0.2);
62 | background: -webkit-gradient(
63 | linear,
64 | left top,
65 | right top,
66 | color-stop(8%, rgba(130, 130, 130, 0.2)),
67 | color-stop(18%, rgba(170, 170, 170, 0.3)),
68 | color-stop(33%, rgba(150, 150, 150, 0.2))
69 | );
70 | background: linear-gradient(
71 | to right,
72 | rgba(130, 130, 130, 0.2) 8%,
73 | rgba(170, 170, 170, 0.3) 18%,
74 | rgba(150, 150, 150, 0.2) 33%
75 | );
76 | background-size: 800px 100px;
77 | animation: wave-lines 2s infinite ease-out;
78 | }
79 |
80 | @keyframes wave-lines {
81 | 0% {
82 | background-position: -468px 0;
83 | }
84 | 100% {
85 | background-position: 468px 0;
86 | }
87 | }
88 | @keyframes wave-squares {
89 | 0% {
90 | background-position: -468px 0;
91 | }
92 | 100% {
93 | background-position: 468px 0;
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/features/user/components/SavedProducts.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import React from 'react';
3 | import Boundary from '@/components/internal/Boundary';
4 | import ImagePlaceholder from '@/components/ui/ImagePlaceholder';
5 | import { getSavedProducts } from '../../product/product-queries';
6 | import SaveProductButton from './SaveProductButton';
7 |
8 | export default async function SavedProducts() {
9 | const savedProducts = await getSavedProducts();
10 |
11 | if (savedProducts.length === 0) {
12 | return (
13 |
14 |
You haven't saved any products yet.
15 |
19 | Browse products
20 |
21 |
22 | );
23 | }
24 |
25 | return (
26 |
27 |
28 | {savedProducts.map(product => {
29 | return (
30 |
34 |
35 |
36 |
37 |
{product.name}
38 |
${product.price.toFixed(2)}
39 |
40 |
41 |
42 |
43 | );
44 | })}
45 |
46 |
47 | );
48 | }
49 |
50 | export function SavedProductsSkeleton() {
51 | return (
52 |
53 | {Array.from({ length: 3 }).map((_, index) => {
54 | return (
55 |
66 | );
67 | })}
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/components/ui/ImagePlaceholder.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { cn } from '@/utils/cn';
3 |
4 | type Props = {
5 | className?: string;
6 | variant?: 'default' | 'simple';
7 | };
8 |
9 | export default function ImagePlaceholder({ className, variant = 'default' }: Props) {
10 | if (variant === 'simple') {
11 | return (
12 |
28 | );
29 | }
30 | return (
31 |
37 | {/* Stretched background pattern */}
38 |
42 |
43 | {/* Centered icon with subtle glow */}
44 |
45 |
61 |
Product Image
62 |
63 |
64 | {/* Corner indicators showing stretch */}
65 |
66 |
67 |
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/prisma/migrations/20250801100125_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "public"."Account" (
3 | "id" TEXT NOT NULL,
4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
5 | "name" TEXT NOT NULL,
6 | "email" TEXT NOT NULL,
7 | "firstName" TEXT,
8 | "lastName" TEXT,
9 | "phone" TEXT,
10 | "address" TEXT,
11 | "city" TEXT,
12 | "country" TEXT,
13 | "zipCode" TEXT,
14 | "birthDate" TIMESTAMP(3),
15 |
16 | CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
17 | );
18 |
19 | -- CreateTable
20 | CREATE TABLE "public"."AccountDetail" (
21 | "id" SERIAL NOT NULL,
22 | "accountId" TEXT NOT NULL,
23 | "theme" TEXT DEFAULT 'light',
24 | "language" TEXT DEFAULT 'en',
25 | "timezone" TEXT,
26 | "newsletter" BOOLEAN NOT NULL DEFAULT true,
27 | "notifications" BOOLEAN NOT NULL DEFAULT true,
28 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
29 | "updatedAt" TIMESTAMP(3) NOT NULL,
30 |
31 | CONSTRAINT "AccountDetail_pkey" PRIMARY KEY ("id")
32 | );
33 |
34 | -- CreateTable
35 | CREATE TABLE "public"."Product" (
36 | "id" SERIAL NOT NULL,
37 | "name" TEXT NOT NULL,
38 | "description" TEXT,
39 | "price" DOUBLE PRECISION NOT NULL,
40 | "category" TEXT,
41 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
42 | "updatedAt" TIMESTAMP(3) NOT NULL,
43 |
44 | CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
45 | );
46 |
47 | -- CreateTable
48 | CREATE TABLE "public"."ProductDetail" (
49 | "id" SERIAL NOT NULL,
50 | "productId" INTEGER NOT NULL,
51 | "sku" TEXT,
52 | "brand" TEXT,
53 | "stockCount" INTEGER NOT NULL DEFAULT 0,
54 | "weight" DOUBLE PRECISION,
55 | "dimensions" TEXT,
56 | "materials" TEXT,
57 | "origin" TEXT,
58 | "warrantyInfo" TEXT,
59 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
60 | "updatedAt" TIMESTAMP(3) NOT NULL,
61 |
62 | CONSTRAINT "ProductDetail_pkey" PRIMARY KEY ("id")
63 | );
64 |
65 | -- CreateTable
66 | CREATE TABLE "public"."Review" (
67 | "id" SERIAL NOT NULL,
68 | "productId" INTEGER NOT NULL,
69 | "rating" INTEGER NOT NULL,
70 | "comment" TEXT,
71 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
72 | "updatedAt" TIMESTAMP(3) NOT NULL,
73 |
74 | CONSTRAINT "Review_pkey" PRIMARY KEY ("id")
75 | );
76 |
77 | -- CreateIndex
78 | CREATE UNIQUE INDEX "AccountDetail_accountId_key" ON "public"."AccountDetail"("accountId");
79 |
80 | -- CreateIndex
81 | CREATE UNIQUE INDEX "ProductDetail_productId_key" ON "public"."ProductDetail"("productId");
82 |
83 | -- AddForeignKey
84 | ALTER TABLE "public"."AccountDetail" ADD CONSTRAINT "AccountDetail_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "public"."Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
85 |
86 | -- AddForeignKey
87 | ALTER TABLE "public"."ProductDetail" ADD CONSTRAINT "ProductDetail_productId_fkey" FOREIGN KEY ("productId") REFERENCES "public"."Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
88 |
89 | -- AddForeignKey
90 | ALTER TABLE "public"."Review" ADD CONSTRAINT "Review_productId_fkey" FOREIGN KEY ("productId") REFERENCES "public"."Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
91 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { fixupConfigRules, fixupPluginRules } from '@eslint/compat';
2 | import autofix from 'eslint-plugin-autofix';
3 | import reactHooks from 'eslint-plugin-react-hooks';
4 | import sortKeysFix from 'eslint-plugin-sort-keys-fix';
5 | import reactCompiler from 'eslint-plugin-react-compiler';
6 | import globals from 'globals';
7 | import tsParser from '@typescript-eslint/parser';
8 | import path from 'node:path';
9 | import { fileURLToPath } from 'node:url';
10 | import js from '@eslint/js';
11 | import { FlatCompat } from '@eslint/eslintrc';
12 |
13 | const __filename = fileURLToPath(import.meta.url);
14 | const __dirname = path.dirname(__filename);
15 | const compat = new FlatCompat({
16 | allConfig: js.configs.all,
17 | baseDirectory: __dirname,
18 | recommendedConfig: js.configs.recommended,
19 | });
20 |
21 | const eslintConfig = [{
22 | ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts"]
23 | }, {
24 | ignores: ['**/next-env.d.ts'],
25 | }, ...fixupConfigRules(
26 | compat.extends(
27 | 'eslint:recommended',
28 | 'eslint-config-prettier',
29 | 'plugin:react/recommended',
30 | 'plugin:@typescript-eslint/recommended',
31 | 'plugin:import/recommended',
32 | 'plugin:jsx-a11y/recommended',
33 | 'next',
34 | 'next/core-web-vitals',
35 | 'prettier',
36 | ),
37 | ), {
38 | languageOptions: {
39 | ecmaVersion: 12,
40 | globals: {
41 | ...globals.browser,
42 | },
43 | parser: tsParser,
44 | parserOptions: {
45 | ecmaFeatures: {
46 | jsx: true,
47 | },
48 | },
49 | sourceType: 'module',
50 | },
51 | plugins: {
52 | autofix,
53 | 'react-compiler': reactCompiler,
54 | 'react-hooks': fixupPluginRules(reactHooks),
55 | 'sort-keys-fix': sortKeysFix,
56 | },
57 | rules: {
58 | 'react-compiler/react-compiler': 'error',
59 | 'sort-keys-fix/sort-keys-fix': 'warn',
60 | },
61 | }, {
62 | files: ['**/*.ts?(x)'],
63 | rules: {
64 | '@typescript-eslint/consistent-type-imports': [
65 | 'warn',
66 | {
67 | prefer: 'type-imports',
68 | },
69 | ],
70 | 'arrow-body-style': ['warn', 'always'],
71 | 'autofix/no-unused-vars': [
72 | 'warn',
73 | {
74 | argsIgnorePattern: '^_',
75 | destructuredArrayIgnorePattern: '^_',
76 | ignoreRestSiblings: true,
77 | },
78 | ],
79 | 'import/order': [
80 | 'warn',
81 | {
82 | alphabetize: {
83 | order: 'asc',
84 | },
85 | groups: ['builtin', 'external', 'parent', 'sibling', 'index', 'object', 'type'],
86 | pathGroups: [
87 | {
88 | group: 'parent',
89 | pattern: '@/**/**',
90 | position: 'before',
91 | },
92 | ],
93 | },
94 | ],
95 | 'no-console': 'warn',
96 | 'no-redeclare': 'warn',
97 | quotes: ['warn', 'single'],
98 | 'react/display-name': 'error',
99 | 'react/jsx-key': 'warn',
100 | 'react/react-in-jsx-scope': 'off',
101 | 'react/self-closing-comp': [
102 | 'error',
103 | {
104 | component: true,
105 | html: true,
106 | },
107 | ],
108 | 'spaced-comment': 'warn',
109 | },
110 | }];
111 |
112 | export default eslintConfig;
113 |
--------------------------------------------------------------------------------
/features/product/components/ProductDetails.tsx:
--------------------------------------------------------------------------------
1 | import { Bookmark } from 'lucide-react';
2 | import { cacheTag } from 'next/dist/server/use-cache/cache-tag';
3 | import React from 'react';
4 | import Boundary from '@/components/internal/Boundary';
5 | import Button from '@/components/ui/Button';
6 | import Divider from '@/components/ui/Divider';
7 | import Skeleton from '@/components/ui/Skeleton';
8 | import { getIsAuthenticated } from '@/features/auth/auth-queries';
9 | import SaveProductButton from '../../user/components/SaveProductButton';
10 | import { setFeaturedProduct } from '../product-actions';
11 | import { getProductDetails, isSavedProduct } from '../product-queries';
12 |
13 | type Props = {
14 | productId: number;
15 | children?: React.ReactNode;
16 | };
17 |
18 | export default async function ProductDetails({ productId, children }: Props) {
19 | 'use cache';
20 |
21 | cacheTag('product-' + productId);
22 |
23 | const productDetails = await getProductDetails(productId);
24 | const setFeaturedForProduct = setFeaturedProduct.bind(null, productId);
25 |
26 | return (
27 |
28 |
29 |
30 |
Product Details
31 |
36 |
37 |
44 |
45 |
46 |
{children}
47 |
48 |
49 |
50 | );
51 | }
52 |
53 | export async function SavedProduct({ productId }: { productId: number }) {
54 | const loggedIn = await getIsAuthenticated();
55 |
56 | if (!loggedIn) {
57 | return (
58 |
59 |
60 |
61 | );
62 | }
63 |
64 | const productIsSaved = await isSavedProduct(productId);
65 | return ;
66 | }
67 |
68 | export function ProductDetailsSkeleton() {
69 | return (
70 |
79 | );
80 | }
81 |
82 | type ProductDetailFieldsProps = {
83 | brand?: string | null;
84 | sku?: string | null;
85 | stockCount?: number | null;
86 | warrantyInfo?: string | null;
87 | weight?: number | null;
88 | };
89 |
90 | function ProductDetailFields({ brand, sku, stockCount, warrantyInfo, weight }: ProductDetailFieldsProps) {
91 | return (
92 |
93 |
94 | Brand: {brand || 'N/A'}
95 |
96 |
97 | SKU: {sku || 'N/A'}
98 |
99 |
100 | In Stock: {stockCount || 0} units
101 |
102 |
103 | Weight: {weight ? `${weight} kg` : 'N/A'}
104 |
105 |
106 | Warranty: {warrantyInfo || 'No warranty information'}
107 |
108 |
109 | );
110 | }
111 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | }
7 |
8 | datasource db {
9 | provider = "postgresql"
10 | url = env("DATABASE_URL") // Change to "file:./dev.db" with sqlite
11 | // shadowDatabaseUrl = env("SHADOW_DATABASE_URL") // For sqlserver, remove with sqlite and postgres
12 | }
13 |
14 | model Account {
15 | id String @id @default(uuid())
16 | createdAt DateTime @default(now())
17 | name String
18 | email String
19 | firstName String?
20 | lastName String?
21 | phone String?
22 | address String?
23 | city String?
24 | country String?
25 | zipCode String?
26 | birthDate DateTime?
27 |
28 | accountDetail AccountDetail?
29 | savedProducts SavedProduct[]
30 | userDiscounts UserDiscount[]
31 | }
32 |
33 | model AccountDetail {
34 | id Int @id @default(autoincrement())
35 | accountId String @unique
36 | theme String? @default("light")
37 | language String? @default("en")
38 | timezone String?
39 | newsletter Boolean @default(true)
40 | notifications Boolean @default(true)
41 | createdAt DateTime @default(now())
42 | updatedAt DateTime @updatedAt
43 |
44 | account Account @relation(fields: [accountId], references: [id])
45 | }
46 |
47 | model Product {
48 | id Int @id @default(autoincrement())
49 | name String
50 | description String?
51 | price Float
52 | category String?
53 | featured Boolean @default(false)
54 | createdAt DateTime @default(now())
55 | updatedAt DateTime @updatedAt
56 | Review Review[]
57 | productDetail ProductDetail?
58 | savedByUsers SavedProduct[]
59 |
60 | @@index([category])
61 | @@index([name])
62 | @@index([createdAt])
63 | @@index([category, createdAt])
64 | @@index([featured])
65 | }
66 |
67 | model Category {
68 | id Int @id @default(autoincrement())
69 | name String @unique
70 | description String
71 | createdAt DateTime @default(now())
72 | updatedAt DateTime @updatedAt
73 | }
74 |
75 | model ProductDetail {
76 | id Int @id @default(autoincrement())
77 | productId Int @unique
78 | sku String?
79 | brand String?
80 | stockCount Int @default(0)
81 | weight Float?
82 | dimensions String?
83 | materials String?
84 | origin String?
85 | warrantyInfo String?
86 | createdAt DateTime @default(now())
87 | updatedAt DateTime @updatedAt
88 |
89 | product Product @relation(fields: [productId], references: [id])
90 | }
91 |
92 | model Review {
93 | id Int @id @default(autoincrement())
94 | productId Int
95 | rating Int
96 | comment String?
97 | createdAt DateTime @default(now())
98 | updatedAt DateTime @updatedAt
99 |
100 | product Product @relation(fields: [productId], references: [id])
101 | }
102 |
103 | model SavedProduct {
104 | id Int @id @default(autoincrement())
105 | accountId String
106 | productId Int
107 | createdAt DateTime @default(now())
108 |
109 | account Account @relation(fields: [accountId], references: [id])
110 | product Product @relation(fields: [productId], references: [id])
111 |
112 | @@unique([accountId, productId])
113 | }
114 |
115 | model Discount {
116 | id Int @id @default(autoincrement())
117 | code String @unique
118 | description String
119 | percentage Int
120 | expiry DateTime
121 | isActive Boolean @default(true)
122 | createdAt DateTime @default(now())
123 | updatedAt DateTime @updatedAt
124 |
125 | userDiscounts UserDiscount[]
126 | }
127 |
128 | model UserDiscount {
129 | id Int @id @default(autoincrement())
130 | accountId String
131 | discountId Int
132 | isUsed Boolean @default(false)
133 | usedAt DateTime?
134 | createdAt DateTime @default(now())
135 |
136 | account Account @relation(fields: [accountId], references: [id])
137 | discount Discount @relation(fields: [discountId], references: [id])
138 |
139 | @@unique([accountId, discountId])
140 | }
141 |
--------------------------------------------------------------------------------
/app/user/page.tsx:
--------------------------------------------------------------------------------
1 | import { Mail, MapPin, Phone, User } from 'lucide-react';
2 | import React from 'react';
3 | import Boundary from '@/components/internal/Boundary';
4 | import { getCurrentAccountWithDetails } from '@/features/auth/auth-queries';
5 |
6 | export default async function UserPage() {
7 | const account = await getCurrentAccountWithDetails();
8 |
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
{account?.name}
16 | {account?.firstName && account?.lastName && (
17 |
18 | {account?.firstName} {account?.lastName}
19 |
20 | )}
21 |
22 |
23 |
24 |
25 |
Contact Information
26 |
27 |
28 |
29 | {account?.email}
30 |
31 | {account?.phone && (
32 |
33 |
34 |
{account?.phone}
35 |
36 | )}
37 | {(account?.address || account?.city || account?.country || account?.zipCode) && (
38 |
39 |
40 |
41 | {account?.address && {account?.address} }
42 |
43 | {[account?.city, account?.zipCode].filter(Boolean).join(' ')}
44 | {account?.city && account?.country && ', '}
45 | {account?.country}
46 |
47 |
48 |
49 | )}
50 |
51 |
52 | {account?.accountDetail && (
53 |
54 |
Preferences
55 |
56 |
57 |
58 | {account?.accountDetail.timezone && (
59 |
60 | )}
61 |
62 |
66 |
67 |
68 | )}
69 |
70 | {account?.birthDate && (
71 |
72 |
Personal Information
73 |
74 |
75 | )}
76 |
77 |
78 | );
79 | }
80 |
81 | function PreferenceItem({ label, value }: { label: string; value: string }) {
82 | return (
83 |
84 | {label}:
85 | {value}
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/components/ui/ProductCard.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import React, { Suspense } from 'react';
3 | import Product, { ProductSkeleton } from '@/features/product/components/Product';
4 | import ProductModal from '@/features/product/components/ProductModal';
5 | import ImagePlaceholder from './ImagePlaceholder';
6 |
7 | type ProductCardProps = {
8 | id: number;
9 | name: string;
10 | price: number;
11 | description?: string;
12 | badge?: string;
13 | variant?: 'default' | 'compact';
14 | className?: string;
15 | enableQuickPreview?: boolean;
16 | };
17 |
18 | export default function ProductCard({
19 | id,
20 | name,
21 | price,
22 | description,
23 | badge,
24 | variant = 'default',
25 | className = '',
26 | enableQuickPreview = false,
27 | }: ProductCardProps) {
28 | const card =
29 | variant === 'compact' ? (
30 |
34 |
35 |
36 |
37 |
38 |
39 | {name}
40 |
41 | {description && (
42 |
43 | {description}
44 |
45 | )}
46 |
${price.toFixed(2)}
47 |
48 |
49 | ) : (
50 |
54 |
55 |
56 | {badge && (
57 |
58 | {badge}
59 |
60 | )}
61 |
62 |
63 |
{name}
64 |
65 | ${price.toFixed(2)}
66 |
67 |
68 |
69 | );
70 |
71 | if (enableQuickPreview) {
72 | return (
73 |
74 | {card}
75 |
76 | }>
77 |
78 |
79 |
80 |
81 | );
82 | }
83 |
84 | return card;
85 | }
86 |
87 | export function ProductCardSkeleton({
88 | variant = 'default',
89 | className = '',
90 | }: {
91 | variant?: 'default' | 'compact';
92 | className?: string;
93 | }) {
94 | if (variant === 'compact') {
95 | return (
96 |
97 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 | );
108 | }
109 |
110 | return (
111 |
118 | );
119 | }
120 |
--------------------------------------------------------------------------------
/components/banner/WelcomeBanner.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import { Suspense } from 'react';
3 | import { getCurrentAccount, getIsAuthenticated } from '@/features/auth/auth-queries';
4 | import { getSavedProducts } from '@/features/product/product-queries';
5 | import { getUserDiscounts } from '@/features/user/user-queries';
6 | import Boundary from '../internal/Boundary';
7 | import { MotionDiv } from '../ui/MotionWrappers';
8 | import { BannerContainer } from './BannerContainer';
9 |
10 | export default function WelcomeBanner() {
11 | return (
12 |
13 | }>
14 |
15 |
16 |
17 | );
18 | }
19 |
20 | export async function PersonalBanner() {
21 | const loggedIn = await getIsAuthenticated();
22 | if (!loggedIn) return ;
23 |
24 | const [account, discounts, savedProducts] = await Promise.all([
25 | getCurrentAccount(),
26 | getUserDiscounts(),
27 | getSavedProducts(),
28 | ]);
29 |
30 | const featuredDiscount = discounts[0];
31 | const firstName = account?.firstName || account?.name.split(' ')[0];
32 |
33 | return (
34 |
35 |
45 |
46 | {featuredDiscount ? 'Exclusive Discount' : 'Welcome Back'}
47 |
48 | Welcome back {firstName}
49 |
50 | {featuredDiscount ? (
51 | <>
52 | Use code{' '}
53 |
54 | {featuredDiscount.code}
55 | {' '}
56 | for {featuredDiscount.percentage}% off –{' '}
57 | {featuredDiscount.description.toLowerCase()}. Expires {featuredDiscount.expiry.toLocaleDateString()}.
58 | >
59 | ) : savedProducts.length > 0 ? (
60 | <>
61 | You have {savedProducts.length} saved products waiting for you. Ready
62 | to continue shopping?
63 | >
64 | ) : (
65 | <>Ready to discover something new? Start browsing our latest collection.>
66 | )}
67 |
68 |
69 | {featuredDiscount && (
70 |
71 | {discounts.length > 1 ? `View all ${discounts.length} discounts` : 'View discount details'} →
72 |
73 | )}
74 | {savedProducts.length > 0 && (
75 |
76 | View Saved Items ({savedProducts.length}) →
77 |
78 | )}
79 | {!featuredDiscount && savedProducts.length === 0 && (
80 |
81 | Start Shopping →
82 |
83 | )}
84 |
85 |
86 |
87 | );
88 | }
89 |
90 | export function GeneralBanner() {
91 | return (
92 |
93 |
94 |
95 | Member Perks
96 |
97 |
Unlock Exclusive Discounts
98 |
99 |
100 | Sign up
101 | {' '}
102 | to access special offers on your favorite products.
103 |
104 |
105 |
106 | );
107 | }
108 |
--------------------------------------------------------------------------------
/components/internal/Boundary.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useRef, useEffect, useState } from 'react';
4 | import { cn } from '@/utils/cn';
5 | import { useBoundaryMode } from './BoundaryProvider';
6 |
7 | type RenderingType = 'static' | 'dynamic' | 'hybrid';
8 | type HydrationType = 'server' | 'client' | 'hybrid';
9 |
10 | type Props = {
11 | children: React.ReactNode;
12 | rendering?: RenderingType;
13 | hydration?: HydrationType;
14 | label?: string;
15 | showLabel?: boolean;
16 | cached?: boolean;
17 | };
18 |
19 | const renderingColors = {
20 | dynamic: 'border-blue-500 bg-blue-50 dark:bg-blue-950/20',
21 | hybrid: 'border-purple-500 bg-purple-50 dark:bg-purple-950/20',
22 | static: 'border-red-500 bg-red-50 dark:bg-red-950/20',
23 | } as const;
24 |
25 | const componentColors = {
26 | client: 'border-blue-500 bg-blue-50 dark:bg-blue-950/20',
27 | hybrid: 'border-purple-500 bg-purple-50 dark:bg-purple-950/20',
28 | server: 'border-red-500 bg-red-50 dark:bg-red-950/20',
29 | } as const;
30 |
31 | export default function Boundary({ children, rendering, hydration, label, showLabel = true, cached = false }: Props) {
32 | const { mode } = useBoundaryMode();
33 | const containerRef = useRef(null);
34 | const [isSmall, setIsSmall] = useState(false);
35 |
36 | useEffect(() => {
37 | const checkSize = () => {
38 | if (containerRef.current) {
39 | const { width, height } = containerRef.current.getBoundingClientRect();
40 | setIsSmall(width < 60 || height < 60);
41 | }
42 | };
43 |
44 | checkSize();
45 | const resizeObserver = new ResizeObserver(checkSize);
46 | if (containerRef.current) {
47 | resizeObserver.observe(containerRef.current);
48 | }
49 |
50 | return () => {
51 | resizeObserver.disconnect();
52 | };
53 | }, []);
54 |
55 | if (mode === 'off') {
56 | return <>{children}>;
57 | }
58 |
59 | const showRendering = mode === 'rendering' && rendering;
60 | const showComponent = mode === 'hydration' && hydration;
61 |
62 | if (!showRendering && !showComponent) {
63 | return <>{children}>;
64 | }
65 |
66 | let colorClasses = '';
67 | let labelText = '';
68 | let labelColor = '';
69 |
70 | if (showRendering) {
71 | colorClasses = renderingColors[rendering!];
72 | if (showLabel) {
73 | labelText = label || `${rendering} rendering`;
74 | labelColor =
75 | rendering === 'dynamic'
76 | ? 'text-blue-700 dark:text-blue-300'
77 | : rendering === 'hybrid'
78 | ? 'text-purple-700 dark:text-purple-300'
79 | : 'text-red-700 dark:text-red-300';
80 | }
81 | } else if (showComponent) {
82 | colorClasses = componentColors[hydration!];
83 | if (showLabel) {
84 | labelText = label || `${hydration} component`;
85 | labelColor =
86 | hydration === 'client'
87 | ? 'text-blue-700 dark:text-blue-300'
88 | : hydration === 'hybrid'
89 | ? 'text-purple-700 dark:text-purple-300'
90 | : 'text-red-700 dark:text-red-300';
91 | }
92 | }
93 |
94 | if (isSmall) {
95 | let circleColorClasses = '';
96 |
97 | if (showRendering) {
98 | circleColorClasses =
99 | rendering === 'dynamic' ? 'border-blue-500' : rendering === 'hybrid' ? 'border-purple-500' : 'border-red-500';
100 | } else if (showComponent) {
101 | circleColorClasses =
102 | hydration === 'client' ? 'border-blue-500' : hydration === 'hybrid' ? 'border-purple-500' : 'border-red-500';
103 | }
104 |
105 | return (
106 |
107 |
{children}
108 |
112 |
113 | );
114 | }
115 |
116 | return (
117 |
118 | {showLabel && labelText && (
119 |
120 |
126 | {labelText}
127 |
128 | {cached && mode === 'rendering' && (
129 |
130 | cached
131 |
132 | )}
133 |
134 | )}
135 | {children}
136 |
137 | );
138 | }
139 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import { Suspense } from 'react';
3 | import WelcomeBanner from '@/components/banner/WelcomeBanner';
4 | import Boundary from '@/components/internal/Boundary';
5 | import LinkButton from '@/components/ui/LinkButton';
6 | import { getIsAuthenticated } from '@/features/auth/auth-queries';
7 | import FeaturedCategories from '@/features/category/components/FeaturedCategories';
8 | import FeaturedProducts from '@/features/product/components/FeaturedProducts';
9 | import Hero from '@/features/product/components/Hero';
10 | import Recommendations, { RecommendationsSkeleton } from '@/features/user/components/Recommendations';
11 |
12 | export default async function HomePage() {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
Featured Categories
22 |
23 | View All →
24 |
25 |
26 |
27 |
28 |
Featured Products
29 |
30 | View All Products →
31 |
32 |
33 |
34 |
35 |
36 |
Member Rewards
37 |
38 | Unlock exclusive perks like extra discounts, early product launches, and priority support. Sign in to access
39 | your dashboard and discover new offers!
40 |
41 |
}>
42 |
43 |
44 |
45 |
46 |
Trade-In Program
47 |
Upgrade your devices and get credit towards your next purchase.
48 |
49 | Learn More
50 |
51 |
52 |
53 |
54 | Quick Links
55 |
56 | Price Match
57 | Support
58 | Free Delivery
59 | My Account
60 | Returns
61 | Gift Cards
62 |
63 |
64 |
65 | );
66 | }
67 |
68 | async function PersonalizedSection() {
69 | const loggedIn = await getIsAuthenticated();
70 |
71 | if (!loggedIn) {
72 | return null;
73 | }
74 |
75 | return (
76 | <>
77 |
78 |
79 |
Something for You?
80 |
81 | Personalized recommendations based on your interests
82 |
83 |
84 |
85 | View Saved →
86 |
87 |
88 | }>
89 |
90 |
91 | >
92 | );
93 | }
94 |
95 | async function PersonalMembershipLink() {
96 | const loggedIn = await getIsAuthenticated();
97 | if (!loggedIn) return ;
98 |
99 | return (
100 |
101 |
102 | Go to Dashboard
103 |
104 |
105 | );
106 | }
107 |
108 | function GeneralMembershipLink() {
109 | return (
110 |
111 | Sign In to Join
112 |
113 | );
114 | }
115 |
--------------------------------------------------------------------------------
/.vscode/snippets.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | "welcomeBanner": {
3 | "scope": "typescriptreact",
4 | "prefix": "welcomeBanner",
5 | "body": [
6 | "export default function WelcomeBanner() {",
7 | " return (",
8 | " ",
9 | " }>",
10 | " ",
11 | " ",
12 | " ",
13 | " );",
14 | "}"
15 | ],
16 | },
17 | "membershipLink": {
18 | "scope": "typescriptreact",
19 | "prefix": "membershipLink",
20 | "body": [
21 | " }>",
22 | " ",
23 | ""
24 | ]
25 | },
26 | "generateStaticParams": {
27 | "scope": "typescriptreact",
28 | "prefix": "generateStaticParams",
29 | "body": [
30 | "export async function generateStaticParams() {",
31 | " return [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }];",
32 | "}"
33 | ]
34 | },
35 | "bookmarkSuspense": {
36 | "scope": "typescriptreact",
37 | "prefix": "bookmarkSuspense",
38 | "body": [
39 | " }>",
40 |
41 | ]
42 | },
43 | "useAuth": {
44 | "scope": "typescriptreact",
45 | "prefix": "useAuth",
46 | "body": [
47 | "const { loggedIn: loggedInPromise } = useAuth()",
48 | "const loggedIn = use(loggedInPromise);"
49 | ]
50 | },
51 | "suspenseWithFallback": {
52 | "scope": "typescriptreact",
53 | "prefix": "suspenseWithFallback",
54 | "body": [
55 | " }>",
56 | " ${TM_SELECTED_TEXT}",
57 | ""
58 | ],
59 | "description": "Wraps selected component with Suspense and duplicates the name for fallback Skeleton."
60 | },
61 | "isAuth": {
62 | "scope": "typescriptreact",
63 | "prefix": "isAuth",
64 | "body": [
65 | "const loggedIn = await getIsAuthenticated();",
66 | ]
67 | },
68 | "getDiscountData": {
69 | "scope": "typescriptreact",
70 | "prefix": "getDiscountData",
71 | "body": [
72 | "const [account, discounts, savedProducts] = await Promise.all([",
73 | " getCurrentAccount(),",
74 | " getUserDiscounts(),",
75 | " getSavedProducts(),",
76 | "]);",
77 | ]
78 | },
79 | "bannerContainer": {
80 | "scope": "typescriptreact",
81 | "prefix": "bannerContainer",
82 | "body": [
83 | "export default function BannerContainer({children}: { children: React.ReactNode }) {",
84 | ]
85 | },
86 | "uc": {
87 | "scope": "typescriptreact",
88 | "prefix": "uc",
89 | "body": [
90 | "\"use cache\"",
91 | ]
92 | },
93 | "cacheApis": {
94 | "scope": "typescriptreact",
95 | "prefix": "cacheApis",
96 | "body": [
97 | "cacheTag('featured-product')",
98 | "revalidateTag('featured-product')",
99 | "cacheLife('days')"
100 | ]
101 | },
102 | "loadingPage": {
103 | "scope": "typescriptreact",
104 | "prefix": "loadingPage",
105 | "body": [
106 | "export default function Loading() {",
107 | " return (",
108 | " <>",
109 | " ",
110 | " ",
111 | "
",
112 | "
",
113 | "
Categories ",
114 | " ",
115 | " ",
116 | "
",
117 | "
",
118 | "
",
119 | "
",
120 | "
",
121 | " ",
122 | "
",
123 | "
",
124 | "
",
125 | " ",
126 | "
",
127 | "
",
128 | "
",
129 | "
",
130 | " >",
131 | " );",
132 | "}"
133 | ]
134 | }
135 | }
136 |
137 |
--------------------------------------------------------------------------------
/app/about/page.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowRight, Award, Gift, Package, RefreshCw, Shield, Truck } from 'lucide-react';
2 | import React from 'react';
3 | import Card from '@/components/ui/Card';
4 | import Divider from '@/components/ui/Divider';
5 | import LinkButton from '@/components/ui/LinkButton';
6 |
7 | export default function AboutPage() {
8 | return (
9 |
10 |
11 |
12 | About Us
13 |
14 |
Premium Electronics & Services
15 |
16 | Your trusted partner for cutting-edge technology and exceptional service.
17 |
18 |
19 | Start Shopping
20 |
21 |
22 |
23 | }
25 | title="Price Match Guarantee"
26 | description="Found a lower price? We'll match it and beat it by 5%."
27 | badge="Best Price"
28 | />
29 | }
31 | title="Free Delivery"
32 | description="Complimentary shipping on orders over $50 with same-day processing."
33 | badge="Free Shipping"
34 | />
35 | }
37 | title="Trade-In Program"
38 | description="Get instant credit towards your next purchase with our certified appraisal."
39 | badge="Instant Credit"
40 | />
41 | }
43 | title="24/7 Support"
44 | description="Expert technical support available around the clock."
45 | badge="Expert Help"
46 | />
47 | }
49 | title="Easy Returns"
50 | description="30-day hassle-free returns with free return shipping."
51 | badge="30-Day Policy"
52 | />
53 | }
55 | title="Gift Cards"
56 | description="Perfect for tech enthusiasts. No expiration date, digital delivery available."
57 | badge="No Expiry"
58 | />
59 |
60 |
61 |
62 |
63 |
Built for Excellence
64 |
65 | Our platform leverages Next.js 15 with advanced caching for lightning-fast performance and seamless shopping
66 | experiences.
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
Our Promise
75 |
76 | Quality products, exceptional service, and customer satisfaction are the pillars of our business.
77 |
78 |
79 |
80 | Browse Products
81 |
82 |
83 | Contact Support
84 |
85 |
86 |
87 |
88 |
89 | );
90 | }
91 |
92 | function ServiceCard({
93 | icon,
94 | title,
95 | description,
96 | badge,
97 | }: {
98 | icon: React.ReactNode;
99 | title: string;
100 | description: string;
101 | badge: string;
102 | }) {
103 | return (
104 |
105 |
106 | {badge}
107 |
108 |
109 |
{icon}
110 |
111 |
{title}
112 |
{description}
113 |
114 |
115 |
116 | );
117 | }
118 |
119 | function StatCard({ number, label }: { number: string; label: string }) {
120 | return (
121 |
122 |
{number}
123 |
{label}
124 |
125 | );
126 | }
127 |
--------------------------------------------------------------------------------
/features/product/product-queries.ts:
--------------------------------------------------------------------------------
1 | import 'server-only';
2 |
3 | import { notFound } from 'next/navigation';
4 | import { cache } from 'react';
5 | import { prisma } from '@/db';
6 | import { slow } from '@/utils/slow';
7 | import { verifyAuth } from '../auth/auth-actions';
8 |
9 | export const getProduct = cache(async (productId: number) => {
10 | await slow();
11 |
12 | if (!productId || isNaN(productId) || productId <= 0) {
13 | notFound();
14 | }
15 |
16 | const product = await prisma.product.findUnique({
17 | where: { id: productId },
18 | });
19 | if (!product) {
20 | notFound();
21 | }
22 | return product;
23 | });
24 |
25 | export const getProductDetails = cache(async (productId: number) => {
26 | await slow();
27 |
28 | if (!productId || isNaN(productId) || productId <= 0) {
29 | notFound();
30 | }
31 |
32 | const productDetails = await prisma.productDetail.findUnique({
33 | where: { productId },
34 | });
35 |
36 | if (!productDetails) {
37 | notFound();
38 | }
39 | return productDetails;
40 | });
41 |
42 | export const getProducts = cache(
43 | async (searchQuery?: string, sort?: 'asc' | 'desc', page = 1, limit = 9, category?: string) => {
44 | const skip = (page - 1) * limit;
45 |
46 | const whereClause: {
47 | name?: { contains: string; mode: 'insensitive' };
48 | category?: { equals: string; mode: 'insensitive' };
49 | } = {};
50 |
51 | if (searchQuery) {
52 | whereClause.name = {
53 | contains: searchQuery,
54 | mode: 'insensitive' as const,
55 | };
56 | }
57 |
58 | if (category) {
59 | whereClause.category = {
60 | equals: category,
61 | mode: 'insensitive' as const,
62 | };
63 | }
64 |
65 | const [products, total] = await prisma.$transaction([
66 | prisma.product.findMany({
67 | orderBy: {
68 | name: sort === 'asc' ? 'asc' : 'desc', // Fixed: was reversed
69 | },
70 | select: {
71 | category: true,
72 | createdAt: true,
73 | description: true,
74 | id: true,
75 | name: true,
76 | price: true,
77 | },
78 | skip,
79 | take: limit,
80 | where: whereClause,
81 | }),
82 | prisma.product.count({
83 | where: whereClause,
84 | }),
85 | ]);
86 |
87 | return {
88 | currentPage: page,
89 | products,
90 | total,
91 | totalPages: Math.ceil(total / limit),
92 | };
93 | },
94 | );
95 |
96 | export const getReviews = cache(async (productId: number) => {
97 | await slow();
98 |
99 | return prisma.review.findMany({
100 | orderBy: { createdAt: 'desc' },
101 | where: { productId },
102 | });
103 | });
104 |
105 | export const isSavedProduct = cache(async (productId: number) => {
106 | const accountId = await verifyAuth();
107 |
108 | const savedProduct = await prisma.savedProduct.findUnique({
109 | where: {
110 | accountId_productId: {
111 | accountId,
112 | productId,
113 | },
114 | },
115 | });
116 |
117 | return !!savedProduct;
118 | });
119 |
120 | export const getSavedProducts = cache(async () => {
121 | await slow();
122 |
123 | const accountId = await verifyAuth();
124 |
125 | const savedProducts = await prisma.savedProduct.findMany({
126 | include: {
127 | product: true,
128 | },
129 | orderBy: { createdAt: 'desc' },
130 | where: { accountId },
131 | });
132 |
133 | return savedProducts.map(saved => {
134 | return saved.product;
135 | });
136 | });
137 |
138 | export const getFeaturedProducts = cache(async (limit = 4) => {
139 | await slow(2000);
140 |
141 | const featuredProducts = await prisma.product.findMany({
142 | orderBy: { updatedAt: 'desc' },
143 | take: limit,
144 | where: { featured: true },
145 | });
146 |
147 | if (featuredProducts.length < limit) {
148 | const additionalNeeded = limit - featuredProducts.length;
149 | const featuredIds = featuredProducts.map(p => {
150 | return p.id;
151 | });
152 |
153 | const recentProducts = await prisma.product.findMany({
154 | orderBy: { createdAt: 'desc' },
155 | take: additionalNeeded,
156 | where: {
157 | id: { notIn: featuredIds },
158 | },
159 | });
160 |
161 | return [...featuredProducts, ...recentProducts];
162 | }
163 |
164 | return featuredProducts;
165 | });
166 |
167 | export const getRecommendedProducts = cache(async (limit = 4) => {
168 | await slow(500);
169 |
170 | const accountId = await verifyAuth();
171 |
172 | // Get user's saved products to understand their preferences
173 | const savedProducts = await prisma.savedProduct.findMany({
174 | include: {
175 | product: true,
176 | },
177 | where: { accountId },
178 | });
179 |
180 | let recommendedProducts: Awaited> = [];
181 |
182 | if (savedProducts.length > 0) {
183 | // Get a random category from saved products
184 | const categories = Array.from(
185 | new Set(
186 | savedProducts
187 | .map(sp => {
188 | return sp.product.category;
189 | })
190 | .filter(Boolean),
191 | ),
192 | );
193 | const randomCategory = categories[Math.floor(Math.random() * categories.length)];
194 |
195 | if (randomCategory) {
196 | // Get products from that category, excluding already saved ones
197 | recommendedProducts = await prisma.product.findMany({
198 | orderBy: { createdAt: 'desc' },
199 | take: limit,
200 | where: {
201 | category: {
202 | equals: randomCategory,
203 | mode: 'insensitive',
204 | },
205 | id: {
206 | notIn: savedProducts.map(sp => {
207 | return sp.product.id;
208 | }),
209 | },
210 | },
211 | });
212 | }
213 | }
214 |
215 | // If no recommendations or not enough, fall back to featured products
216 | if (recommendedProducts.length < limit) {
217 | const featuredProducts = await getFeaturedProducts(limit);
218 | const excludeIds = [
219 | ...savedProducts.map(sp => {
220 | return sp.product.id;
221 | }),
222 | ...recommendedProducts.map(p => {
223 | return p.id;
224 | }),
225 | ];
226 |
227 | const additionalProducts = featuredProducts.filter(product => {
228 | return !excludeIds.includes(product.id);
229 | });
230 |
231 | recommendedProducts = [...recommendedProducts, ...additionalProducts];
232 | }
233 |
234 | return recommendedProducts.slice(0, limit);
235 | });
236 |
237 | export async function setFeaturedProduct(productId: number) {
238 | if (!productId || isNaN(productId) || productId <= 0) {
239 | throw new Error('Invalid product ID');
240 | }
241 |
242 | // First, unfeatured all other products
243 | await prisma.product.updateMany({
244 | data: { featured: false },
245 | where: { featured: true },
246 | });
247 |
248 | // Then feature the specified product
249 | const updatedProduct = await prisma.product.update({
250 | data: { featured: true },
251 | where: { id: productId },
252 | });
253 |
254 | if (!updatedProduct) {
255 | throw new Error('Product not found');
256 | }
257 |
258 | return updatedProduct;
259 | }
260 |
--------------------------------------------------------------------------------
/STEPS.md:
--------------------------------------------------------------------------------
1 | # DEMO STEPS
2 |
3 | ## Setup and problem
4 |
5 | - This is a simple app mimicking e commerce platform.
6 | - Show app. Home page user dep, browse page, product about page, login page, page user dep page. We have a good mix of static and dynamic content because of our user dependent features. (Everything here looks pretty decent, but there's certainly too many loading states for an ecommerce app.)
7 | - Let's see the code.
8 | - App router, I have all my pages here. I'm using feature slicing to keep the app router folder clean and easy to read. Services and queries talking to my db which is using Prisma ORM. Purposefully added slowness to my data fetching.
9 | - App actually has commonly seen issues with prop drilling making it hard to maintain and refactor features, excessive client side JS, and lack of static rendering strategies leading to additional server costs and degraded performance.
10 | - The goal here is to improve this regular Next.js codebase and enhance it with examples of modern patterns regarding architecture, composition, and caching capabilities, to make it faster, more scalable, and easier to maintain.
11 | - (Improvements based on my exp building with server comp also and other codebases I have seen, and what devs commonly do wrong or struggle to find solutions for).
12 |
13 | ## Excessive prop drilling -> component level fetching and authProvider: app/page.tsx
14 |
15 | - Let's start simple, the first issue is with architecture and deep prop drilling.
16 | - I'm noticing some issues. Fetching auth state top level, passing to components multiple levels down. This is a common problem, making our components less reusable and composable, and the code hard to read.
17 | - But we are blocking the initial load, might be hard to know. We don't need to fetch top level with server components. Best practice is to push promises to resolve deeper down. We will see later how we can get help with this though. Co-locate data fetching with UI.
18 | - Refactor to add reach cache to deduplicate multiple calls to this per page load. If using fetch it's auto deduped. Fetch inside components, improve structure: PersonalizedSection suspend.
19 | - (MembershipTile, suspend the personalized for the general, ensuring we have a proper fallback and avoiding CLS).
20 | - What about client WelcomeBanner, WelcomeBanner? Cant use my await isAuth. Always need this dep when using WelcomeBanner, passing it multiple levels down, forcing the parent to handle this dep, cant move this freely. This loggedIn a dep we will encounter forever into the future of our apps life.
21 | - Let's utilize a smart pattern. Add authprovider. Let's not await this and block the root page, instead pass it as a promise down, keep it as a promise in the client provider.
22 | - Welcomebanner: Remove prop all the way down, rather read it with use() inside PersonalBanner. Now we need to suspend Personalbanner with GeneralBanner, same pattern as before to avoid CLS and provide something useful, while promise resolves with use(). WelcomeBanner is now composable again.
23 | - Any time we need the logged in variable, with is a lot, we can fetch it locally either with async functions or this auth provider, avoiding a lot of future prop drilling.
24 | - Bonus: Create a custom hook useLoggedIn to avoid using use() inside components. Showcase in Provider.
25 | - (Same problem in our user profile, getting the logged in state of a user on the server and passing it to the client. Do the same refactor here, login button composable and easily reused somewhere else in the future.)
26 | - Since our welcomebanner is composable again, we can add it to the all page easily.
27 | - Through those patterns, by fetching inside components and utilizing cache() and use() we can now maintain good component architecture. Reusable and composable.
28 |
29 | ## Excessive client JS -> Client/Server composition: WelcomeBanner
30 |
31 | - The next issue is excessive client side JS, and large components with multiple responsibilities.
32 | - (Check out this client-side Pagination using search params. Client side due to nav status with a transition. Preventing default. There are some new tools we can use to handle this very common use case better. Remove all client side code here and isPending. Lost interactivity).
33 | - (Replace with LinkStatus. A rather new nextjs feature, useLinkStatus. Like useFormStatus, avoid lack of feedback on stale navigation while waiting for the search param. See local pending state, using this also on the category links in the bottom here and the sort. Very small amount of client JS added, only what is needed for interactivity).
34 | - Revisit the WelcomeBanner. It's dismissing this with a useState(). Switched to client side fetching with useSWR just to make this dismissable, multiple ways to fetch now with API layer, no types.
35 | - Also, we break separation of concerns by involving UI logic with data. Instead, let's extract a client component wrapper, and use whats referred to as the donut pattern. Cut all except top line of comp. New file bannerContainer: use client here, rename, children, wrapper. We won't covert the content of this to client because it's a prop, could be any prop. It's a reference to server-rendered content.
36 | - PersonalBanner remove use client and switch to server fetching getDiscountData, isAuth and return general, and delete API layer, no longer needed. Export WelcomeBanner client wrapper with suspense. Type safe also.
37 | - Still have an error. For the motion.div, this simple animation might still be forcing the entire banner to be client. Let's move this to a MotionWrapper component, that can be reused for other animations. Could also switch to a react view transition! Back to server components now. Delete API layer.
38 | - I have a boundary UI helper here. Turn on hydration mode, marking my components. See the donut pattern visual. Notice other boundaries, like client side search, and these server side categories.
39 | - Since we learned the donut pattern, let's use it for something else as well. I want to hide the some categories if theres many. Notice the individual server components here. We again want to avoid excessive client side JS, so avoid creating API endpoints and converting everything. Replace div with ShowMore client wrapper and React.Children to maintain our separation of concerns. Now, we have this reusable and interactive ShowMore wrapper, and reusable categories. Notice the boundaries client and server, donut pattern again.
40 | - The compositional power of server components, Categories is passed into this ShowMore, handles its own data. Both can be used freely all over the app.
41 | - Donut pattern can be used for anything, like carousels and modals more. Actually using it for this quick preview modal, showcase modal boundary donut pattern again. Remember this next time you want to add interactivity to a server component.
42 | - Learned donut pattern, how to utilize composition, we avoided client side js, which means we can move further to the last issue. Remove boundary UI.
43 |
44 | ## Discuss dynamic issues
45 |
46 | - The last issue is a lack of static rendering strategies.
47 | - See build output: The entire app is entirely dynamic, problem is clear. Every page has a dynamic API dependency.
48 | - This is preventing us from using static rendering benefits and for example using ISR, even though so much of the app is static.
49 | - Demo all pages: wasting server resources constantly, slowing down our app. Why is this happening?
50 | - (Crawlers will wait for content and it can be indexed, and the QWV is not terrible, but it's slower than it needs to be).
51 | - The main culprit is actually this auth check in my layout. My header is hiding my user profile, which is using cookies, which is forcing dynamic rendering. Auth check in layout, which I definitely need. Classic mistake. Everything I do is now dynamically being run on the server. Because remember, my pages are either be static OR dynamic.
52 | - (Even my non-user personalized content on my home screen like the featured product, I need to suspend too to avoid blocking the page, and even my about page which doesn't even have a dynamic API dep!)
53 | - This is a common issue and something that's been solved before. Let's briefly see which solutions people might resort to in previous versions of Next.
54 |
55 | ### Static/dynamic split
56 |
57 | - Open new vs code branch and web browser deployed branch.
58 | - Could make a route group. Move all static pages out, simple auth layout. Create AppLayout and pass it data from the auth layout.
59 | - Show build output about page. Good for apps with very clear static/dynamic page boundaries.
60 | - Now we have additional layouts and deps to remember, and more complexity.
61 | - And still, the home page is dynamic because of the recommendations and banner loggedIn, and product page due to this save feature and dynamic reviews. Route groups is not a good solution for this app. These are important pages. What else can we try?
62 |
63 | ### Request context
64 |
65 | - Open next vs code branch and web browser deployed branch. Here, I created a request context URL param, that is being set in middleware, now called proxy. Encoded request context in the URL.
66 | - We can generateStaticParams the different variants of loggedIn/nonLoggedIn state.
67 | - Call this function instead of isAuthenticated now. Avoiding doing auth check in the components themselves.
68 | - This is actually a common pattern, and is also recommended by the vercel flags sdk using a precompute function. And used by i18n libraries.
69 | - But to be able to cache this product page, I need to client side fetch with useSWR the user specific stuff on the product page, and the reviews because we want them fresh. All to get this cache HIT!
70 | - (Passing lot's of props now from the server side params, losing compositional benefits.)
71 | - It's even more complex, hassle API endpoints, multiple data fetching strategies again. Pages routes flashbacks.
72 | - (And we're even breaking the sweet new feature typed routes! Need this as Route everywhere.)
73 | - And what about the home page, do we need to client side fetch everything user specific here too?
74 | - This is a viable pattern, and useful for many regardless etc, but let's say we are actually not interested in rewriting our app.
75 | - What if we could avoid all of these smart workarounds? What if there was a simpler way?
76 | - Go back to real vscode.
77 |
78 | ## Excessive dynamic rendering -> Composable caching with 'use cache'
79 |
80 | ### Home page
81 |
82 | - There is a simpler way: Enable next 16 cacheComponents. This will opt all our async calls into dynamic, and also give us errors whenever an async call does not have a suspense boundary above it, and allow us to use the new 'use cache' directive to mark components, functions, or pages as cachable.
83 | - First', let's review the rendering strategy of our apps home page. Let's go back to our banner on the Home page.
84 | - Toggle the rendering boundary, see the dynamic WelcomeBanner, user profile dynamic too.
85 | - What about these other ones? For example Hero.
86 | - Hero.tsx is async, but doesn't depend on dynamic APIs. In this dynamic route, its slow. In a static, would be fast. Marked hybrid, also notice mark on FeaturedCategories, FeaturedProducts, not depending on dynamic APIs either.
87 | - Now, everything here that's marked as hybrid can be cached. It's async and fetching something, but it does not depend on request time information like cookies, so we can share it across multiple users. Notice how right now its loading on every request.
88 | - (Try "use cache" Home page, see the error. Dynamic components imported here.)
89 | - Add "use cache" to the Hero to cache this. Add cacheApis snippet: cacheTag for fine grained revalidation with revalidateTag, cacheLife revalidation period. Mark it as "cached". We can remove this suspense boundary and skeleton. See it's no longer loading.
90 | - (One cache key linked to components, no hassle revalidating many different pages).
91 | - And every cached segment will included in the statically generated shell from Partial Prerendering, cached on the CDN. PPR goes down as far as the cache goes, until it meets a dynamic API, like the WelcomeBanner or the PersonalizedSection. Our Hero can be included in the static shell.
92 | - Do the same for the FeaturedCategories and FeaturedProducts: use cache and mark, remove suspense. Less stress with skeletons and CLS.
93 | - Now they're all cached, no longer loading on every request. Only thing that loads is the personalized content. We are no longer bound to page level static/dynamic rendering.
94 | - If I had this auth dep here, PPR would not be able to include anything in the static shell. That's why my pattern of resolving promises deeper is good for both composition and caching.
95 |
96 | ### All page
97 |
98 | - See the rest of the boundaries pre-marked on other pages: all products. Categories and products. Reload -> we can cache this too.
99 | - We have an error from nextjs though, categories doesn't have a suspense above it. Blocking route. Ah, my page actually blocked, slow loading.
100 | - CacheComponents helping identifying blocking calls, a is common problem, avoiding performance issues. Next.js tells us we should either cache or suspend this. Make a choice: I can either a loading.tsx, or cache the data fetch.
101 | - Simple solution, add huge loading.tsx skeleton code. That works, not useful loading UI though, cant interact with anything.
102 | - So, with cacheComponents, dynamic is like a scale, and it's up to us to decide how much static we want. Let's shift the page more towards static, and create a bigger static shell here. Delete loading.tsx.
103 | - Use pattern we learned in the beginning, resolve getCategories deeper down, inside the CategoryFilters component twice for my responsive view, add react cache() deduping, not a problem for my responsive view. Add use cache to this, and mark it as cached, dont need to suspend.
104 | - As you can see, CacheComponents making sure we follow best practices for RSC, and actually helping us think about where we resolve our promises, improving component architecture.
105 | - Still error on searchparams, dynamic API, cant cache this. Refactor to resolve deeper down. Now I have a bigger static shell. Error gone, suspended by the product list.
106 | - Loading state, search is now accessible from the start, and I can see my welcome banner and close this already. Great UX improvement.
107 | - Keep my Products hybrid, because I want them fresh.
108 | - Footer -> Categories: Can only use cache async functions, but since we already use the donut here it’s not a problem for the ShowMore, allowing us to cache more content as well as getting compositional benefits. It's all connected. Remove suspense.
109 | - See initial load, big static shell, only product list loads.
110 |
111 | ### Product page
112 |
113 | - Let's finally tackle the product page, also pre-marked with boundaries.
114 | - Add "use cache" to Product, mark cached, remove suspense, it's no longer loading on every request.
115 | - Try add use cache to the product details. It fails, exposing our dynamic API. Why? We have a dynamic dep. A pretty cool optimistic save product button, showing the saved state of that product for the user. Instead of importing the dynamic dep, slot as children, and interleave it. Composable caching! Children reference can change without affecting the cache entry. Donut pattern, but for caching. Now, we can cache the ProductDetails, mark cached.
116 | - Remove details suspense, add the suspense there with Bookmark! Interactive user specific content still works.
117 | - Keep the Reviews hybrid, want them fresh.
118 | - No longer loading on every request. Small chunk of dynamic content only, pushed the loading state all the way down.
119 | - Error in product page, no cache above params. We will still see this params resolve in the deployment, it's inside params, so it can't be static. Either we add a loading.tsx, or we can use generateStaticParams. Add an example generateStaticParams for a few products. Now it will ready for those, then cached as it's generated by users. The error is gone. Pick what is best for your use case and data set.
120 | - (For incrementally adopting cacheComponents, we would need to start high up with a dep, then build down and refactor out our dynamic APIs).
121 | - Done with the codebase refactor. Head over to a deployed version.
122 |
123 | ## Final demo
124 |
125 | - Remember i have purposefully added a lot of slows to this app.
126 | - New tab: see the initial page loads. Almost my entire home page is already available. Only the personalized section and banner load. Navigate to the all products page, then the product page.
127 | - See the boundary: again, every cached segment will be a part of the statically generated shell from Partial Prerendering, and in prod, improved prefetching new client side router from next 16, shell is prefetched for even faster navigations.
128 | - (Params are already known for all links on the page. Clicking categories within the app already resolved search params, so the shell is already there. Only on reload can we see it resolve here).
129 | - (With just a few code changes and smart patterns, we improved components architecture, removed redundant client js and allowed for more component reuse, and by caching more content we increased performance drastically and reduce server costs.)
130 | - To summarize, with cacheComponents, there is no static OR dynamic pages. We don't need to be avoiding dynamic APIs anymore, or compromise dynamic content. Skip creating complex hacks or workarounds or add multiple data fetching strategies, and make the developer experience worse, just for that cache HIT.
131 | - In modern Next.js, dynamic vs static is a scale, and we decide how much static we want in our apps. By following certain patterns, we can use this one mental model, performant, composable and salable by default.
132 |
--------------------------------------------------------------------------------
/prisma/seed.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import { PrismaClient } from '@prisma/client';
3 |
4 | const prisma = new PrismaClient();
5 |
6 | const CATEGORIES = [
7 | {
8 | description: 'Essential gadgets and accessories to enhance your productivity.',
9 | name: 'Accessories',
10 | },
11 | {
12 | description: 'Premium headphones, speakers, and audio equipment for conference-quality sound.',
13 | name: 'Audio',
14 | },
15 | {
16 | description: 'High-performance devices and electronics for professionals.',
17 | name: 'Electronics',
18 | },
19 | {
20 | description: 'Essential items and appliances to enhance your living space.',
21 | name: 'Home',
22 | },
23 | {
24 | description: 'Premium kitchen appliances and tools for your modern kitchen.',
25 | name: 'Kitchen',
26 | },
27 | {
28 | description: 'Connect your workspace with intelligent automation solutions.',
29 | name: 'Smart Home',
30 | },
31 | {
32 | description: 'Smart devices to track your health and productivity.',
33 | name: 'Wearables',
34 | },
35 | ];
36 |
37 | const ACCOUNTS = [
38 | {
39 | address: '123 Tech Street',
40 | birthDate: new Date('1990-03-15'),
41 | city: 'San Francisco',
42 | country: 'United States',
43 | email: 'jane.smith@gmail.com',
44 | firstName: 'Jane',
45 | id: 'a833bc10-64dd-4069-8573-4bbb4b0065ed',
46 | lastName: 'Smith',
47 | name: 'Jane Smith',
48 | phone: '+1-555-0123',
49 | zipCode: '94105',
50 | },
51 | ];
52 |
53 | const ACCOUNT_DETAILS = [
54 | {
55 | accountId: 'a833bc10-64dd-4069-8573-4bbb4b0065ed',
56 | language: 'en',
57 | newsletter: true,
58 | notifications: true,
59 | theme: 'dark',
60 | timezone: 'America/Los_Angeles',
61 | },
62 | ];
63 |
64 | const PRODUCTS = [
65 | // Audio Category (6 products)
66 | {
67 | category: 'Audio',
68 | description: 'High-quality noise cancelling headphones with 20 hours battery life',
69 | featured: true,
70 | id: 1,
71 | name: 'Wireless Headphones',
72 | price: 199.99,
73 | },
74 | {
75 | category: 'Audio',
76 | description: 'Waterproof Bluetooth speaker with 360-degree sound',
77 | id: 3,
78 | name: 'Portable Speaker',
79 | price: 79.99,
80 | },
81 | {
82 | category: 'Audio',
83 | description: 'Premium studio-quality headphones for audio professionals',
84 | id: 13,
85 | name: 'Studio Headphones',
86 | price: 299.99,
87 | },
88 | {
89 | category: 'Audio',
90 | description: 'Compact earbuds with active noise cancellation',
91 | id: 14,
92 | name: 'True Wireless Earbuds',
93 | price: 129.99,
94 | },
95 | {
96 | category: 'Audio',
97 | description: 'High-power Bluetooth speaker with deep bass',
98 | id: 15,
99 | name: 'Bass Speaker',
100 | price: 149.99,
101 | },
102 | {
103 | category: 'Audio',
104 | description: 'Professional USB microphone for podcasting and streaming',
105 | id: 16,
106 | name: 'USB Microphone',
107 | price: 89.99,
108 | },
109 |
110 | // Wearables Category (5 products)
111 | {
112 | category: 'Wearables',
113 | description: 'Fitness tracker with heart rate monitor and sleep tracking',
114 | id: 2,
115 | name: 'Smart Watch',
116 | price: 149.95,
117 | },
118 | {
119 | category: 'Wearables',
120 | description: 'Advanced fitness tracker with GPS and water resistance',
121 | id: 17,
122 | name: 'Fitness Tracker Pro',
123 | price: 199.99,
124 | },
125 | {
126 | category: 'Wearables',
127 | description: 'Smart ring for health monitoring and sleep analysis',
128 | id: 18,
129 | name: 'Smart Ring',
130 | price: 249.99,
131 | },
132 | {
133 | category: 'Wearables',
134 | description: 'Kids smartwatch with GPS tracking and parental controls',
135 | id: 19,
136 | name: 'Kids Smart Watch',
137 | price: 99.99,
138 | },
139 | {
140 | category: 'Wearables',
141 | description: 'Heart rate monitor chest strap for serious athletes',
142 | id: 20,
143 | name: 'Heart Rate Monitor',
144 | price: 69.99,
145 | },
146 |
147 | // Accessories Category (6 products)
148 | {
149 | category: 'Accessories',
150 | description: 'Ergonomic wireless mouse with precision tracking',
151 | id: 4,
152 | name: 'Wireless Mouse',
153 | price: 29.99,
154 | },
155 | {
156 | category: 'Accessories',
157 | description: 'Mechanical keyboard with RGB backlighting',
158 | id: 5,
159 | name: 'Gaming Keyboard',
160 | price: 89.95,
161 | },
162 | {
163 | category: 'Accessories',
164 | description: 'Ultra-thin laptop stand with adjustable height',
165 | id: 6,
166 | name: 'Laptop Stand',
167 | price: 39.99,
168 | },
169 | {
170 | category: 'Accessories',
171 | description: 'Fast wireless charger compatible with all Qi devices',
172 | id: 7,
173 | name: 'Wireless Charger',
174 | price: 24.99,
175 | },
176 | {
177 | category: 'Accessories',
178 | description: 'Multi-port USB-C hub with 4K HDMI output',
179 | id: 21,
180 | name: 'USB-C Hub',
181 | price: 79.99,
182 | },
183 | {
184 | category: 'Accessories',
185 | description: 'Premium leather laptop sleeve with magnetic closure',
186 | id: 22,
187 | name: 'Laptop Sleeve',
188 | price: 49.99,
189 | },
190 |
191 | // Electronics Category (6 products)
192 | {
193 | category: 'Electronics',
194 | description: 'HD webcam with auto-focus and noise reduction',
195 | id: 8,
196 | name: 'HD Webcam',
197 | price: 59.99,
198 | },
199 | {
200 | category: 'Electronics',
201 | description: 'Portable power bank with 20,000mAh capacity',
202 | id: 9,
203 | name: 'Power Bank',
204 | price: 49.95,
205 | },
206 | {
207 | category: 'Electronics',
208 | description: '4K action camera with image stabilization',
209 | id: 23,
210 | name: '4K Action Camera',
211 | price: 199.99,
212 | },
213 | {
214 | category: 'Electronics',
215 | description: 'Wireless charging pad with fast charging support',
216 | id: 24,
217 | name: 'Charging Pad',
218 | price: 34.99,
219 | },
220 | {
221 | category: 'Electronics',
222 | description: 'Portable projector with wireless connectivity',
223 | id: 25,
224 | name: 'Mini Projector',
225 | price: 299.99,
226 | },
227 | {
228 | category: 'Electronics',
229 | description: 'Digital photo frame with Wi-Fi and cloud sync',
230 | id: 26,
231 | name: 'Digital Photo Frame',
232 | price: 129.99,
233 | },
234 |
235 | // Smart Home Category (5 products)
236 | {
237 | category: 'Smart Home',
238 | description: 'Smart home security camera with night vision',
239 | id: 10,
240 | name: 'Security Camera',
241 | price: 129.99,
242 | },
243 | {
244 | category: 'Smart Home',
245 | description: 'Smart doorbell with video and two-way audio',
246 | id: 27,
247 | name: 'Smart Doorbell',
248 | price: 179.99,
249 | },
250 | {
251 | category: 'Smart Home',
252 | description: 'Smart light bulbs with color changing and app control',
253 | id: 28,
254 | name: 'Smart Light Bulbs',
255 | price: 39.99,
256 | },
257 | {
258 | category: 'Smart Home',
259 | description: 'Smart thermostat with energy saving features',
260 | id: 29,
261 | name: 'Smart Thermostat',
262 | price: 249.99,
263 | },
264 | {
265 | category: 'Smart Home',
266 | description: 'Smart plug with voice control and scheduling',
267 | id: 30,
268 | name: 'Smart Plug',
269 | price: 19.99,
270 | },
271 |
272 | // Kitchen Category (5 products)
273 | {
274 | category: 'Kitchen',
275 | description: 'Premium coffee maker with programmable timer',
276 | id: 11,
277 | name: 'Coffee Maker',
278 | price: 179.95,
279 | },
280 | {
281 | category: 'Kitchen',
282 | description: 'Smart air fryer with app control and presets',
283 | id: 31,
284 | name: 'Smart Air Fryer',
285 | price: 149.99,
286 | },
287 | {
288 | category: 'Kitchen',
289 | description: 'High-speed blender with multiple speed settings',
290 | id: 32,
291 | name: 'High-Speed Blender',
292 | price: 199.99,
293 | },
294 | {
295 | category: 'Kitchen',
296 | description: 'Electric kettle with temperature control',
297 | id: 33,
298 | name: 'Electric Kettle',
299 | price: 69.99,
300 | },
301 | {
302 | category: 'Kitchen',
303 | description: 'Food processor with multiple attachments',
304 | id: 34,
305 | name: 'Food Processor',
306 | price: 129.99,
307 | },
308 |
309 | // Home Category (5 products)
310 | {
311 | category: 'Home',
312 | description: 'Air purifier with HEPA filter for clean air',
313 | id: 12,
314 | name: 'Air Purifier',
315 | price: 249.99,
316 | },
317 | {
318 | category: 'Home',
319 | description: 'Humidifier with essential oil diffuser function',
320 | id: 35,
321 | name: 'Humidifier',
322 | price: 89.99,
323 | },
324 | {
325 | category: 'Home',
326 | description: 'Robot vacuum with smart mapping technology',
327 | id: 36,
328 | name: 'Robot Vacuum',
329 | price: 399.99,
330 | },
331 | {
332 | category: 'Home',
333 | description: 'Tower fan with remote control and timer',
334 | id: 37,
335 | name: 'Tower Fan',
336 | price: 119.99,
337 | },
338 | {
339 | category: 'Home',
340 | description: 'LED desk lamp with wireless charging base',
341 | id: 38,
342 | name: 'LED Desk Lamp',
343 | price: 79.99,
344 | },
345 | ];
346 |
347 | const REVIEWS = [
348 | // Audio category reviews
349 | {
350 | comment: 'Best headphones I have ever owned. The noise cancellation is amazing!',
351 | productId: 1,
352 | rating: 5,
353 | },
354 | {
355 | comment: 'Great sound quality but a bit uncomfortable after long use.',
356 | productId: 1,
357 | rating: 4,
358 | },
359 | {
360 | comment: 'Good sound but not as loud as I expected.',
361 | productId: 3,
362 | rating: 3,
363 | },
364 | {
365 | comment: 'Incredible sound quality for studio work!',
366 | productId: 13,
367 | rating: 5,
368 | },
369 | {
370 | comment: 'Perfect for my daily commute, great noise cancellation.',
371 | productId: 14,
372 | rating: 5,
373 | },
374 | {
375 | comment: 'Amazing bass response, perfect for parties.',
376 | productId: 15,
377 | rating: 4,
378 | },
379 | {
380 | comment: 'Crystal clear audio for my podcast recordings.',
381 | productId: 16,
382 | rating: 5,
383 | },
384 |
385 | // Wearables category reviews
386 | {
387 | comment: 'Perfect fitness companion, battery lasts for days!',
388 | productId: 2,
389 | rating: 5,
390 | },
391 | {
392 | comment: 'GPS tracking is very accurate for my runs.',
393 | productId: 17,
394 | rating: 5,
395 | },
396 | {
397 | comment: 'Comfortable to wear 24/7, great sleep tracking.',
398 | productId: 18,
399 | rating: 4,
400 | },
401 | {
402 | comment: 'My kids love it and I feel more secure knowing their location.',
403 | productId: 19,
404 | rating: 5,
405 | },
406 | {
407 | comment: 'Very accurate heart rate monitoring for workouts.',
408 | productId: 20,
409 | rating: 4,
410 | },
411 |
412 | // Accessories category reviews
413 | {
414 | comment: 'Perfect mouse for daily use, very comfortable grip.',
415 | productId: 4,
416 | rating: 5,
417 | },
418 | {
419 | comment: 'Great tactile feedback and the RGB looks amazing!',
420 | productId: 5,
421 | rating: 4,
422 | },
423 | {
424 | comment: 'Sturdy build quality, perfect for my MacBook.',
425 | productId: 6,
426 | rating: 5,
427 | },
428 | {
429 | comment: 'Charges my phone quickly, very convenient.',
430 | productId: 7,
431 | rating: 4,
432 | },
433 | {
434 | comment: 'All the ports I need in one compact hub.',
435 | productId: 21,
436 | rating: 5,
437 | },
438 | {
439 | comment: 'Beautiful leather quality, fits my laptop perfectly.',
440 | productId: 22,
441 | rating: 5,
442 | },
443 |
444 | // Electronics category reviews
445 | {
446 | comment: 'Crystal clear video quality for video calls.',
447 | productId: 8,
448 | rating: 5,
449 | },
450 | {
451 | comment: 'Reliable power bank, charges my devices multiple times.',
452 | productId: 9,
453 | rating: 4,
454 | },
455 | {
456 | comment: 'Amazing 4K quality for my adventure videos.',
457 | productId: 23,
458 | rating: 5,
459 | },
460 | {
461 | comment: 'Fast charging and sleek design.',
462 | productId: 24,
463 | rating: 4,
464 | },
465 | {
466 | comment: 'Perfect for movie nights, surprisingly bright picture.',
467 | productId: 25,
468 | rating: 4,
469 | },
470 | {
471 | comment: 'Love seeing family photos displayed all day.',
472 | productId: 26,
473 | rating: 5,
474 | },
475 |
476 | // Smart Home category reviews
477 | {
478 | comment: 'Easy setup and great night vision quality.',
479 | productId: 10,
480 | rating: 5,
481 | },
482 | {
483 | comment: 'Never miss a delivery again, great video quality.',
484 | productId: 27,
485 | rating: 5,
486 | },
487 | {
488 | comment: 'Love changing colors to match my mood.',
489 | productId: 28,
490 | rating: 4,
491 | },
492 | {
493 | comment: 'Saves money on energy bills and very smart.',
494 | productId: 29,
495 | rating: 5,
496 | },
497 | {
498 | comment: 'Simple setup and works perfectly with voice commands.',
499 | productId: 30,
500 | rating: 4,
501 | },
502 |
503 | // Kitchen category reviews
504 | {
505 | comment: 'Makes excellent coffee, timer feature is very useful.',
506 | productId: 11,
507 | rating: 4,
508 | },
509 | {
510 | comment: 'Healthy cooking made easy, food comes out crispy.',
511 | productId: 31,
512 | rating: 5,
513 | },
514 | {
515 | comment: 'Powerful blender, makes perfect smoothies every time.',
516 | productId: 32,
517 | rating: 5,
518 | },
519 | {
520 | comment: 'Perfect temperature control for different teas.',
521 | productId: 33,
522 | rating: 4,
523 | },
524 | {
525 | comment: 'Saves so much time in meal prep.',
526 | productId: 34,
527 | rating: 5,
528 | },
529 |
530 | // Home category reviews
531 | {
532 | comment: 'Noticeably cleaner air, quiet operation.',
533 | productId: 12,
534 | rating: 5,
535 | },
536 | {
537 | comment: 'Perfect humidity levels and love the aromatherapy feature.',
538 | productId: 35,
539 | rating: 4,
540 | },
541 | {
542 | comment: 'Keeps my floors spotless without any effort.',
543 | productId: 36,
544 | rating: 5,
545 | },
546 | {
547 | comment: 'Quiet operation and powerful airflow.',
548 | productId: 37,
549 | rating: 4,
550 | },
551 | {
552 | comment: 'Great light quality and convenient wireless charging.',
553 | productId: 38,
554 | rating: 5,
555 | },
556 | ];
557 |
558 | const PRODUCT_DETAILS = [
559 | // Audio category details
560 | {
561 | brand: 'SoundMaster',
562 | dimensions: '7.5 x 6.5 x 3.2 inches',
563 | materials: 'Memory foam, aluminum, plastic',
564 | origin: 'Japan',
565 | productId: 1,
566 | sku: 'WH-NC100',
567 | stockCount: 45,
568 | warrantyInfo: '2 year limited warranty',
569 | weight: 0.25,
570 | },
571 | {
572 | brand: 'AudioPro',
573 | dimensions: '5.5 x 5.5 x 8.2 inches',
574 | materials: 'Rubber, fabric, plastic',
575 | origin: 'Taiwan',
576 | productId: 3,
577 | sku: 'PS-BT300',
578 | stockCount: 78,
579 | warrantyInfo: '1 year limited warranty',
580 | weight: 0.6,
581 | },
582 | {
583 | brand: 'ProAudio',
584 | dimensions: '8.2 x 7.1 x 3.8 inches',
585 | materials: 'Premium leather, steel, memory foam',
586 | origin: 'Germany',
587 | productId: 13,
588 | sku: 'SH-PRO130',
589 | stockCount: 23,
590 | warrantyInfo: '3 year limited warranty',
591 | weight: 0.35,
592 | },
593 | {
594 | brand: 'SoundMini',
595 | dimensions: '1.2 x 1.0 x 0.8 inches',
596 | materials: 'Silicone, titanium drivers',
597 | origin: 'Denmark',
598 | productId: 14,
599 | sku: 'TWE-NC140',
600 | stockCount: 67,
601 | warrantyInfo: '1 year limited warranty',
602 | weight: 0.01,
603 | },
604 | {
605 | brand: 'BassMax',
606 | dimensions: '8.0 x 8.0 x 12.5 inches',
607 | materials: 'Metal grille, rubber, plastic',
608 | origin: 'USA',
609 | productId: 15,
610 | sku: 'BS-MAX150',
611 | stockCount: 34,
612 | warrantyInfo: '2 year limited warranty',
613 | weight: 1.2,
614 | },
615 | {
616 | brand: 'StreamPro',
617 | dimensions: '3.5 x 3.5 x 8.0 inches',
618 | materials: 'Metal mesh, aluminum body',
619 | origin: 'Sweden',
620 | productId: 16,
621 | sku: 'USB-MIC160',
622 | stockCount: 89,
623 | warrantyInfo: '2 year limited warranty',
624 | weight: 0.4,
625 | },
626 |
627 | // Wearables category details
628 | {
629 | brand: 'TechFit',
630 | dimensions: '1.6 x 1.6 x 0.5 inches',
631 | materials: 'Silicone, aluminum, glass',
632 | origin: 'China',
633 | productId: 2,
634 | sku: 'SW-FIT200',
635 | stockCount: 32,
636 | warrantyInfo: '1 year limited warranty',
637 | weight: 0.05,
638 | },
639 | {
640 | brand: 'FitTrack',
641 | dimensions: '1.8 x 1.8 x 0.6 inches',
642 | materials: 'Titanium, sapphire glass, silicone',
643 | origin: 'Switzerland',
644 | productId: 17,
645 | sku: 'FT-PRO170',
646 | stockCount: 45,
647 | warrantyInfo: '2 year limited warranty',
648 | weight: 0.08,
649 | },
650 | {
651 | brand: 'HealthRing',
652 | dimensions: '0.8 x 0.8 x 0.3 inches',
653 | materials: 'Titanium, ceramic, medical-grade silicone',
654 | origin: 'Finland',
655 | productId: 18,
656 | sku: 'HR-SMART180',
657 | stockCount: 28,
658 | warrantyInfo: '1 year limited warranty',
659 | weight: 0.005,
660 | },
661 | {
662 | brand: 'KidSafe',
663 | dimensions: '1.4 x 1.4 x 0.5 inches',
664 | materials: 'Food-grade silicone, plastic',
665 | origin: 'Canada',
666 | productId: 19,
667 | sku: 'KS-WATCH190',
668 | stockCount: 76,
669 | warrantyInfo: '1 year limited warranty',
670 | weight: 0.04,
671 | },
672 | {
673 | brand: 'AthleteHR',
674 | dimensions: '2.5 x 1.5 x 0.8 inches',
675 | materials: 'Soft fabric strap, plastic sensor',
676 | origin: 'Norway',
677 | productId: 20,
678 | sku: 'AH-MONITOR200',
679 | stockCount: 123,
680 | warrantyInfo: '1 year limited warranty',
681 | weight: 0.06,
682 | },
683 |
684 | // Accessories category details
685 | {
686 | brand: 'ErgoTech',
687 | dimensions: '4.7 x 2.8 x 1.5 inches',
688 | materials: 'ABS plastic, rubber grip',
689 | origin: 'China',
690 | productId: 4,
691 | sku: 'WM-ERG400',
692 | stockCount: 156,
693 | warrantyInfo: '1 year limited warranty',
694 | weight: 0.1,
695 | },
696 | {
697 | brand: 'GamePro',
698 | dimensions: '17.3 x 5.1 x 1.4 inches',
699 | materials: 'Aluminum frame, mechanical switches',
700 | origin: 'Taiwan',
701 | productId: 5,
702 | sku: 'KB-RGB500',
703 | stockCount: 89,
704 | warrantyInfo: '2 year limited warranty',
705 | weight: 1.2,
706 | },
707 | {
708 | brand: 'DeskMate',
709 | dimensions: '10 x 9 x 6 inches',
710 | materials: 'Aluminum alloy, silicone pads',
711 | origin: 'USA',
712 | productId: 6,
713 | sku: 'LS-ADJ600',
714 | stockCount: 67,
715 | warrantyInfo: '1 year limited warranty',
716 | weight: 0.8,
717 | },
718 | {
719 | brand: 'ChargeFast',
720 | dimensions: '4 x 4 x 0.4 inches',
721 | materials: 'Tempered glass, aluminum',
722 | origin: 'Korea',
723 | productId: 7,
724 | sku: 'WC-QI700',
725 | stockCount: 234,
726 | warrantyInfo: '1 year limited warranty',
727 | weight: 0.2,
728 | },
729 | {
730 | brand: 'ConnectPro',
731 | dimensions: '4.5 x 2.0 x 0.7 inches',
732 | materials: 'Aluminum alloy, USB-C connectors',
733 | origin: 'Netherlands',
734 | productId: 21,
735 | sku: 'CP-HUB210',
736 | stockCount: 145,
737 | warrantyInfo: '2 year limited warranty',
738 | weight: 0.15,
739 | },
740 | {
741 | brand: 'LeatherCraft',
742 | dimensions: '14 x 10 x 0.5 inches',
743 | materials: 'Premium leather, microfiber lining',
744 | origin: 'Italy',
745 | productId: 22,
746 | sku: 'LC-SLEEVE220',
747 | stockCount: 87,
748 | warrantyInfo: '1 year limited warranty',
749 | weight: 0.25,
750 | },
751 |
752 | // Electronics category details
753 | {
754 | brand: 'StreamCam',
755 | dimensions: '3.7 x 2.1 x 2.1 inches',
756 | materials: 'Plastic housing, glass lens',
757 | origin: 'China',
758 | productId: 8,
759 | sku: 'HD-CAM800',
760 | stockCount: 123,
761 | warrantyInfo: '1 year limited warranty',
762 | weight: 0.15,
763 | },
764 | {
765 | brand: 'PowerMax',
766 | dimensions: '6.3 x 3 x 0.8 inches',
767 | materials: 'Lithium polymer, ABS plastic',
768 | origin: 'China',
769 | productId: 9,
770 | sku: 'PB-20K900',
771 | stockCount: 178,
772 | warrantyInfo: '1 year limited warranty',
773 | weight: 0.45,
774 | },
775 | {
776 | brand: 'ActionPro',
777 | dimensions: '2.5 x 1.8 x 1.2 inches',
778 | materials: 'Waterproof housing, glass lens',
779 | origin: 'Japan',
780 | productId: 23,
781 | sku: 'AP-4K230',
782 | stockCount: 56,
783 | warrantyInfo: '2 year limited warranty',
784 | weight: 0.12,
785 | },
786 | {
787 | brand: 'QuickCharge',
788 | dimensions: '6 x 4 x 0.5 inches',
789 | materials: 'Tempered glass, wireless coils',
790 | origin: 'Korea',
791 | productId: 24,
792 | sku: 'QC-PAD240',
793 | stockCount: 198,
794 | warrantyInfo: '1 year limited warranty',
795 | weight: 0.3,
796 | },
797 | {
798 | brand: 'MiniBeam',
799 | dimensions: '5.5 x 3.5 x 1.8 inches',
800 | materials: 'Aluminum housing, LED projector',
801 | origin: 'Taiwan',
802 | productId: 25,
803 | sku: 'MB-PROJ250',
804 | stockCount: 34,
805 | warrantyInfo: '2 year limited warranty',
806 | weight: 0.6,
807 | },
808 | {
809 | brand: 'PhotoDisplay',
810 | dimensions: '8.5 x 6.0 x 0.8 inches',
811 | materials: 'Wood frame, LCD display',
812 | origin: 'China',
813 | productId: 26,
814 | sku: 'PD-FRAME260',
815 | stockCount: 73,
816 | warrantyInfo: '1 year limited warranty',
817 | weight: 0.5,
818 | },
819 |
820 | // Smart Home category details
821 | {
822 | brand: 'SecureHome',
823 | dimensions: '4.5 x 4.5 x 6.2 inches',
824 | materials: 'Weather-resistant plastic, glass',
825 | origin: 'Taiwan',
826 | productId: 10,
827 | sku: 'SC-NV1000',
828 | stockCount: 56,
829 | warrantyInfo: '2 year limited warranty',
830 | weight: 0.35,
831 | },
832 | {
833 | brand: 'DoorGuard',
834 | dimensions: '4.8 x 2.4 x 1.0 inches',
835 | materials: 'Weather-resistant plastic, metal',
836 | origin: 'USA',
837 | productId: 27,
838 | sku: 'DG-BELL270',
839 | stockCount: 89,
840 | warrantyInfo: '2 year limited warranty',
841 | weight: 0.25,
842 | },
843 | {
844 | brand: 'LightSmart',
845 | dimensions: '2.4 x 2.4 x 4.3 inches',
846 | materials: 'Plastic housing, LED chips',
847 | origin: 'Netherlands',
848 | productId: 28,
849 | sku: 'LS-BULB280',
850 | stockCount: 267,
851 | warrantyInfo: '2 year limited warranty',
852 | weight: 0.08,
853 | },
854 | {
855 | brand: 'ClimateControl',
856 | dimensions: '4.2 x 4.2 x 1.0 inches',
857 | materials: 'Plastic, electronic components',
858 | origin: 'Germany',
859 | productId: 29,
860 | sku: 'CC-THERMO290',
861 | stockCount: 43,
862 | warrantyInfo: '3 year limited warranty',
863 | weight: 0.18,
864 | },
865 | {
866 | brand: 'PowerSmart',
867 | dimensions: '2.7 x 1.6 x 2.8 inches',
868 | materials: 'Fire-resistant plastic',
869 | origin: 'China',
870 | productId: 30,
871 | sku: 'PS-PLUG300',
872 | stockCount: 456,
873 | warrantyInfo: '1 year limited warranty',
874 | weight: 0.12,
875 | },
876 |
877 | // Kitchen category details
878 | {
879 | brand: 'BrewMaster',
880 | dimensions: '12 x 8 x 14 inches',
881 | materials: 'Stainless steel, glass carafe',
882 | origin: 'Germany',
883 | productId: 11,
884 | sku: 'CM-PRO1100',
885 | stockCount: 34,
886 | warrantyInfo: '3 year limited warranty',
887 | weight: 3.2,
888 | },
889 | {
890 | brand: 'CookSmart',
891 | dimensions: '14 x 11 x 13 inches',
892 | materials: 'Stainless steel, non-stick coating',
893 | origin: 'France',
894 | productId: 31,
895 | sku: 'CS-AIRFRY310',
896 | stockCount: 67,
897 | warrantyInfo: '2 year limited warranty',
898 | weight: 4.5,
899 | },
900 | {
901 | brand: 'BlendMax',
902 | dimensions: '7 x 8 x 17 inches',
903 | materials: 'Stainless steel blades, BPA-free plastic',
904 | origin: 'USA',
905 | productId: 32,
906 | sku: 'BM-BLEND320',
907 | stockCount: 45,
908 | warrantyInfo: '3 year limited warranty',
909 | weight: 3.8,
910 | },
911 | {
912 | brand: 'HotWater',
913 | dimensions: '9 x 6 x 10 inches',
914 | materials: 'Stainless steel, plastic base',
915 | origin: 'UK',
916 | productId: 33,
917 | sku: 'HW-KETTLE330',
918 | stockCount: 98,
919 | warrantyInfo: '2 year limited warranty',
920 | weight: 1.5,
921 | },
922 | {
923 | brand: 'ChopMaster',
924 | dimensions: '12 x 9 x 9 inches',
925 | materials: 'Stainless steel, BPA-free plastic',
926 | origin: 'Germany',
927 | productId: 34,
928 | sku: 'CM-PROC340',
929 | stockCount: 56,
930 | warrantyInfo: '2 year limited warranty',
931 | weight: 2.8,
932 | },
933 |
934 | // Home category details
935 | {
936 | brand: 'CleanAir',
937 | dimensions: '16 x 8 x 20 inches',
938 | materials: 'ABS plastic, HEPA filter',
939 | origin: 'Sweden',
940 | productId: 12,
941 | sku: 'AP-HEPA1200',
942 | stockCount: 45,
943 | warrantyInfo: '2 year limited warranty',
944 | weight: 4.8,
945 | },
946 | {
947 | brand: 'MoistureMax',
948 | dimensions: '8 x 8 x 12 inches',
949 | materials: 'BPA-free plastic, ceramic components',
950 | origin: 'Japan',
951 | productId: 35,
952 | sku: 'MM-HUMID350',
953 | stockCount: 78,
954 | warrantyInfo: '1 year limited warranty',
955 | weight: 2.1,
956 | },
957 | {
958 | brand: 'CleanBot',
959 | dimensions: '13.4 x 13.4 x 3.6 inches',
960 | materials: 'ABS plastic, rubber brushes',
961 | origin: 'Korea',
962 | productId: 36,
963 | sku: 'CB-ROBOT360',
964 | stockCount: 23,
965 | warrantyInfo: '2 year limited warranty',
966 | weight: 6.2,
967 | },
968 | {
969 | brand: 'AirFlow',
970 | dimensions: '12 x 12 x 42 inches',
971 | materials: 'Plastic housing, metal fan',
972 | origin: 'China',
973 | productId: 37,
974 | sku: 'AF-TOWER370',
975 | stockCount: 67,
976 | warrantyInfo: '2 year limited warranty',
977 | weight: 8.5,
978 | },
979 | {
980 | brand: 'DeskLight',
981 | dimensions: '6 x 6 x 18 inches',
982 | materials: 'Aluminum, LED strips, wireless charging coils',
983 | origin: 'Denmark',
984 | productId: 38,
985 | sku: 'DL-LAMP380',
986 | stockCount: 134,
987 | warrantyInfo: '2 year limited warranty',
988 | weight: 1.8,
989 | },
990 | ];
991 |
992 | const SAVED_PRODUCTS = [
993 | {
994 | accountId: 'a833bc10-64dd-4069-8573-4bbb4b0065ed',
995 | productId: 1,
996 | },
997 | {
998 | accountId: 'a833bc10-64dd-4069-8573-4bbb4b0065ed',
999 | productId: 3,
1000 | },
1001 | {
1002 | accountId: 'a833bc10-64dd-4069-8573-4bbb4b0065ed',
1003 | productId: 5,
1004 | },
1005 | {
1006 | accountId: 'a833bc10-64dd-4069-8573-4bbb4b0065ed',
1007 | productId: 8,
1008 | },
1009 | ];
1010 |
1011 | const DISCOUNTS = [
1012 | {
1013 | code: 'WELCOME20',
1014 | description: '20% off your first order',
1015 | expiry: new Date('2025-12-31'),
1016 | id: 1,
1017 | percentage: 20,
1018 | },
1019 | {
1020 | code: 'LOYAL15',
1021 | description: '15% off for loyal customers',
1022 | expiry: new Date('2025-09-30'),
1023 | id: 2,
1024 | percentage: 15,
1025 | },
1026 | {
1027 | code: 'TECH10',
1028 | description: '10% off electronics',
1029 | expiry: new Date('2025-08-15'),
1030 | id: 3,
1031 | percentage: 10,
1032 | },
1033 | ];
1034 |
1035 | const USER_DISCOUNTS = [
1036 | {
1037 | accountId: 'a833bc10-64dd-4069-8573-4bbb4b0065ed',
1038 | discountId: 1,
1039 | },
1040 | {
1041 | accountId: 'a833bc10-64dd-4069-8573-4bbb4b0065ed',
1042 | discountId: 2,
1043 | },
1044 | {
1045 | accountId: 'a833bc10-64dd-4069-8573-4bbb4b0065ed',
1046 | discountId: 3,
1047 | },
1048 | ];
1049 |
1050 | async function seed() {
1051 | // Delete all existing data in the correct order (respecting foreign key constraints)
1052 | console.info('[SEED] Deleting existing data...');
1053 | await prisma.userDiscount.deleteMany({});
1054 | await prisma.discount.deleteMany({});
1055 | await prisma.savedProduct.deleteMany({});
1056 | await prisma.review.deleteMany({});
1057 | await prisma.productDetail.deleteMany({});
1058 | await prisma.product.deleteMany({});
1059 | await prisma.accountDetail.deleteMany({});
1060 | await prisma.account.deleteMany({});
1061 | await prisma.category.deleteMany({});
1062 | console.info('[SEED] Successfully deleted existing data');
1063 |
1064 | // Create categories first
1065 | await Promise.all(
1066 | CATEGORIES.map(category => {
1067 | return prisma.category.create({
1068 | data: {
1069 | description: category.description,
1070 | name: category.name,
1071 | },
1072 | });
1073 | }),
1074 | )
1075 | .then(() => {
1076 | return console.info('[SEED] Successfully created category records');
1077 | })
1078 | .catch(e => {
1079 | return console.error('[SEED] Failed to create category records', e);
1080 | });
1081 |
1082 | // Create accounts
1083 | await Promise.all(
1084 | ACCOUNTS.map(account => {
1085 | return prisma.account.create({
1086 | data: {
1087 | address: account.address,
1088 | birthDate: account.birthDate,
1089 | city: account.city,
1090 | country: account.country,
1091 | email: account.email,
1092 | firstName: account.firstName,
1093 | id: account.id,
1094 | lastName: account.lastName,
1095 | name: account.name,
1096 | phone: account.phone,
1097 | zipCode: account.zipCode,
1098 | },
1099 | });
1100 | }),
1101 | )
1102 | .then(() => {
1103 | return console.info('[SEED] Successfully create account records');
1104 | })
1105 | .catch(e => {
1106 | return console.error('[SEED] Failed to create account records', e);
1107 | });
1108 |
1109 | // Create account details
1110 | await Promise.all(
1111 | ACCOUNT_DETAILS.map(detail => {
1112 | return prisma.accountDetail.create({
1113 | data: {
1114 | accountId: detail.accountId,
1115 | language: detail.language,
1116 | newsletter: detail.newsletter,
1117 | notifications: detail.notifications,
1118 | theme: detail.theme,
1119 | timezone: detail.timezone,
1120 | },
1121 | });
1122 | }),
1123 | )
1124 | .then(() => {
1125 | return console.info('[SEED] Successfully created account details records');
1126 | })
1127 | .catch(e => {
1128 | return console.error('[SEED] Failed to create account details records', e);
1129 | });
1130 |
1131 | // Create products
1132 | await Promise.all(
1133 | PRODUCTS.map(product => {
1134 | return prisma.product.create({
1135 | data: {
1136 | category: product.category,
1137 | description: product.description,
1138 | id: product.id,
1139 | name: product.name,
1140 | price: product.price,
1141 | },
1142 | });
1143 | }),
1144 | )
1145 | .then(() => {
1146 | console.info('[SEED] Successfully created product records');
1147 | })
1148 | .catch(e => {
1149 | console.error('[SEED] Failed to create product records', e);
1150 | });
1151 |
1152 | // Create reviews
1153 | await Promise.all(
1154 | REVIEWS.map(review => {
1155 | return prisma.review.create({
1156 | data: {
1157 | comment: review.comment,
1158 | productId: review.productId,
1159 | rating: review.rating,
1160 | },
1161 | });
1162 | }),
1163 | )
1164 | .then(() => {
1165 | console.info('[SEED] Successfully created review records');
1166 | })
1167 | .catch(e => {
1168 | console.error('[SEED] Failed to create review records', e);
1169 | });
1170 |
1171 | // Create product details
1172 | await Promise.all(
1173 | PRODUCT_DETAILS.map(detail => {
1174 | return prisma.productDetail.create({
1175 | data: {
1176 | brand: detail.brand,
1177 | dimensions: detail.dimensions,
1178 | materials: detail.materials,
1179 | origin: detail.origin,
1180 | productId: detail.productId,
1181 | sku: detail.sku,
1182 | stockCount: detail.stockCount,
1183 | warrantyInfo: detail.warrantyInfo,
1184 | weight: detail.weight,
1185 | },
1186 | });
1187 | }),
1188 | )
1189 | .then(() => {
1190 | console.info('[SEED] Successfully created product details records');
1191 | })
1192 | .catch(e => {
1193 | console.error('[SEED] Failed to create product details records', e);
1194 | });
1195 |
1196 | // Create saved products
1197 | await Promise.all(
1198 | SAVED_PRODUCTS.map(savedProduct => {
1199 | return prisma.savedProduct.create({
1200 | data: {
1201 | accountId: savedProduct.accountId,
1202 | productId: savedProduct.productId,
1203 | },
1204 | });
1205 | }),
1206 | )
1207 | .then(() => {
1208 | console.info('[SEED] Successfully created saved products records');
1209 | })
1210 | .catch(e => {
1211 | console.error('[SEED] Failed to create saved products records', e);
1212 | });
1213 |
1214 | // Create discounts
1215 | await Promise.all(
1216 | DISCOUNTS.map(discount => {
1217 | return prisma.discount.create({
1218 | data: {
1219 | code: discount.code,
1220 | description: discount.description,
1221 | expiry: discount.expiry,
1222 | id: discount.id,
1223 | percentage: discount.percentage,
1224 | },
1225 | });
1226 | }),
1227 | )
1228 | .then(() => {
1229 | console.info('[SEED] Successfully created discount records');
1230 | })
1231 | .catch(e => {
1232 | console.error('[SEED] Failed to create discount records', e);
1233 | });
1234 |
1235 | // Create user discounts
1236 | await Promise.all(
1237 | USER_DISCOUNTS.map(userDiscount => {
1238 | return prisma.userDiscount.create({
1239 | data: {
1240 | accountId: userDiscount.accountId,
1241 | discountId: userDiscount.discountId,
1242 | },
1243 | });
1244 | }),
1245 | )
1246 | .then(() => {
1247 | console.info('[SEED] Successfully created user discount records');
1248 | })
1249 | .catch(e => {
1250 | console.error('[SEED] Failed to create user discount records', e);
1251 | });
1252 | }
1253 |
1254 | seed();
1255 |
--------------------------------------------------------------------------------