diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index a3caa02..d70fb81 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -1,19 +1,21 @@
-import type { NextConfig } from 'next';
-import * as path from 'path';
-import * as fs from 'fs';
+import type { NextConfig } from "next";
+import * as path from "path";
+import * as fs from "fs";
-// Automatically load environment variables from the root .env file if present
-const rootEnvPath = path.resolve(__dirname, '../.env');
+const rootEnvPath = path.resolve(__dirname, "../.env");
if (fs.existsSync(rootEnvPath)) {
- const envConfig = fs.readFileSync(rootEnvPath, 'utf-8');
- for (const line of envConfig.split('\n')) {
+ const envConfig = fs.readFileSync(rootEnvPath, "utf-8");
+ for (const line of envConfig.split("\n")) {
const trimmed = line.trim();
- if (trimmed && !trimmed.startsWith('#') && trimmed.includes('=')) {
- const firstEq = trimmed.indexOf('=');
+ if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) {
+ const firstEq = trimmed.indexOf("=");
const key = trimmed.slice(0, firstEq).trim();
let value = trimmed.slice(firstEq + 1).trim();
- // Remove quotes if present
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+
+ if (
+ (value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))
+ ) {
value = value.slice(1, -1);
}
if (!process.env[key]) {
@@ -24,11 +26,12 @@ if (fs.existsSync(rootEnvPath)) {
}
const nextConfig: NextConfig = {
- output: 'standalone',
+ output: "standalone",
env: {
- NEON_AUTH_BASE_URL: process.env.NEON_AUTH_BASE_URL || '',
- NEON_AUTH_COOKIE_SECRET: process.env.NEON_AUTH_COOKIE_SECRET || '',
- NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api/v1',
+ NEON_AUTH_BASE_URL: process.env.NEON_AUTH_BASE_URL || "",
+ NEON_AUTH_COOKIE_SECRET: process.env.NEON_AUTH_COOKIE_SECRET || "",
+ NEXT_PUBLIC_API_URL:
+ process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api/v1",
},
};
diff --git a/frontend/src/app/catalog/[slug]/page.tsx b/frontend/src/app/catalog/[slug]/page.tsx
new file mode 100644
index 0000000..315bb55
--- /dev/null
+++ b/frontend/src/app/catalog/[slug]/page.tsx
@@ -0,0 +1,237 @@
+import React from 'react';
+import Link from 'next/link';
+import { notFound } from 'next/navigation';
+import { Navbar } from '@/components/layout/Navbar';
+import { fetchProductByIdentifier, fetchProducts } from '@/lib/api/catalog';
+import { ProductCard } from '@/components/catalog/ProductCard';
+import { Button } from '@/components/ui/button';
+import {
+ ChevronRight,
+ ArrowRight,
+ FileText,
+ Box,
+ MessageSquare,
+} from 'lucide-react';
+
+interface ProductPageProps {
+ params: Promise<{
+ slug: string;
+ }>;
+}
+
+export default async function ProductDetailPage({ params }: ProductPageProps) {
+ const { slug } = await params;
+ const product = await fetchProductByIdentifier(slug);
+
+ if (!product) {
+ notFound();
+ }
+
+ // Fetch companion/similar products in the same category
+ const companionResponse = product.category?.slug
+ ? await fetchProducts({ category: product.category.slug, limit: 3 })
+ : null;
+
+ const companionProducts = (companionResponse?.data || []).filter(
+ (p) => p.id !== product.id
+ );
+
+ const { data } = product;
+
+ const specEntries = Object.entries(data || {}).filter(
+ ([key]) => key !== 'rag_chunk' && typeof data[key] !== 'object'
+ );
+
+ return (
+
+
+
+
+ {/* Breadcrumb Navigation */}
+
+
+ {/* Balanced 2-Column Product Layout with Inline Sizing */}
+
+
+ {/* Left Column: Image perfectly inline with right content height */}
+
+ {product.imageUrl ? (
+

+ ) : (
+
+
+
+ )}
+
+ {/* Category Pill Overlay */}
+ {product.category && (
+
+ {product.category.name}
+
+ )}
+
+ {/* SKU Overlay at bottom of image */}
+
+
+ SKU: {product.sku}
+
+
+
+
+ {/* Right Column: Details, Price/CTA, & 2-Column Specs Grid */}
+
+ {/* Header: Manufacturer & Title */}
+
+
+ {product.manufacturer || 'Building Manufacturer'}
+
+
+ {product.name}
+
+
+ {product.description && (
+
+ {product.description}
+
+ )}
+
+
+ {/* Price & Action Bar */}
+
+
+
+ Unit Price
+
+
+
+ €{Number(product.price).toFixed(2)}
+
+
+ / {product.unit}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Physical & Engineering Specifications (Single Column Table List) */}
+ {specEntries.length > 0 && (
+
+
+
+ Physical & Engineering Properties
+
+
+
+ {specEntries.map(([key, val]) => (
+
+
+ {formatSpecKey(key)}
+
+
+ {String(val)}
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ {/* Similar / Companion Materials Section */}
+ {companionProducts.length > 0 && (
+
+
+
+
+ Related Materials in {product.category?.name || 'Category'}
+
+
+ Compatible drywall boards, profiles, and companion insulation.
+
+
+
+ View category →
+
+
+
+
+ {companionProducts.map((comp) => (
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+
+function formatSpecKey(key: string): string {
+ return key
+ .replace(/_/g, ' ')
+ .replace(/\b\w/g, (c) => c.toUpperCase())
+ .replace('Db', 'dB')
+ .replace('Mm', 'mm')
+ .replace('W Mk', 'W/mK')
+ .replace('Kg M3', 'kg/m³');
+}
diff --git a/frontend/src/app/catalog/page.tsx b/frontend/src/app/catalog/page.tsx
new file mode 100644
index 0000000..3953f44
--- /dev/null
+++ b/frontend/src/app/catalog/page.tsx
@@ -0,0 +1,377 @@
+'use client';
+
+import React, { useState, useEffect, useCallback } from 'react';
+import { useSearchParams, useRouter } from 'next/navigation';
+import { Navbar } from '@/components/layout/Navbar';
+import { FilterSidebar } from '@/components/catalog/FilterSidebar';
+import { CatalogHeader } from '@/components/catalog/CatalogHeader';
+import { ProductCard } from '@/components/catalog/ProductCard';
+import { ProductGridSkeleton } from '@/components/catalog/ProductGridSkeleton';
+import { Pagination } from '@/components/catalog/Pagination';
+import { fetchCategories, fetchProducts } from '@/lib/api/catalog';
+import { Category, Product, ProductQueryParams } from '@/types/catalog';
+import {
+ PackageSearch,
+ RotateCcw,
+ SlidersHorizontal,
+ ChevronDown,
+ ChevronUp,
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+
+export default function CatalogPage() {
+ return (
+ }>
+
+
+ );
+}
+
+function CatalogLoadingFallback() {
+ return (
+
+ );
+}
+
+function CatalogContent() {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+
+ // Data State
+ const [categories, setCategories] = useState([]);
+ const [products, setProducts] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ // Mobile Filter Drawer Toggle State
+ const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
+
+ // Filter & Search State
+ const [search, setSearch] = useState(searchParams.get('search') || '');
+ const [debouncedSearch, setDebouncedSearch] = useState(search);
+ const [category, setCategory] = useState(searchParams.get('category') || '');
+ const [manufacturers, setManufacturers] = useState(
+ searchParams.get('manufacturer')
+ ? searchParams.get('manufacturer')!.split(',')
+ : []
+ );
+ const [minPrice, setMinPrice] = useState(searchParams.get('minPrice') || '');
+ const [maxPrice, setMaxPrice] = useState(searchParams.get('maxPrice') || '');
+ const [sortBy, setSortBy] = useState(
+ (searchParams.get('sortBy') as ProductQueryParams['sortBy']) || 'newest'
+ );
+ const [currentPage, setCurrentPage] = useState(
+ Number(searchParams.get('page')) || 1
+ );
+ const [totalPages, setTotalPages] = useState(1);
+ const [totalProducts, setTotalProducts] = useState(0);
+
+ const [availableManufacturers, setAvailableManufacturers] = useState([]);
+
+ // 1. Initial Load: Fetch Categories
+ useEffect(() => {
+ async function loadCategories() {
+ const cats = await fetchCategories();
+ setCategories(cats);
+ }
+ loadCategories();
+ }, []);
+
+ // 2. Debounce Search Input (300ms)
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedSearch(search);
+ setCurrentPage(1);
+ }, 300);
+ return () => clearTimeout(timer);
+ }, [search]);
+
+ // 3. Sync State to URL Query Parameters
+ const updateUrlParams = useCallback(() => {
+ const params = new URLSearchParams();
+ if (debouncedSearch) params.set('search', debouncedSearch);
+ if (category) params.set('category', category);
+ if (manufacturers.length > 0)
+ params.set('manufacturer', manufacturers.join(','));
+ if (minPrice) params.set('minPrice', minPrice);
+ if (maxPrice) params.set('maxPrice', maxPrice);
+ if (sortBy && sortBy !== 'newest') params.set('sortBy', sortBy);
+ if (currentPage > 1) params.set('page', currentPage.toString());
+
+ const newQuery = params.toString();
+ const target = newQuery ? `/catalog?${newQuery}` : '/catalog';
+ router.replace(target, { scroll: false });
+ }, [
+ debouncedSearch,
+ category,
+ manufacturers,
+ minPrice,
+ maxPrice,
+ sortBy,
+ currentPage,
+ router,
+ ]);
+
+ // 4. Fetch Products whenever filters change
+ useEffect(() => {
+ let isCancelled = false;
+
+ async function loadProducts() {
+ setLoading(true);
+
+ const params: ProductQueryParams = {
+ page: currentPage,
+ limit: 12,
+ search: debouncedSearch || undefined,
+ category: category || undefined,
+ manufacturer:
+ manufacturers.length > 0 ? manufacturers : undefined,
+ minPrice: minPrice ? Number(minPrice) : undefined,
+ maxPrice: maxPrice ? Number(maxPrice) : undefined,
+ sortBy,
+ };
+
+ const response = await fetchProducts(params);
+
+ if (!isCancelled) {
+ if (response && response.data) {
+ setProducts(response.data);
+ setTotalPages(response.pagination?.totalPages || 1);
+ setTotalProducts(response.pagination?.total || 0);
+
+ if (availableManufacturers.length === 0 && response.data.length > 0) {
+ const mfgs = Array.from(
+ new Set(
+ response.data
+ .map((p) => p.manufacturer)
+ .filter(Boolean) as string[]
+ )
+ ).sort();
+ setAvailableManufacturers(mfgs);
+ }
+ }
+ setLoading(false);
+ }
+ }
+
+ loadProducts();
+ updateUrlParams();
+
+ return () => {
+ isCancelled = true;
+ };
+ }, [
+ debouncedSearch,
+ category,
+ manufacturers,
+ minPrice,
+ maxPrice,
+ sortBy,
+ currentPage,
+ updateUrlParams,
+ ]);
+
+ // Filter Handlers
+ const handleToggleManufacturer = (mfg: string) => {
+ setManufacturers((prev) =>
+ prev.includes(mfg) ? prev.filter((item) => item !== mfg) : [...prev, mfg]
+ );
+ setCurrentPage(1);
+ };
+
+ const handleSelectCategory = (catSlug: string) => {
+ setCategory(catSlug);
+ setCurrentPage(1);
+ };
+
+ const handleResetFilters = () => {
+ setSearch('');
+ setDebouncedSearch('');
+ setCategory('');
+ setManufacturers([]);
+ setMinPrice('');
+ setMaxPrice('');
+ setSortBy('newest');
+ setCurrentPage(1);
+ };
+
+ const selectedCategoryObj = categories.find((c) => c.slug === category);
+ const activeFiltersCount =
+ (category ? 1 : 0) +
+ manufacturers.length +
+ (minPrice ? 1 : 0) +
+ (maxPrice ? 1 : 0) +
+ (debouncedSearch ? 1 : 0);
+
+ const hasActiveFilters = activeFiltersCount > 0;
+
+ return (
+
+
+
+
+ {/* Page Header */}
+
+
+ Materials & Systems Catalog
+
+
+ Filter certified building products by acoustic rating (dB), fire resistance (EI), dimensions, and manufacturer.
+
+
+
+ {/* MOBILE FILTERS TOGGLE BUTTON (Placed at the TOP on mobile) */}
+
+
+
+ {/* Collapsible Filter Panel on Mobile */}
+ {mobileFiltersOpen && (
+
+ {
+ handleSelectCategory(slug);
+ setMobileFiltersOpen(false);
+ }}
+ selectedManufacturers={manufacturers}
+ onToggleManufacturer={handleToggleManufacturer}
+ minPrice={minPrice}
+ maxPrice={maxPrice}
+ onMinPriceChange={(val) => {
+ setMinPrice(val);
+ setCurrentPage(1);
+ }}
+ onMaxPriceChange={(val) => {
+ setMaxPrice(val);
+ setCurrentPage(1);
+ }}
+ onResetFilters={handleResetFilters}
+ availableManufacturers={availableManufacturers}
+ hasActiveFilters={hasActiveFilters}
+ />
+
+ )}
+
+
+ {/* 2-Column Catalog Layout */}
+
+ {/* Left Filter Sidebar (Visible on Desktop) */}
+
+ {
+ setMinPrice(val);
+ setCurrentPage(1);
+ }}
+ onMaxPriceChange={(val) => {
+ setMaxPrice(val);
+ setCurrentPage(1);
+ }}
+ onResetFilters={handleResetFilters}
+ availableManufacturers={availableManufacturers}
+ hasActiveFilters={hasActiveFilters}
+ />
+
+
+ {/* Right Product Grid Column */}
+
+ {/* Header: Search, Sort, Filter Chips */}
+
{
+ setSortBy(val);
+ setCurrentPage(1);
+ }}
+ totalProducts={totalProducts}
+ selectedCategoryName={selectedCategoryObj?.name}
+ onClearCategory={() => setCategory('')}
+ selectedManufacturers={manufacturers}
+ onRemoveManufacturer={handleToggleManufacturer}
+ minPrice={minPrice}
+ maxPrice={maxPrice}
+ onClearPrice={() => {
+ setMinPrice('');
+ setMaxPrice('');
+ }}
+ />
+
+ {/* Product Cards or Skeleton Loader */}
+ {loading ? (
+
+ ) : products.length > 0 ? (
+ <>
+
+ {products.map((product) => (
+
+ ))}
+
+
+ {/* Pagination Controls */}
+ {
+ setCurrentPage(page);
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }}
+ />
+ >
+ ) : (
+ /* Clean Empty State */
+
+
+
+ No materials match your filters
+
+
+ Try adjusting your search query, clearing specific manufacturers, or broadening your price bounds.
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/catalog/CatalogHeader.tsx b/frontend/src/components/catalog/CatalogHeader.tsx
new file mode 100644
index 0000000..583d534
--- /dev/null
+++ b/frontend/src/components/catalog/CatalogHeader.tsx
@@ -0,0 +1,135 @@
+'use client';
+
+import React from 'react';
+import { Search, X, ArrowUpDown } from 'lucide-react';
+import { ProductQueryParams } from '@/types/catalog';
+
+interface CatalogHeaderProps {
+ search: string;
+ onSearchChange: (value: string) => void;
+ sortBy: ProductQueryParams['sortBy'];
+ onSortChange: (value: ProductQueryParams['sortBy']) => void;
+ totalProducts: number;
+ selectedCategoryName?: string;
+ onClearCategory: () => void;
+ selectedManufacturers: string[];
+ onRemoveManufacturer: (mfg: string) => void;
+ minPrice: string;
+ maxPrice: string;
+ onClearPrice: () => void;
+}
+
+export function CatalogHeader({
+ search,
+ onSearchChange,
+ sortBy,
+ onSortChange,
+ totalProducts,
+ selectedCategoryName,
+ onClearCategory,
+ selectedManufacturers,
+ onRemoveManufacturer,
+ minPrice,
+ maxPrice,
+ onClearPrice,
+}: CatalogHeaderProps) {
+ const hasActiveChips =
+ selectedCategoryName ||
+ selectedManufacturers.length > 0 ||
+ minPrice ||
+ maxPrice;
+
+ return (
+
+ {/* Top Row: Search Input & Sort Dropdown */}
+
+ {/* Search Input */}
+
+
+ onSearchChange(e.target.value)}
+ placeholder="Search materials, SKUs, standards, or performance specs..."
+ className="w-full h-10 pl-10 pr-9 rounded-lg border border-zinc-200 bg-white text-sm text-zinc-900 placeholder:text-zinc-400 shadow-2xs focus:outline-hidden focus:border-amber-500 focus:ring-1 focus:ring-amber-500 transition-all"
+ />
+ {search && (
+
+ )}
+
+
+ {/* Sort Controls */}
+
+
+
+
+
+
+ {/* Results Count & Active Filter Chips */}
+
+
+ Showing {totalProducts} certified materials
+
+
+ {/* Filter Chips Bar */}
+ {hasActiveChips && (
+
+ {selectedCategoryName && (
+
+ {selectedCategoryName}
+
+
+ )}
+
+ {selectedManufacturers.map((mfg) => (
+
+ {mfg}
+
+
+ ))}
+
+ {(minPrice || maxPrice) && (
+
+
+ €{minPrice || '0'} – €{maxPrice || '∞'}
+
+
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/catalog/FilterSidebar.tsx b/frontend/src/components/catalog/FilterSidebar.tsx
new file mode 100644
index 0000000..fcf2640
--- /dev/null
+++ b/frontend/src/components/catalog/FilterSidebar.tsx
@@ -0,0 +1,181 @@
+'use client';
+
+import React from 'react';
+import { Category } from '@/types/catalog';
+import {
+ Layers,
+ Volume2,
+ Grid,
+ Brush,
+ Wrench,
+ ShieldAlert,
+ Building2,
+ SquareStack,
+ RotateCcw,
+ SlidersHorizontal,
+} from 'lucide-react';
+
+interface FilterSidebarProps {
+ categories: Category[];
+ selectedCategory: string;
+ onSelectCategory: (slug: string) => void;
+ selectedManufacturers: string[];
+ onToggleManufacturer: (mfg: string) => void;
+ minPrice: string;
+ maxPrice: string;
+ onMinPriceChange: (val: string) => void;
+ onMaxPriceChange: (val: string) => void;
+ onResetFilters: () => void;
+ availableManufacturers: string[];
+ hasActiveFilters: boolean;
+}
+
+const ICON_MAP: Record = {
+ Layers,
+ Volume2,
+ Grid,
+ Brush,
+ Wrench,
+ ShieldAlert,
+ Building2,
+ SquareStack,
+};
+
+export function FilterSidebar({
+ categories,
+ selectedCategory,
+ onSelectCategory,
+ selectedManufacturers,
+ onToggleManufacturer,
+ minPrice,
+ maxPrice,
+ onMinPriceChange,
+ onMaxPriceChange,
+ onResetFilters,
+ availableManufacturers,
+ hasActiveFilters,
+}: FilterSidebarProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/catalog/Pagination.tsx b/frontend/src/components/catalog/Pagination.tsx
new file mode 100644
index 0000000..1a776b6
--- /dev/null
+++ b/frontend/src/components/catalog/Pagination.tsx
@@ -0,0 +1,61 @@
+'use client';
+
+import React from 'react';
+import { ChevronLeft, ChevronRight } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+
+interface PaginationProps {
+ currentPage: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+}
+
+export function Pagination({
+ currentPage,
+ totalPages,
+ onPageChange,
+}: PaginationProps) {
+ if (totalPages <= 1) return null;
+
+ return (
+
+
+
+
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/catalog/ProductCard.tsx b/frontend/src/components/catalog/ProductCard.tsx
new file mode 100644
index 0000000..f401e96
--- /dev/null
+++ b/frontend/src/components/catalog/ProductCard.tsx
@@ -0,0 +1,149 @@
+'use client';
+
+import React from 'react';
+import Link from 'next/link';
+import { Product } from '@/types/catalog';
+import { ArrowRight, Volume2, ShieldAlert, Layers, Gauge, Box } from 'lucide-react';
+
+interface ProductCardProps {
+ product: Product;
+}
+
+export function ProductCard({ product }: ProductCardProps) {
+ const specs = extractSpecHighlights(product.data);
+
+ return (
+
+
+ {/* Image Container with Smooth Zoom */}
+
+ {product.imageUrl ? (
+

+ ) : (
+
+
+
+ )}
+
+ {/* Category Tag Overlay */}
+ {product.category && (
+
+ {product.category.name}
+
+ )}
+
+
+ {/* Manufacturer & SKU */}
+
+
+ {product.manufacturer || 'Standard Build'}
+
+
+ {product.sku}
+
+
+
+ {/* Product Title */}
+
+ {product.name}
+
+
+ {/* Short Description */}
+ {product.description && (
+
+ {product.description}
+
+ )}
+
+ {/* Technical Spec Badges */}
+ {specs.length > 0 && (
+
+ {specs.map((spec, i) => (
+
+ {spec.icon && }
+ {spec.label}
+
+ ))}
+
+ )}
+
+
+ {/* Pricing & CTA */}
+
+
+
+ Price
+
+
+
+ €{Number(product.price).toFixed(2)}
+
+
+ / {product.unit || 'unit'}
+
+
+
+
+
+ View Specs
+
+
+
+
+ );
+}
+
+function extractSpecHighlights(data: Record = []) {
+ const highlights: { label: string; icon?: React.ElementType }[] = [];
+
+ if (
+ data.sound_reduction_index_rw_db ||
+ data.sound_insulation_rw_db ||
+ data.weighted_sound_reduction_index_rw_db
+ ) {
+ const db =
+ data.sound_reduction_index_rw_db ||
+ data.sound_insulation_rw_db ||
+ data.weighted_sound_reduction_index_rw_db;
+ highlights.push({ label: `${db} dB`, icon: Volume2 });
+ }
+
+ if (
+ data.fire_classification ||
+ data.fire_resistance ||
+ data.fire_reaction_class ||
+ data.fire_rating
+ ) {
+ const fire =
+ data.fire_classification ||
+ data.fire_resistance ||
+ data.fire_reaction_class ||
+ data.fire_rating;
+ highlights.push({ label: String(fire).split(' ')[0], icon: ShieldAlert });
+ }
+
+ if (data.thickness_mm || data.board_thickness_mm || data.core_thickness_mm) {
+ const thk =
+ data.thickness_mm || data.board_thickness_mm || data.core_thickness_mm;
+ highlights.push({ label: `${thk} mm`, icon: Layers });
+ }
+
+ if (data.thermal_conductivity_lambda_w_mk || data.thermal_conductivity_w_mk) {
+ const lambda =
+ data.thermal_conductivity_lambda_w_mk || data.thermal_conductivity_w_mk;
+ highlights.push({ label: `λ ${lambda}`, icon: Gauge });
+ }
+
+ return highlights.slice(0, 3);
+}
diff --git a/frontend/src/components/catalog/ProductDetailSheet.tsx b/frontend/src/components/catalog/ProductDetailSheet.tsx
new file mode 100644
index 0000000..2ded80a
--- /dev/null
+++ b/frontend/src/components/catalog/ProductDetailSheet.tsx
@@ -0,0 +1,171 @@
+'use client';
+
+import React from 'react';
+import Link from 'next/link';
+import { Product } from '@/types/catalog';
+import { Button } from '@/components/ui/button';
+import {
+ X,
+ ArrowRight,
+ ShieldCheck,
+ FileText,
+ Sliders,
+ Check,
+ Building2,
+ Box,
+} from 'lucide-react';
+
+interface ProductDetailSheetProps {
+ product: Product | null;
+ onClose: () => void;
+}
+
+export function ProductDetailSheet({ product, onClose }: ProductDetailSheetProps) {
+ if (!product) return null;
+
+ const { data } = product;
+ const ragChunk = data?.rag_chunk as string | undefined;
+
+ // Filter out internal/rag fields for the clean specs table
+ const specEntries = Object.entries(data || {}).filter(
+ ([key]) => key !== 'rag_chunk' && typeof data[key] !== 'object'
+ );
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Slide-Over Panel */}
+
+ {/* Header */}
+
+
+
+ {product.sku}
+
+ {product.category && (
+
+ {product.category.name}
+
+ )}
+
+
+
+
+
+ {/* Scrollable Body */}
+
+ {/* Image */}
+ {product.imageUrl ? (
+
+

+
+ ) : (
+
+
+
+ )}
+
+ {/* Title & Manufacturer */}
+
+
+ {product.manufacturer || 'Certified Manufacturer'}
+
+
+ {product.name}
+
+ {product.description && (
+
+ {product.description}
+
+ )}
+
+
+ {/* Technical Specs Table */}
+ {specEntries.length > 0 && (
+
+
+
+ Physical & Performance Specifications
+
+
+
+ {specEntries.map(([key, val]) => (
+
+
+ {formatSpecKey(key)}
+
+
+ {String(val)}
+
+
+ ))}
+
+
+ )}
+
+ {/* Structured RAG Specification Box */}
+ {ragChunk && (
+
+
+
+ RAG Specification & Standards Knowledge
+
+
+ {ragChunk}
+
+
+ )}
+
+
+ {/* Footer with Price & Build Action */}
+
+
+
Price
+
+
+ €{Number(product.price).toFixed(2)}
+
+
+ / {product.unit}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function formatSpecKey(key: string): string {
+ return key
+ .replace(/_/g, ' ')
+ .replace(/\b\w/g, (c) => c.toUpperCase())
+ .replace('Db', 'dB')
+ .replace('Mm', 'mm')
+ .replace('W Mk', 'W/mK')
+ .replace('Kg M3', 'kg/m³');
+}
diff --git a/frontend/src/components/catalog/ProductGridSkeleton.tsx b/frontend/src/components/catalog/ProductGridSkeleton.tsx
new file mode 100644
index 0000000..0c71e67
--- /dev/null
+++ b/frontend/src/components/catalog/ProductGridSkeleton.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+
+export function ProductGridSkeleton({ count = 9 }: { count?: number }) {
+ return (
+
+ {Array.from({ length: count }).map((_, index) => (
+
+
+ {/* Image Placeholder */}
+
+
+ {/* Manufacturer & SKU Skeleton */}
+
+
+ {/* Title Skeleton */}
+
+
+
+ {/* Spec Badges Skeleton */}
+
+
+
+ {/* Price & Action Skeleton */}
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/layout/Navbar.tsx b/frontend/src/components/layout/Navbar.tsx
index a4c7693..cf826ac 100644
--- a/frontend/src/components/layout/Navbar.tsx
+++ b/frontend/src/components/layout/Navbar.tsx
@@ -1,20 +1,19 @@
-"use client";
+'use client';
-import React from "react";
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { Layers, ArrowUpRight, User } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { authClient } from "@/lib/auth/client";
+import React from 'react';
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import { Layers, User } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { authClient } from '@/lib/auth/client';
export function Navbar() {
const pathname = usePathname();
const { data: session } = authClient.useSession();
const navLinks = [
- { href: "/catalog", label: "Catalog" },
- { href: "/solutions", label: "Solution Architect" },
- { href: "/projects", label: "Saved Projects" },
+ { href: '/catalog', label: 'Catalog' },
+ { href: '/projects', label: 'Saved Projects' },
];
return (
@@ -43,8 +42,8 @@ export function Navbar() {
href={link.href}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
isActive
- ? "text-foreground bg-secondary"
- : "text-muted-foreground hover:text-foreground hover:bg-secondary/60"
+ ? 'text-foreground bg-secondary'
+ : 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
}`}
>
{link.label}
@@ -59,11 +58,7 @@ export function Navbar() {
{session?.user ? (
-
-
-
- Start Build
-
-
-
)}
diff --git a/frontend/src/types/catalog.ts b/frontend/src/types/catalog.ts
new file mode 100644
index 0000000..6088325
--- /dev/null
+++ b/frontend/src/types/catalog.ts
@@ -0,0 +1,59 @@
+export interface Category {
+ id: string;
+ name: string;
+ slug: string;
+ description?: string;
+ icon?: string;
+ createdAt?: string;
+ productCount?: number;
+}
+
+export interface Product {
+ id: string;
+ sku: string;
+ name: string;
+ slug: string;
+ manufacturer?: string;
+ description?: string;
+ price: number;
+ unit: string;
+ imageUrl?: string;
+ data: Record;
+ category?: Category;
+ createdAt?: string;
+ updatedAt?: string;
+}
+
+export interface ProductQueryParams {
+ page?: number;
+ limit?: number;
+ search?: string;
+ category?: string;
+ manufacturer?: string | string[];
+ minPrice?: number;
+ maxPrice?: number;
+ sortBy?: 'price_asc' | 'price_desc' | 'name_asc' | 'name_desc' | 'newest';
+}
+
+export interface ProductsResponse {
+ success: boolean;
+ data: Product[];
+ pagination: {
+ page: number;
+ limit: number;
+ total: number;
+ totalPages: number;
+ hasNext: boolean;
+ hasPrev: boolean;
+ };
+}
+
+export interface CategoriesResponse {
+ success: boolean;
+ data: Category[];
+}
+
+export interface ProductDetailResponse {
+ success: boolean;
+ data: Product;
+}