From 912dcc6ab3991b6095fc1385f7276ef1e9135ec3 Mon Sep 17 00:00:00 2001 From: Andymick Date: Tue, 21 Jul 2026 19:15:27 +0100 Subject: [PATCH] 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 --- .claude/settings.local.json | 3 +- src/app/made-to-order/page.tsx | 79 ++++++++++++++++++++++++++-------- src/app/products/page.tsx | 76 ++++++++++++++++++++++++-------- src/lib/cache.ts | 38 ++++++++++++++++ 4 files changed, 159 insertions(+), 37 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b871948..f2f82fe 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/src/app/made-to-order/page.tsx b/src/app/made-to-order/page.tsx index beb762f..0a3c1a3 100644 --- a/src/app/made-to-order/page.tsx +++ b/src/app/made-to-order/page.tsx @@ -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(); - 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

Shop all

- {products.length} template{products.length === 1 ? '' : 's'} + {totalCount} template{totalCount === 1 ? '' : 's'}

@@ -246,10 +261,36 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea

Try widening your price range or clearing a filter.

) : ( -
- {products.map((p) => ( - - ))} +
+
+ {products.map((p) => ( + + ))} +
+ + {totalPages > 1 && ( +
+ {currentPage > 1 && ( + + ← Previous + + )} +

+ Page {currentPage} of {totalPages} +

+ {currentPage < totalPages && ( + + Next → + + )} +
+ )}
)}
diff --git a/src/app/products/page.tsx b/src/app/products/page.tsx index cd306d5..8c7ce41 100644 --- a/src/app/products/page.tsx +++ b/src/app/products/page.tsx @@ -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(); - 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

Try widening your price range or clearing a filter.

) : ( -
- {products.map((p) => ( - - ))} +
+
+ {products.map((p) => ( + + ))} +
+ + {totalPages > 1 && ( +
+ {currentPage > 1 && ( + + ← Previous + + )} +

+ Page {currentPage} of {totalPages} +

+ {currentPage < totalPages && ( + + Next → + + )} +
+ )}
)}
diff --git a/src/lib/cache.ts b/src/lib/cache.ts index ff31d16..fc47285 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -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(); + 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(); + 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(); }