diff --git a/.claude/settings.local.json b/.claude/settings.local.json index cbc53dd..b871948 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -148,7 +148,13 @@ "Bash(awk '{print $2}')", "Bash(xargs kill -9)", "Bash(taskkill /F /PID 30912)", - "Bash(curl -s http://localhost:3000)" + "Bash(curl -s http://localhost:3000)", + "Bash(Stop-Process -Id 30912 -Force -ErrorAction SilentlyContinue)", + "Bash(Start-Sleep -Milliseconds 500)", + "PowerShell(Stop-Process -Id 30912 -Force -ErrorAction SilentlyContinue)", + "Bash(psql postgresql://postgres:craft2prints@192.168.0.190:5432/craft2prints -c ' *)", + "Bash(node scripts/add-indexes.js)", + "Bash(curl -s http://127.0.0.1:3000)" ] } } diff --git a/prisma/migrations/add_performance_indexes/migration.sql b/prisma/migrations/add_performance_indexes/migration.sql new file mode 100644 index 0000000..6cc5d8c --- /dev/null +++ b/prisma/migrations/add_performance_indexes/migration.sql @@ -0,0 +1,8 @@ +-- Add performance indexes +CREATE INDEX IF NOT EXISTS "Category_showOnPersonalised_idx" ON "Category"("showOnPersonalised"); +CREATE INDEX IF NOT EXISTS "Category_showOnProducts_idx" ON "Category"("showOnProducts"); +CREATE INDEX IF NOT EXISTS "Category_parentId_idx" ON "Category"("parentId"); +CREATE INDEX IF NOT EXISTS "Collection_group_idx" ON "Collection"("group"); +CREATE INDEX IF NOT EXISTS "Product_showOnPersonalised_idx" ON "Product"("showOnPersonalised"); +CREATE INDEX IF NOT EXISTS "Product_showAsReadyMade_idx" ON "Product"("showAsReadyMade"); +CREATE INDEX IF NOT EXISTS "Product_category_idx" ON "Product"("category"); diff --git a/scripts/add-indexes.js b/scripts/add-indexes.js new file mode 100644 index 0000000..194549e --- /dev/null +++ b/scripts/add-indexes.js @@ -0,0 +1,53 @@ +const { PrismaClient } = require('@prisma/client'); + +const prisma = new PrismaClient(); + +async function main() { + try { + console.log('Adding performance indexes...'); + + await prisma.$executeRawUnsafe( + 'CREATE INDEX IF NOT EXISTS "Category_showOnPersonalised_idx" ON "Category"("showOnPersonalised")' + ); + console.log('✓ Category_showOnPersonalised_idx'); + + await prisma.$executeRawUnsafe( + 'CREATE INDEX IF NOT EXISTS "Category_showOnProducts_idx" ON "Category"("showOnProducts")' + ); + console.log('✓ Category_showOnProducts_idx'); + + await prisma.$executeRawUnsafe( + 'CREATE INDEX IF NOT EXISTS "Category_parentId_idx" ON "Category"("parentId")' + ); + console.log('✓ Category_parentId_idx'); + + await prisma.$executeRawUnsafe( + 'CREATE INDEX IF NOT EXISTS "Collection_group_idx" ON "Collection"("group")' + ); + console.log('✓ Collection_group_idx'); + + await prisma.$executeRawUnsafe( + 'CREATE INDEX IF NOT EXISTS "Product_showOnPersonalised_idx" ON "Product"("showOnPersonalised")' + ); + console.log('✓ Product_showOnPersonalised_idx'); + + await prisma.$executeRawUnsafe( + 'CREATE INDEX IF NOT EXISTS "Product_showAsReadyMade_idx" ON "Product"("showAsReadyMade")' + ); + console.log('✓ Product_showAsReadyMade_idx'); + + await prisma.$executeRawUnsafe( + 'CREATE INDEX IF NOT EXISTS "Product_category_idx" ON "Product"("category")' + ); + console.log('✓ Product_category_idx'); + + console.log('\n✓ All indexes added successfully!'); + } catch (error) { + console.error('Error adding indexes:', error.message); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +main(); diff --git a/src/app/admin/categories/[id]/edit/page.tsx b/src/app/admin/categories/[id]/edit/page.tsx new file mode 100644 index 0000000..60d1420 --- /dev/null +++ b/src/app/admin/categories/[id]/edit/page.tsx @@ -0,0 +1,93 @@ +import { notFound } from 'next/navigation'; +import { prisma } from '@/lib/prisma'; +import { updateCategory } from '../../actions'; +import AdminNav from '@/components/AdminNav'; + +export default async function EditCategoryPage({ params }: { params: { id: string } }) { + const category = await prisma.category.findUnique({ where: { id: params.id } }); + if (!category) notFound(); + + const parentCategories = await prisma.category.findMany({ + where: { parentId: null, id: { not: params.id } }, + orderBy: { name: 'asc' }, + }); + + return ( +
+ +

Edit {category.name}

+

+ Changes apply immediately to this category and all products that use it. +

+ +
+ + +
+ + +
+ +
+ + +

+ Make this a subcategory under another category for hierarchical organization. +

+
+ +
+ +
+ + +
+
+ + +
+
+ ); +} diff --git a/src/app/admin/categories/actions.ts b/src/app/admin/categories/actions.ts index 7c0d38b..872f129 100644 --- a/src/app/admin/categories/actions.ts +++ b/src/app/admin/categories/actions.ts @@ -23,6 +23,7 @@ async function uniqueSlug(base: string) { export async function createCategory(formData: FormData) { const name = String(formData.get('name') ?? '').trim(); + const parentId = String(formData.get('parentId') ?? '').trim() || null; if (!name) throw new Error('Category name is required.'); const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1'; @@ -30,7 +31,7 @@ export async function createCategory(formData: FormData) { const slug = await uniqueSlug(slugify(name)); - await prisma.category.create({ data: { name, slug, showOnPersonalised, showOnProducts } }); + await prisma.category.create({ data: { name, slug, parentId, showOnPersonalised, showOnProducts } }); redirect('/admin/categories'); } @@ -46,6 +47,24 @@ export async function updateCategoryVisibility(formData: FormData) { redirect('/admin/categories'); } +export async function updateCategory(formData: FormData) { + const id = String(formData.get('id') ?? ''); + const name = String(formData.get('name') ?? '').trim(); + const parentId = String(formData.get('parentId') ?? '').trim() || null; + + if (!id || !name) throw new Error('Category ID and name are required.'); + + const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1'; + const showOnProducts = String(formData.get('showOnProducts') ?? '') === '1'; + + await prisma.category.update({ + where: { id }, + data: { name, parentId, showOnPersonalised, showOnProducts }, + }); + + redirect('/admin/categories'); +} + export async function deleteCategory(formData: FormData) { const id = String(formData.get('id') ?? ''); if (!id) return; diff --git a/src/app/admin/categories/new/page.tsx b/src/app/admin/categories/new/page.tsx index 843431d..0ff966a 100644 --- a/src/app/admin/categories/new/page.tsx +++ b/src/app/admin/categories/new/page.tsx @@ -1,7 +1,13 @@ +import { prisma } from '@/lib/prisma'; import { createCategory } from '../actions'; import AdminNav from '@/components/AdminNav'; -export default function NewCategoryPage() { +export default async function NewCategoryPage() { + const parentCategories = await prisma.category.findMany({ + where: { parentId: null }, + orderBy: { name: 'asc' }, + }); + return (
@@ -24,6 +30,27 @@ export default function NewCategoryPage() { />
+
+ + +

+ Make this a subcategory under another category for hierarchical organization. +

+
+
diff --git a/src/app/admin/categories/page.tsx b/src/app/admin/categories/page.tsx index 29fb710..47c023d 100644 --- a/src/app/admin/categories/page.tsx +++ b/src/app/admin/categories/page.tsx @@ -38,6 +38,13 @@ export default async function CategoriesPage({ searchParams }: { searchParams: {

+ + Edit + +
-
-
- -
-
- - -
+
+
{(occasions.length > 0 || recipients.length > 0) && ( diff --git a/src/app/admin/products/new/page.tsx b/src/app/admin/products/new/page.tsx index c6ed68a..9cfcf94 100644 --- a/src/app/admin/products/new/page.tsx +++ b/src/app/admin/products/new/page.tsx @@ -5,12 +5,20 @@ import ColorPicker from '@/components/ColorPicker'; import SizePicker from '@/components/SizePicker'; import PhotoPrintAreaField from '@/components/PhotoPrintAreaField'; import CategoryPicker from '@/components/CategoryPicker'; -import { MOCKUP_OPTIONS } from '@/lib/mockupDefaults'; export default async function NewProductPage() { const categories = await prisma.category.findMany({ + where: { parentId: null }, orderBy: { name: 'asc' }, - include: { children: { orderBy: { name: 'asc' } } }, + select: { + id: true, + name: true, + slug: true, + children: { + select: { id: true, name: true, slug: true }, + orderBy: { name: 'asc' }, + }, + }, }); const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } }); const occasions = collections.filter((c) => c.group === 'OCCASION'); @@ -70,30 +78,11 @@ export default async function NewProductPage() { />
-
-
- - - + Add a new category - -
-
- - -
+
+ + + + Add a new category +
{(occasions.length > 0 || recipients.length > 0) && ( diff --git a/src/app/made-to-order/page.tsx b/src/app/made-to-order/page.tsx index 195deb3..beb762f 100644 --- a/src/app/made-to-order/page.tsx +++ b/src/app/made-to-order/page.tsx @@ -31,7 +31,10 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea ]); const CATEGORIES = allCategories; - const collectionRows = await prisma.collection.findMany({ orderBy: { name: 'asc' } }); + const collectionRows = await prisma.collection.findMany({ + select: { slug: true, name: true, group: true }, + orderBy: { name: 'asc' }, + }); const OCCASIONS = collectionRows.filter((c) => c.group === 'OCCASION').map((c) => ({ value: c.slug, label: c.name })); const RECIPIENTS = collectionRows.filter((c) => c.group === 'RECIPIENT').map((c) => ({ value: c.slug, label: c.name })); @@ -47,6 +50,7 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea const allProducts = await prisma.product.findMany({ where: { showOnPersonalised: true }, select: { colors: true }, + take: 10000, }); const paletteSet = new Set(); for (const p of allProducts) { diff --git a/src/app/products/page.tsx b/src/app/products/page.tsx index 79bf433..cd306d5 100644 --- a/src/app/products/page.tsx +++ b/src/app/products/page.tsx @@ -31,7 +31,10 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa ]); const CATEGORIES = allCategories; - const collectionRows = await prisma.collection.findMany({ orderBy: { name: 'asc' } }); + const collectionRows = await prisma.collection.findMany({ + select: { slug: true, name: true, group: true }, + orderBy: { name: 'asc' }, + }); const OCCASIONS = collectionRows.filter((c) => c.group === 'OCCASION').map((c) => ({ value: c.slug, label: c.name })); const RECIPIENTS = collectionRows.filter((c) => c.group === 'RECIPIENT').map((c) => ({ value: c.slug, label: c.name })); @@ -46,6 +49,7 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa const allProducts = await prisma.product.findMany({ where: { showAsReadyMade: true }, select: { colors: true }, + take: 10000, }); const paletteSet = new Set(); for (const p of allProducts) { diff --git a/src/components/CategoryPicker.tsx b/src/components/CategoryPicker.tsx index e170d41..9faf4cf 100644 --- a/src/components/CategoryPicker.tsx +++ b/src/components/CategoryPicker.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import type { Category } from '@prisma/client'; type CategoryWithChildren = Category & { children: Category[] }; @@ -26,10 +26,14 @@ export default function CategoryPicker({ }; // Initialize parent if we have a default child value - const initialParent = defaultValue ? getParentFromChild(defaultValue) : ''; - if (defaultValue && !selectedParent && initialParent) { - setSelectedParent(initialParent); - } + useEffect(() => { + if (defaultValue && !selectedParent) { + const initialParent = getParentFromChild(defaultValue); + if (initialParent) { + setSelectedParent(initialParent); + } + } + }, [defaultValue, selectedParent, categories]); const currentParent = categories.find((c) => c.id === selectedParent); const childOptions = currentParent?.children || []; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index b9ea3a1..b13e3a1 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -6,25 +6,47 @@ import ThemeToggle from './ThemeToggle'; import NavDropdown from './NavDropdown'; import CurrencySelector from './CurrencySelector'; import MobileNav from './MobileNav'; -import { prisma } from '@/lib/prisma'; import { getCurrentCustomer } from '@/lib/auth'; import { isAdminAuthenticated } from '@/lib/adminAuth'; import AdminMenu from './AdminMenu'; import AdminNotificationsWrapper from './AdminNotificationsWrapper'; +import { getCategoriesWithChildren, getCollections } from '@/lib/cache'; export default async function Header() { - const [categories, collections, customer, isAdmin] = await Promise.all([ - prisma.category.findMany({ - where: { showOnPersonalised: true }, - orderBy: { name: 'asc' }, - }), - prisma.collection.findMany({ orderBy: { name: 'asc' } }), + const [allCategories, collections, customer, isAdmin] = await Promise.all([ + getCategoriesWithChildren(), + getCollections(), getCurrentCustomer(), isAdminAuthenticated(), ]); - const personalisedLinks = [ - { href: '/made-to-order', label: 'Shop all' }, - ...categories.map((c) => ({ href: `/made-to-order?category=${c.slug}`, label: c.name })), + + const personalisedCategories = allCategories.filter((c) => c.showOnPersonalised); + const productsCategories = allCategories.filter((c) => c.showOnProducts); + + const personalisedSections = [ + { title: 'All Personalised', links: [{ href: '/made-to-order', label: 'Shop all' }] }, + ...personalisedCategories.map((c) => ({ + title: c.name, + links: [ + { href: `/made-to-order?category=${c.slug}`, label: 'All' }, + ...c.children + .filter((ch) => ch.showOnPersonalised) + .map((child) => ({ href: `/made-to-order?category=${child.slug}`, label: child.name })), + ], + })), + ]; + + const productsSections = [ + { title: 'All Products', links: [{ href: '/products', label: 'Shop all' }] }, + ...productsCategories.map((c) => ({ + title: c.name, + links: [ + { href: `/products?category=${c.slug}`, label: 'All' }, + ...c.children + .filter((ch) => ch.showOnProducts) + .map((child) => ({ href: `/products?category=${child.slug}`, label: child.name })), + ], + })), ]; const shopBySections = [ { @@ -46,7 +68,7 @@ export default async function Header() {
@@ -62,10 +84,8 @@ export default async function Header() {