Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions src/__tests__/templateFilters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, it, expect } from "vitest";
import {
ALL,
EMPTY_FILTERS,
filterTemplates,
sortTemplates,
categoryCounts,
backgroundCounts,
filtersToQuery,
filtersFromQuery,
hasActiveFilters,
type TemplateFilters,
} from "@/lib/templateFilters";
import { TEMPLATES, templateCategories } from "@/lib/templates";

/**
* The gallery had one filter and a search box. Every listing people actually buy from
* carries two axes and a count beside each value, because a filter that would return
* nothing is worth knowing about before it is clicked.
*/

const all = TEMPLATES.map((t) => ({ name: t.name, tagline: t.tagline, category: t.category, tags: t.tags, style: t.style }));
const categories = templateCategories();

describe("finding a template in a catalogue of 22", () => {
it("filters by what the site is for", () => {
const launch = filterTemplates(all, { ...EMPTY_FILTERS, category: "Launch" });
expect(launch.length).toBeGreaterThan(0);
expect(launch.every((t) => t.category === "Launch")).toBe(true);
});

it("filters by how the background moves", () => {
const waves = filterTemplates(all, { ...EMPTY_FILTERS, background: "wave" });
expect(waves.length).toBeGreaterThan(0);
expect(waves.every((t) => t.style === "wave")).toBe(true);
});

it("narrows on both axes at once", () => {
const f: TemplateFilters = { q: "", category: "SaaS", background: "gradient" };
expect(filterTemplates(all, f).every((t) => t.category === "SaaS" && t.style === "gradient")).toBe(true);
});

it("searches names, taglines, categories, tags and the background's name", () => {
expect(filterTemplates(all, { ...EMPTY_FILTERS, q: "kept" }).map((t) => t.name)).toContain("Kept");
expect(filterTemplates(all, { ...EMPTY_FILTERS, q: "waitlist" }).length).toBeGreaterThan(0);
expect(filterTemplates(all, { ...EMPTY_FILTERS, q: "particles" }).every((t) => t.style === "particles")).toBe(true);
});

it("returns everything when nothing is asked of it", () => {
expect(filterTemplates(all, EMPTY_FILTERS)).toHaveLength(all.length);
expect(hasActiveFilters(EMPTY_FILTERS)).toBe(false);
expect(hasActiveFilters({ ...EMPTY_FILTERS, category: "Launch" })).toBe(true);
});

it("sorts by name when asked, and keeps the catalogue's own order otherwise", () => {
expect(sortTemplates(all, "Catalogue order").map((t) => t.name)).toEqual(all.map((t) => t.name));
const az = sortTemplates(all, "A to Z").map((t) => t.name);
expect(az).toEqual([...az].sort((a, b) => a.localeCompare(b)));
});
});

describe("the count beside each filter answers what happens if you click it", () => {
it("counts every template when nothing else is filtered", () => {
const counts = categoryCounts(all, EMPTY_FILTERS);
expect(counts[ALL]).toBe(all.length);
for (const category of categories) {
expect(counts[category] ?? 0).toBe(all.filter((t) => t.category === category).length);
}
});

it("counts one axis with the other applied, not with itself applied", () => {
// Choosing Launch must not reduce the Launch count to Launch-only, or the number
// stops meaning "what you would get".
const f: TemplateFilters = { ...EMPTY_FILTERS, category: "Launch" };
const counts = categoryCounts(all, f);
expect(counts[ALL]).toBe(all.length);
expect(counts.Launch).toBe(all.filter((t) => t.category === "Launch").length);
});

it("shows a background count that reflects the chosen category", () => {
const f: TemplateFilters = { ...EMPTY_FILTERS, category: "SaaS" };
const counts = backgroundCounts(all, f);
const saas = all.filter((t) => t.category === "SaaS");
expect(counts[ALL]).toBe(saas.length);
for (const style of ["gradient", "geometric", "particles", "wave"]) {
expect(counts[style] ?? 0).toBe(saas.filter((t) => t.style === style).length);
}
});

it("every count matches what the filter actually returns", () => {
for (const category of categories) {
const f: TemplateFilters = { ...EMPTY_FILTERS, category };
expect(filterTemplates(all, f)).toHaveLength(categoryCounts(all, EMPTY_FILTERS)[category]);
}
});
});

