refactor(products): organize product showcase into feature module

This commit is contained in:
2026-09-29 09:44:00 +03:30
parent fcded6f139
commit ae6018a1f0
13 changed files with 68 additions and 45 deletions
-58
View File
@@ -1,58 +0,0 @@
'use client';
// Animate decorative snack images with gentle randomized motion.
import type {FloatingSnack} from '@/types/domain';
import {useEffect, useRef} from 'react';
import {gsap} from 'gsap';
/**
* Renders the centralized `floatingSnacks` config as absolutely-positioned
* images and gives each one a gentle, randomized floating drift via GSAP.
* Positions/sizes/images all come from data/content.ts — edit there only.
*/
export default function FloatingItems({items}: {items: FloatingSnack[]}) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const nodes = containerRef.current?.querySelectorAll('[data-float-item]');
if (!nodes) return;
const tweens = Array.from(nodes).map((node, i) => {
const drift = 14 + Math.random() * 14;
const rotate = 4 + Math.random() * 6;
return gsap.to(node, {
y: `+=${drift}`,
rotate: i % 2 === 0 ? rotate : -rotate,
duration: 3.5 + Math.random() * 2.5,
ease: 'sine.inOut',
yoyo: true,
repeat: -1,
delay: Math.random() * 1.5,
});
});
return () => tweens.forEach(t => t.kill());
}, [items]);
return (
<div ref={containerRef} className="absolute inset-0 pointer-events-none hidden md:block">
{items.map(item => (
<img
key={item.id}
data-float-item
src={item.image}
alt=""
aria-hidden="true"
className="absolute drop-shadow-[0_8px_20px_rgba(0,0,0,0.35)]"
style={{
top: item.top,
left: item.left,
width: item.size,
height: 'auto',
}}
/>
))}
</div>
);
}
-92
View File
@@ -1,92 +0,0 @@
'use client';
import {useEffect, useRef} from 'react';
import {gsap} from 'gsap';
import {ScrollTrigger} from 'gsap/ScrollTrigger';
import VendingCard from './VendingCard';
import {featuredMachines} from '@/data/products';
import {useLanguage} from '@/app/context/LanguageContext';
gsap.registerPlugin(ScrollTrigger);
export default function HorizontalShowcase() {
const sectionRef = useRef<HTMLElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const backgroundRef = useRef<HTMLDivElement>(null);
const {t} = useLanguage();
useEffect(() => {
const ctx = gsap.context(() => {
const track = trackRef.current;
const section = sectionRef.current;
const background = backgroundRef.current;
if (!track || !section) return;
const scrollDistance = () => Math.max(track.scrollWidth - window.innerWidth, 0);
const backgroundColors = ['#062522', '#0b3d38', '#123d67', '#45235e', '#4a2c18'];
const colorAtProgress = (progress: number) => gsap.utils.interpolate(backgroundColors, progress);
const horizontalTween = gsap.to(track, {
x: () => -scrollDistance(),
ease: 'none',
scrollTrigger: {
trigger: section,
start: 'top top',
end: () => `+=${scrollDistance()}`,
scrub: 1,
pin: true,
invalidateOnRefresh: true,
onUpdate: self => {
if (!background) return;
background.style.backgroundColor = colorAtProgress(self.progress);
background.style.backgroundPosition = `${self.progress * 100}% ${50 + self.progress * 30}%`;
},
},
});
return () => {
horizontalTween.scrollTrigger?.kill();
horizontalTween.kill();
};
}, sectionRef);
return () => ctx.revert();
}, []);
return (
<section id="showcase" ref={sectionRef} className="relative w-full overflow-hidden">
<div
ref={backgroundRef}
className="absolute inset-0 z-0 bg-[#062522] transition-[background-color] duration-300"
style={{
backgroundImage:
'radial-gradient(circle at 18% 22%, rgba(92, 204, 180, 0.42), transparent 31%), radial-gradient(circle at 78% 72%, rgba(255, 177, 92, 0.36), transparent 36%), linear-gradient(125deg, rgba(255,255,255,0.06), transparent 55%)',
backgroundSize: '145% 145%, 145% 145%, 100% 100%',
backgroundPosition: '0% 50%',
}}
>
<div
className="absolute inset-0"
style={{
background:
'linear-gradient(180deg, rgba(4,18,15,0.55) 0%, rgba(7,30,25,0.3) 50%, rgba(4,18,15,0.6) 100%)',
}}
/>
</div>
<div
ref={trackRef}
className="horizontal-track relative z-10 [&_.horizontal-panel]:h-full [&_.spotlight-stage]:max-h-[calc(100vh-4rem)]"
style={{height: '100vh'}}
dir="ltr"
>
{featuredMachines(t.machines).map(machine => (
<VendingCard key={machine.id} machine={machine} />
))}
</div>
</section>
);
}
-54
View File
@@ -1,54 +0,0 @@
'use client';
import {PhoneOutlined} from '@ant-design/icons';
import {featuredMachines} from '@/data/products';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
export default function MobileShowcase() {
const {t, dir, locale} = useLanguage();
const copy = commerceCopy[locale];
const contact = t.contactInfo;
return (
<section id="showcase" className="relative w-full py-8 px-4" dir={dir}>
<div className="flex flex-col gap-4 pb-4">
{featuredMachines(t.machines).map(machine => (
<article key={machine.id} className="w-full bg-surface border border-subtle rounded-2xl p-4">
<div className="flex gap-4 items-center">
{/*<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">
<span className="text-brand-400 text-xs font-semibold">{machine.shortLabel}</span>
<h3 className="text-lg font-bold text-primary mt-1">{machine.name}</h3>
<p className="text-sm text-secondary line-clamp-2 mt-2">{machine.description}</p>
</div>
{/*</Link>*/}
</div>
<p className="text-base font-bold text-primary my-4">{machine.priceLabel}</p>
{/* <div className="flex flex-wrap justify-between items-center gap-3">
<QuantityControl product={machine} />
<Link href={`/products/${machine.id}`} className="text-sm text-brand-400 hover:underline">
{copy.details}
</Link>
</div>*/}
</article>
))}
</div>
<div className="mt-3 rounded-2xl border border-subtle bg-elevated p-5 text-center">
<h3 className="text-xl font-bold text-primary">{copy.supportOrderTitle}</h3>
<p className="mt-3 text-sm leading-7 text-secondary">{copy.supportOrderDescription}</p>
<a
href={`tel:${contact.orderPhoneHref}`}
className="mt-5 inline-flex w-full items-center justify-center gap-2 rounded-xl bg-brand px-5 py-3 font-bold text-ink-950 transition-colors hover:bg-brand-300"
dir="ltr"
>
<PhoneOutlined />
<span>{copy.callSupport}</span>
<span>{contact.orderPhone}</span>
</a>
</div>
</section>
);
}
-88
View File
@@ -1,88 +0,0 @@
'use client';
import {useState} from 'react';
import Link from 'next/link';
import {CheckCircleOutlined} from '@ant-design/icons';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
import QuantityControl from './QuantityControl';
export default function ProductDetails({id}: {id: string}) {
const {t, locale} = useLanguage();
const copy = commerceCopy[locale];
const machine = t.machines.find(item => item.id === id);
const [alternate, setAlternate] = useState(false);
if (!machine) return null;
return (
<main className="max-w-7xl mx-auto px-5 md:px-8 pt-28 pb-20">
<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="/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">
<section aria-label={copy.gallery} className="rounded-3xl border border-subtle bg-surface p-6">
<img
src={alternate ? machine.imageAlt : machine.image}
alt={`${machine.name} — ${alternate ? copy.alternateImage : copy.baseImage}`}
className="w-full h-72 md:h-[450px] object-contain"
/>
<div className="flex justify-center gap-3 mt-6">
{[false, true].map(value => (
<button
key={String(value)}
onClick={() => setAlternate(value)}
aria-pressed={alternate === value}
aria-label={value ? copy.alternateImage : copy.baseImage}
className={`rounded-xl border p-2 ${alternate === value ? 'border-brand bg-brand/10' : 'border-subtle'}`}
>
<img
src={value ? machine.imageAlt : machine.image}
alt=""
className="h-20 w-20 object-contain"
/>
</button>
))}
</div>
</section>
<section>
<p className="text-brand-400 font-semibold">{machine.shortLabel}</p>
<h1 className="text-3xl lg:text-4xl font-extrabold text-primary mt-3 leading-relaxed">
{machine.name}
</h1>
<p className="text-secondary mt-5 leading-8">{machine.description}</p>
<p className="text-sm text-secondary mt-4">
{copy.model}: <span dir="ltr">{machine.id.toUpperCase()}</span>
</p>
<h2 className="font-bold text-primary text-xl mt-8 mb-4">{copy.specifications}</h2>
<ul className="space-y-3">
{machine.highlights.map(feature => (
<li key={feature} className="flex gap-3 text-secondary">
<CheckCircleOutlined className="text-brand-400" />
{feature}
</li>
))}
</ul>
<div className="border-t border-subtle mt-8 pt-6">
<p className="text-sm text-secondary">{copy.unitPrice}</p>
<p className="text-2xl font-bold text-primary mt-2">{machine.priceLabel}</p>
<div className="mt-5 flex flex-wrap gap-4 items-center">
<QuantityControl product={machine} />
<Link href="/cart" className="text-brand-400 font-semibold hover:underline">
{copy.cartLink}
</Link>
</div>
<p className="text-secondary text-sm leading-7 mt-5">{copy.checkoutUnavailable}</p>
</div>
</section>
</div>
</main>
);
}
-105
View File
@@ -1,105 +0,0 @@
'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>*/}
<div className="rounded-2xl bg-page p-5">
<img src={machine.image} alt={machine.name} className="w-full h-60 object-contain" />
</div>
<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>*/}
<div>{machine.name}</div>
</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>
{/*FAZ1*/}
{/*<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>
);
}
-65
View File
@@ -1,65 +0,0 @@
'use client';
import {useEffect, useState} from 'react';
import Link from 'next/link';
import {PhoneOutlined} from '@ant-design/icons';
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 {t, locale} = useLanguage();
const copy = commerceCopy[locale];
const [isMobile, setIsMobile] = useState<boolean | null>(null);
useEffect(() => {
const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const handleChange = (event: MediaQueryListEvent) => setIsMobile(event.matches);
setIsMobile(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
if (isMobile === null) return null;
if (!isMobile)
return (
<>
<HorizontalShowcase />
<section className="px-6 md:px-16 py-14 bg-surface border-t border-subtle">
<div className="max-w-5xl mx-auto flex flex-wrap items-center justify-between gap-6 rounded-2xl border border-subtle bg-elevated p-7">
<div className="max-w-2xl">
<h3 className="text-2xl font-bold text-primary">{copy.supportOrderTitle}</h3>
<p className="mt-3 text-secondary leading-7">{copy.supportOrderDescription}</p>
</div>
<a
href={`tel:${t.contactInfo.orderPhoneHref}`}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-brand px-6 py-3 font-bold text-ink-950 transition-colors hover:bg-brand-300"
dir="ltr"
>
<PhoneOutlined />
<span>{copy.callSupport}</span>
<span>{t.contactInfo.orderPhone}</span>
</a>
</div>
</section>
</>
);
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 />
</>
);
}
-103
View File
@@ -1,103 +0,0 @@
'use client';
import type {Machine} from '@/types/domain';
import type {MouseEvent} from 'react';
import {useRef} from 'react';
import QuantityControl from './QuantityControl';
import ProtectedLink from './auth/ProtectedLink';
import {commerceCopy} from '@/data/commerce';
import {useLanguage} from '@/app/context/LanguageContext';
import FloatingItems from './FloatingItems';
import {floatingSnacks} from '@/data/content';
export default function VendingCard({machine}: {machine: Machine}) {
const {t, dir, locale} = useLanguage();
const stageRef = useRef<HTMLDivElement>(null);
const lensRef = useRef<HTMLDivElement>(null);
const revealRef = useRef<HTMLImageElement>(null);
const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
const stage = stageRef.current;
if (!stage) return;
const rect = stage.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (lensRef.current) {
lensRef.current.style.transform = `translate(${x}px, ${y}px) translate(-50%, -50%)`;
}
if (revealRef.current) {
revealRef.current.style.clipPath = `circle(95px at ${x}px ${y}px)`;
}
};
const handleMouseLeave = () => {
if (revealRef.current) revealRef.current.style.clipPath = 'circle(0px at 50% 50%)';
};
return (
<div className="horizontal-panel flex items-center justify-center px-6 md:px-16" dir={dir}>
<div className="relative z-10 max-w-6xl w-full grid md:grid-cols-2 gap-0 md:gap-10 items-center">
<div
ref={stageRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className="spotlight-stage relative h-48 md:h-[500px] flex items-center justify-center"
>
<FloatingItems items={floatingSnacks} />
<img src={machine.image} alt={machine.name} className="base-layer z-10" />
<img
ref={revealRef}
src={machine.imageAlt}
alt={`${machine.name} - ${t.machineActions.altVariant}`}
className="reveal-layer z-20"
/>
<div ref={lensRef} className="spotlight-lens" />
<span className="absolute hidden md:block top-3 z-30 text-[11px] px-3 py-1 rounded-full bg-brand/15 border border-subtle text-brand-400 start-3">
{t.machineActions.hoverHint}
</span>
</div>
<div className="flex flex-col justify-center items-center gap-5 ">
<span className="text-brand-400 text-sm font-semibold tracking-wide">{machine.shortLabel}</span>
<h3 className="text-2xl md:text-4xl font-extrabold text-primary">{machine.name}</h3>
<div className="grid grid-cols-[auto,1fr] gap-4 items-start bg-surface border border-subtle rounded-2xl p-4">
<img
src={machine.gif}
alt={t.machineActions.gifAlt(machine.name)}
className="w-28 h-14 md:w-32 md:h-24 object-cover rounded-lg border border-subtle"
/>
<p className="text-sm md:text-base text-secondary md:leading-7">{machine.description}</p>
</div>
<ul className="flex flex-wrap gap-2">
{machine.highlights.map(h => (
<li
key={h}
className="text-xs md:text-sm text-brand-400 bg-brand/10 border border-subtle rounded-full px-3 py-1"
>
{h}
</li>
))}
</ul>
<p className="text-xl md:text-2xl font-bold text-primary mt-2">{machine.priceLabel}</p>
{/*FAZ1*/}
{/*<div className="flex flex-wrap items-center gap-2 mt-2 ">*/}
{/* <QuantityControl product={machine} />*/}
{/* <ProtectedLink*/}
{/* href={`/products/${machine.id}`}*/}
{/* className="px-5 py-3 rounded-xl border border-subtle text-primary hover:text-brand-400"*/}
{/* >*/}
{/* {commerceCopy[locale].details}*/}
{/* </ProtectedLink>*/}
{/*</div>*/}
</div>
</div>
</div>
);
}