refactor(products): organize product showcase into feature module
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useRef} from 'react';
|
||||
import {gsap} from 'gsap';
|
||||
import {ScrollTrigger} from 'gsap/ScrollTrigger';
|
||||
import VendingCard from './VendingCard';
|
||||
import {featuredMachines} from '@/features/products/data/catalog';
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import {PhoneOutlined} from '@ant-design/icons';
|
||||
import {featuredMachines} from '@/features/products/data/catalog';
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'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 '@/components/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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'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 '@/features/products/data/catalog';
|
||||
import QuantityControl from '@/components/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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import type {Machine} from '@/types/domain';
|
||||
import type {MouseEvent} from 'react';
|
||||
import {useRef} from 'react';
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type {Locale, Machine, ProductCategory} from '@/types/domain';
|
||||
|
||||
export const productCategories: {id: ProductCategory; label: Record<Locale, string>}[] = [
|
||||
{id: 'warm-vending', label: {fa: 'وندینگ گرم', en: 'Warm vending'}},
|
||||
];
|
||||
|
||||
// Keep the home showcase independent of future catalog additions.
|
||||
const featuredIds = ['rm-35', 'rm-48', 'rm-60'];
|
||||
|
||||
export function featuredMachines(machines: Machine[]): Machine[] {
|
||||
return featuredIds.flatMap(id => machines.filter(machine => machine.id === id));
|
||||
}
|
||||
|
||||
export function filterMachines(machines: Machine[], category: string): Machine[] {
|
||||
return category ? machines.filter(machine => machine.category === category) : machines;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export {default as ProductDetails} from './components/ProductDetails';
|
||||
export {default as ProductsPage} from './components/ProductsPage';
|
||||
export {default as ShowcaseSection} from './components/ShowcaseSection';
|
||||
Reference in New Issue
Block a user