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
@@ -148,7 +148,13 @@
|
|||||||
"Bash(awk '{print $2}')",
|
"Bash(awk '{print $2}')",
|
||||||
"Bash(xargs kill -9)",
|
"Bash(xargs kill -9)",
|
||||||
"Bash(taskkill /F /PID 30912)",
|
"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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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();
|
||||||
@@ -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) {
|
export async function createCategory(formData: FormData) {
|
||||||
const name = String(formData.get('name') ?? '').trim();
|
const name = String(formData.get('name') ?? '').trim();
|
||||||
|
const parentId = String(formData.get('parentId') ?? '').trim() || null;
|
||||||
if (!name) throw new Error('Category name is required.');
|
if (!name) throw new Error('Category name is required.');
|
||||||
|
|
||||||
const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1';
|
const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1';
|
||||||
@@ -30,7 +31,7 @@ export async function createCategory(formData: FormData) {
|
|||||||
|
|
||||||
const slug = await uniqueSlug(slugify(name));
|
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');
|
redirect('/admin/categories');
|
||||||
}
|
}
|
||||||
@@ -46,6 +47,24 @@ export async function updateCategoryVisibility(formData: FormData) {
|
|||||||
redirect('/admin/categories');
|
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) {
|
export async function deleteCategory(formData: FormData) {
|
||||||
const id = String(formData.get('id') ?? '');
|
const id = String(formData.get('id') ?? '');
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
import { createCategory } from '../actions';
|
import { createCategory } from '../actions';
|
||||||
import AdminNav from '@/components/AdminNav';
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-md px-6 py-12">
|
<div className="mx-auto max-w-md px-6 py-12">
|
||||||
<AdminNav />
|
<AdminNav />
|
||||||
@@ -24,6 +30,27 @@ export default function NewCategoryPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="tag-label mb-2 block">Show as a filter on</label>
|
<label className="tag-label mb-2 block">Show as a filter on</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ export default async function CategoriesPage({ searchParams }: { searchParams: {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<form action={updateCategoryVisibility} className="flex items-center gap-3">
|
||||||
<input type="hidden" name="id" value={c.id} />
|
<input type="hidden" name="id" value={c.id} />
|
||||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
<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 SizePicker from '@/components/SizePicker';
|
||||||
import PhotoPrintAreaField, { type Box } from '@/components/PhotoPrintAreaField';
|
import PhotoPrintAreaField, { type Box } from '@/components/PhotoPrintAreaField';
|
||||||
import CategoryPicker from '@/components/CategoryPicker';
|
import CategoryPicker from '@/components/CategoryPicker';
|
||||||
import { MOCKUP_OPTIONS } from '@/lib/mockupDefaults';
|
|
||||||
|
|
||||||
export default async function EditProductPage({ params }: { params: { id: string } }) {
|
export default async function EditProductPage({ params }: { params: { id: string } }) {
|
||||||
const activePromotions = await fetchActivePromotions();
|
const activePromotions = await fetchActivePromotions();
|
||||||
@@ -17,8 +16,17 @@ export default async function EditProductPage({ params }: { params: { id: string
|
|||||||
const product = toProductDTO(row, activePromotions);
|
const product = toProductDTO(row, activePromotions);
|
||||||
|
|
||||||
const categories = await prisma.category.findMany({
|
const categories = await prisma.category.findMany({
|
||||||
|
where: { parentId: null },
|
||||||
orderBy: { name: 'asc' },
|
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 collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
|
||||||
const occasions = collections.filter((c) => c.group === 'OCCASION');
|
const occasions = collections.filter((c) => c.group === 'OCCASION');
|
||||||
@@ -102,28 +110,8 @@ export default async function EditProductPage({ params }: { params: { id: string
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div>
|
||||||
<div>
|
<CategoryPicker categories={categories} defaultValue={product.category} />
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{(occasions.length > 0 || recipients.length > 0) && (
|
{(occasions.length > 0 || recipients.length > 0) && (
|
||||||
|
|||||||
@@ -5,12 +5,20 @@ import ColorPicker from '@/components/ColorPicker';
|
|||||||
import SizePicker from '@/components/SizePicker';
|
import SizePicker from '@/components/SizePicker';
|
||||||
import PhotoPrintAreaField from '@/components/PhotoPrintAreaField';
|
import PhotoPrintAreaField from '@/components/PhotoPrintAreaField';
|
||||||
import CategoryPicker from '@/components/CategoryPicker';
|
import CategoryPicker from '@/components/CategoryPicker';
|
||||||
import { MOCKUP_OPTIONS } from '@/lib/mockupDefaults';
|
|
||||||
|
|
||||||
export default async function NewProductPage() {
|
export default async function NewProductPage() {
|
||||||
const categories = await prisma.category.findMany({
|
const categories = await prisma.category.findMany({
|
||||||
|
where: { parentId: null },
|
||||||
orderBy: { name: 'asc' },
|
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 collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
|
||||||
const occasions = collections.filter((c) => c.group === 'OCCASION');
|
const occasions = collections.filter((c) => c.group === 'OCCASION');
|
||||||
@@ -70,30 +78,11 @@ export default async function NewProductPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div>
|
||||||
<div>
|
<CategoryPicker categories={categories} />
|
||||||
<CategoryPicker categories={categories} />
|
<a href="/admin/categories/new" className="mt-3 inline-block text-xs text-clay hover:text-clay-dark">
|
||||||
<a href="/admin/categories/new" className="mt-3 inline-block text-xs text-clay hover:text-clay-dark">
|
+ Add a new category
|
||||||
+ Add a new category
|
</a>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{(occasions.length > 0 || recipients.length > 0) && (
|
{(occasions.length > 0 || recipients.length > 0) && (
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
|
|||||||
]);
|
]);
|
||||||
const CATEGORIES = allCategories;
|
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 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 }));
|
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({
|
const allProducts = await prisma.product.findMany({
|
||||||
where: { showOnPersonalised: true },
|
where: { showOnPersonalised: true },
|
||||||
select: { colors: true },
|
select: { colors: true },
|
||||||
|
take: 10000,
|
||||||
});
|
});
|
||||||
const paletteSet = new Set<string>();
|
const paletteSet = new Set<string>();
|
||||||
for (const p of allProducts) {
|
for (const p of allProducts) {
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
|
|||||||
]);
|
]);
|
||||||
const CATEGORIES = allCategories;
|
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 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 }));
|
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({
|
const allProducts = await prisma.product.findMany({
|
||||||
where: { showAsReadyMade: true },
|
where: { showAsReadyMade: true },
|
||||||
select: { colors: true },
|
select: { colors: true },
|
||||||
|
take: 10000,
|
||||||
});
|
});
|
||||||
const paletteSet = new Set<string>();
|
const paletteSet = new Set<string>();
|
||||||
for (const p of allProducts) {
|
for (const p of allProducts) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import type { Category } from '@prisma/client';
|
import type { Category } from '@prisma/client';
|
||||||
|
|
||||||
type CategoryWithChildren = Category & { children: Category[] };
|
type CategoryWithChildren = Category & { children: Category[] };
|
||||||
@@ -26,10 +26,14 @@ export default function CategoryPicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Initialize parent if we have a default child value
|
// Initialize parent if we have a default child value
|
||||||
const initialParent = defaultValue ? getParentFromChild(defaultValue) : '';
|
useEffect(() => {
|
||||||
if (defaultValue && !selectedParent && initialParent) {
|
if (defaultValue && !selectedParent) {
|
||||||
setSelectedParent(initialParent);
|
const initialParent = getParentFromChild(defaultValue);
|
||||||
}
|
if (initialParent) {
|
||||||
|
setSelectedParent(initialParent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [defaultValue, selectedParent, categories]);
|
||||||
|
|
||||||
const currentParent = categories.find((c) => c.id === selectedParent);
|
const currentParent = categories.find((c) => c.id === selectedParent);
|
||||||
const childOptions = currentParent?.children || [];
|
const childOptions = currentParent?.children || [];
|
||||||
|
|||||||
+35
-15
@@ -6,25 +6,47 @@ import ThemeToggle from './ThemeToggle';
|
|||||||
import NavDropdown from './NavDropdown';
|
import NavDropdown from './NavDropdown';
|
||||||
import CurrencySelector from './CurrencySelector';
|
import CurrencySelector from './CurrencySelector';
|
||||||
import MobileNav from './MobileNav';
|
import MobileNav from './MobileNav';
|
||||||
import { prisma } from '@/lib/prisma';
|
|
||||||
import { getCurrentCustomer } from '@/lib/auth';
|
import { getCurrentCustomer } from '@/lib/auth';
|
||||||
import { isAdminAuthenticated } from '@/lib/adminAuth';
|
import { isAdminAuthenticated } from '@/lib/adminAuth';
|
||||||
import AdminMenu from './AdminMenu';
|
import AdminMenu from './AdminMenu';
|
||||||
import AdminNotificationsWrapper from './AdminNotificationsWrapper';
|
import AdminNotificationsWrapper from './AdminNotificationsWrapper';
|
||||||
|
import { getCategoriesWithChildren, getCollections } from '@/lib/cache';
|
||||||
|
|
||||||
export default async function Header() {
|
export default async function Header() {
|
||||||
const [categories, collections, customer, isAdmin] = await Promise.all([
|
const [allCategories, collections, customer, isAdmin] = await Promise.all([
|
||||||
prisma.category.findMany({
|
getCategoriesWithChildren(),
|
||||||
where: { showOnPersonalised: true },
|
getCollections(),
|
||||||
orderBy: { name: 'asc' },
|
|
||||||
}),
|
|
||||||
prisma.collection.findMany({ orderBy: { name: 'asc' } }),
|
|
||||||
getCurrentCustomer(),
|
getCurrentCustomer(),
|
||||||
isAdminAuthenticated(),
|
isAdminAuthenticated(),
|
||||||
]);
|
]);
|
||||||
const personalisedLinks = [
|
|
||||||
{ href: '/made-to-order', label: 'Shop all' },
|
const personalisedCategories = allCategories.filter((c) => c.showOnPersonalised);
|
||||||
...categories.map((c) => ({ href: `/made-to-order?category=${c.slug}`, label: c.name })),
|
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 = [
|
const shopBySections = [
|
||||||
{
|
{
|
||||||
@@ -46,7 +68,7 @@ export default async function Header() {
|
|||||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-4 py-3 sm:px-6 sm:py-4">
|
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-4 py-3 sm:px-6 sm:py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<MobileNav
|
<MobileNav
|
||||||
personalisedLinks={personalisedLinks}
|
personalisedSections={personalisedSections}
|
||||||
shopBySections={shopBySections}
|
shopBySections={shopBySections}
|
||||||
customer={customer ? { name: customer.name, email: customer.email } : null}
|
customer={customer ? { name: customer.name, email: customer.email } : null}
|
||||||
/>
|
/>
|
||||||
@@ -62,10 +84,8 @@ export default async function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<nav className="hidden items-center gap-6 md:flex">
|
<nav className="hidden items-center gap-6 md:flex">
|
||||||
<NavDropdown label="Personalised" links={personalisedLinks} />
|
<NavDropdown label="Personalised" sections={personalisedSections} />
|
||||||
<Link href="/products" className="tag-label hover:text-ink">
|
<NavDropdown label="Products" sections={productsSections} />
|
||||||
Products
|
|
||||||
</Link>
|
|
||||||
<NavDropdown label="Shop by" sections={shopBySections} />
|
<NavDropdown label="Shop by" sections={shopBySections} />
|
||||||
<Link href="/gallery" className="tag-label hover:text-ink">
|
<Link href="/gallery" className="tag-label hover:text-ink">
|
||||||
Gallery
|
Gallery
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ type NavLink = { href: string; label: string };
|
|||||||
type NavSection = { title: string; links: NavLink[] };
|
type NavSection = { title: string; links: NavLink[] };
|
||||||
|
|
||||||
export default function MobileNav({
|
export default function MobileNav({
|
||||||
personalisedLinks,
|
personalisedSections,
|
||||||
shopBySections,
|
shopBySections,
|
||||||
customer,
|
customer,
|
||||||
}: {
|
}: {
|
||||||
personalisedLinks: NavLink[];
|
personalisedSections: NavSection[];
|
||||||
shopBySections: NavSection[];
|
shopBySections: NavSection[];
|
||||||
customer: { name: string | null; email: string } | null;
|
customer: { name: string | null; email: string } | null;
|
||||||
}) {
|
}) {
|
||||||
@@ -69,12 +69,24 @@ export default function MobileNav({
|
|||||||
<span className={`transition-transform ${expanded === 'personalised' ? 'rotate-180' : ''}`}>▾</span>
|
<span className={`transition-transform ${expanded === 'personalised' ? 'rotate-180' : ''}`}>▾</span>
|
||||||
</button>
|
</button>
|
||||||
{expanded === 'personalised' && (
|
{expanded === 'personalised' && (
|
||||||
<div className="flex flex-col pb-2 pl-2">
|
<div className="flex flex-col gap-3 pb-2 pl-2">
|
||||||
{personalisedLinks.map((link) => (
|
{personalisedSections
|
||||||
<Link key={link.href} href={link.href} onClick={close} className="py-2 text-sm text-muted hover:text-ink">
|
.filter((s) => s.links.length > 0)
|
||||||
{link.label}
|
.map((section) => (
|
||||||
</Link>
|
<div key={section.title}>
|
||||||
))}
|
<p className="text-xs font-medium uppercase tracking-wide text-muted">{section.title}</p>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{section.links.map((link) => (
|
||||||
|
<Link key={link.href} href={link.href} onClick={close} className="py-2 text-sm text-muted hover:text-ink">
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{personalisedSections.every((s) => s.links.length === 0) && (
|
||||||
|
<p className="text-sm text-muted">Coming soon.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
// Fades + slides content in the first time it scrolls into view. Respects
|
|
||||||
// prefers-reduced-motion globally via the CSS transition itself (see globals.css).
|
|
||||||
export default function Reveal({
|
export default function Reveal({
|
||||||
children,
|
children,
|
||||||
className = '',
|
className = '',
|
||||||
delayMs = 0,
|
delayMs = 0,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
delayMs?: number;
|
delayMs?: number;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
@@ -31,7 +36,7 @@ export default function Reveal({
|
|||||||
);
|
);
|
||||||
observer.observe(el);
|
observer.observe(el);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, [mounted]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
// Simple in-memory cache with TTL
|
||||||
|
interface CacheEntry<T> {
|
||||||
|
data: T;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, CacheEntry<any>>();
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
function getCached<T>(key: string): T | null {
|
||||||
|
const entry = cache.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
const isExpired = Date.now() - entry.timestamp > CACHE_TTL;
|
||||||
|
if (isExpired) {
|
||||||
|
cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCached<T>(key: string, data: T): void {
|
||||||
|
cache.set(key, { data, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCategoriesWithChildren() {
|
||||||
|
const cached = getCached('categories_with_children');
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const categories = await prisma.category.findMany({
|
||||||
|
where: { parentId: null },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
showOnPersonalised: true,
|
||||||
|
showOnProducts: true,
|
||||||
|
children: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
showOnPersonalised: true,
|
||||||
|
showOnProducts: true,
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setCached('categories_with_children', categories);
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCollections() {
|
||||||
|
const cached = getCached('collections');
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const collections = await prisma.collection.findMany({
|
||||||
|
select: { slug: true, name: true, group: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
setCached('collections', collections);
|
||||||
|
return collections;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCache(): void {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user