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:
@@ -154,7 +154,8 @@
|
|||||||
"PowerShell(Stop-Process -Id 30912 -Force -ErrorAction SilentlyContinue)",
|
"PowerShell(Stop-Process -Id 30912 -Force -ErrorAction SilentlyContinue)",
|
||||||
"Bash(psql postgresql://postgres:craft2prints@192.168.0.190:5432/craft2prints -c ' *)",
|
"Bash(psql postgresql://postgres:craft2prints@192.168.0.190:5432/craft2prints -c ' *)",
|
||||||
"Bash(node scripts/add-indexes.js)",
|
"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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { toProductDTO } from '@/lib/product';
|
import { toProductDTO } from '@/lib/product';
|
||||||
import { fetchActivePromotions } from '@/lib/promotions';
|
import { fetchActivePromotions } from '@/lib/promotions';
|
||||||
|
import { getPersonalisedPaletteColors } from '@/lib/cache';
|
||||||
import ProductCard from '@/components/ProductCard';
|
import ProductCard from '@/components/ProductCard';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
@@ -11,8 +12,11 @@ type SearchParams = {
|
|||||||
min?: string;
|
min?: string;
|
||||||
max?: string;
|
max?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
page?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PRODUCTS_PER_PAGE = 20;
|
||||||
|
|
||||||
function toArray(v?: string | string[]) {
|
function toArray(v?: string | string[]) {
|
||||||
if (!v) return [];
|
if (!v) return [];
|
||||||
return Array.isArray(v) ? v : [v];
|
return Array.isArray(v) ? v : [v];
|
||||||
@@ -22,7 +26,16 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
|
|||||||
const categoryRows = await prisma.category.findMany({
|
const categoryRows = await prisma.category.findMany({
|
||||||
where: { showOnPersonalised: true, parentId: null },
|
where: { showOnPersonalised: true, parentId: null },
|
||||||
orderBy: { name: 'asc' },
|
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
|
// Build flat list of all categories (parents and children) for filtering
|
||||||
const allCategories = categoryRows.flatMap((parent) => [
|
const allCategories = categoryRows.flatMap((parent) => [
|
||||||
@@ -44,19 +57,10 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
|
|||||||
const q = searchParams.q?.trim() ?? '';
|
const q = searchParams.q?.trim() ?? '';
|
||||||
const minDollars = searchParams.min ? Number(searchParams.min) : undefined;
|
const minDollars = searchParams.min ? Number(searchParams.min) : undefined;
|
||||||
const maxDollars = searchParams.max ? Number(searchParams.max) : 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
|
// Cached palette across every personalised product
|
||||||
// whatever else is currently filtered, so switching colors doesn't hide options.
|
const palette = await getPersonalisedPaletteColors();
|
||||||
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);
|
|
||||||
|
|
||||||
const where: Prisma.ProductWhereInput = { showOnPersonalised: true };
|
const where: Prisma.ProductWhereInput = { showOnPersonalised: true };
|
||||||
if (selectedCategories.length) {
|
if (selectedCategories.length) {
|
||||||
@@ -79,8 +83,19 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activePromotions = await fetchActivePromotions();
|
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 products = rows.map((p) => toProductDTO(p, activePromotions, 'personalised'));
|
||||||
|
const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
|
||||||
|
|
||||||
const hasFilters =
|
const hasFilters =
|
||||||
selectedCategories.length > 0 ||
|
selectedCategories.length > 0 ||
|
||||||
@@ -95,7 +110,7 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
|
|||||||
<div className="mb-8 flex items-baseline justify-between">
|
<div className="mb-8 flex items-baseline justify-between">
|
||||||
<h1 className="font-display text-4xl">Shop all</h1>
|
<h1 className="font-display text-4xl">Shop all</h1>
|
||||||
<p className="tag-label">
|
<p className="tag-label">
|
||||||
{products.length} template{products.length === 1 ? '' : 's'}
|
{totalCount} template{totalCount === 1 ? '' : 's'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<p className="mt-2 text-sm text-muted">Try widening your price range or clearing a filter.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
|
<div>
|
||||||
{products.map((p) => (
|
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<ProductCard key={p.id} product={p} />
|
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+59
-17
@@ -1,6 +1,7 @@
|
|||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { toProductDTO } from '@/lib/product';
|
import { toProductDTO } from '@/lib/product';
|
||||||
import { fetchActivePromotions } from '@/lib/promotions';
|
import { fetchActivePromotions } from '@/lib/promotions';
|
||||||
|
import { getPaletteColors } from '@/lib/cache';
|
||||||
import ProductCard from '@/components/ProductCard';
|
import ProductCard from '@/components/ProductCard';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
@@ -11,8 +12,11 @@ type SearchParams = {
|
|||||||
min?: string;
|
min?: string;
|
||||||
max?: string;
|
max?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
page?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PRODUCTS_PER_PAGE = 20;
|
||||||
|
|
||||||
function toArray(v?: string | string[]) {
|
function toArray(v?: string | string[]) {
|
||||||
if (!v) return [];
|
if (!v) return [];
|
||||||
return Array.isArray(v) ? v : [v];
|
return Array.isArray(v) ? v : [v];
|
||||||
@@ -22,7 +26,16 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
|
|||||||
const categoryRows = await prisma.category.findMany({
|
const categoryRows = await prisma.category.findMany({
|
||||||
where: { showOnProducts: true, parentId: null },
|
where: { showOnProducts: true, parentId: null },
|
||||||
orderBy: { name: 'asc' },
|
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
|
// Build flat list of all categories (parents and children) for filtering
|
||||||
const allCategories = categoryRows.flatMap((parent) => [
|
const allCategories = categoryRows.flatMap((parent) => [
|
||||||
@@ -44,18 +57,10 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
|
|||||||
const q = searchParams.q?.trim() ?? '';
|
const q = searchParams.q?.trim() ?? '';
|
||||||
const minDollars = searchParams.min ? Number(searchParams.min) : undefined;
|
const minDollars = searchParams.min ? Number(searchParams.min) : undefined;
|
||||||
const maxDollars = searchParams.max ? Number(searchParams.max) : 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.
|
// Cached palette across ready-made products for the swatch filter
|
||||||
const allProducts = await prisma.product.findMany({
|
const palette = await getPaletteColors();
|
||||||
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);
|
|
||||||
|
|
||||||
const where: Prisma.ProductWhereInput = { showAsReadyMade: true };
|
const where: Prisma.ProductWhereInput = { showAsReadyMade: true };
|
||||||
if (selectedCategories.length) {
|
if (selectedCategories.length) {
|
||||||
@@ -78,8 +83,19 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activePromotions = await fetchActivePromotions();
|
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 products = rows.map((p) => toProductDTO(p, activePromotions, 'plain'));
|
||||||
|
const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
|
||||||
|
|
||||||
const hasFilters =
|
const hasFilters =
|
||||||
selectedCategories.length > 0 ||
|
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>
|
<p className="mt-2 text-sm text-muted">Try widening your price range or clearing a filter.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
|
<div>
|
||||||
{products.map((p) => (
|
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<ProductCard key={p.id} product={p} hrefBase="/products" />
|
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,6 +69,44 @@ export async function getCollections() {
|
|||||||
return collections;
|
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 {
|
export function clearCache(): void {
|
||||||
cache.clear();
|
cache.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user