This commit is contained in:
Hamed
2026-08-01 15:15:34 +04:00
parent 87b518cf4e
commit 9697c98999
50 changed files with 2619 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
# Copy this file to .env.local and fill in your real keys.
# Neshan (نشان) map API key — get one from https://platform.neshan.org
# Used by components/MapSection.jsx for the static map image.
NEXT_PUBLIC_NESHAN_API_KEY=
+40
View File
@@ -0,0 +1,40 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { content } from '@/data/content';
const LanguageContext = createContext(null);
export function LanguageProvider({ children }) {
const [locale, setLocale] = useState('fa');
const [mounted, setMounted] = useState(false);
useEffect(() => {
const saved = window.localStorage.getItem('rm-locale');
if (saved === 'fa' || saved === 'en') setLocale(saved);
setMounted(true);
}, []);
useEffect(() => {
const root = document.documentElement;
root.lang = locale;
root.dir = content[locale].dir;
window.localStorage.setItem('rm-locale', locale);
}, [locale]);
const toggleLocale = () => setLocale((l) => (l === 'fa' ? 'en' : 'fa'));
return (
<LanguageContext.Provider
value={{ locale, setLocale, toggleLocale, t: content[locale], dir: content[locale].dir, mounted }}
>
{children}
</LanguageContext.Provider>
);
}
export function useLanguage() {
const ctx = useContext(LanguageContext);
if (!ctx) throw new Error('useLanguage must be used within LanguageProvider');
return ctx;
}
+43
View File
@@ -0,0 +1,43 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('dark');
const [mounted, setMounted] = useState(false);
// On mount: read saved preference, else fall back to system preference.
useEffect(() => {
const saved = window.localStorage.getItem('rm-theme');
if (saved === 'light' || saved === 'dark') {
setTheme(saved);
} else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
setTheme('light');
}
setMounted(true);
}, []);
// Reflect theme on <html> class + persist.
useEffect(() => {
const root = document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(theme);
window.localStorage.setItem('rm-theme', theme);
}, [theme]);
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme, mounted }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
+228
View File
@@ -0,0 +1,228 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ------------------------------------------------------------------ */
/* Theme tokens — dark is the default; .light overrides on <html> */
/* ------------------------------------------------------------------ */
:root {
--brand: #00a693;
--color-bg-page: #04120f;
--color-bg-elevated: #0c2b24;
--color-bg-surface: rgba(255, 255, 255, 0.045);
--color-bg-surface-strong: rgba(255, 255, 255, 0.08);
--color-border: rgba(0, 166, 147, 0.28);
--color-text-primary: #ffffff;
--color-text-secondary: rgba(230, 247, 244, 0.72);
--color-scrollbar-track: #071e19;
--color-scrollbar-thumb: #0b5c50;
color-scheme: dark;
}
html.light {
--color-bg-page: #f5fbf9;
--color-bg-elevated: #ffffff;
--color-bg-surface: rgba(0, 89, 79, 0.045);
--color-bg-surface-strong: rgba(0, 89, 79, 0.08);
--color-border: rgba(0, 166, 147, 0.25);
--color-text-primary: #04231f;
--color-text-secondary: rgba(4, 35, 31, 0.68);
--color-scrollbar-track: #e1f3ef;
--color-scrollbar-thumb: #8fdccc;
color-scheme: light;
}
html,
body {
padding: 0;
margin: 0;
scroll-behavior: smooth;
}
html[dir='rtl'] body {
font-family: var(--font-vazirmatn), Tahoma, sans-serif;
}
* {
box-sizing: border-box;
}
::selection {
background: rgba(0, 166, 147, 0.35);
color: var(--color-text-primary);
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-scrollbar-track);
}
::-webkit-scrollbar-thumb {
background: var(--color-scrollbar-thumb);
border-radius: 999px;
}
/* Semantic surface/text utilities — read the theme variables above so every
component that uses them automatically supports dark + light mode. */
.bg-page {
background-color: var(--color-bg-page);
}
.bg-elevated {
background-color: var(--color-bg-elevated);
}
.bg-surface {
background-color: var(--color-bg-surface);
}
.bg-surface-strong {
background-color: var(--color-bg-surface-strong);
}
.border-subtle {
border-color: var(--color-border);
}
.text-primary {
color: var(--color-text-primary);
}
.text-secondary {
color: var(--color-text-secondary);
}
/* Opacity variants of the tokens above (Tailwind's /NN modifier only works on
Tailwind-generated utilities, not these hand-written classes). */
.bg-page-60 {
background-color: color-mix(in srgb, var(--color-bg-page) 60%, transparent);
}
.bg-page-85 {
background-color: color-mix(in srgb, var(--color-bg-page) 85%, transparent);
}
.bg-elevated-95 {
background-color: color-mix(in srgb, var(--color-bg-elevated) 95%, transparent);
}
.text-primary-90 {
color: color-mix(in srgb, var(--color-text-primary) 90%, transparent);
}
/* ------------------------------------------------------------------ */
/* Horizontal scroll showcase (pinned via GSAP ScrollTrigger) */
/* ------------------------------------------------------------------ */
.horizontal-track {
display: flex;
height: 100vh;
/*width: 100vw;*/
will-change: transform;
}
.horizontal-panel {
height: 100vh;
width: 100vw;
flex-shrink: 0;
position: relative;
overflow: hidden;
}
/* ------------------------------------------------------------------ */
/* Cursor spotlight / lens reveal effect over the vending machine */
/* ------------------------------------------------------------------ */
.spotlight-stage {
position: relative;
cursor: none;
isolation: isolate;
}
.spotlight-stage .base-layer,
.spotlight-stage .reveal-layer {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
user-select: none;
}
.spotlight-stage .reveal-layer {
clip-path: circle(0px at 50% 50%);
transition: clip-path 0.05s linear;
}
.spotlight-lens {
position: absolute;
top: 0;
left: 0;
width: 190px;
height: 190px;
border-radius: 999px;
border: 2px solid rgba(0, 166, 147, 0.9);
box-shadow: 0 0 0 4px rgba(0, 166, 147, 0.15),
0 0 40px 6px rgba(0, 166, 147, 0.45);
pointer-events: none;
transform: translate(-50%, -50%);
opacity: 0;
transition: opacity 0.15s ease;
z-index: 30;
}
.spotlight-stage:hover .spotlight-lens {
opacity: 1;
}
/* Utility: hide native scrollbar but keep scroll functionality */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Gradient text helper for headings */
.brand-gradient-text {
background: linear-gradient(90deg, #6fe0c9 0%, #00a693 55%, #017065 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* ------------------------------------------------------------------ */
/* Typewriter caret for the hero headline */
/* ------------------------------------------------------------------ */
.typewriter-caret {
display: inline-block;
width: 3px;
margin-inline-start: 4px;
background-color: var(--brand);
animation: caretBlink 0.9s steps(1) infinite;
}
@keyframes caretBlink {
0%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
/* ------------------------------------------------------------------ */
/* Chat widget */
/* ------------------------------------------------------------------ */
.chat-bubble {
max-width: 80%;
padding: 10px 14px;
border-radius: 14px;
font-size: 0.875rem;
line-height: 1.6;
}
+28
View File
@@ -0,0 +1,28 @@
import { Vazirmatn } from 'next/font/google';
import './globals.css';
import Providers from './providers';
const vazirmatn = Vazirmatn({
subsets: ['arabic'],
variable: '--font-vazirmatn',
display: 'swap',
});
export const metadata = {
title: 'روبات مارکت | دستگاه‌های وندینگ هوشمند',
description:
'روبات مارکت؛ دستگاه‌های فروش خودکار هوشمند با مدیریت آنلاین، تحویل نرم و طراحی مدرن.',
};
export default function RootLayout({ children }) {
// Default server-rendered shell: Persian + dark. ThemeProvider/LanguageProvider
// read localStorage on mount and correct lang/dir/class client-side if needed —
// suppressHydrationWarning avoids a noisy (harmless) mismatch warning for that swap.
return (
<html lang="fa" dir="rtl" className={`${vazirmatn.variable} dark`} suppressHydrationWarning>
<body className="bg-page text-primary antialiased transition-colors duration-300">
<Providers>{children}</Providers>
</body>
</html>
);
}
+47
View File
@@ -0,0 +1,47 @@
import Header from '@/components/Header';
import HeroSection from '@/components/HeroSection';
import HorizontalShowcase from '@/components/HorizontalShowcase';
import FeaturesSection from '@/components/FeaturesSection';
import AboutSection from '@/components/AboutSection';
import CatalogSection from '@/components/CatalogSection';
import LocationsSection from '@/components/LocationsSection';
import IntroVideoSection from '@/components/IntroVideoSection';
import MapSection from '@/components/MapSection';
import TicketForm from '@/components/TicketForm';
import Footer from '@/components/Footer';
import CartDrawer from '@/components/CartDrawer';
import ChatWidget from '@/components/ChatWidget';
export default function HomePage() {
return (
<main className="relative bg-page">
<Header/>
<CartDrawer/>
<ChatWidget/>
<HeroSection/>
<HorizontalShowcase/>
<FeaturesSection/>
<AboutSection/>
<CatalogSection/>
<LocationsSection/>
<IntroVideoSection/>
<MapSection/>
<TicketForm/>
<Footer/>
</main>
);
}
+59
View File
@@ -0,0 +1,59 @@
'use client';
import { Provider as ReduxProvider } from 'react-redux';
import { ConfigProvider, theme as antdThemeApi } from 'antd';
import faIR from 'antd/locale/fa_IR';
import enUS from 'antd/locale/en_US';
import { store } from '@/store/store';
import { ThemeProvider, useTheme } from './context/ThemeContext';
import { LanguageProvider, useLanguage } from './context/LanguageContext';
function AntdBridge({ children }) {
const { theme } = useTheme();
const { locale, dir } = useLanguage();
const antdTheme = {
algorithm: theme === 'dark' ? antdThemeApi.darkAlgorithm : antdThemeApi.defaultAlgorithm,
token: {
colorPrimary: '#00a693',
colorInfo: '#00a693',
borderRadius: 10,
fontFamily: 'var(--font-vazirmatn), Tahoma, sans-serif',
colorBgContainer: theme === 'dark' ? '#0c2b24' : '#ffffff',
colorBgElevated: theme === 'dark' ? '#0f332b' : '#ffffff',
colorBorder: theme === 'dark' ? '#0b3d38' : '#c0ebe3',
colorText: theme === 'dark' ? '#e6f7f4' : '#04231f',
colorTextSecondary: theme === 'dark' ? 'rgba(230,247,244,0.7)' : 'rgba(4,35,31,0.65)',
},
components: {
Button: { controlHeight: 44, fontWeight: 600 },
Badge: { colorError: '#ff6b57' },
Modal: {
contentBg: theme === 'dark' ? '#0c2b24' : '#ffffff',
headerBg: theme === 'dark' ? '#0c2b24' : '#ffffff',
titleColor: theme === 'dark' ? '#e6f7f4' : '#04231f',
},
Drawer: {
colorBgElevated: theme === 'dark' ? '#0c2b24' : '#ffffff',
},
},
};
return (
<ConfigProvider direction={dir} locale={locale === 'fa' ? faIR : enUS} theme={antdTheme}>
{children}
</ConfigProvider>
);
}
export default function Providers({ children }) {
return (
<ReduxProvider store={store}>
<ThemeProvider>
<LanguageProvider>
<AntdBridge>{children}</AntdBridge>
</LanguageProvider>
</ThemeProvider>
</ReduxProvider>
);
}
+37
View File
@@ -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>
);
}
+109
View File
@@ -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>
);
}
+82
View File
@@ -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>
);
}
+99
View File
@@ -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>
</>
);
}
+42
View File
@@ -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>
);
}
+55
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+119
View File
@@ -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>
);
}
+175
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
}
+33
View File
@@ -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>
);
}
+16
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+113
View File
@@ -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>
);
}
+21
View File
@@ -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>
);
}
+114
View File
@@ -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>
);
}
+120
View File
@@ -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>
);
}
+447
View File
@@ -0,0 +1,447 @@
// ---------------------------------------------------------------------------
// Single bilingual content source (fa/en). Every UI string and every piece of
// business copy lives here, per locale. Components read via useLanguage().
// Asset paths (images/gifs/video) are locale-independent and stay the same.
// ---------------------------------------------------------------------------
const machinesFa = [
{
id: 'rm-35',
name: 'روبات مارکت ۳۵ کانال',
shortLabel: '۳۵ کانال',
priceLabel: '۳,۸۶۰,۰۰۰,۰۰۰ ریال',
priceNumeric: 3860000000,
description: 'یخچالدار، ۳۵ کانال اختصاصی، سیستم خنک‌کننده قدرتمند، تحویل نرم، مدیریت آنلاین ۲۴ ساعته.',
highlights: ['یخچالدار', '۳۵ کانال اختصاصی', 'تحویل نرم', 'مدیریت آنلاین ۲۴ ساعته'],
image: '/images/machine-35-base.svg',
imageAlt: '/images/machine-35-white.svg',
gif: '/gifs/machine-35.gif',
},
{
id: 'rm-48',
name: 'روبات مارکت ۴۸ کانال',
shortLabel: '۴۸ کانال',
priceLabel: '۴,۴۸۰,۰۰۰,۰۰۰ ریال',
priceNumeric: 4480000000,
description: 'یخچالدار، ۴۸ کانال اختصاصی، قابلیت‌های پیشرفته مدیریت آنلاین و تحویل کالا.',
highlights: ['یخچالدار', '۴۸ کانال اختصاصی', 'مدیریت آنلاین پیشرفته', 'تحویل هوشمند کالا'],
image: '/images/machine-48-base.svg',
imageAlt: '/images/machine-48-white.svg',
gif: '/gifs/machine-48.gif',
},
{
id: 'rm-60',
name: 'روبات مارکت ۶۰ کانال',
shortLabel: '۶۰ کانال',
priceLabel: '۴,۹۸۰,۰۰۰,۰۰۰ ریال',
priceNumeric: 4980000000,
description: 'یخچالدار، ۶۰ کانال اختصاصی برای کسب‌وکارهای پرتردد.',
highlights: ['یخچالدار', '۶۰ کانال اختصاصی', 'مناسب اماکن پرتردد', 'ظرفیت بالا'],
image: '/images/machine-60-base.svg',
imageAlt: '/images/machine-60-white.svg',
gif: '/gifs/machine-60.gif',
},
];
const machinesEn = [
{
id: 'rm-35',
name: 'Robot Market RM-35',
shortLabel: '35 Channels',
priceLabel: '3,860,000,000 Rials',
priceNumeric: 3860000000,
description: 'Refrigerated, 35 dedicated channels, powerful cooling system, soft delivery, 24/7 online management.',
highlights: ['Refrigerated', '35 dedicated channels', 'Soft delivery', '24/7 online management'],
image: '/images/machine-35-base.svg',
imageAlt: '/images/machine-35-white.svg',
gif: '/gifs/machine-35.gif',
},
{
id: 'rm-48',
name: 'Robot Market RM-48',
shortLabel: '48 Channels',
priceLabel: '4,480,000,000 Rials',
priceNumeric: 4480000000,
description: 'Refrigerated, 48 dedicated channels, advanced online management and delivery capabilities.',
highlights: ['Refrigerated', '48 dedicated channels', 'Advanced online management', 'Smart delivery'],
image: '/images/machine-48-base.svg',
imageAlt: '/images/machine-48-white.svg',
gif: '/gifs/machine-48.gif',
},
{
id: 'rm-60',
name: 'Robot Market RM-60',
shortLabel: '60 Channels',
priceLabel: '4,980,000,000 Rials',
priceNumeric: 4980000000,
description: 'Refrigerated, 60 dedicated channels for high-traffic businesses.',
highlights: ['Refrigerated', '60 dedicated channels', 'Ideal for high-traffic venues', 'High capacity'],
image: '/images/machine-60-base.svg',
imageAlt: '/images/machine-60-white.svg',
gif: '/gifs/machine-60.gif',
},
];
export const content = {
fa: {
dir: 'rtl',
lang: 'fa',
meta: {
title: 'روبات مارکت | دستگاه‌های وندینگ هوشمند',
description:
'روبات مارکت؛ دستگاه‌های فروش خودکار هوشمند با مدیریت آنلاین، تحویل نرم و طراحی مدرن.',
},
nav: {
home: 'خانه',
showcase: 'دستگاه‌ها',
about: 'درباره ما',
locations: 'مکان‌های نصب',
catalog: 'کاتالوگ محصولات',
video: 'ویدیوی معرفی',
map: 'نقشه',
support: 'پشتیبانی',
contact: 'تماس با ما',
login: 'ورود به پنل کاربری',
},
hero: {
loading: 'در حال بارگذاری روبات مارکت…',
typingPhrases: [
'به دنیای خودکار و هوشمند خوش آمدید!',
'تجربه‌ای نوین از خرید آسان و سریع.',
'روبات مارکت؛ فروشنده هوشمند شما، ۲۴ ساعته.',
],
sub: 'تجربه‌ای نوین از خرید آسان و سریع با دستگاه‌های وندینگ روبات مارکت.',
cta: 'مشاهده دستگاه‌ها',
},
cart: {
title: 'سبد خرید',
empty: 'سبد خرید شما خالی است',
total: 'جمع کل',
checkout: 'پرداخت / تسویه حساب',
checkoutToast: 'درگاه پرداخت به‌زودی متصل می‌شود.',
addToast: (name) => `${name} به سبد خرید اضافه شد`,
},
machineActions: {
addToCart: 'افزودن به سبد خرید',
quickView: 'مشاهده سریع',
wishlistAdd: 'افزودن به علاقه‌مندی‌ها',
wishlistRemove: 'حذف از علاقه‌مندی‌ها',
hoverHint: 'نشانگر را روی دستگاه حرکت دهید',
altVariant: 'رنگ جایگزین',
gifAlt: (name) => `نمایش عملکرد ${name}`,
},
machines: machinesFa,
features: [
{ id: 'warranty', icon: 'safety', title: 'ضمانت کالا', description: 'کلیه محصولات شرکت دارای یک سال خدمات پس از فروش می‌باشند.' },
{ id: 'consulting', icon: 'consulting', title: 'مشاوره تخصصی و رایگان', description: 'خدمات مشاوره رایگان در مورد نوع دستگاه و چیدمان محصولات.' },
{ id: 'installment', icon: 'installment', title: 'امکان خرید اقساطی', description: 'امکان خرید اقساط با شرایط آسان و مناسب جهت کلیه مشاغل.' },
{ id: 'packaging', icon: 'packaging', title: 'بسته‌بندی ایمن', description: 'با درخواست شما، محصولات با بسته‌بندی باکس پالت با هزینه‌ای اندک ارسال می‌شود.' },
],
about: {
eyebrow: 'معرفی شرکت',
legalName: 'شرکت نگین سازان پیشگام رادمهر آتوسا',
more: 'ادامه مطلب',
less: 'نمایش کمتر',
paragraphs: [
'شرکت نگین سازان پیشگام رادمهر آتوسا با شماره ثبت ۸۴۰۹۴ با نام‌های «ایده آوران پیشگام آتوسا» به شماره ثبت ۶۳۰۹۷ و «پیشگام گستر نگین آتوسا» به شماره ثبت ۷۳۴۱۶، از سال ۱۳۹۶ فعالیت خود را با تولید دستگاه‌های دقیق فرآوری زعفران آغاز نمود و با تشکیل شبکه گسترده توزیع و پخش زعفران در سراسر کشور، در سال ۱۴۰۱ با تولید دستگاه‌های وندینگ ماشین با نام تجاری «روبات مارکت» ویژه فروش زعفران وارد این حوزه شد.',
'سال ۱۴۰۲ برای حفظ کیفیت زعفران، قابلیت سرمایش به این دستگاه‌ها افزوده شد و روبات مارکت قابلیت عرضه و فروش تنقلات و نوشیدنی سرد را نیز به دست آورد؛ تا اینکه در بهار سال ۱۴۰۳ با معرفی گسترده دستگاه‌های «روبات مارکت»، این ربات‌ها به‌عنوان فروشنده رسمی تنقلات و نوشیدنی‌های سرد در ایران معرفی شدند.',
'روبات مارکت تنها یک نام نیست، بلکه نماد تفکری نوین و پیشرو در صنعت فروش خودکار است. آنچه در روبات مارکت اتفاق می‌افتد با تکیه بر دانش فنی مهندسین و بهره‌گیری از تکنولوژی روز دنیا، تجلی پیدا کرده است. هر دستگاه روبات مارکت یک دستیار هوشمند و خستگی‌ناپذیر است که با دقت و سرعت بالا، پلی میان نیاز مصرف‌کنندگان و اهداف تجاری شما می‌سازد.',
'تعهد ما تولید دستگاه‌هایی با کیفیت بالا، طراحی مدرن و عملکردی بی‌نقص است تا تجربه‌ای متفاوت از خرید و درآمدزایی رقم بخورد. روبات مارکت نماد اعتماد، نوآوری و تلاش برای پیشرفت صنعت فروش خودکار است.',
'در کالبد روبات مارکت، صدها ساعت کار مهندسی و تست‌های عملیاتی نهفته است. ما بر این باوریم که دوام و طول عمر دستگاه‌ها مهم‌ترین شاخص برای سرمایه‌گذاری مشتریان ماست. به همین دلیل، در تمامی مراحل تولید، از انتخاب بهترین متریال و قطعات الکترونیک گرفته تا مونتاژ نهایی، استانداردهای سخت‌گیرانه‌ای را رعایت می‌کنیم تا دستگاه‌ها در شرایط مختلف، عملکردی پایدار و بدون نقص داشته باشند.',
'دستگاه‌های هوشمند ما با رابط کاربری ساده و در عین حال پیشرفته، تجربه‌ای لذت‌بخش را برای کاربران و خریداران فراهم می‌کنند. سیستم‌های پرداخت امن، قابلیت اتصال به اینترنت برای مدیریت آنلاین و نظارت بر قیمت اجناس، از جمله ویژگی‌هایی است که روبات مارکت را از سایر رقبا متمایز می‌کند. ما تلاش کرده‌ایم فرآیند خرید را به سادگی چند لمس ساده تبدیل کنیم تا مشتریان شما در هر ساعت از شبانه‌روز، در کمترین زمان به کالای موردنظر خود دسترسی پیدا کنند. روبات مارکت تنها یک تولیدکننده نیست، بلکه مشاوری قابل اعتماد برای توسعه کسب‌وکار شماست؛ لذا راهکارهای فروش خودکار متناسب با نیازهای شما طراحی و ارائه می‌شوند.',
'فرقی نمی‌کند یک فروشگاه زنجیره‌ای بزرگ داشته باشید یا مدیر یک مجموعه اداری کوچک باشید؛ دستگاه‌های ما این امکان را می‌دهند که بدون محدودیت زمانی و مکانی و بدون نگرانی از مشکلات نیروی انسانی، فروش خود را افزایش دهید.',
'آنچه از ورای تولیدات تخصصی و پیشروی روبات مارکت قابل مشاهده است، تکنولوژی‌ای است که جایگزین نمونه‌های خارجی شده و نه‌تنها نیازهای داخلی را برطرف می‌کند، بلکه در آینده‌ای نچندان دور سهم قابل رقابتی را در بازارهای خارجی از آن خود خواهد کرد. افتخار ما این است که محصولی ایرانی می‌سازیم که با استانداردهای جهانی رقابت می‌کند و گامی کوچک اما استوار در جهت خودکفایی و شکوفایی اقتصاد کشور برمی‌داریم.',
],
},
locations: {
title: 'مکان مناسب برای نصب روبات مارکت',
items: [
{ id: 'hotels', icon: 'hotel', title: 'هتل‌ها و مراکز اقامتی', description: 'هر جایی که جمعیت زیادی در صف انتظار قرار دارند، به‌جز درآمد، روبات مارکت جزو امکانات رفاهی آن مکان محسوب شده و میزان رضایت مراجعان را نیز افزایش می‌دهد.' },
{ id: 'schools', icon: 'school', title: 'مراکز آموزشی و مدارس', description: 'در صورتی که هر یک از دانش‌آموزان و دانشجویان برای خرید مایحتاج خود به خارج از مجموعه مراجعه کنند و خدای‌ناکرده اتفاقی بیفتد، مسئولیت آن مستقیماً به عهده مدیر آموزشگاه است. در نتیجه وجود یک روبات مارکت در هر آموزشگاه ضروری است.' },
{ id: 'pharmacies', icon: 'pharmacy', title: 'داروخانه‌ها و سوپرمارکت‌ها', description: 'برای فروش محصولات جنسی و پیشگیری، ویژه مشتریانی که برای خرید آن معذوریت دارند.' },
{ id: 'hospitals', icon: 'hospital', title: 'بیمارستان‌ها و مراکز درمانی', description: 'برای فروش محصولات جنسی و پیشگیری، ویژه مشتریانی که برای خرید آن معذوریت دارند.' },
{ id: 'night-shift', icon: 'night', title: 'کشیک شیفت شب', description: 'امکان تعبیه روبات مارکت پشت شیشه سکوریت مغازه‌ها نیز وجود دارد تا در زمان تعطیلی فروشگاه و حتی نیمه‌شب‌ها، روبات زحمتکش در حال خدمت‌دهی به مشتریان عزیز شما باشد.' },
{ id: 'specialty', icon: 'specialty', title: 'محصولات تخصصی', description: 'اگر تولیدکننده هستید یا نماینده تولیدکننده محصولی خاص هستید، یک روبات مارکت می‌تواند مسئولیت معرفی و عرضه محصولات تخصصی شما را نیز بر عهده بگیرد.' },
],
},
catalog: {
eyebrow: 'کاتالوگ محصولات',
title: 'محصولات قابل سفارش برای دستگاه شما',
subtitle:
'با خرید دستگاه روبات مارکت، می‌توانید این محصولات را با قیمت عمده و کمتر از بازار سفارش دهید؛ تیم ما هم چیدمان اولیه و شارژ دستگاه شما را انجام می‌دهد.',
addToOrder: 'افزودن به سفارش',
originalPriceLabel: 'قیمت بازار',
machinePriceLabel: 'قیمت ویژه مشتریان روبات مارکت',
addToast: (name) => `${name} به لیست سفارش اضافه شد`,
items: [
{ id: 'saffron', name: 'زعفران ممتاز درجه‌یک', category: 'زعفران', image: '/images/catalog/saffron.svg', originalPriceNumeric: 12000000, priceNumeric: 8900000 },
{ id: 'chips', name: 'چیپس و پفک (بسته عمده)', category: 'تنقلات', image: '/images/snacks/chips-1.svg', originalPriceNumeric: 450000, priceNumeric: 320000 },
{ id: 'soda', name: 'نوشابه و نوشیدنی گازدار', category: 'نوشیدنی', image: '/images/snacks/soda-1.svg', originalPriceNumeric: 250000, priceNumeric: 180000 },
{ id: 'water', name: 'آب معدنی', category: 'نوشیدنی', image: '/images/catalog/water.svg', originalPriceNumeric: 120000, priceNumeric: 80000 },
{ id: 'coffee', name: 'قهوه فوری', category: 'گرم', image: '/images/catalog/coffee.svg', originalPriceNumeric: 650000, priceNumeric: 470000 },
{ id: 'tea', name: 'چای کیسه‌ای', category: 'گرم', image: '/images/catalog/tea.svg', originalPriceNumeric: 380000, priceNumeric: 270000 },
],
},
introVideo: {
eyebrow: 'ویدیوی معرفی',
title: 'با روبات مارکت آشنا شوید',
subtitle: 'نگاهی کوتاه به کارخانه، فرآیند تولید و عملکرد دستگاه‌های روبات مارکت.',
videoNotSupported: 'مرورگر شما از پخش ویدیو پشتیبانی نمی‌کند.',
},
map: {
eyebrow: 'موقعیت ما روی نقشه',
title: 'کارخانه و دفاتر روبات مارکت',
factoryLabel: 'کارخانه',
salesLabel: 'دفتر فروش مرکزی',
openInMaps: 'مشاهده در نشان',
configNotice: 'برای فعال شدن نقشه، کلید API نشان را در تنظیمات پروژه قرار دهید.',
},
ticket: {
eyebrow: 'پشتیبانی',
title: 'ثبت تیکت پشتیبانی',
subtitle: 'سوال یا درخواستی دارید؟ فرم زیر را پر کنید تا تیم پشتیبانی در سریع‌ترین زمان با شما تماس بگیرد.',
name: 'نام و نام خانوادگی',
namePlaceholder: 'مثال: علی رضایی',
phone: 'شماره تماس',
phonePlaceholder: '09xxxxxxxxx',
email: 'ایمیل (اختیاری)',
emailPlaceholder: 'example@email.com',
subject: 'موضوع',
subjectPlaceholder: 'موضوع درخواست خود را بنویسید',
priority: 'اولویت',
priorities: [
{ value: 'low', label: 'کم' },
{ value: 'medium', label: 'متوسط' },
{ value: 'high', label: 'فوری' },
],
message: 'پیام شما',
messagePlaceholder: 'توضیحات کامل درخواست یا مشکل خود را بنویسید…',
submit: 'ارسال تیکت',
requiredMsg: 'این فیلد الزامی است',
invalidPhone: 'شماره تماس معتبر نیست',
invalidEmail: 'ایمیل معتبر نیست',
successMsg: 'تیکت شما با موفقیت ثبت شد. به‌زودی با شما تماس می‌گیریم.',
},
chat: {
fabLabel: 'گفتگوی آنلاین',
title: 'پشتیبانی آنلاین روبات مارکت',
placeholder: 'پیام خود را بنویسید…',
send: 'ارسال',
initialBotMessage: 'سلام! 👋 چطور می‌تونم امروز کمکتون کنم؟',
fallbackReply: 'ممنون از پیامتون؛ همکاران ما به‌زودی پاسخ می‌دهند.',
},
footer: {
aboutLabel: 'روبات مارکت',
aboutText: 'ارائه‌دهنده دستگاه‌های وندینگ هوشمند برای کسب‌وکارهای مدرن — محصولی از شرکت نگین سازان پیشگام رادمهر آتوسا.',
quickLinks: 'دسترسی سریع',
contactTitle: 'تماس با ما',
factoryLabel: 'کارخانه',
salesLabel: 'دفتر فروش مرکزی',
rights: 'تمامی حقوق محفوظ است.',
},
contactInfo: {
orderPhone: '۰۹۱۵۱۶۰۱۸۴۷',
orderPhoneHref: '09151601847',
factoryAddress: 'مشهد، کیلومتر ۸ جاده مشهد - چناران، دور برگردان اصلی شهرک صنعتی توس به سمت مشهد، بعد از نیروگاه، اولین فرعی سمت راست (خیابان کاله)، اولین کوچه سمت راست، پلاک ۱۱۰، شرکت نگین سازان پیشگام رادمهر آتوسا',
salesOfficeAddress: 'مشهد، نبش پیامبر اعظم ۷، جنب بیمارستان رضوی، برج نخل، واحد ۴۰۱',
socialLinks: [
{ id: 'instagram', label: 'صفحه اینستاگرام', href: 'http://instagram.com/vending_machin_life' },
{ id: 'bale-join', label: 'کانال پیام‌رسان بله', href: 'https://ble.ir/join/AiTbV6UpBA' },
{ id: 'bale-site', label: 'وب‌سایت', href: 'https://my-rm.com/' },
],
},
},
en: {
dir: 'ltr',
lang: 'en',
meta: {
title: 'Robot Market | Smart Vending Machines',
description:
'Robot Market — smart vending machines with online management, soft delivery and modern design.',
},
nav: {
home: 'Home',
showcase: 'Machines',
about: 'About Us',
locations: 'Where to Install',
catalog: 'Product Catalog',
video: 'Intro Video',
map: 'Map',
support: 'Support',
contact: 'Contact',
login: 'Sign In',
},
hero: {
loading: 'Loading Robot Market…',
typingPhrases: [
'Welcome to a smart, automated world!',
'A new experience of fast, easy shopping.',
'Robot Market — your smart seller, around the clock.',
],
sub: 'A new experience of fast, easy shopping with Robot Market vending machines.',
cta: 'View Machines',
},
cart: {
title: 'Cart',
empty: 'Your cart is empty',
total: 'Total',
checkout: 'Checkout',
checkoutToast: 'Payment gateway will be connected soon.',
addToast: (name) => `${name} added to cart`,
},
machineActions: {
addToCart: 'Add to Cart',
quickView: 'Quick View',
wishlistAdd: 'Add to Wishlist',
wishlistRemove: 'Remove from Wishlist',
hoverHint: 'Move your cursor over the machine',
altVariant: 'Alternate color',
gifAlt: (name) => `${name} in action`,
},
machines: machinesEn,
features: [
{ id: 'warranty', icon: 'safety', title: 'Product Warranty', description: 'All our products come with one year of after-sales service.' },
{ id: 'consulting', icon: 'consulting', title: 'Free Expert Consulting', description: 'Free consulting on the right machine type and product layout.' },
{ id: 'installment', icon: 'installment', title: 'Installment Purchase', description: 'Flexible, easy installment plans suitable for any business.' },
{ id: 'packaging', icon: 'packaging', title: 'Secure Packaging', description: 'On request, products ship in pallet-box packaging for a small fee.' },
],
about: {
eyebrow: 'Company Profile',
legalName: 'Negin Sazan Pishgam Radmehr Atousa Co.',
more: 'Read more',
less: 'Show less',
paragraphs: [
'Negin Sazan Pishgam Radmehr Atousa Co. (registration No. 84094), also known as "Ideh Avaran Pishgam Atousa" (No. 63097) and "Pishgam Gostar Negin Atousa" (No. 73416), began operations in 2017 producing precision saffron-processing machines. After building a nationwide saffron distribution network, in 2022 the company entered the vending-machine industry with saffron-dedicated machines under the "Robot Market" brand.',
'In 2023, cooling capability was added to preserve saffron quality, and Robot Market gained the ability to sell snacks and cold drinks as well. By spring 2024, with the wide rollout of "Robot Market" machines, these robots were introduced as an official retailer of snacks and cold drinks in Iran.',
'Robot Market is not just a name — it is a symbol of a modern, forward-looking way of thinking in the vending industry. What happens inside Robot Market is the result of our engineers\' technical expertise combined with today\'s technology. Every Robot Market machine is a tireless, intelligent assistant that bridges consumer needs and your business goals with precision and speed.',
'Our commitment is to build high-quality machines with modern design and flawless performance, delivering a different experience of shopping and revenue generation. Robot Market stands for trust, innovation, and continuous progress in the vending industry.',
'Hundreds of hours of engineering work and operational testing go into every Robot Market unit. We believe durability and machine lifespan are the most important measures of our customers\' investment. That is why, from selecting the best materials and electronic components to final assembly, we follow strict standards so our machines perform reliably in any condition.',
'Our smart machines offer a simple yet advanced user interface for an enjoyable experience. Secure payment systems and internet connectivity for online management and price monitoring set Robot Market apart from competitors. We have made the purchase process as simple as a few taps, so your customers can access what they need, any time of day, in the shortest time possible. Robot Market is not just a manufacturer — it is a trusted advisor for growing your business, designing automated sales solutions tailored to your needs.',
'Whether you run a large retail chain or manage a small office, our machines let you increase sales without time or location limits, and without worrying about staffing issues.',
'Behind Robot Market\'s specialized, forward-looking products lies technology that replaces foreign alternatives — meeting domestic needs today, and poised to compete meaningfully in export markets in the near future. We take pride in building an Iranian product that competes with global standards — a small but firm step toward self-sufficiency and economic growth.',
],
},
locations: {
title: 'Ideal Locations for a Robot Market Machine',
items: [
{ id: 'hotels', icon: 'hotel', title: 'Hotels & Accommodations', description: 'Anywhere large crowds wait in line — beyond the revenue, Robot Market becomes an amenity that increases guest satisfaction.' },
{ id: 'schools', icon: 'school', title: 'Schools & Educational Centers', description: 'If a student has to leave campus to buy something and, God forbid, an incident occurs, responsibility falls directly on the school administrator. A Robot Market on-site is essential.' },
{ id: 'pharmacies', icon: 'pharmacy', title: 'Pharmacies & Supermarkets', description: 'For selling personal/health products discreetly, for customers who prefer not to purchase them in person.' },
{ id: 'hospitals', icon: 'hospital', title: 'Hospitals & Medical Centers', description: 'For selling personal/health products discreetly, for customers who prefer not to purchase them in person.' },
{ id: 'night-shift', icon: 'night', title: 'Night-Shift Coverage', description: 'Robot Market can be installed behind a shop\'s security glass, serving customers even after closing time and in the middle of the night.' },
{ id: 'specialty', icon: 'specialty', title: 'Specialty Products', description: 'If you manufacture or represent a specific product line, a Robot Market machine can take on introducing and offering your specialty products too.' },
],
},
catalog: {
eyebrow: 'Product Catalog',
title: 'Products You Can Order for Your Machine',
subtitle:
'When you buy a Robot Market machine, you can order these products at a wholesale price below market rate — our team also handles the initial stocking and layout for you.',
addToOrder: 'Add to Order',
originalPriceLabel: 'Market price',
machinePriceLabel: 'Robot Market customer price',
addToast: (name) => `${name} added to your order`,
items: [
{ id: 'saffron', name: 'Premium Grade-1 Saffron', category: 'Saffron', image: '/images/catalog/saffron.svg', originalPriceNumeric: 12000000, priceNumeric: 8900000 },
{ id: 'chips', name: 'Chips & Crisps (bulk pack)', category: 'Snacks', image: '/images/snacks/chips-1.svg', originalPriceNumeric: 450000, priceNumeric: 320000 },
{ id: 'soda', name: 'Soda & Carbonated Drinks', category: 'Drinks', image: '/images/snacks/soda-1.svg', originalPriceNumeric: 250000, priceNumeric: 180000 },
{ id: 'water', name: 'Mineral Water', category: 'Drinks', image: '/images/catalog/water.svg', originalPriceNumeric: 120000, priceNumeric: 80000 },
{ id: 'coffee', name: 'Instant Coffee', category: 'Hot Drinks', image: '/images/catalog/coffee.svg', originalPriceNumeric: 650000, priceNumeric: 470000 },
{ id: 'tea', name: 'Tea Bags', category: 'Hot Drinks', image: '/images/catalog/tea.svg', originalPriceNumeric: 380000, priceNumeric: 270000 },
],
},
introVideo: {
eyebrow: 'Intro Video',
title: 'Get to Know Robot Market',
subtitle: 'A short look at the factory, our production process, and the machines in action.',
videoNotSupported: 'Your browser does not support video playback.',
},
map: {
eyebrow: 'Find Us on the Map',
title: 'Robot Market Factory & Offices',
factoryLabel: 'Factory',
salesLabel: 'Central Sales Office',
openInMaps: 'Open in Neshan',
configNotice: 'Add your Neshan API key in the project settings to activate the map.',
},
ticket: {
eyebrow: 'Support',
title: 'Submit a Support Ticket',
subtitle: 'Have a question or request? Fill out the form below and our support team will get back to you as soon as possible.',
name: 'Full Name',
namePlaceholder: 'e.g. John Smith',
phone: 'Phone Number',
phonePlaceholder: '09xxxxxxxxx',
email: 'Email (optional)',
emailPlaceholder: 'example@email.com',
subject: 'Subject',
subjectPlaceholder: 'Write the subject of your request',
priority: 'Priority',
priorities: [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'Urgent' },
],
message: 'Your Message',
messagePlaceholder: 'Describe your request or issue in full…',
submit: 'Submit Ticket',
requiredMsg: 'This field is required',
invalidPhone: 'Invalid phone number',
invalidEmail: 'Invalid email address',
successMsg: 'Your ticket was submitted successfully. We will contact you soon.',
},
chat: {
fabLabel: 'Live Chat',
title: 'Robot Market Live Support',
placeholder: 'Type your message…',
send: 'Send',
initialBotMessage: "Hi! 👋 How can we help you today?",
fallbackReply: "Thanks for your message — our team will reply shortly.",
},
footer: {
aboutLabel: 'Robot Market',
aboutText: 'Smart vending machines for modern businesses — a product of Negin Sazan Pishgam Radmehr Atousa Co.',
quickLinks: 'Quick Links',
contactTitle: 'Contact Us',
factoryLabel: 'Factory',
salesLabel: 'Central Sales Office',
rights: 'All rights reserved.',
},
contactInfo: {
orderPhone: '09151601847',
orderPhoneHref: '09151601847',
factoryAddress: 'Mashhad, Mashhad–Chenaran Rd. km 8, main roundabout of Toos Industrial Town toward Mashhad, after the power plant, 1st street on the right (Kaleh St.), 1st alley on the right, No. 110 — Negin Sazan Pishgam Radmehr Atousa Co.',
salesOfficeAddress: 'Mashhad, corner of Payambar-e Azam 7, next to Razavi Hospital, Nakhl Tower, Unit 401',
socialLinks: [
{ id: 'instagram', label: 'Instagram', href: 'http://instagram.com/vending_machin_life' },
{ id: 'bale-join', label: 'Bale Channel', href: 'https://ble.ir/join/AiTbV6UpBA' },
{ id: 'bale-site', label: 'Website', href: 'https://my-rm.com/' },
],
},
},
};
// Locale-independent asset config (positions/paths only — same in both languages).
export const floatingSnacks = [
{ id: 'chips-1', image: '/images/snacks/chips-1.svg', size: 76, top: '18%', left: '8%', duration: 5.5, delay: 0 },
{ id: 'soda-1', image: '/images/snacks/soda-1.svg', size: 64, top: '68%', left: '12%', duration: 6.2, delay: 0.6 },
{ id: 'chips-2', image: '/images/snacks/chips-2.svg', size: 58, top: '22%', left: '85%', duration: 4.8, delay: 0.3 },
{ id: 'soda-2', image: '/images/snacks/soda-2.svg', size: 70, top: '72%', left: '88%', duration: 5.9, delay: 0.9 },
{ id: 'chips-3', image: '/images/snacks/chips-1.svg', size: 48, top: '42%', left: '90%', duration: 6.6, delay: 1.2 },
{ id: 'soda-3', image: '/images/snacks/soda-1.svg', size: 52, top: '46%', left: '4%', duration: 5.1, delay: 1.5 },
];
export const showcaseVideoSrc = '/video/showcase-placeholder.mp4';
// Dedicated company introduction video (different from the horizontal-showcase
// scroll-scrubbed background video above). Drop the real file at this path.
export const introVideoSrc = '/video/company-intro-placeholder.mp4';
export const introVideoPoster = '/images/video-poster.svg';
// Placeholder coordinates for Toos Industrial Town, Mashhad (approximate).
// Replace with the exact factory pin — see README.
export const mapLocations = {
factory: { lat: 36.42, lng: 59.445 },
salesOffice: { lat: 36.3110, lng: 59.5683 }, // approximate central Mashhad placeholder
};
+2 -1
View File
@@ -3,6 +3,7 @@
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./*"] "@/*": ["./*"]
} },
"jsx": "react"
} }
} }
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// AntD ships CJS + uses some patterns that benefit from transpilation in the App Router.
transpilePackages: ['antd', '@ant-design/icons', 'rc-util', 'rc-pagination', 'rc-picker'],
};
module.exports = nextConfig;
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 120" width="140" height="120">
<rect x="20" y="30" width="80" height="70" rx="8" fill="#6b3f2a" stroke="#3a1f13" stroke-width="3"/>
<path d="M100 45 q22 0 22 20 q0 20 -22 20" fill="none" stroke="#3a1f13" stroke-width="6"/>
<ellipse cx="60" cy="30" rx="40" ry="10" fill="#c8a27a"/>
<text x="60" y="72" font-family="Tahoma, sans-serif" font-size="12" fill="#ffe9d6" text-anchor="middle">قهوه</text>
</svg>

After

Width:  |  Height:  |  Size: 473 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 140" width="120" height="140">
<rect x="15" y="20" width="90" height="105" rx="10" fill="#7a1f2b" stroke="#3d0f15" stroke-width="3"/>
<rect x="15" y="20" width="90" height="34" rx="10" fill="#00a693"/>
<path d="M40 70 q10 -14 20 0 q10 -14 20 0" stroke="#ffcf4d" stroke-width="3" fill="none"/>
<text x="60" y="100" font-family="Tahoma, sans-serif" font-size="13" fill="#ffe9b0" text-anchor="middle">زعفران</text>
</svg>

After

Width:  |  Height:  |  Size: 490 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 140" width="120" height="140">
<rect x="20" y="25" width="80" height="95" rx="6" fill="#00a693" stroke="#017065" stroke-width="3"/>
<rect x="30" y="15" width="60" height="16" rx="4" fill="#e6f7f4"/>
<line x1="60" y1="0" x2="60" y2="15" stroke="#8fdccc" stroke-width="2"/>
<text x="60" y="80" font-family="Tahoma, sans-serif" font-size="13" fill="#e6f7f4" text-anchor="middle">چای</text>
</svg>

After

Width:  |  Height:  |  Size: 462 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 90 160" width="90" height="160">
<path d="M30 10 h30 v18 l8 10 v100 a6 6 0 0 1 -6 6 h-34 a6 6 0 0 1 -6 -6 v-100 l8 -10 z" fill="#bfe9ff" stroke="#00a693" stroke-width="3"/>
<rect x="26" y="70" width="38" height="30" fill="#ffffff" opacity="0.85"/>
<text x="45" y="90" font-family="Tahoma, sans-serif" font-size="11" fill="#017065" text-anchor="middle">آب</text>
</svg>

After

Width:  |  Height:  |  Size: 430 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 60" width="220" height="60">
<rect x="2" y="2" width="56" height="56" rx="14" fill="#00a693"/>
<rect x="16" y="14" width="28" height="32" rx="4" fill="#04120f"/>
<rect x="20" y="18" width="8" height="10" rx="1" fill="#00a693"/>
<rect x="32" y="18" width="8" height="10" rx="1" fill="#00a693"/>
<text x="70" y="27" font-family="Tahoma, sans-serif" font-size="20" font-weight="bold" fill="#e6f7f4">روبات مارکت</text>
<text x="70" y="46" font-family="Tahoma, sans-serif" font-size="12" fill="#8fdccc">Robot Market</text>
</svg>

After

Width:  |  Height:  |  Size: 604 B

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700" width="400" height="700">
<defs>
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#00a693" stop-opacity="0.95"/>
<stop offset="1" stop-color="#08554e"/>
</linearGradient>
</defs>
<rect x="40" y="30" width="320" height="620" rx="26" fill="url(#body)" stroke="#012e28" stroke-width="6"/>
<rect x="70" y="70" width="260" height="360" rx="10" fill="#04120f" opacity="0.55"/>
<g fill="#0b3d38" opacity="0.9">
<rect x="85" y="85" width="55" height="80" rx="4"/>
<rect x="150" y="85" width="55" height="80" rx="4"/>
<rect x="215" y="85" width="55" height="80" rx="4"/>
<rect x="280" y="85" width="35" height="80" rx="4"/>
<rect x="85" y="175" width="55" height="80" rx="4"/>
<rect x="150" y="175" width="55" height="80" rx="4"/>
<rect x="215" y="175" width="55" height="80" rx="4"/>
<rect x="280" y="175" width="35" height="80" rx="4"/>
<rect x="85" y="265" width="55" height="80" rx="4"/>
<rect x="150" y="265" width="55" height="80" rx="4"/>
<rect x="215" y="265" width="55" height="80" rx="4"/>
<rect x="280" y="265" width="35" height="80" rx="4"/>
</g>
<rect x="70" y="450" width="260" height="150" rx="10" fill="#04120f" opacity="0.4"/>
<circle cx="200" cy="525" r="34" fill="none" stroke="#e6f7f4" stroke-width="4"/>
<rect x="185" y="595" width="30" height="40" rx="4" fill="#04120f"/>
<text x="200" y="670" font-family="Tahoma, sans-serif" font-size="26" fill="#e6f7f4" text-anchor="middle">RM-35</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700" width="400" height="700">
<defs>
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="1" stop-color="#dff3ef"/>
</linearGradient>
</defs>
<rect x="40" y="30" width="320" height="620" rx="26" fill="url(#body)" stroke="#00a693" stroke-width="6"/>
<rect x="70" y="70" width="260" height="360" rx="10" fill="#ffffff" opacity="0.9" stroke="#8fdccc" stroke-width="2"/>
<g fill="#c0ebe3" stroke="#00a693" stroke-width="1.5">
<rect x="85" y="85" width="55" height="80" rx="4"/>
<rect x="150" y="85" width="55" height="80" rx="4"/>
<rect x="215" y="85" width="55" height="80" rx="4"/>
<rect x="280" y="85" width="35" height="80" rx="4"/>
<rect x="85" y="175" width="55" height="80" rx="4"/>
<rect x="150" y="175" width="55" height="80" rx="4"/>
<rect x="215" y="175" width="55" height="80" rx="4"/>
<rect x="280" y="175" width="35" height="80" rx="4"/>
<rect x="85" y="265" width="55" height="80" rx="4"/>
<rect x="150" y="265" width="55" height="80" rx="4"/>
<rect x="215" y="265" width="55" height="80" rx="4"/>
<rect x="280" y="265" width="35" height="80" rx="4"/>
</g>
<rect x="70" y="450" width="260" height="150" rx="10" fill="#ffffff" opacity="0.9" stroke="#8fdccc" stroke-width="2"/>
<circle cx="200" cy="525" r="34" fill="none" stroke="#00a693" stroke-width="4"/>
<rect x="185" y="595" width="30" height="40" rx="4" fill="#00a693"/>
<text x="200" y="670" font-family="Tahoma, sans-serif" font-size="26" fill="#00a693" text-anchor="middle">RM-35 · White</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700" width="400" height="700">
<defs>
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#00a693" stop-opacity="0.95"/>
<stop offset="1" stop-color="#08554e"/>
</linearGradient>
</defs>
<rect x="40" y="30" width="320" height="620" rx="26" fill="url(#body)" stroke="#012e28" stroke-width="6"/>
<rect x="70" y="70" width="260" height="360" rx="10" fill="#04120f" opacity="0.55"/>
<g fill="#0b3d38" opacity="0.9">
<rect x="85" y="85" width="55" height="80" rx="4"/>
<rect x="150" y="85" width="55" height="80" rx="4"/>
<rect x="215" y="85" width="55" height="80" rx="4"/>
<rect x="280" y="85" width="35" height="80" rx="4"/>
<rect x="85" y="175" width="55" height="80" rx="4"/>
<rect x="150" y="175" width="55" height="80" rx="4"/>
<rect x="215" y="175" width="55" height="80" rx="4"/>
<rect x="280" y="175" width="35" height="80" rx="4"/>
<rect x="85" y="265" width="55" height="80" rx="4"/>
<rect x="150" y="265" width="55" height="80" rx="4"/>
<rect x="215" y="265" width="55" height="80" rx="4"/>
<rect x="280" y="265" width="35" height="80" rx="4"/>
</g>
<rect x="70" y="450" width="260" height="150" rx="10" fill="#04120f" opacity="0.4"/>
<circle cx="200" cy="525" r="34" fill="none" stroke="#e6f7f4" stroke-width="4"/>
<rect x="185" y="595" width="30" height="40" rx="4" fill="#04120f"/>
<text x="200" y="670" font-family="Tahoma, sans-serif" font-size="26" fill="#e6f7f4" text-anchor="middle">RM-48</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700" width="400" height="700">
<defs>
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="1" stop-color="#dff3ef"/>
</linearGradient>
</defs>
<rect x="40" y="30" width="320" height="620" rx="26" fill="url(#body)" stroke="#00a693" stroke-width="6"/>
<rect x="70" y="70" width="260" height="360" rx="10" fill="#ffffff" opacity="0.9" stroke="#8fdccc" stroke-width="2"/>
<g fill="#c0ebe3" stroke="#00a693" stroke-width="1.5">
<rect x="85" y="85" width="55" height="80" rx="4"/>
<rect x="150" y="85" width="55" height="80" rx="4"/>
<rect x="215" y="85" width="55" height="80" rx="4"/>
<rect x="280" y="85" width="35" height="80" rx="4"/>
<rect x="85" y="175" width="55" height="80" rx="4"/>
<rect x="150" y="175" width="55" height="80" rx="4"/>
<rect x="215" y="175" width="55" height="80" rx="4"/>
<rect x="280" y="175" width="35" height="80" rx="4"/>
<rect x="85" y="265" width="55" height="80" rx="4"/>
<rect x="150" y="265" width="55" height="80" rx="4"/>
<rect x="215" y="265" width="55" height="80" rx="4"/>
<rect x="280" y="265" width="35" height="80" rx="4"/>
</g>
<rect x="70" y="450" width="260" height="150" rx="10" fill="#ffffff" opacity="0.9" stroke="#8fdccc" stroke-width="2"/>
<circle cx="200" cy="525" r="34" fill="none" stroke="#00a693" stroke-width="4"/>
<rect x="185" y="595" width="30" height="40" rx="4" fill="#00a693"/>
<text x="200" y="670" font-family="Tahoma, sans-serif" font-size="26" fill="#00a693" text-anchor="middle">RM-48 · White</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700" width="400" height="700">
<defs>
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#00a693" stop-opacity="0.95"/>
<stop offset="1" stop-color="#08554e"/>
</linearGradient>
</defs>
<rect x="40" y="30" width="320" height="620" rx="26" fill="url(#body)" stroke="#012e28" stroke-width="6"/>
<rect x="70" y="70" width="260" height="360" rx="10" fill="#04120f" opacity="0.55"/>
<g fill="#0b3d38" opacity="0.9">
<rect x="85" y="85" width="55" height="80" rx="4"/>
<rect x="150" y="85" width="55" height="80" rx="4"/>
<rect x="215" y="85" width="55" height="80" rx="4"/>
<rect x="280" y="85" width="35" height="80" rx="4"/>
<rect x="85" y="175" width="55" height="80" rx="4"/>
<rect x="150" y="175" width="55" height="80" rx="4"/>
<rect x="215" y="175" width="55" height="80" rx="4"/>
<rect x="280" y="175" width="35" height="80" rx="4"/>
<rect x="85" y="265" width="55" height="80" rx="4"/>
<rect x="150" y="265" width="55" height="80" rx="4"/>
<rect x="215" y="265" width="55" height="80" rx="4"/>
<rect x="280" y="265" width="35" height="80" rx="4"/>
</g>
<rect x="70" y="450" width="260" height="150" rx="10" fill="#04120f" opacity="0.4"/>
<circle cx="200" cy="525" r="34" fill="none" stroke="#e6f7f4" stroke-width="4"/>
<rect x="185" y="595" width="30" height="40" rx="4" fill="#04120f"/>
<text x="200" y="670" font-family="Tahoma, sans-serif" font-size="26" fill="#e6f7f4" text-anchor="middle">RM-60</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700" width="400" height="700">
<defs>
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="1" stop-color="#dff3ef"/>
</linearGradient>
</defs>
<rect x="40" y="30" width="320" height="620" rx="26" fill="url(#body)" stroke="#00a693" stroke-width="6"/>
<rect x="70" y="70" width="260" height="360" rx="10" fill="#ffffff" opacity="0.9" stroke="#8fdccc" stroke-width="2"/>
<g fill="#c0ebe3" stroke="#00a693" stroke-width="1.5">
<rect x="85" y="85" width="55" height="80" rx="4"/>
<rect x="150" y="85" width="55" height="80" rx="4"/>
<rect x="215" y="85" width="55" height="80" rx="4"/>
<rect x="280" y="85" width="35" height="80" rx="4"/>
<rect x="85" y="175" width="55" height="80" rx="4"/>
<rect x="150" y="175" width="55" height="80" rx="4"/>
<rect x="215" y="175" width="55" height="80" rx="4"/>
<rect x="280" y="175" width="35" height="80" rx="4"/>
<rect x="85" y="265" width="55" height="80" rx="4"/>
<rect x="150" y="265" width="55" height="80" rx="4"/>
<rect x="215" y="265" width="55" height="80" rx="4"/>
<rect x="280" y="265" width="35" height="80" rx="4"/>
</g>
<rect x="70" y="450" width="260" height="150" rx="10" fill="#ffffff" opacity="0.9" stroke="#8fdccc" stroke-width="2"/>
<circle cx="200" cy="525" r="34" fill="none" stroke="#00a693" stroke-width="4"/>
<rect x="185" y="595" width="30" height="40" rx="4" fill="#00a693"/>
<text x="200" y="670" font-family="Tahoma, sans-serif" font-size="26" fill="#00a693" text-anchor="middle">RM-60 · White</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 160" width="120" height="160">
<path d="M20 10 L100 10 L92 150 L28 150 Z" fill="#ffb020" stroke="#7a4a00" stroke-width="3"/>
<path d="M20 10 L100 10 L96 45 L24 45 Z" fill="#00a693"/>
<circle cx="60" cy="90" r="26" fill="#fff4d6" opacity="0.85"/>
<text x="60" y="30" font-family="Tahoma, sans-serif" font-size="14" fill="#ffffff" text-anchor="middle">چیپس</text>
</svg>

After

Width:  |  Height:  |  Size: 438 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 160" width="120" height="160">
<path d="M20 10 L100 10 L92 150 L28 150 Z" fill="#e6533c" stroke="#6b1f10" stroke-width="3"/>
<path d="M20 10 L100 10 L96 45 L24 45 Z" fill="#00a693"/>
<circle cx="60" cy="90" r="26" fill="#ffe3d6" opacity="0.85"/>
<text x="60" y="30" font-family="Tahoma, sans-serif" font-size="14" fill="#ffffff" text-anchor="middle">چیپس</text>
</svg>

After

Width:  |  Height:  |  Size: 438 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 180" width="100" height="180">
<rect x="20" y="10" width="60" height="160" rx="14" fill="#00a693" stroke="#012e28" stroke-width="3"/>
<rect x="20" y="60" width="60" height="45" fill="#e6f7f4" opacity="0.9"/>
<text x="50" y="88" font-family="Tahoma, sans-serif" font-size="13" fill="#00594f" text-anchor="middle">نوشابه</text>
<rect x="38" y="0" width="24" height="14" rx="3" fill="#8fdccc"/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 180" width="100" height="180">
<rect x="20" y="10" width="60" height="160" rx="14" fill="#ffb020" stroke="#012e28" stroke-width="3"/>
<rect x="20" y="60" width="60" height="45" fill="#fff4d6" opacity="0.9"/>
<text x="50" y="88" font-family="Tahoma, sans-serif" font-size="13" fill="#7a4a00" text-anchor="middle">نوشابه</text>
<rect x="38" y="0" width="24" height="14" rx="3" fill="#ffe0a3"/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<defs>
<linearGradient id="posterBg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0b3d38"/>
<stop offset="1" stop-color="#04120f"/>
</linearGradient>
</defs>
<rect width="1280" height="720" fill="url(#posterBg)"/>
<circle cx="640" cy="360" r="70" fill="#00a693" opacity="0.9"/>
<path d="M615 325 l70 35 l-70 35 z" fill="#04120f"/>
<text x="640" y="480" font-family="Tahoma, sans-serif" font-size="30" fill="#e6f7f4" text-anchor="middle">ویدیوی معرفی روبات مارکت — placeholder</text>
</svg>

After

Width:  |  Height:  |  Size: 649 B

Binary file not shown.
Binary file not shown.
+71
View File
@@ -0,0 +1,71 @@
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
items: [], // { id, name, priceNumeric, priceLabel, qty, image }
isDrawerOpen: false,
};
const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
addToCart: (state, action) => {
const product = action.payload;
const existing = state.items.find((item) => item.id === product.id);
if (existing) {
existing.qty += 1;
} else {
state.items.push({ ...product, qty: 1 });
}
state.isDrawerOpen = true;
},
removeFromCart: (state, action) => {
state.items = state.items.filter((item) => item.id !== action.payload);
},
incrementQty: (state, action) => {
const item = state.items.find((item) => item.id === action.payload);
if (item) item.qty += 1;
},
decrementQty: (state, action) => {
const item = state.items.find((item) => item.id === action.payload);
if (item && item.qty > 1) {
item.qty -= 1;
} else if (item) {
state.items = state.items.filter((i) => i.id !== action.payload);
}
},
clearCart: (state) => {
state.items = [];
},
openDrawer: (state) => {
state.isDrawerOpen = true;
},
closeDrawer: (state) => {
state.isDrawerOpen = false;
},
toggleDrawer: (state) => {
state.isDrawerOpen = !state.isDrawerOpen;
},
},
});
export const {
addToCart,
removeFromCart,
incrementQty,
decrementQty,
clearCart,
openDrawer,
closeDrawer,
toggleDrawer,
} = cartSlice.actions;
// Selectors
export const selectCartItems = (state) => state.cart.items;
export const selectCartCount = (state) =>
state.cart.items.reduce((sum, item) => sum + item.qty, 0);
export const selectCartTotal = (state) =>
state.cart.items.reduce((sum, item) => sum + item.qty * item.priceNumeric, 0);
export const selectIsDrawerOpen = (state) => state.cart.isDrawerOpen;
export default cartSlice.reducer;
+12
View File
@@ -0,0 +1,12 @@
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './cartSlice';
export const makeStore = () =>
configureStore({
reducer: {
cart: cartReducer,
},
});
// Singleton store for the client (App Router renders providers on the client tree).
export const store = makeStore();