Feat: Pagination and palette caching for scale

- Add pagination to products and made-to-order pages (20 per page)
- Cache color palette separately for ready-made and personalised
- Fix category queries to use select instead of include
- Calculate total page count for pagination UI
- Add Previous/Next pagination controls

Solves N+1 query problem on color palette generation. With 100+ products:
- Before: Loading all 10,000 products just for color palette
- After: Load palette once (cached 5 mins), show 20 products per page
This commit is contained in:
Andymick
2026-07-21 19:15:27 +01:00
parent 2dddf7d27a
commit 912dcc6ab3
4 changed files with 159 additions and 37 deletions
+2 -1
View File
@@ -154,7 +154,8 @@
"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)"
"Bash(curl -s http://127.0.0.1:3000)",
"Bash(curl -s http://127.0.0.1:3000/products)"
]
}
}
+60 -19
View File
@@ -1,6 +1,7 @@
import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions';
import { getPersonalisedPaletteColors } from '@/lib/cache';
import ProductCard from '@/components/ProductCard';
import type { Prisma } from '@prisma/client';
@@ -11,8 +12,11 @@ type SearchParams = {
min?: string;
max?: string;
q?: string;
page?: string;
};
const PRODUCTS_PER_PAGE = 20;
function toArray(v?: string | string[]) {
if (!v) return [];
return Array.isArray(v) ? v : [v];
@@ -22,7 +26,16 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
const categoryRows = await prisma.category.findMany({
where: { showOnPersonalised: true, parentId: null },
orderBy: { name: 'asc' },
include: { children: { where: { showOnPersonalised: true }, orderBy: { name: 'asc' } } },
select: {
id: true,
name: true,
slug: true,
children: {
where: { showOnPersonalised: true },
select: { id: true, name: true, slug: true },
orderBy: { name: 'asc' },
},
},
});
// Build flat list of all categories (parents and children) for filtering
const allCategories = categoryRows.flatMap((parent) => [
@@ -44,19 +57,10 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
const q = searchParams.q?.trim() ?? '';
const minDollars = searchParams.min ? Number(searchParams.min) : undefined;
const maxDollars = searchParams.max ? Number(searchParams.max) : undefined;
const currentPage = Math.max(1, Number(searchParams.page) || 1);
// Full palette across every product, for the swatch filter — independent of
// whatever else is currently filtered, so switching colors doesn't hide options.
const allProducts = await prisma.product.findMany({
where: { showOnPersonalised: true },
select: { colors: true },
take: 10000,
});
const paletteSet = new Set<string>();
for (const p of allProducts) {
(JSON.parse(p.colors) as string[]).forEach((c) => paletteSet.add(c));
}
const palette = Array.from(paletteSet);
// Cached palette across every personalised product
const palette = await getPersonalisedPaletteColors();
const where: Prisma.ProductWhereInput = { showOnPersonalised: true };
if (selectedCategories.length) {
@@ -79,8 +83,19 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
}
const activePromotions = await fetchActivePromotions();
const rows = await prisma.product.findMany({ where, orderBy: { createdAt: 'asc' } });
const [rows, totalCount] = await Promise.all([
prisma.product.findMany({
where,
orderBy: { createdAt: 'asc' },
take: PRODUCTS_PER_PAGE,
skip: (currentPage - 1) * PRODUCTS_PER_PAGE,
}),
prisma.product.count({ where }),
]);
const products = rows.map((p) => toProductDTO(p, activePromotions, 'personalised'));
const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
const hasFilters =
selectedCategories.length > 0 ||
@@ -95,7 +110,7 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
<div className="mb-8 flex items-baseline justify-between">
<h1 className="font-display text-4xl">Shop all</h1>
<p className="tag-label">
{products.length} template{products.length === 1 ? '' : 's'}
{totalCount} template{totalCount === 1 ? '' : 's'}
</p>
</div>
@@ -246,10 +261,36 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
<p className="mt-2 text-sm text-muted">Try widening your price range or clearing a filter.</p>
</div>
) : (
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
<div>
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
{totalPages > 1 && (
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
{currentPage > 1 && (
<a
href={`/made-to-order?page=${currentPage - 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Previous
</a>
)}
<p className="text-sm text-muted">
Page {currentPage} of {totalPages}
</p>
{currentPage < totalPages && (
<a
href={`/made-to-order?page=${currentPage + 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Next
</a>
)}
</div>
)}
</div>
)}
</div>
+59 -17
View File
@@ -1,6 +1,7 @@
import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions';
import { getPaletteColors } from '@/lib/cache';
import ProductCard from '@/components/ProductCard';
import type { Prisma } from '@prisma/client';
@@ -11,8 +12,11 @@ type SearchParams = {
min?: string;
max?: string;
q?: string;
page?: string;
};
const PRODUCTS_PER_PAGE = 20;
function toArray(v?: string | string[]) {
if (!v) return [];
return Array.isArray(v) ? v : [v];
@@ -22,7 +26,16 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
const categoryRows = await prisma.category.findMany({
where: { showOnProducts: true, parentId: null },
orderBy: { name: 'asc' },
include: { children: { where: { showOnProducts: true }, orderBy: { name: 'asc' } } },
select: {
id: true,
name: true,
slug: true,
children: {
where: { showOnProducts: true },
select: { id: true, name: true, slug: true },
orderBy: { name: 'asc' },
},
},
});
// Build flat list of all categories (parents and children) for filtering
const allCategories = categoryRows.flatMap((parent) => [
@@ -44,18 +57,10 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
const q = searchParams.q?.trim() ?? '';
const minDollars = searchParams.min ? Number(searchParams.min) : undefined;
const maxDollars = searchParams.max ? Number(searchParams.max) : undefined;
const currentPage = Math.max(1, Number(searchParams.page) || 1);
// Full palette across ready-made products only, for the swatch filter.
const allProducts = await prisma.product.findMany({
where: { showAsReadyMade: true },
select: { colors: true },
take: 10000,
});
const paletteSet = new Set<string>();
for (const p of allProducts) {
(JSON.parse(p.colors) as string[]).forEach((c) => paletteSet.add(c));
}
const palette = Array.from(paletteSet);
// Cached palette across ready-made products for the swatch filter
const palette = await getPaletteColors();
const where: Prisma.ProductWhereInput = { showAsReadyMade: true };
if (selectedCategories.length) {
@@ -78,8 +83,19 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
}
const activePromotions = await fetchActivePromotions();
const rows = await prisma.product.findMany({ where, orderBy: { createdAt: 'asc' } });
const [rows, totalCount] = await Promise.all([
prisma.product.findMany({
where,
orderBy: { createdAt: 'asc' },
take: PRODUCTS_PER_PAGE,
skip: (currentPage - 1) * PRODUCTS_PER_PAGE,
}),
prisma.product.count({ where }),
]);
const products = rows.map((p) => toProductDTO(p, activePromotions, 'plain'));
const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
const hasFilters =
selectedCategories.length > 0 ||
@@ -254,10 +270,36 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
<p className="mt-2 text-sm text-muted">Try widening your price range or clearing a filter.</p>
</div>
) : (
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<ProductCard key={p.id} product={p} hrefBase="/products" />
))}
<div>
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<ProductCard key={p.id} product={p} hrefBase="/products" />
))}
</div>
{totalPages > 1 && (
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
{currentPage > 1 && (
<a
href={`/products?page=${currentPage - 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Previous
</a>
)}
<p className="text-sm text-muted">
Page {currentPage} of {totalPages}
</p>
{currentPage < totalPages && (
<a
href={`/products?page=${currentPage + 1}${selectedCategories.length ? `&category=${selectedCategories.join('&category=')}` : ''}${selectedCollections.length ? `&collection=${selectedCollections.join('&collection=')}` : ''}${selectedColors.length ? `&color=${selectedColors.join('&color=')}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}${minDollars !== undefined ? `&min=${minDollars}` : ''}${maxDollars !== undefined ? `&max=${maxDollars}` : ''}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Next
</a>
)}
</div>
)}
</div>
)}
</div>
+38
View File
@@ -69,6 +69,44 @@ export async function getCollections() {
return collections;
}
export async function getPaletteColors() {
const cached = getCached('palette_ready_made');
if (cached) return cached;
const products = await prisma.product.findMany({
where: { showAsReadyMade: true },
select: { colors: true },
});
const paletteSet = new Set<string>();
for (const p of products) {
(JSON.parse(p.colors) as string[]).forEach((c) => paletteSet.add(c));
}
const palette = Array.from(paletteSet);
setCached('palette_ready_made', palette);
return palette;
}
export async function getPersonalisedPaletteColors() {
const cached = getCached('palette_personalised');
if (cached) return cached;
const products = await prisma.product.findMany({
where: { showOnPersonalised: true },
select: { colors: true },
});
const paletteSet = new Set<string>();
for (const p of products) {
(JSON.parse(p.colors) as string[]).forEach((c) => paletteSet.add(c));
}
const palette = Array.from(paletteSet);
setCached('palette_personalised', palette);
return palette;
}
export function clearCache(): void {
cache.clear();
}