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:
co-authored by
Claude Haiku 4.5
parent
ac10b33207
commit
e716e8256c
@@ -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/products/awd-hoodie)",
|
||||||
"Bash(curl -s http://127.0.0.1:3000/made-to-order)",
|
"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(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
@@ -1,17 +1,9 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
type SearchParams = {
|
type SearchParams = {
|
||||||
category?: string | string[];
|
|
||||||
collection?: string | string[];
|
|
||||||
color?: string | string[];
|
|
||||||
min?: string;
|
|
||||||
max?: string;
|
|
||||||
q?: string;
|
|
||||||
page?: string;
|
page?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,278 +15,72 @@ function toArray(v?: string | string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProductsPage({ searchParams }: { searchParams: SearchParams }) {
|
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);
|
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 activePromotions = await fetchActivePromotions();
|
||||||
|
|
||||||
const [rows, totalCount] = await Promise.all([
|
const [rows, totalCount] = await Promise.all([
|
||||||
prisma.product.findMany({
|
prisma.product.findMany({
|
||||||
where,
|
where: { showOnPersonalised: true },
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
take: PRODUCTS_PER_PAGE,
|
take: PRODUCTS_PER_PAGE,
|
||||||
skip: (currentPage - 1) * 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 products = rows.map((p) => toProductDTO(p, activePromotions, 'personalised'));
|
||||||
const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-6xl px-6 py-12">
|
<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>
|
<h1 className="font-display text-4xl">Shop all</h1>
|
||||||
<p className="tag-label">
|
<p className="tag-label">
|
||||||
{totalCount} template{totalCount === 1 ? '' : 's'}
|
{totalCount} template{totalCount === 1 ? '' : 's'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="GET" className="grid gap-10 md:grid-cols-[220px_1fr]">
|
<div>
|
||||||
{/* Sidebar filters */}
|
{/* Sidebar filters */}
|
||||||
<aside className="space-y-8">
|
{products.length === 0 ? (
|
||||||
<div>
|
<div className="border border-line bg-surface p-12 text-center">
|
||||||
<label htmlFor="q" className="tag-label mb-2 block">
|
<p className="font-display text-xl">Nothing here yet</p>
|
||||||
Search
|
<p className="mt-2 text-sm text-muted">Templates will show up here once created.</p>
|
||||||
</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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<p className="tag-label mb-3">Category</p>
|
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="space-y-2">
|
{products.map((p) => (
|
||||||
{CATEGORIES.map((c) => (
|
<ProductCard key={p.id} product={p} />
|
||||||
<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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{OCCASIONS.length > 0 && (
|
{totalPages > 1 && (
|
||||||
<div>
|
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
|
||||||
<p className="tag-label mb-3">Occasion</p>
|
{currentPage > 1 && (
|
||||||
<div className="space-y-2">
|
<a
|
||||||
{OCCASIONS.map((c) => (
|
href={`/made-to-order?page=${currentPage - 1}`}
|
||||||
<label key={c.value} className="flex items-center gap-2 text-sm">
|
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||||
<input
|
>
|
||||||
type="checkbox"
|
← Previous
|
||||||
name="collection"
|
</a>
|
||||||
value={c.value}
|
)}
|
||||||
defaultChecked={selectedCollections.includes(c.value)}
|
<p className="text-sm text-muted">
|
||||||
className="h-4 w-4 accent-splash-pink"
|
Page {currentPage} of {totalPages}
|
||||||
/>
|
</p>
|
||||||
{c.label}
|
{currentPage < totalPages && (
|
||||||
</label>
|
<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>
|
||||||
</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>
|
</div>
|
||||||
</aside>
|
)}
|
||||||
|
</div>
|
||||||
{/* 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
@@ -1,120 +1,41 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
type SearchParams = {
|
type SearchParams = {
|
||||||
category?: string | string[];
|
|
||||||
collection?: string | string[];
|
|
||||||
color?: string | string[];
|
|
||||||
min?: string;
|
|
||||||
max?: string;
|
|
||||||
q?: string;
|
|
||||||
page?: string;
|
page?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PRODUCTS_PER_PAGE = 20;
|
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 }) {
|
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);
|
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 activePromotions = await fetchActivePromotions();
|
||||||
|
|
||||||
const [rows, totalCount] = await Promise.all([
|
const [rows, totalCount] = await Promise.all([
|
||||||
prisma.product.findMany({
|
prisma.product.findMany({
|
||||||
where,
|
where: { showAsReadyMade: true },
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
take: PRODUCTS_PER_PAGE,
|
take: PRODUCTS_PER_PAGE,
|
||||||
skip: (currentPage - 1) * 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 products = rows.map((p) => toProductDTO(p, activePromotions, 'plain'));
|
||||||
const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-6xl px-6 py-12">
|
<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>
|
<h1 className="font-display text-4xl">Products</h1>
|
||||||
<p className="tag-label">
|
<p className="tag-label">
|
||||||
{products.length} item{products.length === 1 ? '' : 's'}
|
{totalCount} item{totalCount === 1 ? '' : 's'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{products.length === 0 && !hasFilters ? (
|
{products.length === 0 ? (
|
||||||
<div className="border border-line bg-surface p-12 text-center">
|
<div className="border border-line bg-surface p-12 text-center">
|
||||||
<p className="font-display text-xl">Nothing here yet</p>
|
<p className="font-display text-xl">Nothing here yet</p>
|
||||||
<p className="mt-2 text-sm text-muted">
|
<p className="mt-2 text-sm text-muted">
|
||||||
@@ -123,187 +44,37 @@ export default async function ReadyMadeProductsPage({ searchParams }: { searchPa
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form method="GET" className="grid gap-10 md:grid-cols-[220px_1fr]">
|
<div>
|
||||||
{/* Sidebar filters */}
|
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<aside className="space-y-8">
|
{products.map((p) => (
|
||||||
<div>
|
<ProductCard key={p.id} product={p} hrefBase="/products" />
|
||||||
<label htmlFor="q" className="tag-label mb-2 block">
|
))}
|
||||||
Search
|
</div>
|
||||||
</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>
|
{totalPages > 1 && (
|
||||||
<p className="tag-label mb-3">Category</p>
|
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
|
||||||
<div className="space-y-2">
|
{currentPage > 1 && (
|
||||||
{CATEGORIES.map((c) => (
|
<a
|
||||||
<label key={c.value} className="flex items-center gap-2 text-sm">
|
href={`/products?page=${currentPage - 1}`}
|
||||||
<input
|
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||||
type="checkbox"
|
>
|
||||||
name="category"
|
← Previous
|
||||||
value={c.value}
|
</a>
|
||||||
defaultChecked={selectedCategories.includes(c.value)}
|
)}
|
||||||
className="h-4 w-4 accent-splash-pink"
|
<p className="text-sm text-muted">
|
||||||
/>
|
Page {currentPage} of {totalPages}
|
||||||
{c.label}
|
</p>
|
||||||
</label>
|
{currentPage < totalPages && (
|
||||||
))}
|
<a
|
||||||
</div>
|
href={`/products?page=${currentPage + 1}`}
|
||||||
</div>
|
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
||||||
|
>
|
||||||
{OCCASIONS.length > 0 && (
|
Next →
|
||||||
<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
|
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
)}
|
||||||
|
</div>
|
||||||
{/* 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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user