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:
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user