259 lines
9.5 KiB
TypeScript
259 lines
9.5 KiB
TypeScript
import { prisma } from '@/lib/prisma';
|
||
import { toProductDTO } from '@/lib/product';
|
||
import { fetchActivePromotions } from '@/lib/promotions';
|
||
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;
|
||
};
|
||
|
||
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 },
|
||
orderBy: { name: 'asc' },
|
||
});
|
||
const CATEGORIES = categoryRows.map((c) => ({ value: c.slug, label: c.name }));
|
||
|
||
const collectionRows = await prisma.collection.findMany({ 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;
|
||
|
||
// Full palette across ready-made products only, for the swatch filter.
|
||
const allProducts = await prisma.product.findMany({
|
||
where: { showOnProducts: true },
|
||
select: { colors: true },
|
||
});
|
||
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 = { showOnProducts: 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 = await prisma.product.findMany({ where, orderBy: { createdAt: 'asc' } });
|
||
const products = rows.map((p) => toProductDTO(p, activePromotions));
|
||
|
||
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">
|
||
<h1 className="font-display text-4xl">Products</h1>
|
||
<p className="tag-label">
|
||
{products.length} item{products.length === 1 ? '' : 's'}
|
||
</p>
|
||
</div>
|
||
|
||
{products.length === 0 && !hasFilters ? (
|
||
<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">
|
||
Ready-made items will show up here once added — check Admin → Add product and enable
|
||
"Products catalog".
|
||
</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>
|
||
<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
|
||
</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 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>
|
||
</form>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|