From 9697c98999358021cf43894666dfda216f9aa322 Mon Sep 17 00:00:00 2001 From: Hamed Date: Sat, 1 Aug 2026 15:15:34 +0400 Subject: [PATCH] update --- .env.local.example | 5 + app/context/LanguageContext.jsx | 40 ++ app/context/ThemeContext.jsx | 43 ++ app/globals.css | 228 +++++++++++ app/layout.jsx | 28 ++ app/page.jsx | 47 +++ app/providers.jsx | 59 +++ components/AboutSection.jsx | 37 ++ components/CartDrawer.jsx | 109 +++++ components/CatalogSection.jsx | 82 ++++ components/ChatWidget.jsx | 99 +++++ components/FeaturesSection.jsx | 42 ++ components/FloatingItems.jsx | 55 +++ components/Footer.jsx | 80 ++++ components/Header.jsx | 119 ++++++ components/HeroSection.jsx | 175 ++++++++ components/HorizontalShowcase.jsx | 127 ++++++ components/IntroVideoSection.jsx | 33 ++ components/LanguageToggle.jsx | 16 + components/LocationsSection.jsx | 52 +++ components/MapSection.jsx | 113 ++++++ components/ThemeToggle.jsx | 21 + components/TicketForm.jsx | 114 ++++++ components/VendingCard.jsx | 120 ++++++ data/content.js | 447 +++++++++++++++++++++ jsconfig.json | 3 +- next.config.js | 8 + public/gifs/machine-35.gif | Bin 0 -> 28869 bytes public/gifs/machine-48.gif | Bin 0 -> 28680 bytes public/gifs/machine-60.gif | Bin 0 -> 28931 bytes public/images/catalog/coffee.svg | 6 + public/images/catalog/saffron.svg | 6 + public/images/catalog/tea.svg | 6 + public/images/catalog/water.svg | 5 + public/images/logo.svg | 8 + public/images/machine-35-base.svg | 28 ++ public/images/machine-35-white.svg | 28 ++ public/images/machine-48-base.svg | 28 ++ public/images/machine-48-white.svg | 28 ++ public/images/machine-60-base.svg | 28 ++ public/images/machine-60-white.svg | 28 ++ public/images/snacks/chips-1.svg | 6 + public/images/snacks/chips-2.svg | 6 + public/images/snacks/soda-1.svg | 6 + public/images/snacks/soda-2.svg | 6 + public/images/video-poster.svg | 12 + public/video/company-intro-placeholder.mp4 | Bin 0 -> 18837 bytes public/video/showcase-placeholder.mp4 | Bin 0 -> 10782 bytes store/cartSlice.js | 71 ++++ store/store.js | 12 + 50 files changed, 2619 insertions(+), 1 deletion(-) create mode 100644 .env.local.example create mode 100644 app/context/LanguageContext.jsx create mode 100644 app/context/ThemeContext.jsx create mode 100644 app/globals.css create mode 100644 app/layout.jsx create mode 100644 app/page.jsx create mode 100644 app/providers.jsx create mode 100644 components/AboutSection.jsx create mode 100644 components/CartDrawer.jsx create mode 100644 components/CatalogSection.jsx create mode 100644 components/ChatWidget.jsx create mode 100644 components/FeaturesSection.jsx create mode 100644 components/FloatingItems.jsx create mode 100644 components/Footer.jsx create mode 100644 components/Header.jsx create mode 100644 components/HeroSection.jsx create mode 100644 components/HorizontalShowcase.jsx create mode 100644 components/IntroVideoSection.jsx create mode 100644 components/LanguageToggle.jsx create mode 100644 components/LocationsSection.jsx create mode 100644 components/MapSection.jsx create mode 100644 components/ThemeToggle.jsx create mode 100644 components/TicketForm.jsx create mode 100644 components/VendingCard.jsx create mode 100644 data/content.js create mode 100644 next.config.js create mode 100644 public/gifs/machine-35.gif create mode 100644 public/gifs/machine-48.gif create mode 100644 public/gifs/machine-60.gif create mode 100644 public/images/catalog/coffee.svg create mode 100644 public/images/catalog/saffron.svg create mode 100644 public/images/catalog/tea.svg create mode 100644 public/images/catalog/water.svg create mode 100644 public/images/logo.svg create mode 100644 public/images/machine-35-base.svg create mode 100644 public/images/machine-35-white.svg create mode 100644 public/images/machine-48-base.svg create mode 100644 public/images/machine-48-white.svg create mode 100644 public/images/machine-60-base.svg create mode 100644 public/images/machine-60-white.svg create mode 100644 public/images/snacks/chips-1.svg create mode 100644 public/images/snacks/chips-2.svg create mode 100644 public/images/snacks/soda-1.svg create mode 100644 public/images/snacks/soda-2.svg create mode 100644 public/images/video-poster.svg create mode 100644 public/video/company-intro-placeholder.mp4 create mode 100644 public/video/showcase-placeholder.mp4 create mode 100644 store/cartSlice.js create mode 100644 store/store.js diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..32c64a0 --- /dev/null +++ b/.env.local.example @@ -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= diff --git a/app/context/LanguageContext.jsx b/app/context/LanguageContext.jsx new file mode 100644 index 0000000..82f35f7 --- /dev/null +++ b/app/context/LanguageContext.jsx @@ -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 ( + + {children} + + ); +} + +export function useLanguage() { + const ctx = useContext(LanguageContext); + if (!ctx) throw new Error('useLanguage must be used within LanguageProvider'); + return ctx; +} diff --git a/app/context/ThemeContext.jsx b/app/context/ThemeContext.jsx new file mode 100644 index 0000000..ad8d05e --- /dev/null +++ b/app/context/ThemeContext.jsx @@ -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 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 ( + + {children} + + ); +} + +export function useTheme() { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error('useTheme must be used within ThemeProvider'); + return ctx; +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..a12d10e --- /dev/null +++ b/app/globals.css @@ -0,0 +1,228 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* ------------------------------------------------------------------ */ +/* Theme tokens — dark is the default; .light overrides on */ +/* ------------------------------------------------------------------ */ +: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; +} diff --git a/app/layout.jsx b/app/layout.jsx new file mode 100644 index 0000000..058da35 --- /dev/null +++ b/app/layout.jsx @@ -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 ( + + + {children} + + + ); +} diff --git a/app/page.jsx b/app/page.jsx new file mode 100644 index 0000000..39c1da9 --- /dev/null +++ b/app/page.jsx @@ -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 ( +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ ); +} diff --git a/app/providers.jsx b/app/providers.jsx new file mode 100644 index 0000000..0320a39 --- /dev/null +++ b/app/providers.jsx @@ -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 ( + + {children} + + ); +} + +export default function Providers({ children }) { + return ( + + + + {children} + + + + ); +} diff --git a/components/AboutSection.jsx b/components/AboutSection.jsx new file mode 100644 index 0000000..1b9c1aa --- /dev/null +++ b/components/AboutSection.jsx @@ -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 ( +
+
+ {t.about.eyebrow} +

+ {t.about.legalName} +

+ +
+ {visibleParagraphs.map((p, i) => ( +

+ {p} +

+ ))} +
+ + +
+
+ ); +} diff --git a/components/CartDrawer.jsx b/components/CartDrawer.jsx new file mode 100644 index 0000000..e19bed2 --- /dev/null +++ b/components/CartDrawer.jsx @@ -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 ( + dispatch(closeDrawer())} + open={isOpen} + width={400} + > + {items.length === 0 ? ( + {t.cart.empty}} className="mt-16" /> + ) : ( +
+ {items.map((item) => ( +
+ {item.name} +
+

{item.name}

+

+ {toLocaleNumber(item.priceNumeric, locale)} + {currencySuffix} +

+
+ { + if (val > item.qty) dispatch(incrementQty(item.id)); + else if (val < item.qty) dispatch(decrementQty(item.id)); + }} + className="w-16" + /> +
+
+
+ ))} + +
+
+ {t.cart.total} + + {toLocaleNumber(total, locale)} + {currencySuffix} + +
+ +
+
+ )} +
+ ); +} diff --git a/components/CatalogSection.jsx b/components/CatalogSection.jsx new file mode 100644 index 0000000..a6704e3 --- /dev/null +++ b/components/CatalogSection.jsx @@ -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 ( +
+
+
+ {t.catalog.eyebrow} +

{t.catalog.title}

+
+

+ {t.catalog.subtitle} +

+ +
+ {t.catalog.items.map((item) => ( +
+
+ {item.name} +
+ {item.category} +

{item.name}

+
+
+ +
+
+

+ {toLocaleNumber(item.originalPriceNumeric, locale)} + {currencySuffix} +

+

+ {toLocaleNumber(item.priceNumeric, locale)} + {currencySuffix} +

+
+ +
+
+ ))} +
+
+
+ ); +} diff --git a/components/ChatWidget.jsx b/components/ChatWidget.jsx new file mode 100644 index 0000000..d6544dd --- /dev/null +++ b/components/ChatWidget.jsx @@ -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 ( + <> + + + + + setOpen(false)} + footer={null} + closeIcon={} + title={t.chat.title} + centered + > +
+ {messages.map((m, i) => ( +
+ {m.text} +
+ ))} +
+ +
+ setDraft(e.target.value)} + onPressEnter={handleSend} + placeholder={t.chat.placeholder} + /> + +
+
+ + ); +} diff --git a/components/FeaturesSection.jsx b/components/FeaturesSection.jsx new file mode 100644 index 0000000..12cf2fa --- /dev/null +++ b/components/FeaturesSection.jsx @@ -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 ( +
+
+ {t.features.map((feature) => { + const Icon = ICONS[feature.icon]; + return ( +
+
+ {Icon && } +
+

{feature.title}

+

{feature.description}

+
+ ); + })} +
+
+ ); +} diff --git a/components/FloatingItems.jsx b/components/FloatingItems.jsx new file mode 100644 index 0000000..78a8fc3 --- /dev/null +++ b/components/FloatingItems.jsx @@ -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 ( +
+ {items.map((item) => ( + + ))} +
+ ); +} diff --git a/components/Footer.jsx b/components/Footer.jsx new file mode 100644 index 0000000..73b2c59 --- /dev/null +++ b/components/Footer.jsx @@ -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 ( + + ); +} diff --git a/components/Header.jsx b/components/Header.jsx new file mode 100644 index 0000000..4e653e6 --- /dev/null +++ b/components/Header.jsx @@ -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 ( +
+
+ + روبات مارکت + + + + +
+
+ + +
+ + + + + +
+
+ + {mobileOpen && ( + + )} +
+ ); +} diff --git a/components/HeroSection.jsx b/components/HeroSection.jsx new file mode 100644 index 0000000..0a8df3b --- /dev/null +++ b/components/HeroSection.jsx @@ -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 ( +
+ {/* Intro loader overlay */} +
+
+
+ R +
+

{t.hero.loading}

+
+
+ + {/* Background: cycling colors in dark mode, static light tone in light mode */} +
+ + {/* + TODO: swap the background above for a video once the asset is ready: + +
+ ); +} diff --git a/components/HorizontalShowcase.jsx b/components/HorizontalShowcase.jsx new file mode 100644 index 0000000..d59ff5a --- /dev/null +++ b/components/HorizontalShowcase.jsx @@ -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 ( +
+
+ {videoAvailable && ( +
+ ); +} diff --git a/components/IntroVideoSection.jsx b/components/IntroVideoSection.jsx new file mode 100644 index 0000000..c15b5d0 --- /dev/null +++ b/components/IntroVideoSection.jsx @@ -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 ( +
+
+ {t.introVideo.eyebrow} +

+ {t.introVideo.title} +

+

{t.introVideo.subtitle}

+ + {/* + 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. + */} +
+ +
+
+
+ ); +} diff --git a/components/LanguageToggle.jsx b/components/LanguageToggle.jsx new file mode 100644 index 0000000..b06336a --- /dev/null +++ b/components/LanguageToggle.jsx @@ -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 ( + + + + ); +} diff --git a/components/LocationsSection.jsx b/components/LocationsSection.jsx new file mode 100644 index 0000000..e4915d1 --- /dev/null +++ b/components/LocationsSection.jsx @@ -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 ( +
+
+
+

{t.locations.title}

+
+ +
+ {t.locations.items.map((loc) => { + const Icon = ICONS[loc.icon]; + return ( +
+
+ {Icon && } +
+

{loc.title}

+

{loc.description}

+
+ ); + })} +
+
+
+ ); +} diff --git a/components/MapSection.jsx b/components/MapSection.jsx new file mode 100644 index 0000000..4c886cd --- /dev/null +++ b/components/MapSection.jsx @@ -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 ( +
+ +

{t.map.configNotice}

+
+ ); + } + + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt} 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 ( +
+
+
+ {t.map.eyebrow} +

{t.map.title}

+
+ +
+
+ +
+ +
+ {pins.map((pin) => ( +
+
+
+ +
+
+

{pin.label}

+

{pin.address}

+ + {t.map.openInMaps} ↗ + +
+
+
+ ))} +
+
+
+
+ ); +} diff --git a/components/ThemeToggle.jsx b/components/ThemeToggle.jsx new file mode 100644 index 0000000..9d3e78c --- /dev/null +++ b/components/ThemeToggle.jsx @@ -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 ( + +