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 ? ( + {product.name} + ) : ( +
+ +
+ )} + + {/* 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 ? ( + {product.name} + ) : ( +
+ +
+ )} + + {/* 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 ? ( +
+ {product.name} +
+ ) : ( +
+ +
+ )} + + {/* 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 ? (
- @@ -97,15 +92,6 @@ export function Navbar() { Sign in - - -
)}
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; +}