describe("a filtered view is a link someone can send", () => {
it("puts the filters in the query string, and leaves out the defaults", () => {
expect(filtersToQuery(EMPTY_FILTERS, "Catalogue order")).toBe("");
expect(filtersToQuery({ q: "cafe", category: "Launch", background: "wave" }, "A to Z"))
.toBe("q=cafe&for=Launch&background=wave&sort=az");
});

it("reads them back", () => {
const query = filtersToQuery({ q: "clinic", category: "Launch", background: "wave" }, "A to Z");
const read = filtersFromQuery(new URLSearchParams(query), { categories });
expect(read.filters).toEqual({ q: "clinic", category: "Launch", background: "wave" });
expect(read.sort).toBe("A to Z");
});

it("ignores a category or background that does not exist, rather than showing nothing", () => {
const read = filtersFromQuery(new URLSearchParams("for=Nonsense&background=plaid"), { categories });
expect(read.filters.category).toBe(ALL);
expect(read.filters.background).toBe(ALL);
});

it("caps a search term from the URL", () => {
const read = filtersFromQuery(new URLSearchParams(`q=${"a".repeat(500)}`), { categories });
expect(read.filters.q.length).toBeLessThanOrEqual(100);
});
});
124 changes: 91 additions & 33 deletions src/app/templates/TemplatesClient.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArrowRight, Search, Eye } from "lucide-react";
import Navbar from "@/components/Navbar";
import type { Style2D } from "@/lib/generate2DFrames";
import SiteFooter from "@/components/SiteFooter";
import {
ALL,
BACKGROUND_LABELS,
EMPTY_FILTERS,
SORTS,
backgroundCounts,
categoryCounts,
filterTemplates,
filtersFromQuery,
filtersToQuery,
hasActiveFilters,
sortTemplates,
type TemplateFilters,
type TemplateSort,
} from "@/lib/templateFilters";

