update
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
|
||||
export default function AboutSection() {
|
||||
const { t } = useLanguage();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visibleParagraphs = expanded ? t.about.paragraphs : t.about.paragraphs.slice(0, 2);
|
||||
|
||||
return (
|
||||
<section id="about" className="relative px-6 md:px-16 py-20 md:py-28 bg-surface border-t border-subtle">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.about.eyebrow}</span>
|
||||
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text mt-3 mb-8">
|
||||
{t.about.legalName}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
{visibleParagraphs.map((p, i) => (
|
||||
<p key={i} className="text-secondary leading-8 text-base md:text-lg text-justify">
|
||||
{p}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { Drawer, Button, Empty, InputNumber, message } from 'antd';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
selectCartItems,
|
||||
selectCartTotal,
|
||||
selectIsDrawerOpen,
|
||||
closeDrawer,
|
||||
removeFromCart,
|
||||
incrementQty,
|
||||
decrementQty,
|
||||
} from '@/store/cartSlice';
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
|
||||
function toLocaleNumber(n, locale) {
|
||||
return n.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US');
|
||||
}
|
||||
|
||||
export default function CartDrawer() {
|
||||
const dispatch = useDispatch();
|
||||
const isOpen = useSelector(selectIsDrawerOpen);
|
||||
const items = useSelector(selectCartItems);
|
||||
const total = useSelector(selectCartTotal);
|
||||
const { t, locale, dir } = useLanguage();
|
||||
|
||||
const currencySuffix = locale === 'fa' ? ' ریال' : ' Rials';
|
||||
|
||||
const handleCheckout = async () => {
|
||||
// -------------------------------------------------------------------
|
||||
// TODO: Connect to payment gateway API.
|
||||
//
|
||||
// import axios from 'axios';
|
||||
//
|
||||
// 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('...');
|
||||
// }
|
||||
// -------------------------------------------------------------------
|
||||
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}
|
||||
value={item.qty}
|
||||
onChange={(val) => {
|
||||
if (val > item.qty) dispatch(incrementQty(item.id));
|
||||
else if (val < item.qty) dispatch(decrementQty(item.id));
|
||||
}}
|
||||
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,82 @@
|
||||
'use client';
|
||||
|
||||
import { Button, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { addToCart } from '@/store/cartSlice';
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
|
||||
function toLocaleNumber(n, locale) {
|
||||
return n.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US');
|
||||
}
|
||||
|
||||
export default function CatalogSection() {
|
||||
const { t, locale } = useLanguage();
|
||||
const dispatch = useDispatch();
|
||||
const currencySuffix = locale === 'fa' ? ' ریال' : ' Rials';
|
||||
|
||||
const handleAdd = (item) => {
|
||||
dispatch(
|
||||
addToCart({
|
||||
id: `catalog-${item.id}`,
|
||||
name: item.name,
|
||||
priceNumeric: item.priceNumeric,
|
||||
priceLabel: `${toLocaleNumber(item.priceNumeric, locale)}${currencySuffix}`,
|
||||
image: item.image,
|
||||
})
|
||||
);
|
||||
message.success(t.catalog.addToast(item.name));
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="catalog" className="relative px-6 md:px-16 py-20 md:py-28 bg-elevated border-t border-subtle">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-4">
|
||||
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.catalog.eyebrow}</span>
|
||||
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text mt-3">{t.catalog.title}</h2>
|
||||
</div>
|
||||
<p className="text-secondary text-sm md:text-base text-center max-w-2xl mx-auto mb-12">
|
||||
{t.catalog.subtitle}
|
||||
</p>
|
||||
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{t.catalog.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-surface border border-subtle rounded-2xl p-5 flex flex-col hover:border-brand-500/60 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<img src={item.image} alt={item.name} className="w-16 h-16 object-contain shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-[11px] text-brand-400 font-semibold">{item.category}</span>
|
||||
<h3 className="text-primary font-bold text-sm leading-6">{item.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-end justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-secondary line-through opacity-70">
|
||||
{toLocaleNumber(item.originalPriceNumeric, locale)}
|
||||
{currencySuffix}
|
||||
</p>
|
||||
<p className="text-base font-bold text-primary">
|
||||
{toLocaleNumber(item.priceNumeric, locale)}
|
||||
{currencySuffix}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
size="middle"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => handleAdd(item)}
|
||||
>
|
||||
{t.catalog.addToOrder}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Modal, Input, Button, Tooltip } from 'antd';
|
||||
import { MessageOutlined, SendOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
|
||||
export default function ChatWidget() {
|
||||
const { t, dir } = useLanguage();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [messages, setMessages] = useState([]);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
// Seed the bot greeting once, in the current language.
|
||||
useEffect(() => {
|
||||
setMessages([{ from: 'bot', text: t.chat.initialBotMessage }]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [t.chat.initialBotMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}, [messages, open]);
|
||||
|
||||
const handleSend = () => {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
|
||||
setMessages((prev) => [...prev, { from: 'user', text }]);
|
||||
setDraft('');
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// TODO: Connect to a real chat/support backend (e.g. via axios or a
|
||||
// websocket) instead of this local placeholder reply.
|
||||
//
|
||||
// import axios from 'axios';
|
||||
// const { data } = await axios.post('/api/chat', { message: text });
|
||||
// setMessages((prev) => [...prev, { from: 'bot', text: data.reply }]);
|
||||
// -----------------------------------------------------------------
|
||||
setTimeout(() => {
|
||||
setMessages((prev) => [...prev, { from: 'bot', text: t.chat.fallbackReply }]);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={t.chat.fabLabel} placement="left">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={t.chat.fabLabel}
|
||||
className="fixed bottom-6 z-40 w-14 h-14 rounded-full bg-brand hover:bg-brand-400 text-ink-950 shadow-brandGlow flex items-center justify-center text-2xl transition-transform hover:scale-105"
|
||||
style={dir === 'rtl' ? { left: '1.5rem' } : { right: '1.5rem' }}
|
||||
>
|
||||
<MessageOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
footer={null}
|
||||
closeIcon={<CloseOutlined />}
|
||||
title={t.chat.title}
|
||||
centered
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex flex-col gap-3 h-80 overflow-y-auto py-2 px-1"
|
||||
>
|
||||
{messages.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`chat-bubble ${
|
||||
m.from === 'user'
|
||||
? 'self-end bg-brand text-ink-950'
|
||||
: 'self-start bg-surface-strong text-primary'
|
||||
}`}
|
||||
>
|
||||
{m.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-3 border-t border-subtle pt-3">
|
||||
<Input
|
||||
size="large"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onPressEnter={handleSend}
|
||||
placeholder={t.chat.placeholder}
|
||||
/>
|
||||
<Button type="primary" size="large" icon={<SendOutlined />} onClick={handleSend}>
|
||||
{t.chat.send}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
SafetyCertificateOutlined,
|
||||
CustomerServiceOutlined,
|
||||
CreditCardOutlined,
|
||||
InboxOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
|
||||
const ICONS = {
|
||||
safety: SafetyCertificateOutlined,
|
||||
consulting: CustomerServiceOutlined,
|
||||
installment: CreditCardOutlined,
|
||||
packaging: InboxOutlined,
|
||||
};
|
||||
|
||||
export default function FeaturesSection() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<section className="relative px-6 md:px-16 py-16 md:py-20 bg-page border-t border-subtle">
|
||||
<div className="max-w-6xl mx-auto grid sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
{t.features.map((feature) => {
|
||||
const Icon = ICONS[feature.icon];
|
||||
return (
|
||||
<div
|
||||
key={feature.id}
|
||||
className="group bg-surface border border-subtle rounded-2xl p-6 text-center hover:border-brand-500/60 hover:bg-surface-strong transition-colors"
|
||||
>
|
||||
<div className="mx-auto mb-4 w-14 h-14 rounded-xl bg-brand/15 border border-subtle flex items-center justify-center text-brand-400 text-2xl group-hover:bg-brand/25 transition-colors">
|
||||
{Icon && <Icon />}
|
||||
</div>
|
||||
<h3 className="text-primary font-bold text-base mb-2">{feature.title}</h3>
|
||||
<p className="text-secondary text-sm leading-6">{feature.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
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.js — edit there only.
|
||||
*/
|
||||
export default function FloatingItems({ items }) {
|
||||
const containerRef = useRef(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">
|
||||
{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,80 @@
|
||||
'use client';
|
||||
|
||||
import { PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useLanguage();
|
||||
const c = t.contactInfo;
|
||||
|
||||
return (
|
||||
<footer id="contact" className="relative border-t border-subtle bg-surface">
|
||||
<div className="max-w-6xl mx-auto px-6 md:px-16 py-14 grid md:grid-cols-3 gap-10 text-center md:text-start">
|
||||
<div>
|
||||
<h3 className="text-primary font-bold text-lg mb-3">{t.footer.aboutLabel}</h3>
|
||||
<p className="text-secondary text-sm leading-7">{t.footer.aboutText}</p>
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
{c.socialLinks.map((link) => (
|
||||
<a
|
||||
key={link.id}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-400 hover:text-brand-300 text-sm transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="#catalog" className="hover:text-brand-400">{t.nav.catalog}</a></li>
|
||||
<li><a href="#locations" className="hover:text-brand-400">{t.nav.locations}</a></li>
|
||||
<li><a href="#video" className="hover:text-brand-400">{t.nav.video}</a></li>
|
||||
<li><a href="#map" className="hover:text-brand-400">{t.nav.map}</a></li>
|
||||
<li><a href="#support" className="hover:text-brand-400">{t.nav.support}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-primary font-bold text-lg mb-3">{t.footer.contactTitle}</h3>
|
||||
<div className="flex flex-col gap-4 text-sm text-secondary">
|
||||
<a
|
||||
href={`tel:${c.orderPhoneHref}`}
|
||||
className="flex items-center justify-center md:justify-start gap-2 hover:text-brand-400 transition-colors"
|
||||
dir="ltr"
|
||||
>
|
||||
<PhoneOutlined />
|
||||
<span>{c.orderPhone}</span>
|
||||
</a>
|
||||
|
||||
<div className="flex items-start justify-center md:justify-start gap-2">
|
||||
<EnvironmentOutlined className="mt-0.5 shrink-0" />
|
||||
<div className="text-start">
|
||||
<p className="text-primary-90 font-semibold mb-1">{t.footer.salesLabel}</p>
|
||||
<p className="leading-7">{c.salesOfficeAddress}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-center md:justify-start gap-2">
|
||||
<EnvironmentOutlined className="mt-0.5 shrink-0" />
|
||||
<div className="text-start">
|
||||
<p className="text-primary-90 font-semibold mb-1">{t.footer.factoryLabel}</p>
|
||||
<p className="leading-7">{c.factoryAddress}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-subtle py-5 text-center text-xs text-secondary">
|
||||
© {new Date().getFullYear()} {t.footer.aboutLabel}. {t.footer.rights}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Badge, Button } from 'antd';
|
||||
import { ShoppingCartOutlined, MenuOutlined, CloseOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { selectCartCount, openDrawer } from '@/store/cartSlice';
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import LanguageToggle from './LanguageToggle';
|
||||
|
||||
export default function Header() {
|
||||
const dispatch = useDispatch();
|
||||
const cartCount = useSelector(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: '#catalog', label: t.nav.catalog },
|
||||
{ href: '#locations', label: t.nav.locations },
|
||||
{ href: '#video', label: t.nav.video },
|
||||
{ href: '#map', label: t.nav.map },
|
||||
{ href: '#support', label: t.nav.support },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 24);
|
||||
window.addEventListener('scroll', onScroll);
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
<Image src="/images/logo.svg" alt="روبات مارکت" width={150} height={42} priority />
|
||||
</a>
|
||||
|
||||
<nav className="hidden lg:flex items-center gap-6 xl:gap-8">
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm text-secondary hover:text-brand-300 transition-colors whitespace-nowrap"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="hidden sm:inline-flex"
|
||||
icon={<UserOutlined />}
|
||||
onClick={() => {
|
||||
/* TODO: point this at the real customer panel login route */
|
||||
}}
|
||||
>
|
||||
{t.nav.login}
|
||||
</Button>
|
||||
|
||||
<Badge count={cartCount} size="small" offset={[-2, 2]}>
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<ShoppingCartOutlined />}
|
||||
onClick={() => dispatch(openDrawer())}
|
||||
aria-label="cart"
|
||||
/>
|
||||
</Badge>
|
||||
|
||||
<button
|
||||
className="lg:hidden text-brand-400 text-2xl"
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
aria-label="menu"
|
||||
>
|
||||
{mobileOpen ? <CloseOutlined /> : <MenuOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mobileOpen && (
|
||||
<nav className="lg:hidden bg-elevated-95 backdrop-blur-md border-t border-subtle px-5 py-4 flex flex-col gap-4">
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="text-secondary text-base"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
<div className="flex items-center gap-3 pt-2 border-t border-subtle">
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
<Button icon={<UserOutlined />} className="flex-1">
|
||||
{t.nav.login}
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
import { useTheme } from '@/app/context/ThemeContext';
|
||||
|
||||
// Simple typewriter: types each phrase, pauses, deletes, moves to next phrase, loops.
|
||||
function useTypewriter(phrases, { typeSpeed = 55, deleteSpeed = 30, holdTime = 1600 } = {}) {
|
||||
const [text, setText] = useState('');
|
||||
const [phraseIndex, setPhraseIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!phrases?.length) return undefined;
|
||||
let charIndex = 0;
|
||||
let deleting = false;
|
||||
let timeoutId;
|
||||
|
||||
const tick = () => {
|
||||
const current = phrases[phraseIndex % phrases.length];
|
||||
|
||||
if (!deleting) {
|
||||
charIndex += 1;
|
||||
setText(current.slice(0, charIndex));
|
||||
if (charIndex >= current.length) {
|
||||
deleting = true;
|
||||
timeoutId = setTimeout(tick, holdTime);
|
||||
return;
|
||||
}
|
||||
timeoutId = setTimeout(tick, typeSpeed);
|
||||
} else {
|
||||
charIndex -= 1;
|
||||
setText(current.slice(0, charIndex));
|
||||
if (charIndex <= 0) {
|
||||
deleting = false;
|
||||
setPhraseIndex((i) => (i + 1) % phrases.length);
|
||||
timeoutId = setTimeout(tick, 300);
|
||||
return;
|
||||
}
|
||||
timeoutId = setTimeout(tick, deleteSpeed);
|
||||
}
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(tick, 400);
|
||||
return () => clearTimeout(timeoutId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phraseIndex, phrases]);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function HeroSection() {
|
||||
const { t } = useLanguage();
|
||||
const { theme } = useTheme();
|
||||
const introRef = useRef(null);
|
||||
const logoRef = useRef(null);
|
||||
const bgColorRef = useRef(null);
|
||||
const subRef = useRef(null);
|
||||
const ctaRef = useRef(null);
|
||||
|
||||
const typedHeadline = useTypewriter(t.hero.typingPhrases);
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
// Intro loader fade-in / fade-out.
|
||||
useEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
const tl = gsap.timeline();
|
||||
tl.fromTo(logoRef.current, { opacity: 0, scale: 0.85 }, { opacity: 1, scale: 1, duration: 0.9, ease: 'power2.out' })
|
||||
.to(logoRef.current, { opacity: 0, duration: 0.5, delay: 0.4, ease: 'power1.in' })
|
||||
.to(introRef.current, { opacity: 0, duration: 0.7, ease: 'power2.inOut', pointerEvents: 'none' }, '-=0.1')
|
||||
.fromTo(subRef.current, { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.8, ease: 'power3.out' }, '-=0.5')
|
||||
.fromTo(ctaRef.current, { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.7, ease: 'power3.out' }, '-=0.4');
|
||||
});
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
// Background behavior differs by theme:
|
||||
// - Dark mode: looping color cycle (brand green -> black -> white -> green),
|
||||
// kept readable by the dark scrim below.
|
||||
// - Light mode: a calm, static light background (no cycling), paired with
|
||||
// dark text and a light scrim instead.
|
||||
useEffect(() => {
|
||||
const el = bgColorRef.current;
|
||||
if (!el) return undefined;
|
||||
|
||||
if (isDark) {
|
||||
const tl = gsap.timeline({ repeat: -1 });
|
||||
tl.to(el, { backgroundColor: '#04120f', duration: 5, ease: 'sine.inOut' })
|
||||
.to(el, { backgroundColor: '#ffffff', duration: 5, ease: 'sine.inOut' })
|
||||
.to(el, { backgroundColor: '#00a693', duration: 5, ease: 'sine.inOut' });
|
||||
return () => tl.kill();
|
||||
}
|
||||
|
||||
gsap.set(el, { backgroundColor: '#f5fbf9' });
|
||||
return undefined;
|
||||
}, [isDark]);
|
||||
|
||||
const scrimStyle = isDark
|
||||
? {
|
||||
background:
|
||||
'radial-gradient(circle at 50% 40%, rgba(4,18,15,0.35), rgba(4,18,15,0.75) 70%), linear-gradient(180deg, rgba(4,18,15,0.55) 0%, rgba(4,18,15,0.35) 45%, rgba(4,18,15,0.7) 100%)',
|
||||
}
|
||||
: {
|
||||
background:
|
||||
'radial-gradient(circle at 50% 40%, rgba(255,255,255,0.15), rgba(255,255,255,0.55) 70%), linear-gradient(180deg, rgba(255,255,255,0.45) 0%, rgba(255,255,255,0.25) 45%, rgba(255,255,255,0.55) 100%)',
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="hero" className="relative h-screen w-full overflow-hidden">
|
||||
{/* Intro loader overlay */}
|
||||
<div ref={introRef} className="fixed inset-0 z-[100] bg-page flex items-center justify-center">
|
||||
<div ref={logoRef} className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-2xl bg-brand flex items-center justify-center shadow-brandGlow">
|
||||
<span className="text-2xl font-bold text-ink-950">R</span>
|
||||
</div>
|
||||
<p className="text-brand-400 tracking-widest text-sm">{t.hero.loading}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background: cycling colors in dark mode, static light tone in light mode */}
|
||||
<div ref={bgColorRef} className="absolute inset-0" style={{ backgroundColor: '#00a693' }} />
|
||||
|
||||
{/*
|
||||
TODO: swap the background above for a video once the asset is ready:
|
||||
|
||||
<video
|
||||
autoPlay muted loop playsInline
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
src="/video/hero-background.mp4"
|
||||
/>
|
||||
|
||||
Keep the scrim div below on top of it so the headline stays readable.
|
||||
*/}
|
||||
|
||||
{/* Scrim to keep text legible — tuned per theme so it never washes out the copy */}
|
||||
<div className="absolute inset-0" style={scrimStyle} />
|
||||
|
||||
{/* Decorative dot grid — dark dots on light backgrounds, light dots on dark */}
|
||||
<div
|
||||
className={`absolute inset-0 opacity-[0.08] [background-size:26px_26px] ${
|
||||
isDark
|
||||
? '[background-image:radial-gradient(#ffffff_1px,transparent_1px)]'
|
||||
: '[background-image:radial-gradient(#04231f_1px,transparent_1px)]'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 h-full flex flex-col items-center justify-center text-center px-6">
|
||||
<h1 className="text-3xl md:text-6xl font-extrabold leading-tight max-w-4xl text-primary min-h-[1.4em]">
|
||||
{typedHeadline}
|
||||
<span className="typewriter-caret h-[0.9em] align-middle" />
|
||||
</h1>
|
||||
<p ref={subRef} className="mt-6 max-w-2xl text-secondary text-base md:text-xl opacity-0">
|
||||
{t.hero.sub}
|
||||
</p>
|
||||
<a
|
||||
ref={ctaRef}
|
||||
href="#showcase"
|
||||
className="opacity-0 mt-10 inline-flex items-center gap-2 bg-brand hover:bg-brand-400 text-ink-950 font-bold px-8 py-3.5 rounded-full transition-colors shadow-brandGlow"
|
||||
>
|
||||
{t.hero.cta}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-8 inset-x-0 flex justify-center z-10">
|
||||
<div
|
||||
className={`w-6 h-10 rounded-full border-2 flex items-start justify-center p-1.5 ${
|
||||
isDark ? 'border-white/50' : 'border-ink-900/30'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full animate-pulseSoft ${isDark ? 'bg-white' : 'bg-ink-900'}`} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
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 {useDispatch} from 'react-redux';
|
||||
import VendingCard from './VendingCard';
|
||||
import {showcaseVideoSrc} from '@/data/content';
|
||||
import {useLanguage} from '@/app/context/LanguageContext';
|
||||
import {addToCart} from '@/store/cartSlice';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
export default function HorizontalShowcase() {
|
||||
const sectionRef = useRef(null);
|
||||
const trackRef = useRef(null);
|
||||
const videoRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
const {t} = useLanguage();
|
||||
const [quickViewMachine, setQuickViewMachine] = useState(null);
|
||||
const [videoAvailable, setVideoAvailable] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
const track = trackRef.current;
|
||||
const section = sectionRef.current;
|
||||
if (!track || !section) return;
|
||||
|
||||
const scrollDistance = () => track.scrollWidth - window.innerWidth;
|
||||
|
||||
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) => {
|
||||
const video = videoRef.current;
|
||||
if (video && video.duration) {
|
||||
video.currentTime = self.progress * video.duration;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
horizontalTween.scrollTrigger?.kill();
|
||||
horizontalTween.kill();
|
||||
};
|
||||
}, sectionRef);
|
||||
|
||||
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 overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
{videoAvailable && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={showcaseVideoSrc}
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
className="w-full h-full object-cover opacity-60"
|
||||
onError={() => setVideoAvailable(false)}
|
||||
/>
|
||||
)}
|
||||
<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" dir="ltr">
|
||||
{t.machines.map((machine) => (
|
||||
<VendingCard key={machine.id} machine={machine} onQuickView={setQuickViewMachine}/>
|
||||
))}
|
||||
</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,33 @@
|
||||
'use client';
|
||||
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
import { introVideoSrc, introVideoPoster } from '@/data/content';
|
||||
|
||||
export default function IntroVideoSection() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<section id="video" className="relative px-6 md:px-16 py-20 md:py-28 bg-surface border-t border-subtle">
|
||||
<div className="max-w-5xl mx-auto text-center">
|
||||
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.introVideo.eyebrow}</span>
|
||||
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text mt-3 mb-3">
|
||||
{t.introVideo.title}
|
||||
</h2>
|
||||
<p className="text-secondary text-sm md:text-base mb-10">{t.introVideo.subtitle}</p>
|
||||
|
||||
{/*
|
||||
TODO: replace /public/video/company-intro-placeholder.mp4 with the real
|
||||
company introduction video (keep the same filename, or update
|
||||
`introVideoSrc` in data/content.js). The poster image shows before
|
||||
playback starts — swap /public/images/video-poster.svg the same way.
|
||||
*/}
|
||||
<div className="rounded-2xl overflow-hidden border border-subtle shadow-brandGlow">
|
||||
<video controls poster={introVideoPoster} className="w-full aspect-video bg-black">
|
||||
<source src={introVideoSrc} type="video/mp4" />
|
||||
{t.introVideo.videoNotSupported}
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
|
||||
export default function LanguageToggle() {
|
||||
const { locale, toggleLocale } = useLanguage();
|
||||
|
||||
return (
|
||||
<Tooltip title={locale === 'fa' ? 'Switch to English' : 'تغییر به فارسی'}>
|
||||
<Button shape="circle" size="large" onClick={toggleLocale} aria-label="toggle language">
|
||||
{locale === 'fa' ? 'EN' : 'فا'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
HomeOutlined,
|
||||
ReadOutlined,
|
||||
ShopOutlined,
|
||||
MedicineBoxOutlined,
|
||||
ClockCircleOutlined,
|
||||
AppstoreOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
|
||||
const ICONS = {
|
||||
hotel: HomeOutlined,
|
||||
school: ReadOutlined,
|
||||
pharmacy: ShopOutlined,
|
||||
hospital: MedicineBoxOutlined,
|
||||
night: ClockCircleOutlined,
|
||||
specialty: AppstoreOutlined,
|
||||
};
|
||||
|
||||
export default function LocationsSection() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<section id="locations" className="relative px-6 md:px-16 py-20 md:py-28 bg-page border-t border-subtle">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text">{t.locations.title}</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{t.locations.items.map((loc) => {
|
||||
const Icon = ICONS[loc.icon];
|
||||
return (
|
||||
<div
|
||||
key={loc.id}
|
||||
className="bg-page-60 border border-subtle rounded-2xl p-6 hover:border-brand-500/60 transition-colors"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-brand/15 border border-subtle flex items-center justify-center text-brand-400 text-xl mb-4">
|
||||
{Icon && <Icon />}
|
||||
</div>
|
||||
<h3 className="text-primary font-bold text-base mb-2">{loc.title}</h3>
|
||||
<p className="text-secondary text-sm leading-7">{loc.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { EnvironmentOutlined } from '@ant-design/icons';
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
import { useTheme } from '@/app/context/ThemeContext';
|
||||
import { mapLocations } from '@/data/content';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Neshan (نشان) map integration.
|
||||
//
|
||||
// TODO: put your Neshan API key here, or (preferred) set it as an environment
|
||||
// variable in `.env.local`:
|
||||
// NEXT_PUBLIC_NESHAN_API_KEY=your-key-here
|
||||
// Get a key from https://platform.neshan.org
|
||||
// ---------------------------------------------------------------------------
|
||||
const NESHAN_API_KEY = process.env.NEXT_PUBLIC_NESHAN_API_KEY || '';
|
||||
|
||||
// Neshan's Static Map API — a plain image, no JS SDK/script tag needed.
|
||||
// Verify query param names/values against the current Neshan docs
|
||||
// (https://platform.neshan.org/api/static-map/) if this ever needs updating —
|
||||
// `type` accepts their day/night map styles, e.g. "standard-day" / "standard-night".
|
||||
function staticMapUrl({ lat, lng }, mapStyle) {
|
||||
const params = new URLSearchParams({
|
||||
key: NESHAN_API_KEY,
|
||||
type: mapStyle,
|
||||
center: `${lat},${lng}`,
|
||||
zoom: '15',
|
||||
size: '600x400',
|
||||
marker: `${lat},${lng}`,
|
||||
});
|
||||
return `https://api.neshan.org/v5/static?${params.toString()}`;
|
||||
}
|
||||
|
||||
function neshanViewUrl({ lat, lng }) {
|
||||
return `https://neshan.org/maps/@${lat},${lng},15z`;
|
||||
}
|
||||
|
||||
function MapThumb({ coords, mapStyle, alt }) {
|
||||
const [errored, setErrored] = useState(false);
|
||||
const { t } = useLanguage();
|
||||
|
||||
if (!NESHAN_API_KEY || errored) {
|
||||
return (
|
||||
<div className="w-full h-full min-h-[280px] flex flex-col items-center justify-center gap-3 bg-surface text-secondary text-sm text-center p-6">
|
||||
<EnvironmentOutlined className="text-3xl text-brand-400" />
|
||||
<p>{t.map.configNotice}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={staticMapUrl(coords, mapStyle)}
|
||||
alt={alt}
|
||||
onError={() => setErrored(true)}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MapSection() {
|
||||
const { t } = useLanguage();
|
||||
const { theme } = useTheme();
|
||||
const mapStyle = theme === 'dark' ? 'standard-night' : 'standard-day';
|
||||
|
||||
const pins = [
|
||||
{ key: 'factory', label: t.map.factoryLabel, coords: mapLocations.factory, address: t.contactInfo.factoryAddress },
|
||||
{ key: 'sales', label: t.map.salesLabel, coords: mapLocations.salesOffice, address: t.contactInfo.salesOfficeAddress },
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="map" className="relative px-6 md:px-16 py-20 md:py-28 bg-elevated border-t border-subtle">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.map.eyebrow}</span>
|
||||
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text mt-3">{t.map.title}</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-[1.3fr,1fr] gap-6">
|
||||
<div className="rounded-2xl overflow-hidden border border-subtle h-[360px] lg:h-auto min-h-[360px]">
|
||||
<MapThumb coords={pins[0].coords} mapStyle={mapStyle} alt={pins[0].label} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{pins.map((pin) => (
|
||||
<div key={pin.key} className="bg-surface border border-subtle rounded-2xl p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 shrink-0 rounded-lg bg-brand/15 border border-subtle flex items-center justify-center text-brand-400">
|
||||
<EnvironmentOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-primary font-bold text-sm mb-1">{pin.label}</h3>
|
||||
<p className="text-secondary text-sm leading-7 mb-2">{pin.address}</p>
|
||||
<a
|
||||
href={neshanViewUrl(pin.coords)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-400 hover:text-brand-300 text-xs font-semibold transition-colors"
|
||||
>
|
||||
{t.map.openInMaps} ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { SunOutlined, MoonOutlined } from '@ant-design/icons';
|
||||
import { useTheme } from '@/app/context/ThemeContext';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Tooltip title={theme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
onClick={toggleTheme}
|
||||
aria-label="toggle theme"
|
||||
icon={theme === 'dark' ? <SunOutlined /> : <MoonOutlined />}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { Form, Input, Select, Button, message } from 'antd';
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function TicketForm() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleFinish = async (values) => {
|
||||
// -------------------------------------------------------------------
|
||||
// TODO: Connect to the real ticketing/support API.
|
||||
//
|
||||
// import axios from 'axios';
|
||||
//
|
||||
// try {
|
||||
// await axios.post('/api/tickets', values);
|
||||
// } catch (err) {
|
||||
// message.error('...');
|
||||
// return;
|
||||
// }
|
||||
// -------------------------------------------------------------------
|
||||
console.log('Ticket submitted (placeholder):', values);
|
||||
message.success(t.ticket.successMsg);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="support" className="relative px-6 md:px-16 py-20 md:py-28 bg-page border-t border-subtle">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="text-center mb-10">
|
||||
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.ticket.eyebrow}</span>
|
||||
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text mt-3 mb-3">
|
||||
{t.ticket.title}
|
||||
</h2>
|
||||
<p className="text-secondary text-sm md:text-base">{t.ticket.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleFinish}
|
||||
requiredMark={false}
|
||||
initialValues={{ priority: 'medium' }}
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-x-4">
|
||||
<Form.Item
|
||||
label={<span className="text-secondary">{t.ticket.name}</span>}
|
||||
name="name"
|
||||
rules={[{ required: true, message: t.ticket.requiredMsg }]}
|
||||
>
|
||||
<Input size="large" placeholder={t.ticket.namePlaceholder} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-secondary">{t.ticket.phone}</span>}
|
||||
name="phone"
|
||||
rules={[
|
||||
{ required: true, message: t.ticket.requiredMsg },
|
||||
{ pattern: /^[0-9+\s-]{7,15}$/, message: t.ticket.invalidPhone },
|
||||
]}
|
||||
>
|
||||
<Input size="large" placeholder={t.ticket.phonePlaceholder} dir="ltr" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-x-4">
|
||||
<Form.Item
|
||||
label={<span className="text-secondary">{t.ticket.email}</span>}
|
||||
name="email"
|
||||
rules={[{ type: 'email', message: t.ticket.invalidEmail }]}
|
||||
>
|
||||
<Input size="large" placeholder={t.ticket.emailPlaceholder} dir="ltr" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-secondary">{t.ticket.priority}</span>}
|
||||
name="priority"
|
||||
>
|
||||
<Select
|
||||
size="large"
|
||||
options={t.ticket.priorities.map((p) => ({ value: p.value, label: p.label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-secondary">{t.ticket.subject}</span>}
|
||||
name="subject"
|
||||
rules={[{ required: true, message: t.ticket.requiredMsg }]}
|
||||
>
|
||||
<Input size="large" placeholder={t.ticket.subjectPlaceholder} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-secondary">{t.ticket.message}</span>}
|
||||
name="message"
|
||||
rules={[{ required: true, message: t.ticket.requiredMsg }]}
|
||||
>
|
||||
<TextArea rows={5} placeholder={t.ticket.messagePlaceholder} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-0">
|
||||
<Button type="primary" size="large" htmlType="submit" block>
|
||||
{t.ticket.submit}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { Button, Tooltip, message } from 'antd';
|
||||
import { ShoppingCartOutlined, EyeOutlined, HeartOutlined, HeartFilled } from '@ant-design/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { addToCart } from '@/store/cartSlice';
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
import FloatingItems from './FloatingItems';
|
||||
import { floatingSnacks } from '@/data/content';
|
||||
|
||||
export default function VendingCard({ machine, onQuickView }) {
|
||||
const dispatch = useDispatch();
|
||||
const { t, dir } = useLanguage();
|
||||
const [wishlisted, setWishlisted] = useState(false);
|
||||
const stageRef = useRef(null);
|
||||
const lensRef = useRef(null);
|
||||
const revealRef = useRef(null);
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
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%)';
|
||||
};
|
||||
|
||||
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-10 items-center">
|
||||
<div
|
||||
ref={stageRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="spotlight-stage relative h-[420px] md:h-[560px] 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 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 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-20 md:w-32 md:h-24 object-cover rounded-lg border border-subtle"
|
||||
/>
|
||||
<p className="text-sm md:text-base text-secondary 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>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 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>
|
||||
<Tooltip title={wishlisted ? t.machineActions.wishlistRemove : t.machineActions.wishlistAdd}>
|
||||
<Button
|
||||
size="large"
|
||||
shape="circle"
|
||||
icon={wishlisted ? <HeartFilled className="text-brand-400" /> : <HeartOutlined />}
|
||||
onClick={() => setWishlisted((v) => !v)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user