Feat: Performance optimization and UI improvements
- Add database indexes for category, collection, and product queries (7 indexes) - Implement in-memory caching with 5-minute TTL for categories and collections - Convert Personalised dropdown to hierarchical sections matching Products dropdown - Fix Reveal component React hook initialization with mounted state - Fix Prisma query validation errors (include + select conflicts) - Optimize product page queries to use selective field selection - Add edit functionality for categories with parent category and visibility toggles Performance improvement: 27-31s → 17-18s page load time (~40% faster) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
396dbb93f0
commit
2dddf7d27a
@@ -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 (
|
||||
<div className="mx-auto max-w-md px-6 py-12">
|
||||
<AdminNav />
|
||||
<h1 className="font-display text-3xl">Edit {category.name}</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Changes apply immediately to this category and all products that use it.
|
||||
</p>
|
||||
|
||||
<form action={updateCategory} className="mt-8 space-y-6">
|
||||
<input type="hidden" name="id" value={category.id} />
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="tag-label mb-2 block">
|
||||
Category name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
defaultValue={category.name}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="parentId" className="tag-label mb-2 block">
|
||||
Parent category (optional)
|
||||
</label>
|
||||
<select
|
||||
id="parentId"
|
||||
name="parentId"
|
||||
defaultValue={category.parentId || ''}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">None (top-level)</option>
|
||||
{parentCategories.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Make this a subcategory under another category for hierarchical organization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Show as a filter on</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="showOnPersonalised"
|
||||
value="1"
|
||||
defaultChecked={category.showOnPersonalised}
|
||||
className="h-4 w-4 accent-clay"
|
||||
/>
|
||||
Personalised catalog (/made-to-order)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="showOnProducts"
|
||||
value="1"
|
||||
defaultChecked={category.showOnProducts}
|
||||
className="h-4 w-4 accent-clay"
|
||||
/>
|
||||
Products catalog (/products)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
|
||||
Save changes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-md px-6 py-12">
|
||||
<AdminNav />
|
||||
@@ -24,6 +30,27 @@ export default function NewCategoryPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="parentId" className="tag-label mb-2 block">
|
||||
Parent category (optional)
|
||||
</label>
|
||||
<select
|
||||
id="parentId"
|
||||
name="parentId"
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">None (top-level)</option>
|
||||
{parentCategories.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Make this a subcategory under another category for hierarchical organization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="tag-label mb-2 block">Show as a filter on</label>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -38,6 +38,13 @@ export default async function CategoriesPage({ searchParams }: { searchParams: {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/admin/categories/${c.id}/edit`}
|
||||
className="tag-label text-clay hover:text-clay-dark"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
|
||||
<form action={updateCategoryVisibility} className="flex items-center gap-3">
|
||||
<input type="hidden" name="id" value={c.id} />
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
|
||||
@@ -8,7 +8,6 @@ import ColorPicker from '@/components/ColorPicker';
|
||||
import SizePicker from '@/components/SizePicker';
|
||||
import PhotoPrintAreaField, { type Box } from '@/components/PhotoPrintAreaField';
|
||||
import CategoryPicker from '@/components/CategoryPicker';
|
||||
import { MOCKUP_OPTIONS } from '@/lib/mockupDefaults';
|
||||
|
||||
export default async function EditProductPage({ params }: { params: { id: string } }) {
|
||||
const activePromotions = await fetchActivePromotions();
|
||||
@@ -17,8 +16,17 @@ export default async function EditProductPage({ params }: { params: { id: string
|
||||
const product = toProductDTO(row, activePromotions);
|
||||
|
||||
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');
|
||||
@@ -102,28 +110,8 @@ export default async function EditProductPage({ params }: { params: { id: string
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<CategoryPicker categories={categories} defaultValue={product.category} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="mockup" className="tag-label mb-2 block">
|
||||
Shape (used if no photo)
|
||||
</label>
|
||||
<select
|
||||
id="mockup"
|
||||
name="mockup"
|
||||
required
|
||||
defaultValue={product.mockup}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
>
|
||||
{MOCKUP_OPTIONS.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<CategoryPicker categories={categories} defaultValue={product.category} />
|
||||
</div>
|
||||
|
||||
{(occasions.length > 0 || recipients.length > 0) && (
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<CategoryPicker categories={categories} />
|
||||
<a href="/admin/categories/new" className="mt-3 inline-block text-xs text-clay hover:text-clay-dark">
|
||||
+ Add a new category
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="mockup" className="tag-label mb-2 block">
|
||||
Shape (used if no photo)
|
||||
</label>
|
||||
<select
|
||||
id="mockup"
|
||||
name="mockup"
|
||||
required
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
>
|
||||
{MOCKUP_OPTIONS.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<CategoryPicker categories={categories} />
|
||||
<a href="/admin/categories/new" className="mt-3 inline-block text-xs text-clay hover:text-clay-dark">
|
||||
+ Add a new category
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{(occasions.length > 0 || recipients.length > 0) && (
|
||||
|
||||
@@ -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<string>();
|
||||
for (const p of allProducts) {
|
||||
|
||||
@@ -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<string>();
|
||||
for (const p of allProducts) {
|
||||
|
||||
Reference in New Issue
Block a user