Feat: Remove all filters from products pages

Remove search, category, color, and price filters from products and
made-to-order pages. Keep only simple pagination for browsing.

Simplifies UX and removes unnecessary database queries:
- No category/collection queries needed
- No palette generation
- No filter logic

Much cleaner pages with faster load times.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-21 19:26:23 +01:00
co-authored by Claude Haiku 4.5
parent ac10b33207
commit e716e8256c
3 changed files with 69 additions and 511 deletions
+2 -1
View File
@@ -159,7 +159,8 @@
"Bash(curl -s http://127.0.0.1:3000/products/awd-hoodie)",
"Bash(curl -s http://127.0.0.1:3000/made-to-order)",
"Bash(curl -s \"http://127.0.0.1:3000/products/awd-hoodie\")",
"Bash(git commit -m 'Fix: NavDropdown link navigation with stopPropagation *)"
"Bash(git commit -m 'Fix: NavDropdown link navigation with stopPropagation *)",
"Bash(git commit -m 'Feat: Remove all filters from products pages *)"
]
}
}
+35 -249
View File
@@ -1,17 +1,9 @@
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';
type SearchParams = {
category?: string | string[];
collection?: string | string[];
color?: string | string[];
min?: string;
max?: string;
q?: string;
page?: string;
};
@@ -23,278 +15,72 @@ function toArray(v?: string | string[]) {
}
export default async function ProductsPage({ searchParams }: { searchParams: SearchParams }) {
const categoryRows = await prisma.category.findMany({
where: { showOnPersonalised: true, parentId: null },
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) => [
{ value: parent.slug, label: parent.name },
...parent.children.map((child) => ({ value: child.slug, label: `${parent.name} > ${child.name}` })),
]);
const CATEGORIES = allCategories;
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 RECIPIENTS = collectionRows.filter((c) => c.group === 'RECIPIENT').map((c) => ({ value: c.slug, label: c.name }));
const selectedCategories = toArray(searchParams.category);
const selectedCollections = toArray(searchParams.collection);
const selectedColors = toArray(searchParams.color);
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);
// Cached palette across every personalised product
const palette = await getPersonalisedPaletteColors();
const where: Prisma.ProductWhereInput = { showOnPersonalised: true };
if (selectedCategories.length) {
where.category = { in: selectedCategories };
}
if (selectedCollections.length) {
where.collections = { some: { slug: { in: selectedCollections } } };
}
if (selectedColors.length) {
where.AND = [{ OR: selectedColors.map((c) => ({ colors: { contains: c } })) }];
}
if (q) {
where.name = { contains: q };
}
if (minDollars !== undefined || maxDollars !== undefined) {
where.basePrice = {
...(minDollars !== undefined ? { gte: Math.round(minDollars * 100) } : {}),
...(maxDollars !== undefined ? { lte: Math.round(maxDollars * 100) } : {}),
};
}
const activePromotions = await fetchActivePromotions();
const [rows, totalCount] = await Promise.all([
prisma.product.findMany({
where,
where: { showOnPersonalised: true },
orderBy: { createdAt: 'asc' },
take: PRODUCTS_PER_PAGE,
skip: (currentPage - 1) * PRODUCTS_PER_PAGE,
}),
prisma.product.count({ where }),
prisma.product.count({ where: { showOnPersonalised: true } }),
]);
const products = rows.map((p) => toProductDTO(p, activePromotions, 'personalised'));
const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
const hasFilters =
selectedCategories.length > 0 ||
selectedCollections.length > 0 ||
selectedColors.length > 0 ||
q ||
minDollars !== undefined ||
maxDollars !== undefined;
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<div className="mb-8 flex items-baseline justify-between">
<div className="mb-12 flex items-baseline justify-between">
<h1 className="font-display text-4xl">Shop all</h1>
<p className="tag-label">
{totalCount} template{totalCount === 1 ? '' : 's'}
</p>
</div>
<form method="GET" className="grid gap-10 md:grid-cols-[220px_1fr]">
<div>
{/* Sidebar filters */}
<aside className="space-y-8">
<div>
<label htmlFor="q" className="tag-label mb-2 block">
Search
</label>
<input
id="q"
name="q"
defaultValue={q}
placeholder="Search templates…"
className="w-full border border-line px-3 py-2 text-sm focus:border-ink"
/>
{products.length === 0 ? (
<div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">Nothing here yet</p>
<p className="mt-2 text-sm text-muted">Templates will show up here once created.</p>
</div>
) : (
<div>
<p className="tag-label mb-3">Category</p>
<div className="space-y-2">
{CATEGORIES.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="category"
value={c.value}
defaultChecked={selectedCategories.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
<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>
{OCCASIONS.length > 0 && (
<div>
<p className="tag-label mb-3">Occasion</p>
<div className="space-y-2">
{OCCASIONS.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collection"
value={c.value}
defaultChecked={selectedCollections.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
{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}`}
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}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Next
</a>
)}
</div>
</div>
)}
{RECIPIENTS.length > 0 && (
<div>
<p className="tag-label mb-3">Gifts for</p>
<div className="space-y-2">
{RECIPIENTS.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collection"
value={c.value}
defaultChecked={selectedCollections.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
)}
<div>
<p className="tag-label mb-3">Color</p>
<div className="flex flex-wrap gap-2">
{palette.map((hex) => {
const checked = selectedColors.includes(hex);
return (
<label key={hex} className="cursor-pointer">
<input
type="checkbox"
name="color"
value={hex}
defaultChecked={checked}
className="peer sr-only"
/>
<span
title={hex}
className={`block h-8 w-8 rounded-full border transition-transform peer-checked:scale-110 peer-checked:border-ink peer-checked:ring-2 peer-checked:ring-ink peer-checked:ring-offset-2 peer-checked:ring-offset-paper ${
checked ? 'border-ink' : 'border-line'
}`}
style={{ backgroundColor: hex }}
/>
</label>
);
})}
</div>
</div>
<div>
<p className="tag-label mb-3">Price</p>
<div className="flex items-center gap-2">
<input
type="number"
name="min"
min={0}
placeholder="Min"
defaultValue={searchParams.min ?? ''}
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
/>
<span className="text-muted"></span>
<input
type="number"
name="max"
min={0}
placeholder="Max"
defaultValue={searchParams.max ?? ''}
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<button
type="submit"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Apply filters
</button>
{hasFilters && (
<a href="/made-to-order" className="text-center text-sm text-muted hover:text-ink">
Clear filters
</a>
)}
</div>
</aside>
{/* Grid */}
<div>
{products.length === 0 ? (
<div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">No templates match those filters</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">
{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>
</form>
)}
</div>
</div>
);
}
+32 -261
View File
@@ -1,120 +1,41 @@
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';
type SearchParams = {
category?: string | string[];
collection?: string | string[];
color?: string | string[];
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];
}
export default async function ReadyMadeProductsPage({ searchParams }: { searchParams: SearchParams }) {
const categoryRows = await prisma.category.findMany({
where: { showOnProducts: true, parentId: null },
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) => [
{ value: parent.slug, label: parent.name },
...parent.children.map((child) => ({ value: child.slug, label: `${parent.name} > ${child.name}` })),
]);
const CATEGORIES = allCategories;
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 RECIPIENTS = collectionRows.filter((c) => c.group === 'RECIPIENT').map((c) => ({ value: c.slug, label: c.name }));
const selectedCategories = toArray(searchParams.category);
const selectedCollections = toArray(searchParams.collection);
const selectedColors = toArray(searchParams.color);
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);
// Cached palette across ready-made products for the swatch filter
const palette = await getPaletteColors();
const where: Prisma.ProductWhereInput = { showAsReadyMade: true };
if (selectedCategories.length) {
where.category = { in: selectedCategories };
}
if (selectedCollections.length) {
where.collections = { some: { slug: { in: selectedCollections } } };
}
if (selectedColors.length) {
where.AND = [{ OR: selectedColors.map((c) => ({ colors: { contains: c } })) }];
}
if (q) {
where.name = { contains: q };
}
if (minDollars !== undefined || maxDollars !== undefined) {
where.basePrice = {
...(minDollars !== undefined ? { gte: Math.round(minDollars * 100) } : {}),
...(maxDollars !== undefined ? { lte: Math.round(maxDollars * 100) } : {}),
};
}
const activePromotions = await fetchActivePromotions();
const [rows, totalCount] = await Promise.all([
prisma.product.findMany({
where,
where: { showAsReadyMade: true },
orderBy: { createdAt: 'asc' },
take: PRODUCTS_PER_PAGE,
skip: (currentPage - 1) * PRODUCTS_PER_PAGE,
}),
prisma.product.count({ where }),
prisma.product.count({ where: { showAsReadyMade: true } }),
]);
const products = rows.map((p) => toProductDTO(p, activePromotions, 'plain'));
const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
const hasFilters =
selectedCategories.length > 0 ||
selectedCollections.length > 0 ||
selectedColors.length > 0 ||
q ||
minDollars !== undefined ||
maxDollars !== undefined;
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<div className="mb-8 flex items-baseline justify-between">
<div className="mb-12 flex items-baseline justify-between">
<h1 className="font-display text-4xl">Products</h1>
<p className="tag-label">
{products.length} item{products.length === 1 ? '' : 's'}
{totalCount} item{totalCount === 1 ? '' : 's'}
</p>
</div>
{products.length === 0 && !hasFilters ? (
{products.length === 0 ? (
<div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">Nothing here yet</p>
<p className="mt-2 text-sm text-muted">
@@ -123,187 +44,37 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
</p>
</div>
) : (
<form method="GET" className="grid gap-10 md:grid-cols-[220px_1fr]">
{/* Sidebar filters */}
<aside className="space-y-8">
<div>
<label htmlFor="q" className="tag-label mb-2 block">
Search
</label>
<input
id="q"
name="q"
defaultValue={q}
placeholder="Search products…"
className="w-full border border-line px-3 py-2 text-sm focus:border-ink"
/>
</div>
<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>
<p className="tag-label mb-3">Category</p>
<div className="space-y-2">
{CATEGORIES.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="category"
value={c.value}
defaultChecked={selectedCategories.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
{OCCASIONS.length > 0 && (
<div>
<p className="tag-label mb-3">Occasion</p>
<div className="space-y-2">
{OCCASIONS.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collection"
value={c.value}
defaultChecked={selectedCollections.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
)}
{RECIPIENTS.length > 0 && (
<div>
<p className="tag-label mb-3">Gifts for</p>
<div className="space-y-2">
{RECIPIENTS.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collection"
value={c.value}
defaultChecked={selectedCollections.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
)}
<div>
<p className="tag-label mb-3">Color</p>
<div className="flex flex-wrap gap-2">
{palette.map((hex) => {
const checked = selectedColors.includes(hex);
return (
<label key={hex} className="cursor-pointer">
<input
type="checkbox"
name="color"
value={hex}
defaultChecked={checked}
className="peer sr-only"
/>
<span
title={hex}
className={`block h-8 w-8 rounded-full border transition-transform peer-checked:scale-110 peer-checked:border-ink peer-checked:ring-2 peer-checked:ring-ink peer-checked:ring-offset-2 peer-checked:ring-offset-paper ${
checked ? 'border-ink' : 'border-line'
}`}
style={{ backgroundColor: hex }}
/>
</label>
);
})}
</div>
</div>
<div>
<p className="tag-label mb-3">Price</p>
<div className="flex items-center gap-2">
<input
type="number"
name="min"
min={0}
placeholder="Min"
defaultValue={searchParams.min ?? ''}
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
/>
<span className="text-muted"></span>
<input
type="number"
name="max"
min={0}
placeholder="Max"
defaultValue={searchParams.max ?? ''}
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<button
type="submit"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Apply filters
</button>
{hasFilters && (
<a href="/products" className="text-center text-sm text-muted hover:text-ink">
Clear filters
{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}`}
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}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Next
</a>
)}
</div>
</aside>
{/* Grid */}
<div>
{products.length === 0 ? (
<div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">No products match those filters</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">
{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>
</form>
)}
</div>
)}
</div>
);