feat: add cart and product pages, quantity controls, and first-visit development notice
Build & Deploy (develop) / build-and-deploy (push) Successful in 1m44s

This commit is contained in:
2026-09-15 07:14:37 +02:00
parent 4cd34106b0
commit 0390a35d01
23 changed files with 528 additions and 466 deletions
+8 -9
View File
@@ -7,7 +7,7 @@ import {useLanguage} from '@/app/context/LanguageContext';
export default function AboutSection() {
const {t} = useLanguage();
const [expanded, setExpanded] = useState(true);
const [expanded, setExpanded] = useState(false);
const visibleParagraphs = expanded ? t.about.paragraphs : t.about.paragraphs.slice(0, 2);
return (
@@ -26,14 +26,13 @@ export default function AboutSection() {
))}
</div>
{/* faz 1 */}
{/* <button
onClick={() => setExpanded((v) => !v)}
className="mt-6 inline-flex items-center gap-2 text-brand-400 hover:text-brand-300 text-sm font-semibold transition-colors"
>
{expanded ? t.about.less : t.about.more}
<span className={`transition-transform ${expanded ? 'rotate-180' : ''}`}>˅</span>
</button>*/}
<button
onClick={() => setExpanded((v) => !v)}
className="mt-6 inline-flex items-center gap-2 text-brand-400 hover:text-brand-300 text-sm font-semibold transition-colors"
>
{expanded ? t.about.less : t.about.more}
<span className={`transition-transform ${expanded ? 'rotate-180' : ''}`}>˅</span>
</button>
</div>
</section>
);
-115
View File
@@ -1,115 +0,0 @@
'use client';
// Cart drawer with quantity editing, totals, and placeholder checkout.
import {Drawer, Button, Empty, InputNumber, message} from 'antd';
import {DeleteOutlined} from '@ant-design/icons';
import {useAppSelector, useAppDispatch} from '@/store/hooks';
import {
selectCartItems,
selectCartTotal,
selectIsDrawerOpen,
closeDrawer,
removeFromCart,
setQuantity,
} from '@/store/cartSlice';
import {useLanguage} from '@/app/context/LanguageContext';
import type {Locale} from '@/types/domain';
import api from "@/lib/axios";
function toLocaleNumber(n: number, locale: Locale) {
return n.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US');
}
export default function CartDrawer() {
const dispatch = useAppDispatch();
const isOpen = useAppSelector(selectIsDrawerOpen);
const items = useAppSelector(selectCartItems);
const total = useAppSelector(selectCartTotal);
const {t, locale, dir} = useLanguage();
const currencySuffix = locale === 'fa' ? ' ریال' : ' Rials';
const handleCheckout = async () => {
// -------------------------------------------------------------------
// TODO: Connect to payment gateway API..
// try {
// const { data } = await axios.post('/api/payment/create', {
// items: items.map((i) => ({ id: i.id, qty: i.qty })),
// totalAmount: total,
// });
// window.location.href = data.paymentRedirectUrl;
// } catch (err) {
// message.error('...');
// }
// api() <===> todo : use this func to fetch ...
// -------------------------------------------------------------------
await message.info(t.cart.checkoutToast);
};
return (
<Drawer
title={t.cart.title}
placement={dir === 'rtl' ? 'right' : 'left'}
onClose={() => dispatch(closeDrawer())}
open={isOpen}
width={400}
>
{items.length === 0 ? (
<Empty description={<span className="text-secondary">{t.cart.empty}</span>} className="mt-16"/>
) : (
<div className="flex flex-col gap-4">
{items.map((item) => (
<div key={item.id}
className="flex items-center gap-3 bg-surface border border-subtle rounded-xl p-3">
<img src={item.image} alt={item.name} className="w-14 h-20 object-contain shrink-0"/>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-primary truncate">{item.name}</p>
<p className="text-xs text-brand-400 mt-1">
{toLocaleNumber(item.priceNumeric, locale)}
{currencySuffix}
</p>
<div className="flex items-center gap-2 mt-2">
<InputNumber
size="small"
min={1}
precision={0}
value={item.qty}
onChange={(val) => {
if (val !== null) dispatch(setQuantity({id: item.id, qty: val}));
}}
className="w-16"
/>
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined/>}
onClick={() => dispatch(removeFromCart(item.id))}
/>
</div>
</div>
</div>
))}
<div className="border-t border-subtle pt-4 mt-2">
<div className="flex items-center justify-between text-sm text-secondary mb-4">
<span>{t.cart.total}</span>
<span className="text-lg font-bold text-primary">
{toLocaleNumber(total, locale)}
{currencySuffix}
</span>
</div>
<Button type="primary" block size="large" onClick={handleCheckout}>
{t.cart.checkout}
</Button>
</div>
</div>
)}
</Drawer>
);
}
+68
View File
@@ -0,0 +1,68 @@
'use client';
import Link from 'next/link';
import {Button, Empty} from 'antd';
import {DeleteOutlined} from '@ant-design/icons';
import {useAppDispatch, useAppSelector} from '@/store/hooks';
import {removeFromCart, selectCartItems, selectCartTotal} from '@/store/cartSlice';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
import QuantityControl from './QuantityControl';
export default function CartPage() {
const dispatch = useAppDispatch();
const items = useAppSelector(selectCartItems);
const total = useAppSelector(selectCartTotal);
const {t, locale} = useLanguage();
const copy = commerceCopy[locale];
const money = (value: number) => `${value.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')} ${copy.currency}`;
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>
<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>
</div>
) : (
<div className="grid lg:grid-cols-[minmax(0,1fr),340px] gap-8 items-start">
<div className="space-y-4">
{items.map(item => {
const product = t.machines.find(machine => machine.id === item.id) ?? item;
return (
<article key={item.id} className="rounded-2xl border border-subtle bg-surface p-5 flex flex-wrap sm:flex-nowrap gap-5 items-center">
<Link href={`/products/${item.id}`} className="shrink-0">
<img src={product.image} alt={product.name} className="w-24 h-32 object-contain"/>
</Link>
<div className="flex-1 min-w-0">
<Link href={`/products/${item.id}`} className="font-bold text-lg text-primary hover:text-brand-400">{product.name}</Link>
<p className="text-secondary text-sm mt-2">{copy.unitPrice}: {money(item.priceNumeric)}</p>
<div className="flex flex-wrap gap-3 items-center mt-4">
<QuantityControl product={product}/>
<Button danger type="text" icon={<DeleteOutlined/>}
aria-label={`${copy.remove}: ${product.name}`}
onClick={() => dispatch(removeFromCart(item.id))}/>
</div>
<p className="mt-4 text-primary font-semibold">{copy.lineTotal}: {money(item.qty * item.priceNumeric)}</p>
</div>
</article>
);
})}
</div>
<aside className="lg:sticky lg:top-28 border border-subtle bg-elevated rounded-2xl p-6">
<h2 className="font-bold text-xl text-primary">{copy.summary}</h2>
<div className="border-t border-subtle mt-5 pt-5 flex flex-wrap justify-between gap-3">
<span className="text-secondary">{t.cart.total}</span>
<strong className="text-primary">{money(total)}</strong>
</div>
<p className="text-secondary text-sm leading-7 my-5">{copy.checkoutUnavailable}</p>
<Button type="primary" size="large" disabled block>{t.cart.checkout}</Button>
</aside>
</div>
)}
</main>
);
}
+46
View File
@@ -0,0 +1,46 @@
'use client';
import {useEffect} from 'react';
import {useStore} from 'react-redux';
import type {AppStore} from '@/store/store';
import type {CartItem} from '@/types/domain';
import {restoreCart} from '@/store/cartSlice';
import {content} from '@/data/content';
const STORAGE_KEY = 'rm-cart';
const useAppStore = useStore.withTypes<AppStore>();
export default function CartPersistence() {
const store = useAppStore();
useEffect(() => {
try {
const saved: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]');
if (Array.isArray(saved)) {
const items: CartItem[] = [];
for (const entry of saved) {
if (!entry || typeof entry !== 'object' || typeof entry.id !== 'string'
|| !Number.isSafeInteger(entry.qty) || entry.qty < 1) continue;
const machine = content.fa.machines.find(item => item.id === entry.id);
if (!machine || items.some(item => item.id === machine.id)) continue;
// Restore identifiers and quantities; use current catalog prices and images.
items.push({id: machine.id, name: machine.name, image: machine.image,
priceNumeric: machine.priceNumeric, priceLabel: machine.priceLabel, qty: entry.qty});
}
store.dispatch(restoreCart(items));
}
} catch {
// Invalid or unavailable storage must not prevent shopping.
}
return store.subscribe(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(store.getState().cart.items.map(({id, qty}) => ({id, qty}))));
} catch {
// Keep the in-memory cart usable when storage is full or disabled.
}
});
}, [store]);
return null;
}
+41
View File
@@ -0,0 +1,41 @@
'use client';
import {useEffect, useState} from 'react';
import {Button, Modal} from 'antd';
import {ToolOutlined} from '@ant-design/icons';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
const STORAGE_KEY = 'rm-development-notice-seen';
export default function DevelopmentNotice() {
const [open, setOpen] = useState(false);
const {locale} = useLanguage();
const copy = commerceCopy[locale];
useEffect(() => {
try {
setOpen(localStorage.getItem(STORAGE_KEY) !== 'true');
} catch {
// Show the notice even when browser storage is unavailable.
setOpen(true);
}
}, []);
const dismiss = () => {
setOpen(false);
try {
localStorage.setItem(STORAGE_KEY, 'true');
} catch {
// Dismissing the notice must not depend on storage access.
}
};
return (
<Modal open={open} centered title={copy.developmentTitle} onCancel={dismiss}
footer={<Button type="primary" onClick={dismiss}>{copy.acknowledge}</Button>}>
<ToolOutlined className="text-brand-400 text-3xl my-4"/>
<p className="text-secondary leading-8">{copy.developmentMessage}</p>
</Modal>
);
}
+5 -4
View File
@@ -2,6 +2,7 @@
// Company information, quick links, social links, and contact details.
import Link from 'next/link';
import { PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons';
import { useLanguage } from '@/app/context/LanguageContext';
@@ -33,10 +34,10 @@ export default function Footer() {
<div>
<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><a href="#hero" className="hover:text-brand-400">{t.nav.home}</a></li>
<li><a href="#showcase" className="hover:text-brand-400">{t.nav.showcase}</a></li>
<li><a href="#about" className="hover:text-brand-400">{t.nav.about}</a></li>
<li><a href="#locations" className="hover:text-brand-400">{t.nav.locations}</a></li>
<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="/#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>
</div>
+21 -21
View File
@@ -4,30 +4,33 @@
import {useState, useEffect} from 'react';
import Image from 'next/image';
import Link from 'next/link';
import {usePathname} from 'next/navigation';
import {Badge, Button} from 'antd';
import {ShoppingCartOutlined, MenuOutlined, CloseOutlined, UserOutlined} from '@ant-design/icons';
import {useAppSelector, useAppDispatch} from '@/store/hooks';
import {selectCartCount, openDrawer} from '@/store/cartSlice';
import {useAppSelector} from '@/store/hooks';
import {selectCartCount} from '@/store/cartSlice';
import {useLanguage} from '@/app/context/LanguageContext';
import ThemeToggle from './ThemeToggle';
import LanguageToggle from './LanguageToggle';
export default function Header() {
const dispatch = useAppDispatch();
const pathname = usePathname();
const cartCount = useAppSelector(selectCartCount);
const {t} = 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: '#about', label: t.nav.about},
{href: '#locations', label: t.nav.locations},
{href: '/#hero', label: t.nav.home},
{href: '/#showcase', label: t.nav.showcase},
{href: '/#about', label: t.nav.about},
{href: '/#locations', label: t.nav.locations},
];
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24);
onScroll();
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
@@ -40,23 +43,23 @@ export default function Header() {
return (
<header
className={`fixed top-0 inset-x-0 z-50 transition-all duration-300 ${
scrolled ? 'bg-page-85 backdrop-blur-md shadow-[0_1px_0_0_rgba(0,166,147,0.25)]' : 'bg-transparent'
scrolled || pathname !== '/' ? 'bg-page-85 backdrop-blur-md shadow-[0_1px_0_0_rgba(0,166,147,0.25)]' : 'bg-transparent'
}`}
>
<div className="mx-auto max-w-7xl px-5 md:px-8 h-20 flex items-center justify-between gap-4">
<a href="#hero" className="flex items-center gap-2 shrink-0">
<Link href="/#hero" onClick={() => setMobileOpen(false)} className="flex items-center gap-2 shrink-0">
<Image src="/images/icons/logo.png" alt="روبات مارکت" width={150} height={42} priority/>
</a>
</Link>
<nav className="hidden xl:flex items-center gap-5 2xl:gap-7">
{navLinks.map((link) => (
<a
<Link
key={link.href}
href={link.href}
className="text-sm text-secondary hover:text-brand-300 transition-colors whitespace-nowrap"
>
{link.label}
</a>
</Link>
))}
</nav>
@@ -77,13 +80,10 @@ export default function Header() {
</Button>
<Badge count={cartCount} size="small" offset={[-2, 2]}>
<Button
shape="circle"
size="large"
icon={<ShoppingCartOutlined/>}
onClick={() => dispatch(openDrawer())}
aria-label={t.cart.title}
/>
<Link href="/cart" onClick={() => setMobileOpen(false)} aria-label={t.cart.title}
className="w-11 h-11 rounded-full border border-subtle bg-elevated inline-flex items-center justify-center text-xl text-primary hover:text-brand-400">
<ShoppingCartOutlined/>
</Link>
</Badge>
<button
@@ -100,14 +100,14 @@ export default function Header() {
<nav
className="xl:hidden bg-elevated-95 backdrop-blur-md border-t border-subtle px-5 py-4 flex flex-col gap-4 max-h-[calc(100vh-5rem)] overflow-y-auto">
{navLinks.map((link) => (
<a
<Link
key={link.href}
href={link.href}
onClick={() => setMobileOpen(false)}
className="text-secondary text-base"
>
{link.label}
</a>
</Link>
))}
<div className="flex items-center gap-3 pt-2 border-t border-subtle">
<LanguageToggle/>
+1 -52
View File
@@ -1,16 +1,11 @@
'use client';
import type {Machine} from '@/types/domain';
import {useEffect, useRef, useState} from 'react';
import {gsap} from 'gsap';
import {ScrollTrigger} from 'gsap/ScrollTrigger';
import {Modal, Button, message} from 'antd';
import {ShoppingCartOutlined} from '@ant-design/icons';
import {useAppDispatch} from '@/store/hooks';
import VendingCard from './VendingCard';
import {showcaseVideoSrc} from '@/data/content';
import {useLanguage} from '@/app/context/LanguageContext';
import {addToCart} from '@/store/cartSlice';
gsap.registerPlugin(ScrollTrigger);
@@ -18,9 +13,7 @@ export default function HorizontalShowcase() {
const sectionRef = useRef<HTMLElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const dispatch = useAppDispatch();
const {t} = useLanguage();
const [quickViewMachine, setQuickViewMachine] = useState<Machine | null>(null);
const [videoAvailable, setVideoAvailable] = useState(true);
useEffect(() => {
@@ -74,23 +67,6 @@ export default function HorizontalShowcase() {
return () => ctx.revert();
}, []);
const handleModalAddToCart = () => {
if (!quickViewMachine) return;
dispatch(
addToCart({
id: quickViewMachine.id,
name: quickViewMachine.name,
priceNumeric: quickViewMachine.priceNumeric,
priceLabel: quickViewMachine.priceLabel,
image: quickViewMachine.image,
})
);
message.success(t.cart.addToast(quickViewMachine.name));
setQuickViewMachine(null);
};
return (
<section id="showcase" ref={sectionRef} className="relative w-full overflow-hidden">
<div className="absolute inset-0 z-0">
@@ -116,37 +92,10 @@ export default function HorizontalShowcase() {
<div ref={trackRef} className="horizontal-track relative z-10" dir="ltr">
{t.machines.map((machine) => (
<VendingCard key={machine.id} machine={machine} onQuickView={setQuickViewMachine}/>
<VendingCard key={machine.id} machine={machine}/>
))}
</div>
<Modal
open={!!quickViewMachine}
onCancel={() => setQuickViewMachine(null)}
footer={null}
centered
title={quickViewMachine?.name}
>
{quickViewMachine && (
<div className="flex flex-col gap-4">
<img
src={quickViewMachine.image}
alt={quickViewMachine.name}
className="w-full h-64 object-contain"
/>
<p className="text-secondary leading-7">{quickViewMachine.description}</p>
<p className="text-xl font-bold text-primary">{quickViewMachine.priceLabel}</p>
<Button
type="primary"
size="large"
icon={<ShoppingCartOutlined/>}
onClick={handleModalAddToCart}
>
{t.machineActions.addToCart}
</Button>
</div>
)}
</Modal>
</section>
);
}
+20 -92
View File
@@ -1,108 +1,36 @@
'use client';
import type {Machine} from '@/types/domain';
import {useState} from 'react';
import {Modal, Button, message} from 'antd';
import {ShoppingCartOutlined, EyeOutlined} from '@ant-design/icons';
import {useAppDispatch} from '@/store/hooks';
import Link from 'next/link';
import {useLanguage} from '@/app/context/LanguageContext';
import {addToCart} from '@/store/cartSlice';
import {commerceCopy} from '@/data/commerce';
import QuantityControl from './QuantityControl';
export default function MobileShowcase() {
const dispatch = useAppDispatch();
const {t, dir} = useLanguage();
const [quickViewMachine, setQuickViewMachine] = useState<Machine | null>(null);
const handleAddToCart = (machine: Machine) => {
dispatch(
addToCart({
id: machine.id,
name: machine.name,
priceNumeric: machine.priceNumeric,
priceLabel: machine.priceLabel,
image: machine.image,
})
);
message.success(t.cart.addToast(machine.name));
};
const handleModalAddToCart = () => {
if (!quickViewMachine) return;
handleAddToCart(quickViewMachine);
setQuickViewMachine(null);
};
const {t, dir, locale} = useLanguage();
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) => (
<div
key={machine.id}
className="flex gap-4 items-center bg-surface border border-subtle rounded-2xl p-4"
>
<img
src={machine.image}
alt={machine.name}
className="w-24 h-24 object-contain shrink-0"
onClick={() => setQuickViewMachine(machine)}
/>
<div className="flex flex-col gap-1.5 min-w-0 flex-1">
<span className="text-brand-400 text-xs font-semibold tracking-wide">
{machine.shortLabel}
</span>
<h3 className="text-lg font-bold text-primary truncate">
{machine.name}
</h3>
<p className="text-sm text-secondary line-clamp-2">
{machine.description}
</p>
<p className="text-base font-bold text-primary mt-1">
{machine.priceLabel}
</p>
<div className="flex justify-start items-center mt-3">
<Button
type="primary"
size="middle"
icon={<ShoppingCartOutlined/>}
onClick={() => handleAddToCart(machine)}
>
{t.machineActions.addToCart}
</Button>
{t.machines.map(machine => (
<article key={machine.id} className="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">
<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>
<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">
{commerceCopy[locale].details}
</Link>
</div>
</div>
</article>
))}
</div>
<Modal
open={!!quickViewMachine}
onCancel={() => setQuickViewMachine(null)}
footer={null}
centered
title={quickViewMachine?.name}
>
{quickViewMachine && (
<div className="flex flex-col gap-4">
<img
src={quickViewMachine.image}
alt={quickViewMachine.name}
className="w-full h-64 object-contain"
/>
<p className="text-secondary leading-7">{quickViewMachine.description}</p>
<p className="text-xl font-bold text-primary">{quickViewMachine.priceLabel}</p>
<Button
type="primary"
size="large"
icon={<ShoppingCartOutlined/>}
onClick={handleModalAddToCart}
>
{t.machineActions.addToCart}
</Button>
</div>
)}
</Modal>
</section>
);
}
+61
View File
@@ -0,0 +1,61 @@
'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="/#showcase" 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>
);
}
+32
View File
@@ -0,0 +1,32 @@
'use client';
import {Button} from 'antd';
import {MinusOutlined, PlusOutlined} from '@ant-design/icons';
import type {CartProduct} from '@/types/domain';
import {useAppDispatch, useAppSelector} from '@/store/hooks';
import {addToCart, decrementQty} from '@/store/cartSlice';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
export default function QuantityControl({product}: {product: CartProduct}) {
const dispatch = useAppDispatch();
const {locale} = useLanguage();
const copy = commerceCopy[locale];
const quantity = useAppSelector(state => state.cart.items.find(item => item.id === product.id)?.qty ?? 0);
return (
<div role="group" aria-label={`${copy.quantity}: ${product.name}`}
className="inline-flex items-center gap-3 rounded-xl border border-subtle bg-page p-1" dir="ltr">
<Button icon={<MinusOutlined/>} disabled={quantity === 0}
aria-label={`${copy.decrease}: ${product.name}`}
onClick={() => dispatch(decrementQty(product.id))}/>
<span aria-live="polite" aria-atomic="true" className="min-w-8 text-center font-bold text-primary">
{quantity.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')}
</span>
<Button type="primary" icon={<PlusOutlined/>}
aria-label={`${copy.increase}: ${product.name}`}
onClick={() => dispatch(addToCart({id: product.id, name: product.name, image: product.image,
priceLabel: product.priceLabel, priceNumeric: product.priceNumeric}))}/>
</div>
);
}
+9 -26
View File
@@ -4,17 +4,15 @@
import type {Machine} from '@/types/domain';
import type {MouseEvent} from 'react';
import {useRef} from 'react';
import {Button, message} from 'antd';
import {ShoppingCartOutlined, EyeOutlined} from '@ant-design/icons';
import {useAppDispatch} from '@/store/hooks';
import {addToCart} from '@/store/cartSlice';
import Link from 'next/link';
import QuantityControl from './QuantityControl';
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, onQuickView}: {machine: Machine; onQuickView?: (machine: Machine) => void}) {
const dispatch = useAppDispatch();
const {t, dir} = useLanguage();
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);
@@ -38,19 +36,6 @@ export default function VendingCard({machine, onQuickView}: {machine: Machine; o
if (revealRef.current) revealRef.current.style.clipPath = 'circle(0px at 50% 50%)';
};
const handleAddToCart = () => {
dispatch(
addToCart({
id: machine.id,
name: machine.name,
priceNumeric: machine.priceNumeric,
priceLabel: machine.priceLabel,
image: machine.image,
})
);
message.success(t.cart.addToast(machine.name));
};
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">
@@ -103,12 +88,10 @@ export default function VendingCard({machine, onQuickView}: {machine: Machine; o
<p className="text-xl md:text-2xl font-bold text-primary mt-2">{machine.priceLabel}</p>
<div className="flex flex-wrap items-center gap-2 mt-2 ">
{/* <Button type="primary" size="large" icon={<ShoppingCartOutlined/>} onClick={handleAddToCart}>
{t.machineActions.addToCart}
</Button>*/}
<Button size="large" icon={<EyeOutlined/>} onClick={() => onQuickView?.(machine)}>
{t.machineActions.quickView}
</Button>
<QuantityControl product={machine}/>
<Link href={`/products/${machine.id}`} className="px-5 py-3 rounded-xl border border-subtle text-primary hover:text-brand-400">
{commerceCopy[locale].details}
</Link>
</div>
</div>
</div>