/**
* What a gallery card renders. Deliberately not `Template`: the full record carries every
Expand All @@ -26,25 +42,27 @@ export interface TemplateCard {
}

export default function TemplatesClient({ templates, categories: allCategories }: { templates: TemplateCard[]; categories: string[] }) {
const [search, setSearch] = useState("");
const [activeCategory, setActiveCategory] = useState("All");

const categories = useMemo(() => ["All", ...allCategories], [allCategories]);

const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return templates.filter((t) => {
const matchesCategory = activeCategory === "All" || t.category === activeCategory;
if (!matchesCategory) return false;
if (!q) return true;
return (
t.name.toLowerCase().includes(q) ||
t.tagline.toLowerCase().includes(q) ||
t.category.toLowerCase().includes(q) ||
t.tags.some((tag) => tag.toLowerCase().includes(q))
);
});
}, [search, activeCategory, templates]);
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();

// The URL is the starting state, so a filtered gallery can be linked, bookmarked and
// sent to somebody else rather than described to them.
const fromUrl = useMemo(() => filtersFromQuery(params, { categories: allCategories }), [params, allCategories]);
const [filters, setFilters] = useState<TemplateFilters>(fromUrl.filters);
const [sort, setSort] = useState<TemplateSort>(fromUrl.sort);

useEffect(() => {
const query = filtersToQuery(filters, sort);
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [filters, sort, pathname, router]);

const categories = useMemo(() => [ALL, ...allCategories], [allCategories]);
const counts = useMemo(() => categoryCounts(templates, filters), [templates, filters]);
const backgrounds = useMemo(() => backgroundCounts(templates, filters), [templates, filters]);
const filtered = useMemo(() => sortTemplates(filterTemplates(templates, filters), sort), [templates, filters, sort]);
const search = filters.q;
const setSearch = (q: string) => setFilters((f) => ({ ...f, q }));

return (
<main className="min-h-screen bg-background text-foreground">
Expand Down Expand Up @@ -75,41 +93,81 @@ export default function TemplatesClient({ templates, categories: allCategories }
/>
</div>

<div className="flex flex-wrap gap-2 justify-center">
<fieldset className="flex flex-wrap gap-2 justify-center">
<legend className="lc-mono mb-2 w-full text-center text-xs uppercase tracking-[0.18em] text-muted-foreground">What it is for</legend>
{categories.map((c) => (
<button
key={c}
type="button"
onClick={() => setActiveCategory(c)}
aria-pressed={activeCategory === c}
className={`lc-mono h-9 px-4 rounded-full text-[0.8rem] border transition-colors ${
activeCategory === c
onClick={() => setFilters((f) => ({ ...f, category: c }))}
aria-pressed={filters.category === c}
disabled={(counts[c] ?? 0) === 0 && c !== filters.category}
className={`lc-mono h-9 px-4 rounded-full text-[0.8rem] border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
filters.category === c
? "border-primary-ink/60 bg-primary-ink/15 text-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"
}`}
>
{c}
{c} <span className="tabular-nums opacity-80">{counts[c] ?? 0}</span>
</button>
))}
</div>
</fieldset>

<p className="lc-mono text-center text-xs text-muted-foreground" aria-live="polite">
{filtered.length} of {templates.length} templates
</p>
<fieldset className="flex flex-wrap gap-2 justify-center">
<legend className="lc-mono mb-2 w-full text-center text-xs uppercase tracking-[0.18em] text-muted-foreground">How the background moves</legend>
{[ALL, ...Object.keys(BACKGROUND_LABELS)].map((b) => (
<button
key={b}
type="button"
onClick={() => setFilters((f) => ({ ...f, background: b }))}
aria-pressed={filters.background === b}
disabled={(backgrounds[b] ?? 0) === 0 && b !== filters.background}
className={`lc-mono h-9 px-4 rounded-full text-[0.8rem] border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
filters.background === b
? "border-primary-ink/60 bg-primary-ink/15 text-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"
}`}
>
{b === ALL ? ALL : BACKGROUND_LABELS[b]} <span className="tabular-nums opacity-80">{backgrounds[b] ?? 0}</span>
</button>
))}
</fieldset>

<div className="flex flex-wrap items-center justify-center gap-4">
<p className="lc-mono text-xs text-muted-foreground" aria-live="polite">
{filtered.length} of {templates.length} templates
</p>
<div className="flex items-center gap-2">
<label htmlFor="template-sort" className="lc-mono text-xs text-muted-foreground">Order</label>
<select
id="template-sort"
value={sort}
onChange={(e) => setSort(e.target.value as TemplateSort)}
className="lc-mono h-9 rounded-md border border-border bg-card px-2 text-xs text-foreground"
>
{SORTS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</div>
{hasActiveFilters(filters) && (
<Button variant="outline" size="sm" className="lc-mono h-9 border-border text-xs" onClick={() => setFilters(EMPTY_FILTERS)}>
Clear filters
</Button>
)}
</div>
</div>

<section className="px-6 pb-24 max-w-7xl mx-auto">
{filtered.length === 0 ? (
<div className="text-center py-20 border border-border rounded-lg bg-card">
<p className="font-medium mb-1">Nothing matches “{search}”</p>
<p className="font-medium mb-1">No template matches that</p>
<p className="text-sm text-muted-foreground mb-5">
Try a category instead, or clear the search.
Clear the filters, or <Link href="/contact?topic=custom" className="underline underline-offset-4">tell us what you were looking for</Link>.
</p>
<Button
variant="outline"
size="sm"
className="border-border"
onClick={() => { setSearch(""); setActiveCategory("All"); }}
onClick={() => setFilters(EMPTY_FILTERS)}
>
Show all templates
</Button>
Expand Down
9 changes: 8 additions & 1 deletion src/app/templates/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { pageMeta } from "@/lib/pageMeta";
import { TEMPLATES, templateCategories, templateScrollHeight, templateSectionCount } from "@/lib/templates";
import TemplatesClient, { type TemplateCard } from "./TemplatesClient";
Expand Down Expand Up @@ -35,5 +36,11 @@ export default function TemplatesPage() {
scrollHeight: templateScrollHeight(t),
}));

return <TemplatesClient templates={templates} categories={templateCategories()} />;
// The gallery reads its filters from the URL, which is a client-side concern; the
// boundary lets the rest of the page stay static.
return (
<Suspense fallback={null}>
<TemplatesClient templates={templates} categories={templateCategories()} />
</Suspense>
);
}
Loading
Loading