diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 07830ac..ba59612 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -40,6 +40,12 @@ jobs: - name: Run linter run: npm run lint + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v4 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Build production bundle run: npm run build diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..12cf442 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/README.md b/README.md index cb940ed..32b07fa 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,14 @@ const nextConfig = { ### Prerequisites -- Node.js 18+ +- Node.js 20.9.0+ + - Recommend using [nvm](https://github.com/nvm-sh/nvm) (Node Version Manager): + ```bash + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash + # Restart terminal or source ~/.bashrc + nvm install 20 + nvm use 20 + ``` - npm or yarn - Backend services running (see [Backend README](../backend/README.md)) diff --git a/app/cart/page.tsx b/app/cart/page.tsx index 92e8059..d9a8f0d 100644 --- a/app/cart/page.tsx +++ b/app/cart/page.tsx @@ -21,22 +21,22 @@ export default function CartPage() { if (isLoading) { return ( -
- +
+
); } if (items.length === 0) { return ( -
+
- -

+ +

Your cart is empty

-

+

Add some awesome products to get started!

@@ -49,7 +49,7 @@ export default function CartPage() { } return ( -
+
-

+

Shopping Cart

@@ -77,7 +77,7 @@ export default function CartPage() { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.1, duration: 0.5 }} - className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-md border border-gray-200 dark:border-gray-700" + className="bg-card text-card-foreground rounded-xl p-6 shadow-md border border-border" >
@@ -93,14 +93,14 @@ export default function CartPage() {
-

+

{item.product.name}

-

+

{item.product.brand}

-

+

{`LKR ${item.product.price.toLocaleString('en-LK')}`}

@@ -108,15 +108,15 @@ export default function CartPage() {
-
+
@@ -126,7 +126,7 @@ export default function CartPage() { @@ -144,24 +144,24 @@ export default function CartPage() { transition={{ duration: 0.6 }} className="lg:col-span-1" > -
-

+
+

Order Summary

-
+
Subtotal {`LKR ${subtotal.toLocaleString('en-LK')}`}
-
+
VAT (15%) {`LKR ${tax.toLocaleString('en-LK')}`}
-
-
+
+
Total - {`LKR ${total.toLocaleString('en-LK')}`} + {`LKR ${total.toLocaleString('en-LK')}`}
@@ -172,7 +172,7 @@ export default function CartPage() { Continue Shopping diff --git a/app/compare/page.tsx b/app/compare/page.tsx index 8ff3a41..b1878e5 100644 --- a/app/compare/page.tsx +++ b/app/compare/page.tsx @@ -13,7 +13,7 @@ export default function ComparePage() { const { items, removeItem, clearCompare } = useCompareStore(); const { products } = useProductStore(); const { addItem } = useCartStore(); - + // Get full product objects from IDs const compareList: Product[] = items .map(id => products.find(p => p.id === id)) @@ -21,24 +21,24 @@ export default function ComparePage() { if (compareList.length === 0) { return ( -
+
-
- +
+
-

+

No Products to Compare

-

+

Add products from product pages to compare their features

Browse Components @@ -55,41 +55,42 @@ export default function ComparePage() { ); return ( -
+
- + {/* Header */} -

+

Compare Products

-

+

Side-by-side comparison of {compareList.length} product{compareList.length > 1 ? 's' : ''}

+ {/* Comparison Table */} {/* Comparison Table */}
- - + @@ -98,7 +99,7 @@ export default function ComparePage() {
@@ -109,18 +110,18 @@ export default function ComparePage() { height={200} className="w-full h-40 object-cover rounded-lg mb-3" /> -

+

{product.name}

-

+

{product.brand}

-

+

${product.price.toLocaleString()}

{/* Basic Info */} - - + {compareList.map((product) => ( - ))} - - + {compareList.map((product) => ( @@ -155,7 +156,7 @@ export default function ComparePage() { In Stock ) : ( - + Out of Stock @@ -164,12 +165,12 @@ export default function ComparePage() { ))} - - + {compareList.map((product) => ( - ))} @@ -187,21 +188,19 @@ export default function ComparePage() { {allSpecKeys.map((specKey: string, index: number) => ( - {compareList.map((product: Product) => ( - ))} @@ -209,12 +208,12 @@ export default function ComparePage() { ))} {/* Description */} - - + {compareList.map((product) => ( - ))} @@ -230,12 +229,12 @@ export default function ComparePage() { animate={{ opacity: 1, y: 0 }} className="mt-8 text-center" > -

+

You can compare up to 4 products at once

Add More Products diff --git a/app/components/[category]/page.tsx b/app/components/[category]/page.tsx index 47c0b34..df38a92 100644 --- a/app/components/[category]/page.tsx +++ b/app/components/[category]/page.tsx @@ -67,7 +67,7 @@ export default function ComponentsCategoryPage({ params }: { params: { category: }; return ( -
+
-

+

{subcategory}s

-

+

{filteredProducts.length} products found

@@ -91,17 +91,17 @@ export default function ComponentsCategoryPage({ params }: { params: { category: transition={{ duration: 0.6 }} className="lg:w-64 shrink-0" > -
+
- -

+ +

Filters

{/* Price Range */}
-

+

Price Range

@@ -110,21 +110,21 @@ export default function ComponentsCategoryPage({ params }: { params: { category: value={minPrice} onChange={(e) => setMinPrice(Number(e.target.value))} placeholder="Min" - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + className="w-full px-3 py-2 border border-input rounded-lg bg-background text-foreground" /> setMaxPrice(Number(e.target.value))} placeholder="Max" - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + className="w-full px-3 py-2 border border-input rounded-lg bg-background text-foreground" />
{/* Brand Filter */}
-

+

Brand

@@ -134,9 +134,9 @@ export default function ComponentsCategoryPage({ params }: { params: { category: type="checkbox" checked={selectedBrands.includes(brand)} onChange={() => toggleBrand(brand)} - className="mr-2 accent-wso2-orange" + className="mr-2 accent-primary" /> - + {brand} @@ -151,9 +151,9 @@ export default function ComponentsCategoryPage({ params }: { params: { category: type="checkbox" checked={inStockOnly} onChange={(e) => setInStockOnly(e.target.checked)} - className="mr-2 accent-wso2-orange" + className="mr-2 accent-primary" /> - + In Stock Only @@ -168,7 +168,7 @@ export default function ComponentsCategoryPage({ params }: { params: { category: setEmail(e.target.value)} placeholder="Enter your email" required - className="w-full pl-10 pr-4 py-3 rounded-lg border border-gray-200 dark:border-white/15 bg-white dark:bg-white/5 text-gray-900 dark:text-white placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-wso2-orange focus:border-wso2-orange transition" + className="w-full pl-4 pr-10 py-3 rounded-full border border-input bg-background/50 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all" /> +
- {subscribed ? 'Subscribed!' : 'Subscribe'} + {subscribed ? 'Joined!' : 'Subscribe'} + {!subscribed && }
@@ -125,19 +111,19 @@ export default function Footer() {
{/* Main Footer Content */} -
-
+
+
{footerSections.map((section) => (
-

+

{section.title}

-
    +
      {section.links.map((link) => (
    • {link.label} @@ -148,45 +134,31 @@ export default function Footer() { ))}
- {/* Social Links */} -
- {socialLinks.map((social) => ( - - - - ))} -
+ {/* Bottom Section */} +
+
+ CS02 + © 2025 +
- {/* Bottom Bar */} -
-
-
-

© 2025 CS02. All rights reserved.

-
-
- {legalLinks.map((link, index) => ( - - - {link.label} - - {index < legalLinks.length - 1 && ( - | - )} - - ))} -
+
+ {socialLinks.map((social) => ( + + + + ))} +
+ +
+ Privacy + Terms
diff --git a/app/components/ui/LiveChatWidget.tsx b/app/components/ui/LiveChatWidget.tsx index d22c9f9..5109dc0 100644 --- a/app/components/ui/LiveChatWidget.tsx +++ b/app/components/ui/LiveChatWidget.tsx @@ -11,7 +11,7 @@ export default function LiveChatWidget() { { id: 1, type: 'bot', - text: 'Hi! Welcome to CS02. How can I help you today?', + text: 'Hi! Welcome to CS02. How can I help you today?', timestamp: new Date(), }, ]); @@ -53,7 +53,7 @@ export default function LiveChatWidget() { { id: prev.length + 1, type: 'bot', - text: "I'm sorry, I'm having trouble connecting right now.", + text: "I'm sorry, I'm having trouble connecting right now.", timestamp: new Date(), }, ]); @@ -72,7 +72,7 @@ export default function LiveChatWidget() { whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={() => setIsOpen(true)} - className="fixed bottom-6 right-6 z-50 p-4 bg-wso2-orange text-white rounded-full shadow-lg hover:bg-wso2-orange-dark transition-colors" + className="fixed bottom-6 right-6 z-50 p-4 bg-primary text-primary-foreground rounded-full shadow-lg hover:bg-primary/90 transition-colors" aria-label="Open chat" > @@ -87,17 +87,17 @@ export default function LiveChatWidget() { initial={{ opacity: 0, y: 20, scale: 0.95 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 20, scale: 0.95 }} - className="fixed bottom-6 right-6 z-50 w-96 h-[500px] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl flex flex-col overflow-hidden border border-gray-200 dark:border-gray-800" + className="fixed bottom-6 right-6 z-50 w-96 h-[500px] bg-card text-card-foreground rounded-2xl shadow-2xl flex flex-col overflow-hidden border border-border" > {/* Header */} -
+

CS02 Support

-

We're here to help!

+

We're here to help!

{/* Messages */} -
+
{messages.map((message) => (

{message.text}

{message.timestamp.toLocaleTimeString([], { hour: '2-digit', @@ -143,7 +140,7 @@ export default function LiveChatWidget() { {/* Input */}

setInputMessage(e.target.value)} placeholder="Type your message..." - className="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-wso2-orange" + className="flex-1 px-4 py-2 rounded-lg border border-input bg-background/50 text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary" /> diff --git a/app/components/ui/Navigation.tsx b/app/components/ui/Navigation.tsx index 3e1b8bf..937c3d5 100644 --- a/app/components/ui/Navigation.tsx +++ b/app/components/ui/Navigation.tsx @@ -1,7 +1,7 @@ 'use client'; import Link from 'next/link'; -import { motion } from 'framer-motion'; +import { motion, AnimatePresence } from 'framer-motion'; import { Search, ShoppingCart, @@ -11,12 +11,13 @@ import { Sun, Menu, X, + Cpu, } from 'lucide-react'; import { useThemeStore } from '@/lib/store/themeStore'; import { useCartStore } from '@/lib/store/cartStore'; import { useWishlistStore } from '@/lib/store/wishlistStore'; import { useUserStore } from '@/lib/store/userStore'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; export default function Navigation() { const { theme, toggleTheme } = useThemeStore(); @@ -25,6 +26,15 @@ export default function Navigation() { const user = useUserStore((state) => state.user); const [searchQuery, setSearchQuery] = useState(''); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [isScrolled, setIsScrolled] = useState(false); + + useEffect(() => { + const handleScroll = () => { + setIsScrolled(window.scrollY > 20); + }; + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, []); const handleSearch = (e: React.FormEvent) => { e.preventDefault(); @@ -39,169 +49,168 @@ export default function Navigation() { { href: '/pc-builder', label: 'PC Builder' }, { href: '/builderbot', label: 'BuilderBot' }, { href: '/deals', label: 'Deals' }, - { href: '/gallery', label: 'Build Gallery' }, + { href: '/gallery', label: 'Gallery' }, ]; return ( - + {/* Mobile Menu */} + + {isMobileMenuOpen && ( + +
+ {/* Mobile Search */} +
+ setSearchQuery(e.target.value)} + placeholder="Search products..." + className="w-full px-4 py-3 rounded-xl border border-input bg-secondary/50 text-foreground focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all" + /> + + + +
+ {navLinks.map((link) => ( + setIsMobileMenuOpen(false)} + > + {link.label} + + ))} +
+
+
+ )} +
+ + {/* Spacer to prevent content jump when nav becomes fixed/sticky relative to content flow if needed, + but here we use fixed nav so we might need padding on body or main. + The layout has 'pt-16' or similar usually, or we can add a div here. + */} + ); } diff --git a/app/components/ui/ProductCard.tsx b/app/components/ui/ProductCard.tsx index 3e249a5..476f1eb 100644 --- a/app/components/ui/ProductCard.tsx +++ b/app/components/ui/ProductCard.tsx @@ -41,115 +41,112 @@ export default function ProductCard({ product, showCompare = false }: ProductCar return ( - -
+ +
+ + {/* Badges */} +
+ {product.stockLevel > 0 && product.stockLevel < 10 && ( + + Low Stock + + )} + {product.tags?.includes('best-seller') && ( + + Best Seller + + )} +
+ + {/* Quick Actions Overlay (Desktop) */} +
+ + {showCompare && ( + + )} +
+ {product.stockLevel === 0 && ( -
- Out of Stock -
- )} - {product.stockLevel > 0 && product.stockLevel < 10 && ( -
- Only {product.stockLevel} left! -
- )} - {product.tags?.includes('best-seller') && ( -
- Best Seller +
+ Out of Stock
)}
-
-
- +
+
+ {product.brand}
-

+

{product.name}

- - {product.rating && ( -
-
- {'★'.repeat(Math.floor(product.rating))} - {'☆'.repeat(5 - Math.floor(product.rating))} -
- - ({product.reviewCount}) + +
+
+ Price + + LKR {product.price.toLocaleString()}
- )} -
- - LKR {product.price.toLocaleString()} - + {/* Small rating if available */} + {(product.rating ?? 0) > 0 && ( +
+ {product.rating} +
+ )}
-
+ {/* Add to Cart Button */} +
{product.stockLevel > 0 ? ( - Add to Cart - Add + Add to Cart ) : ( )} - - - - - - {showCompare && ( - - {isInCompare ? '✓' : '+'} - - )}
); diff --git a/app/deals/page.tsx b/app/deals/page.tsx index 8d3779f..02ce678 100644 --- a/app/deals/page.tsx +++ b/app/deals/page.tsx @@ -17,19 +17,19 @@ export default function DealsPage() { }, [fetchDeals, fetchBundles]); return ( -
+
- + {/* Header */} -

+

Deals & Promotions

-

+

Save big on top components and bundles

@@ -41,11 +41,11 @@ export default function DealsPage() { className="mb-12" >
-

- +

+ Flash Deals

-
+
Limited Time Only
@@ -58,9 +58,9 @@ export default function DealsPage() { initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} whileHover={{ y: -5 }} - className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6 relative overflow-hidden" + className="bg-card text-card-foreground rounded-2xl shadow-lg border border-border p-6 relative overflow-hidden" > -
+
-{deal.discountPercentage}%
@@ -72,25 +72,25 @@ export default function DealsPage() { className="w-full h-48 object-cover rounded-lg mb-4" /> - + {deal.category} -

+

{deal.name}

- + ${deal.salePrice} - + ${deal.price}
- + Ends in {deal.saleEndDate ? new Date(deal.saleEndDate).toLocaleDateString() : 'Soon'} @@ -100,7 +100,7 @@ export default function DealsPage() { View Deal @@ -115,8 +115,8 @@ export default function DealsPage() { animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} > -

- +

+ Bundle Deals

@@ -127,31 +127,31 @@ export default function DealsPage() { initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} whileHover={{ scale: 1.02 }} - className="bg-linear-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-gray-800 rounded-2xl shadow-lg border-2 border-orange-200 dark:border-orange-800 p-8" + className="bg-card text-card-foreground rounded-2xl shadow-lg border-2 border-primary/20 p-8" >
-

+

{bundle.name}

-

{bundle.description}

+

{bundle.description}

-
+
{bundle.items} Items
-

Individual Price

-

+

Individual Price

+

${bundle.originalPrice}

-
+
-

Bundle Price

-

+

Bundle Price

+

${bundle.bundlePrice}

@@ -164,7 +164,7 @@ export default function DealsPage() {

- diff --git a/app/globals.css b/app/globals.css index 6e4f7d7..b431259 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3,37 +3,120 @@ @theme { --color-wso2-orange: #FF7300; --color-wso2-orange-dark: #E66800; + + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + + --font-heading: var(--font-outfit), sans-serif; + --font-body: var(--font-inter), sans-serif; } @layer base { :root { - --background: #ffffff; - --foreground: #1f2937; - --panel: #f3f4f6; - } + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + + --primary: 27 100% 50%; + --primary-foreground: 0 0% 98%; + + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; - @media (prefers-color-scheme: dark) { - :root { - --background: #111827; - --foreground: #d1d5db; - --panel: #1f2937; - } + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 5.9% 10%; + + --radius: 0.5rem; } .dark { - --background: #111827; - --foreground: #d1d5db; - --panel: #1f2937; + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + + --primary: 27 100% 50%; + --primary-foreground: 0 0% 98%; + + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; } } @layer base { body { - background-color: var(--background); - color: var(--foreground); - font-family: 'Inter', sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + @apply bg-background text-foreground font-body antialiased; + font-feature-settings: "rlig" 1, "calt" 1; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + @apply font-heading tracking-tight; } html { @@ -42,22 +125,17 @@ /* Custom scrollbar */ ::-webkit-scrollbar { - width: 10px; - height: 10px; + width: 8px; + height: 8px; } ::-webkit-scrollbar-track { - background: var(--panel); + @apply bg-transparent; } ::-webkit-scrollbar-thumb { - background: #FF7300; /* wso2-orange */ - border-radius: 5px; - } - - ::-webkit-scrollbar-thumb:hover { - background: #E66800; /* wso2-orange-dark */ + @apply bg-muted-foreground/30 rounded-full hover:bg-muted-foreground/50 transition-colors; } } -@variant dark (&:where(.dark, .dark *)); +@variant dark (&:where(.dark, .dark *)); \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 7093d4f..9230011 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,10 +1,23 @@ import type { Metadata } from "next"; +import { Inter, Outfit } from 'next/font/google'; import "./globals.css"; import Navigation from "./components/ui/Navigation"; import Footer from "./components/ui/Footer"; import LiveChatWidget from "./components/ui/LiveChatWidget"; import ThemeProvider from "./components/providers/ThemeProvider"; +const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", + display: 'swap', +}); + +const outfit = Outfit({ + subsets: ["latin"], + variable: "--font-outfit", + display: 'swap', +}); + export const metadata: Metadata = { title: "CS02 - Build Your Dream PC", description: "Premium computer components and custom PC builds. Build your dream. Forge your power.", @@ -16,13 +29,9 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - - - {/* eslint-disable-next-line @next/next/no-page-custom-font */} -

- +
+ Specification
+
Category + {product.subcategory || product.category}
+
Stock
+
Rating + {product.rating ? (
{product.rating.toFixed(1)} @@ -177,7 +178,7 @@ export default function ComparePage() { ({product.reviewCount || 0} reviews)
) : ( - No ratings + No ratings )}
+ {specKey.charAt(0).toUpperCase() + specKey.slice(1)} + {product.specs[specKey] !== undefined ? ( {product.specs[specKey]} ) : ( - - + - )}
+
Description + {product.description}