diff --git a/frontend/app/marketplace/page.tsx b/frontend/app/marketplace/page.tsx index d605507b..46911c32 100644 --- a/frontend/app/marketplace/page.tsx +++ b/frontend/app/marketplace/page.tsx @@ -11,6 +11,7 @@ import { useLocaleFormatters } from "../../lib/i18n/format"; import { joinListingsWithProjects } from "../../lib/map-utils"; import { colors } from "../../styles/design-system"; import MarketplaceFilter, { FilterState, EMPTY_FILTERS, filtersFromParams } from "../../components/MarketplaceFilter"; +import RefinementPanel, { RefinementState, EMPTY_REFINEMENTS, refinementsFromParams, isRefinementsEmpty } from "../../components/RefinementPanel"; import MarketplaceSortControls from "../../components/MarketplaceSortControls"; import ComparisonTray, { MAX_COMPARISON_ITEMS } from "../../components/ComparisonTray"; import VirtualizedList from "../../components/VirtualizedList"; @@ -36,6 +37,7 @@ function MarketplaceContent() { const searchParams = useSearchParams(); const router = useRouter(); const [filters, setFilters] = useState(() => filtersFromParams(searchParams)); + const [refinements, setRefinements] = useState(() => refinementsFromParams(searchParams)); // Sort state lives in the URL so sorted views are shareable/bookmarkable. const rawSort = searchParams.get("sort"); @@ -54,8 +56,8 @@ function MarketplaceContent() { methodology: filters.methodology || undefined, vintage: filters.vintageYear ? Number(filters.vintageYear) : undefined, country: filters.country || undefined, - minPrice: filters.minPrice || undefined, - maxPrice: filters.maxPrice || undefined, + minPrice: refinements.priceMin !== 0 ? refinements.priceMin : (filters.minPrice || undefined), + maxPrice: refinements.priceMax !== 1000 ? refinements.priceMax : (filters.maxPrice || undefined), projectType: filters.projectType || undefined, search: filters.search || undefined, sortBy: sortBy || undefined, @@ -77,11 +79,39 @@ function MarketplaceContent() { return Array.from(terms); }, [listings]); - // "Available now" isn't a backend query param — applied client-side here so - // it's a single source of truth shared by both the listings grid and the map. - const visibleListings = listings.filter( - l => filters.availableOnly !== "true" || l.amountAvailable > 0 - ); + // Client-side refinement filtering applied on top of API results. + // This lets sliders and verifier chips respond instantly without a round-trip. + const visibleListings = useMemo(() => { + let result = listings; + + // Available only + if (filters.availableOnly === "true") { + result = result.filter(l => l.amountAvailable > 0); + } + + // Carbon reduction range (amountAvailable used as proxy for carbon reduction) + if (refinements.carbonMin !== 0 || refinements.carbonMax !== 1_000_000) { + result = result.filter( + l => l.amountAvailable >= refinements.carbonMin && l.amountAvailable <= refinements.carbonMax + ); + } + + // Vintage year range + if (refinements.vintageMin !== 2015 || refinements.vintageMax !== new Date().getFullYear()) { + result = result.filter( + l => l.vintageYear >= refinements.vintageMin && l.vintageYear <= refinements.vintageMax + ); + } + + // Verifier filter (methodology used as verifier proxy since listings include methodology) + if (refinements.verifiers.length > 0) { + result = result.filter(l => + refinements.verifiers.some(v => l.methodology?.toLowerCase().includes(v.toLowerCase())) + ); + } + + return result; + }, [listings, filters.availableOnly, refinements]); // GET /marketplace/listings doesn't include project coordinates, so they're // fetched separately and joined client-side for the map (see lib/map-utils.ts). @@ -91,8 +121,8 @@ function MarketplaceContent() { projectsData?.projects ?? [] ); - // Check if any filters are active - const hasActiveFilters = Object.values(filters).some(v => v !== ""); + // Check if any filters or refinements are active + const hasActiveFilters = Object.values(filters).some(v => v !== "") || !isRefinementsEmpty(refinements); useEffect(() => { if (error) console.error("[Marketplace] listings fetch failed:", error); @@ -123,9 +153,15 @@ function MarketplaceContent() { const cartCount = items.length; + function handleClearAllFilters() { + setFilters(EMPTY_FILTERS); + setRefinements(EMPTY_REFINEMENTS); + router.push("?", { scroll: false }); + } + return ( -
+
{/* Header */}
@@ -157,159 +193,178 @@ function MarketplaceContent() {
- {!isLoading && !error && ( -
-

- {t("browseByLocation")} -

- - {missingCoordinatesCount > 0 && ( -

- {t("missingCoordinates", { count: missingCoordinatesCount })} -

- )} -
- )} - -
- {error ? ( - mutate()} /> - ) : isLoading ? ( -
- -
- ) : !visibleListings.length ? ( -
-
🔍
-

- {hasActiveFilters ? t("noMatchTitle") : t("noListingsTitle")} -

-

- {hasActiveFilters ? t("noMatchMessage") : t("noListingsMessage")} -

- {hasActiveFilters && ( - - )} -
- ) : ( -
-
- - - {t("resultsCount", { count: visibleListings.length })} - + {/* Two-column layout: refinement sidebar + main results */} +
+ + {/* Sidebar: RefinementPanel */} + + + {/* Main content */} +
+ {!isLoading && !error && ( +
+

+ {t("browseByLocation")} +

+ + {missingCoordinatesCount > 0 && ( +

+ {t("missingCoordinates", { count: missingCoordinatesCount })} +

+ )}
+ )} + +
+ {error ? ( + mutate()} /> + ) : isLoading ? ( +
+ +
+ ) : !visibleListings.length ? ( +
+
🔍
+

+ {hasActiveFilters ? t("noMatchTitle") : t("noListingsTitle")} +

+

+ {hasActiveFilters ? t("noMatchMessage") : t("noListingsMessage")} +

+ {hasActiveFilters && ( + + )} +
+ ) : ( +
+
+ + + {t("resultsCount", { count: visibleListings.length })} + +
- listing.listingId} - renderItem={(listing) => { - const inCart = items.some(i => i.listing.listingId === listing.listingId); - const inComparison = comparisonIds.includes(listing.listingId); - const comparisonDisabled = !inComparison && comparisonIds.length >= MAX_COMPARISON_ITEMS; - return ( -
- - - {/* Project info */} -
-

- -

-

- · · {t("vintageLabel", { year: listing.vintageYear })} · {t("availableLabel", { amount: formatTonnes(listing.amountAvailable) })} -

-
- - {/* Price */} -
-

- ${formatCurrency(listing.pricePerCredit)} -

-

{t("perTonne")}

-
- - {/* Actions */} -
- - {t("buyNow")} - - -
-
- ); - }} - /> - - setComparisonIds(prev => prev.filter(x => x !== id))} - onClear={() => setComparisonIds([])} - /> + listing.listingId} + renderItem={(listing) => { + const inCart = items.some(i => i.listing.listingId === listing.listingId); + const inComparison = comparisonIds.includes(listing.listingId); + const comparisonDisabled = !inComparison && comparisonIds.length >= MAX_COMPARISON_ITEMS; + return ( +
+ + + {/* Project info */} +
+

+ +

+

+ · · {t("vintageLabel", { year: listing.vintageYear })} · {t("availableLabel", { amount: formatTonnes(listing.amountAvailable) })} +

+
+ + {/* Price */} +
+

+ ${formatCurrency(listing.pricePerCredit)} +

+

{t("perTonne")}

+
+ + {/* Actions */} +
+ + {t("buyNow")} + + +
+
+ ); + }} + /> + + setComparisonIds(prev => prev.filter(x => x !== id))} + onClear={() => setComparisonIds([])} + /> +
+ )}
- )} +
+ + {/* Responsive: collapse sidebar below 768px */} + ); } diff --git a/frontend/components/MarketplaceFilter.tsx b/frontend/components/MarketplaceFilter.tsx index d9884162..5808c9bf 100644 --- a/frontend/components/MarketplaceFilter.tsx +++ b/frontend/components/MarketplaceFilter.tsx @@ -16,11 +16,14 @@ export interface FilterState { search: string; /** "true" when the "Available now" checkbox is checked, "" otherwise (kept as a string like the other fields for URL-param round-tripping). */ availableOnly: string; + /** Comma-separated verifier names when multi-select verifier chip filter is active, "" otherwise. */ + verifiers: string; } export const EMPTY_FILTERS: FilterState = { methodology: "", vintageYear: "", country: "", minPrice: "", maxPrice: "", projectType: "", search: "", availableOnly: "", + verifiers: "", }; export function filtersFromParams(params: URLSearchParams): FilterState { @@ -33,6 +36,7 @@ export function filtersFromParams(params: URLSearchParams): FilterState { projectType: params.get("projectType") ?? "", search: params.get("search") ?? "", availableOnly: params.get("availableOnly") ?? "", + verifiers: params.get("verifiers") ?? "", }; } diff --git a/frontend/components/RefinementPanel.tsx b/frontend/components/RefinementPanel.tsx new file mode 100644 index 00000000..42df2425 --- /dev/null +++ b/frontend/components/RefinementPanel.tsx @@ -0,0 +1,670 @@ +"use client"; + +/** + * RefinementPanel — Faceted search refinements for the Marketplace. + * + * Provides: + * - Dual-handle range slider for Price (0–1000 USDC/tCO₂) + * - Dual-handle range slider for Carbon Reduction amount (0–1,000,000 tCO₂) + * - Vintage year range picker (min/max selects, 2015–2025) + * - Multi-select verifier chips (Verra, Gold Standard, ACR, CAR) + * - All state is persisted in URL query params + * - "Clear all filters" button resets everything and clears the URL + * + * Issue: #1031 — Build Advanced Filtering with Refinement + */ + +import { useCallback, useRef, useState, useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { colors } from "../styles/design-system"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface RefinementState { + /** Price range in USDC per tCO₂ */ + priceMin: number; + priceMax: number; + /** Carbon reduction range in tCO₂ */ + carbonMin: number; + carbonMax: number; + /** Vintage year range */ + vintageMin: number; + vintageMax: number; + /** Selected verifiers (empty array = all) */ + verifiers: string[]; +} + +export const VERIFIERS = ["Verra", "Gold Standard", "ACR", "CAR"] as const; + +const PRICE_MIN_DEFAULT = 0; +const PRICE_MAX_DEFAULT = 1000; +const CARBON_MIN_DEFAULT = 0; +const CARBON_MAX_DEFAULT = 1_000_000; +const VINTAGE_MIN_DEFAULT = 2015; +const VINTAGE_MAX_DEFAULT = new Date().getFullYear(); +const VINTAGE_OPTIONS = Array.from( + { length: VINTAGE_MAX_DEFAULT - VINTAGE_MIN_DEFAULT + 1 }, + (_, i) => VINTAGE_MIN_DEFAULT + i +); + +export const EMPTY_REFINEMENTS: RefinementState = { + priceMin: PRICE_MIN_DEFAULT, + priceMax: PRICE_MAX_DEFAULT, + carbonMin: CARBON_MIN_DEFAULT, + carbonMax: CARBON_MAX_DEFAULT, + vintageMin: VINTAGE_MIN_DEFAULT, + vintageMax: VINTAGE_MAX_DEFAULT, + verifiers: [], +}; + +export function refinementsFromParams(params: URLSearchParams): RefinementState { + const n = (key: string, fallback: number) => { + const raw = params.get(key); + const val = raw !== null ? Number(raw) : NaN; + return Number.isFinite(val) ? val : fallback; + }; + const verifiersRaw = params.get("verifiers"); + const verifiers = verifiersRaw + ? verifiersRaw.split(",").filter(v => (VERIFIERS as readonly string[]).includes(v)) + : []; + + return { + priceMin: n("priceMin", PRICE_MIN_DEFAULT), + priceMax: n("priceMax", PRICE_MAX_DEFAULT), + carbonMin: n("carbonMin", CARBON_MIN_DEFAULT), + carbonMax: n("carbonMax", CARBON_MAX_DEFAULT), + vintageMin: n("vintageMin", VINTAGE_MIN_DEFAULT), + vintageMax: n("vintageMax", VINTAGE_MAX_DEFAULT), + verifiers, + }; +} + +export function refinementsToParams(state: RefinementState): Record { + const params: Record = {}; + if (state.priceMin !== PRICE_MIN_DEFAULT) params.priceMin = String(state.priceMin); + if (state.priceMax !== PRICE_MAX_DEFAULT) params.priceMax = String(state.priceMax); + if (state.carbonMin !== CARBON_MIN_DEFAULT) params.carbonMin = String(state.carbonMin); + if (state.carbonMax !== CARBON_MAX_DEFAULT) params.carbonMax = String(state.carbonMax); + if (state.vintageMin !== VINTAGE_MIN_DEFAULT) params.vintageMin = String(state.vintageMin); + if (state.vintageMax !== VINTAGE_MAX_DEFAULT) params.vintageMax = String(state.vintageMax); + if (state.verifiers.length > 0) params.verifiers = state.verifiers.join(","); + return params; +} + +export function isRefinementsEmpty(state: RefinementState): boolean { + return ( + state.priceMin === PRICE_MIN_DEFAULT && + state.priceMax === PRICE_MAX_DEFAULT && + state.carbonMin === CARBON_MIN_DEFAULT && + state.carbonMax === CARBON_MAX_DEFAULT && + state.vintageMin === VINTAGE_MIN_DEFAULT && + state.vintageMax === VINTAGE_MAX_DEFAULT && + state.verifiers.length === 0 + ); +} + +// --------------------------------------------------------------------------- +// Dual-handle range slider +// --------------------------------------------------------------------------- + +interface RangeSliderProps { + id: string; + label: string; + min: number; + max: number; + valueMin: number; + valueMax: number; + step?: number; + formatValue?: (v: number) => string; + onChange: (min: number, max: number) => void; +} + +function RangeSlider({ + id, + label, + min, + max, + valueMin, + valueMax, + step = 1, + formatValue = String, + onChange, +}: RangeSliderProps) { + // Percentage helpers + const pct = (v: number) => ((v - min) / (max - min)) * 100; + + const handleMinChange = (e: React.ChangeEvent) => { + const v = Math.min(Number(e.target.value), valueMax - step); + onChange(v, valueMax); + }; + const handleMaxChange = (e: React.ChangeEvent) => { + const v = Math.max(Number(e.target.value), valueMin + step); + onChange(valueMin, v); + }; + + const trackFillLeft = pct(valueMin); + const trackFillWidth = pct(valueMax) - pct(valueMin); + + return ( +
+
+ {label} + + {formatValue(valueMin)} – {formatValue(valueMax)} + +
+ + {/* Track container */} +
+ {/* Background track */} +
+ {/* Filled range */} +
+ + {/* Min thumb */} + + {/* Max thumb */} + +
+ + {/* Min/max labels */} +
+ {formatValue(min)} + {formatValue(max)} +
+ + {/* Inline CSS for range thumb across browsers */} + +
+ ); +} + +// --------------------------------------------------------------------------- +// Verifier multi-select chips +// --------------------------------------------------------------------------- + +interface VerifierChipsProps { + selected: string[]; + onChange: (verifiers: string[]) => void; +} + +function VerifierChips({ selected, onChange }: VerifierChipsProps) { + const toggle = (v: string) => { + if (selected.includes(v)) onChange(selected.filter(x => x !== v)); + else onChange([...selected, v]); + }; + + return ( +
+ + Verifier + +
+ {VERIFIERS.map(v => { + const isSelected = selected.includes(v); + return ( + + ); + })} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Vintage year range — two selects +// --------------------------------------------------------------------------- + +interface VintageRangeProps { + vintageMin: number; + vintageMax: number; + onChange: (min: number, max: number) => void; +} + +const controlStyle: React.CSSProperties = { + border: `1px solid ${colors.neutral[300]}`, + borderRadius: "0.375rem", + padding: "0.5rem 0.75rem", + fontSize: "0.875rem", + color: colors.neutral[700], + background: colors.surface, + width: "100%", + boxSizing: "border-box", + minHeight: "40px", +}; + +function VintageRangePicker({ vintageMin, vintageMax, onChange }: VintageRangeProps) { + return ( +
+ + Vintage Year Range + +
+
+ + +
+ to +
+ + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main RefinementPanel component +// --------------------------------------------------------------------------- + +interface RefinementPanelProps { + refinements: RefinementState; + onChange: (state: RefinementState) => void; +} + +export default function RefinementPanel({ refinements, onChange }: RefinementPanelProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const debounceRef = useRef | null>(null); + const [mobileOpen, setMobileOpen] = useState(false); + const drawerRef = useRef(null); + + const hasActive = !isRefinementsEmpty(refinements); + const activeCount = [ + refinements.priceMin !== PRICE_MIN_DEFAULT || refinements.priceMax !== PRICE_MAX_DEFAULT, + refinements.carbonMin !== CARBON_MIN_DEFAULT || refinements.carbonMax !== CARBON_MAX_DEFAULT, + refinements.vintageMin !== VINTAGE_MIN_DEFAULT || refinements.vintageMax !== VINTAGE_MAX_DEFAULT, + refinements.verifiers.length > 0, + ].filter(Boolean).length; + + // Debounced URL sync — avoid pushing to router on every slider tick + const syncUrl = useCallback( + (next: RefinementState) => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + const params = new URLSearchParams(searchParams.toString()); + // Remove all refinement keys first + ["priceMin","priceMax","carbonMin","carbonMax","vintageMin","vintageMax","verifiers"].forEach(k => params.delete(k)); + // Write non-default values + const newParams = refinementsToParams(next); + Object.entries(newParams).forEach(([k, v]) => params.set(k, v)); + router.push(`?${params.toString()}`, { scroll: false }); + }, 250); + }, + [router, searchParams] + ); + + const update = useCallback( + (patch: Partial) => { + const next = { ...refinements, ...patch }; + onChange(next); + syncUrl(next); + }, + [refinements, onChange, syncUrl] + ); + + const clearAll = () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + onChange(EMPTY_REFINEMENTS); + const params = new URLSearchParams(searchParams.toString()); + ["priceMin","priceMax","carbonMin","carbonMax","vintageMin","vintageMax","verifiers"].forEach(k => params.delete(k)); + router.push(`?${params.toString()}`, { scroll: false }); + }; + + // Escape to close mobile drawer + useEffect(() => { + if (!mobileOpen) return; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setMobileOpen(false); }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [mobileOpen]); + + const formatPrice = (v: number) => `$${v}`; + const formatCarbon = (v: number) => + v >= 1_000_000 ? "1M tCO₂" : v >= 1000 ? `${(v / 1000).toFixed(0)}k tCO₂` : `${v} tCO₂`; + + const panelContent = ( + <> + update({ priceMin: min, priceMax: max })} + /> + + update({ carbonMin: min, carbonMax: max })} + /> + + update({ vintageMin: min, vintageMax: max })} + /> + + update({ verifiers })} + /> + + + + ); + + return ( + <> + {/* Mobile trigger */} +
+ + {hasActive && ( + + )} +
+ + {/* Desktop: vertical panel */} + + + {/* Mobile drawer */} + {mobileOpen && ( +
{ if (e.target === e.currentTarget) setMobileOpen(false); }} + > +
+
+

+ 🎛 Refine Results +

+ +
+ {panelContent} + +
+
+ )} + + + + ); +}