feat: add product catalog with URL-based category filtering
Build & Deploy (develop) / build-and-deploy (push) Successful in 1m48s

This commit is contained in:
2026-09-15 11:14:40 +03:30
parent 421760dc62
commit f49ef11c9c
16 changed files with 198 additions and 26 deletions
+2 -2
View File
@@ -19,13 +19,13 @@ export default function CartPage() {
return (
<main className="mx-auto max-w-7xl min-h-[65vh] px-5 md:px-8 pt-28 pb-20">
<Link href="/#showcase" className="text-brand-400 hover:underline">{copy.continueShopping}</Link>
<Link href="/products" className="text-brand-400 hover:underline">{copy.continueShopping}</Link>
<h1 className="text-3xl md:text-4xl font-extrabold text-primary mt-6">{t.cart.title}</h1>
<p className="text-secondary mt-3 mb-10">{copy.cartDescription}</p>
{items.length === 0 ? (
<div className="rounded-3xl border border-subtle bg-surface p-10 text-center">
<Empty description={t.cart.empty}/>
<Link href="/#showcase" className="inline-block mt-6 text-brand-400 hover:underline">{copy.continueShopping}</Link>
<Link href="/products" className="inline-block mt-6 text-brand-400 hover:underline">{copy.continueShopping}</Link>
</div>
) : (
<div className="grid lg:grid-cols-[minmax(0,1fr),340px] gap-8 items-start">
+3 -2
View File
@@ -2,12 +2,13 @@
// Company information, quick links, social links, and contact details.
import {commerceCopy} from '@/data/commerce';
import Link from 'next/link';
import { PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons';
import { useLanguage } from '@/app/context/LanguageContext';
export default function Footer() {
const { t } = useLanguage();
const { t, locale } = useLanguage();
const c = t.contactInfo;
return (
@@ -35,7 +36,7 @@ export default function Footer() {
<h3 className="text-primary font-bold text-lg mb-3">{t.footer.quickLinks}</h3>
<ul className="flex flex-col gap-2 text-sm text-secondary">
<li><Link href="/#hero" className="hover:text-brand-400">{t.nav.home}</Link></li>
<li><Link href="/#showcase" className="hover:text-brand-400">{t.nav.showcase}</Link></li>
<li><Link href="/products" className="hover:text-brand-400">{commerceCopy[locale].allProducts}</Link></li>
<li><Link href="/#about" className="hover:text-brand-400">{t.nav.about}</Link></li>
<li><Link href="/#locations" className="hover:text-brand-400">{t.nav.locations}</Link></li>
</ul>
+3 -2
View File
@@ -11,19 +11,20 @@ import {ShoppingCartOutlined, MenuOutlined, CloseOutlined, UserOutlined} from '@
import {useAppSelector} from '@/store/hooks';
import {selectCartCount} from '@/store/cartSlice';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
import ThemeToggle from './ThemeToggle';
import LanguageToggle from './LanguageToggle';
export default function Header() {
const pathname = usePathname();
const cartCount = useAppSelector(selectCartCount);
const {t} = useLanguage();
const {t, locale} = useLanguage();
const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const navLinks = [
{href: '/#hero', label: t.nav.home},
{href: '/#showcase', label: t.nav.showcase},
{href: '/products', label: commerceCopy[locale].allProducts},
{href: '/#about', label: t.nav.about},
{href: '/#locations', label: t.nav.locations},
];
+16 -3
View File
@@ -1,10 +1,13 @@
'use client';
import {useEffect, useRef, useState} from 'react';
import Link from 'next/link';
import {gsap} from 'gsap';
import {ScrollTrigger} from 'gsap/ScrollTrigger';
import VendingCard from './VendingCard';
import {showcaseVideoSrc} from '@/data/content';
import {featuredMachines} from '@/data/products';
import {commerceCopy} from '@/data/commerce';
import {useLanguage} from '@/app/context/LanguageContext';
gsap.registerPlugin(ScrollTrigger);
@@ -13,7 +16,8 @@ export default function HorizontalShowcase() {
const sectionRef = useRef<HTMLElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const {t} = useLanguage();
const {t, locale} = useLanguage();
const copy = commerceCopy[locale];
const [videoAvailable, setVideoAvailable] = useState(true);
useEffect(() => {
@@ -90,8 +94,17 @@ export default function HorizontalShowcase() {
/>
</div>
<div ref={trackRef} className="horizontal-track relative z-10" dir="ltr">
{t.machines.map((machine) => (
<div className="relative z-20 h-40 pt-20 bg-page-85 backdrop-blur-md">
<div className="max-w-7xl h-full mx-auto px-8 flex justify-between items-center gap-4">
<h2 className="text-2xl font-bold text-primary">{copy.featuredProducts}</h2>
<Link href="/products" className="text-brand-400 hover:underline">{copy.allProducts}</Link>
</div>
</div>
<div ref={trackRef}
className="horizontal-track relative z-10 [&_.horizontal-panel]:h-full [&_.spotlight-stage]:max-h-[calc(100vh-12rem)]"
style={{height: 'calc(100vh - 10rem)'}} dir="ltr">
{featuredMachines(t.machines).map((machine) => (
<VendingCard key={machine.id} machine={machine}/>
))}
</div>
+4 -3
View File
@@ -1,6 +1,7 @@
'use client';
import Link from 'next/link';
import {featuredMachines} from '@/data/products';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
import QuantityControl from './QuantityControl';
@@ -10,9 +11,9 @@ export default function MobileShowcase() {
return (
<section id="showcase" className="relative w-full py-8 px-4" dir={dir}>
<div className="flex flex-col gap-4">
{t.machines.map(machine => (
<article key={machine.id} className="bg-surface border border-subtle rounded-2xl p-4">
<div className="flex gap-4 overflow-x-auto snap-x snap-mandatory pb-4">
{featuredMachines(t.machines).map(machine => (
<article key={machine.id} className="w-[90%] shrink-0 snap-start bg-surface border border-subtle rounded-2xl p-4">
<Link href={`/products/${machine.id}`} className="flex gap-4 items-center">
<img src={machine.image} alt={machine.name} className="w-24 h-28 object-contain shrink-0"/>
<div className="min-w-0">
+1 -1
View File
@@ -19,7 +19,7 @@ export default function ProductDetails({id}: {id: string}) {
<nav aria-label={copy.details} className="text-sm text-secondary flex flex-wrap gap-3 mb-8">
<Link href="/" className="hover:text-brand-400">{t.nav.home}</Link>
<span aria-hidden="true">/</span>
<Link href="/#showcase" className="hover:text-brand-400">{t.nav.showcase}</Link>
<Link href="/products" className="hover:text-brand-400">{t.nav.showcase}</Link>
<span aria-hidden="true">/</span><span>{machine.shortLabel}</span>
</nav>
<div className="grid md:grid-cols-2 gap-10 lg:gap-16 items-start">
+69
View File
@@ -0,0 +1,69 @@
'use client';
import Link from 'next/link';
import {usePathname, useRouter, useSearchParams} from 'next/navigation';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
import {filterMachines, productCategories} from '@/data/products';
import QuantityControl from './QuantityControl';
export default function ProductsPage() {
const {t, locale} = useLanguage();
const copy = commerceCopy[locale];
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const category = searchParams.get('category') ?? '';
const products = filterMachines(t.machines, category);
const knownCategory = productCategories.some(item => item.id === category);
const setCategory = (value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) params.set('category', value);
else params.delete('category');
// Preserve other query parameters and allow browser Back to restore filters.
const query = params.toString();
router.push(query ? `${pathname}?${query}` : pathname, {scroll: false});
};
return (
<main className="max-w-7xl mx-auto px-5 md:px-8 pt-28 pb-20 min-h-[70vh]">
<h1 className="text-3xl md:text-4xl font-extrabold text-primary">{copy.allProducts}</h1>
<p className="text-secondary mt-4 leading-7">{copy.productsDescription}</p>
<div className="mt-8 mb-8 rounded-2xl border border-subtle bg-surface p-5 flex flex-wrap items-end gap-4">
<div className="flex flex-col gap-2 w-full sm:w-72">
<label htmlFor="product-category" className="text-sm font-semibold text-primary">{copy.category}</label>
<select id="product-category" value={category} onChange={event => setCategory(event.target.value)}
className="w-full border border-subtle bg-elevated text-primary rounded-xl px-4 py-3 focus:outline-brand">
<option value="">{copy.allCategories}</option>
{category && !knownCategory && <option value={category}>{copy.unknownCategory}</option>}
{productCategories.map(item => <option key={item.id} value={item.id}>{item.label[locale]}</option>)}
</select>
</div>
{category && <button onClick={() => setCategory('')} className="text-brand-400 py-3 hover:underline">{copy.clearFilters}</button>}
<p aria-live="polite" aria-atomic="true" className="text-secondary text-sm py-3 sm:ms-auto">
{products.length.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')} {copy.results}
</p>
</div>
{products.length ? (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map(machine => (
<article key={machine.id} className="flex flex-col rounded-3xl border border-subtle bg-surface p-5">
<Link href={`/products/${machine.id}`} className="rounded-2xl bg-page p-5">
<img src={machine.image} alt={machine.name} className="w-full h-60 object-contain"/>
</Link>
<span className="text-brand-400 text-xs mt-5">{productCategories.find(item => item.id === machine.category)?.label[locale]}</span>
<h2 className="text-xl font-bold text-primary mt-2"><Link href={`/products/${machine.id}`}>{machine.name}</Link></h2>
<p className="text-secondary text-sm leading-7 mt-3 mb-5">{machine.description}</p>
<p className="text-primary text-lg font-bold mt-auto mb-4">{machine.priceLabel}</p>
<div className="flex flex-wrap items-center justify-between gap-3">
<QuantityControl product={machine}/>
<Link href={`/products/${machine.id}`} className="text-brand-400 text-sm hover:underline">{copy.details}</Link>
</div>
</article>
))}
</div>
) : <div className="text-center text-secondary border border-subtle rounded-2xl p-12">{copy.noProducts}</div>}
</main>
);
}
+15 -1
View File
@@ -1,12 +1,17 @@
'use client';
import {useEffect, useState} from 'react';
import Link from 'next/link';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
import HorizontalShowcase from './HorizontalShowcase';
import MobileShowcase from './MobileShowcase';
const MOBILE_BREAKPOINT = 768;
export default function ShowcaseSection() {
const {locale} = useLanguage();
const copy = commerceCopy[locale];
const [isMobile, setIsMobile] = useState<boolean | null>(null);
useEffect(() => {
@@ -23,6 +28,15 @@ export default function ShowcaseSection() {
}, []);
if (isMobile === null) return null;
if (!isMobile) return <HorizontalShowcase/>;
return isMobile ? <MobileShowcase/> : <HorizontalShowcase/>;
return (
<>
<div className="max-w-7xl mx-auto px-5 md:px-8 py-8 flex flex-wrap justify-between items-center gap-4">
<h2 className="text-2xl font-bold text-primary">{copy.featuredProducts}</h2>
<Link href="/products" className="text-brand-400 hover:underline">{copy.allProducts}</Link>
</div>
<MobileShowcase/>
</>
);
}