feat: add cart and product pages, quantity controls, and first-visit development notice
Build & Deploy (develop) / build-and-deploy (push) Successful in 1m44s
Build & Deploy (develop) / build-and-deploy (push) Successful in 1m44s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user