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.
+
+
+
+
+ );
+}
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() {
/>
-
-
-
-
-
-
+
{(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() {