/// import { type Cell, cell, Default, handler, lift, recipe, str, } from "commontools"; interface CatalogItemInput { id?: string; title?: string; category?: string; brand?: string; price?: number; tags?: unknown; } interface CatalogItem { id: string; title: string; category: string; brand: string; price: number; tags: string[]; } interface FacetSelection { categories: string[]; brands: string[]; priceCeiling: number | null; } interface PriceRange { min: number; max: number; average: number; } interface CatalogSearchArgs { catalog: Default; } const defaultCatalog: CatalogItem[] = [ { id: "french-press", title: "Stainless Steel French Press", category: "Kitchen", brand: "Brew Pro", price: 48.5, tags: ["coffee", "brewing"], }, { id: "pour-over-kit", title: "Pour Over Coffee Kit", category: "Kitchen", brand: "Daily Bean", price: 38, tags: ["coffee", "manual"], }, { id: "hiking-pack", title: "Lightweight Hiking Backpack", category: "Outdoors", brand: "Trailhead", price: 96, tags: ["gear", "trail"], }, { id: "trail-shoes", title: "Trail Running Shoes", category: "Outdoors", brand: "Trailhead", price: 120, tags: ["shoes", "running"], }, { id: "noise-cancelling-headphones", title: "Noise Cancelling Headphones", category: "Electronics", brand: "Sonic Pulse", price: 180, tags: ["audio", "travel"], }, { id: "smart-speaker", title: "Smart Home Speaker", category: "Electronics", brand: "Sonic Pulse", price: 140, tags: ["audio", "home"], }, ]; const defaultSelection: FacetSelection = { categories: [], brands: [], priceCeiling: null, }; const toTitleCase = (value: string): string => value.split(/\s+/).filter((part) => part.length > 0).map((part) => { const head = part.charAt(0).toUpperCase(); const rest = part.slice(1).toLowerCase(); return `${head}${rest}`; }).join(" "); const sanitizeString = (value: unknown, fallback: string): string => { if (typeof value === "string") { const trimmed = value.trim(); if (trimmed.length > 0) return trimmed; } return fallback; }; const sanitizeFacet = (value: unknown, fallback: string): string => { const base = sanitizeString(value, fallback); if (base.length === 0) return fallback; return toTitleCase(base); }; const sanitizePrice = (value: unknown, fallback: number): number => { if (typeof value === "number" && Number.isFinite(value)) { const normalized = Math.round(value * 100) / 100; if (normalized >= 0) return normalized; } return fallback; }; const sanitizeTags = (value: unknown, fallback: string[]): string[] => { if (!Array.isArray(value)) return [...fallback]; const seen = new Set(); const tags: string[] = []; for (const raw of value) { if (typeof raw !== "string") continue; const normalized = raw.trim().toLowerCase(); if (normalized.length === 0 || seen.has(normalized)) continue; seen.add(normalized); tags.push(normalized); } tags.sort((left, right) => left.localeCompare(right)); return tags; }; const normalizeId = (value: string): string => value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-") .replace(/^-+/, "").replace(/-+$/, ""); const ensureUniqueId = (candidate: string, used: Set): string => { const base = candidate.length > 0 ? candidate : "item"; if (!used.has(base)) { used.add(base); return base; } let suffix = 2; while (used.has(`${base}-${suffix}`)) { suffix += 1; } const unique = `${base}-${suffix}`; used.add(unique); return unique; }; const sanitizeCatalog = (value: unknown): CatalogItem[] => { const entries = Array.isArray(value) && value.length > 0 ? (value as CatalogItemInput[]) : defaultCatalog; const sanitized: CatalogItem[] = []; const used = new Set(); for (let index = 0; index < entries.length; index++) { const raw = entries[index] ?? {}; const fallback = defaultCatalog[index % defaultCatalog.length]; const title = sanitizeString(raw.title, fallback.title); const category = sanitizeFacet(raw.category, fallback.category); const brand = sanitizeFacet(raw.brand, fallback.brand); const price = sanitizePrice(raw.price, fallback.price); const tags = sanitizeTags(raw.tags, fallback.tags); const idSource = typeof raw.id === "string" ? raw.id : typeof raw.title === "string" ? raw.title : fallback.id; const normalized = normalizeId(idSource); const fallbackId = normalizeId(fallback.id); const id = ensureUniqueId( normalized.length > 0 ? normalized : fallbackId, used, ); sanitized.push({ id, title, category, brand, price, tags }); } sanitized.sort((left, right) => left.title.localeCompare(right.title)); return sanitized; }; const computeAvailableCategories = ( items: readonly CatalogItem[], ): string[] => { const unique = new Set(); for (const item of items) unique.add(item.category); return Array.from(unique).sort((left, right) => left.localeCompare(right)); }; const computeAvailableBrands = ( items: readonly CatalogItem[], ): string[] => { const unique = new Set(); for (const item of items) unique.add(item.brand); return Array.from(unique).sort((left, right) => left.localeCompare(right)); }; const computePriceRange = (items: readonly CatalogItem[]): PriceRange => { if (items.length === 0) { return { min: 0, max: 0, average: 0 }; } let total = 0; let min = Number.POSITIVE_INFINITY; let max = Number.NEGATIVE_INFINITY; for (const item of items) { total += item.price; if (item.price < min) min = item.price; if (item.price > max) max = item.price; } const average = Math.round((total / items.length) * 100) / 100; return { min: Math.round(min * 100) / 100, max: Math.round(max * 100) / 100, average, }; }; const filterItems = (input: { items: readonly CatalogItem[]; categories: readonly string[]; brands: readonly string[]; priceCeiling: number | null; }): CatalogItem[] => { const sourceItems = Array.isArray(input.items) ? input.items : []; const categories = Array.isArray(input.categories) ? input.categories : []; const brands = Array.isArray(input.brands) ? input.brands : []; const ceiling = typeof input.priceCeiling === "number" && Number.isFinite(input.priceCeiling) ? input.priceCeiling : null; const categorySet = new Set(categories); const brandSet = new Set(brands); return sourceItems.filter((item) => { const matchesCategory = categorySet.size === 0 || categorySet.has(item.category); const matchesBrand = brandSet.size === 0 || brandSet.has(item.brand); const matchesPrice = ceiling === null || item.price <= ceiling; return matchesCategory && matchesBrand && matchesPrice; }); }; const formatPrice = (value: number): string => `$${value.toFixed(2)}`; const summarizeSelection = ( categories: readonly string[], brands: readonly string[], priceCeiling: number | null, ): string => { const parts: string[] = []; parts.push( categories.length > 0 ? `Categories: ${categories.join(", ")}` : "Categories: All", ); parts.push( brands.length > 0 ? `Brands: ${brands.join(", ")}` : "Brands: All", ); const hasCeiling = typeof priceCeiling === "number" && Number.isFinite(priceCeiling); parts.push( hasCeiling ? `Price ≤ ${formatPrice(priceCeiling)}` : "Price ≤ Any", ); return parts.join(" • "); }; const toggleFacet = handler( ( event: { value?: string } | undefined, context: { target: Cell }, ) => { const normalized = sanitizeFacet(event?.value, ""); if (normalized.length === 0) return; const current = context.target.get(); const next = current.slice(); const index = next.indexOf(normalized); if (index >= 0) { next.splice(index, 1); } else { next.push(normalized); next.sort((left, right) => left.localeCompare(right)); } context.target.set(next); }, ); const setPriceCeiling = handler( ( event: | { ceiling?: number | null } | undefined, context: { price: Cell; range: Cell }, ) => { if (event?.ceiling === null) { context.price.set(null); return; } if (typeof event?.ceiling !== "number" || !Number.isFinite(event.ceiling)) { context.price.set(null); return; } const sanitized = Math.max(0, Math.round(event.ceiling * 100) / 100); const range = context.range.get(); if (range.max <= 0) { context.price.set(null); return; } const clamped = Math.min(sanitized, range.max); context.price.set(clamped); }, ); const clearFilters = handler( (_event: unknown, context: { categories: Cell; brands: Cell; price: Cell; }) => { context.categories.set([...defaultSelection.categories]); context.brands.set([...defaultSelection.brands]); context.price.set(defaultSelection.priceCeiling); }, ); export const catalogSearchFacets = recipe( "Catalog Search Facets", ({ catalog }) => { const sanitizedCatalog = lift(sanitizeCatalog)(catalog); const selectedCategories = cell( structuredClone(defaultSelection.categories), ); const selectedBrands = cell( structuredClone(defaultSelection.brands), ); const priceCeiling = cell(defaultSelection.priceCeiling); const availableCategories = lift(computeAvailableCategories)( sanitizedCatalog, ); const availableBrands = lift(computeAvailableBrands)(sanitizedCatalog); const priceRange = lift(computePriceRange)(sanitizedCatalog); const filteredItems = lift(filterItems)({ items: sanitizedCatalog, categories: selectedCategories, brands: selectedBrands, priceCeiling, }); const totalCount = lift((items: CatalogItem[]) => items.length)( sanitizedCatalog, ); const filteredCount = lift((items: CatalogItem[]) => items.length)( filteredItems, ); const selectionSummary = lift((input: { categories: string[]; brands: string[]; price: number | null; }) => summarizeSelection(input.categories, input.brands, input.price))({ categories: selectedCategories, brands: selectedBrands, price: priceCeiling, }); const statusLabel = str`Showing ${filteredCount} of ${totalCount} products`; return { catalog: sanitizedCatalog, facets: { available: { categories: availableCategories, brands: availableBrands, priceRange, }, selection: { categories: selectedCategories, brands: selectedBrands, priceCeiling, summary: selectionSummary, }, }, results: { items: filteredItems, count: filteredCount, total: totalCount, statusLabel, }, controls: { toggleCategory: toggleFacet({ target: selectedCategories }), toggleBrand: toggleFacet({ target: selectedBrands }), setPriceCeiling: setPriceCeiling({ price: priceCeiling, range: priceRange, }), clearFilters: clearFilters({ categories: selectedCategories, brands: selectedBrands, price: priceCeiling, }), }, }; }, );