This commit is contained in:
Hamed
2026-08-01 14:48:50 +04:00
parent 31f261bf5f
commit 87b518cf4e
85 changed files with 261 additions and 7587 deletions
-2
View File
@@ -1,2 +0,0 @@
# Base URL for the future backend API
NEXT_PUBLIC_API_BASE_URL=https://api.robotmarket.example.com
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}
+5 -5
View File
@@ -1,6 +1,6 @@
node_modules node_modules/
.next .next/
out out/
.env* .env*.local
.DS_Store .DS_Store
*.log npm-debug.log*
+88 -1
View File
@@ -1,6 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="PropertiesComponent">{}</component> <component name="ChangeListManager">
<list default="true" id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="update">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/jsconfig.json" beforeDir="false" afterPath="$PROJECT_DIR$/jsconfig.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/app/globals.css" beforeDir="false" afterPath="$PROJECT_DIR$/src/app/globals.css" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/app/page.js" beforeDir="false" afterPath="$PROJECT_DIR$/src/app/page.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/animations/ScrollReveal.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/animations/ScrollReveal.jsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/hero/FloatingProducts.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/hero/FloatingProducts.jsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/hero/Hero.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/hero/Hero.jsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/hero/MachineAssembly.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/hero/MachineAssembly.jsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/layout/Navbar.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/layout/Navbar.jsx" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="JsbtTreeLayoutManager">
<layout place="tools.popupnpm">
<scroll-view-position x="0" y="0" />
</layout>
</component>
<component name="ProjectColorInfo">{
&quot;customColor&quot;: &quot;&quot;,
&quot;associatedIndex&quot;: 2
}</component>
<component name="ProjectId" id="3HDEfy1HyzjDcELlwvnRRXxPltq" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;js.debugger.nextJs.config.created.client&quot;: &quot;true&quot;,
&quot;js.debugger.nextJs.config.created.server&quot;: &quot;true&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;editor.preferences.fonts.default&quot;,
&quot;ts.external.directory.path&quot;: &quot;C:\\Program Files\\JetBrains\\WebStorm 2026.1.1\\plugins\\javascript-plugin\\jsLanguageServicesImpl\\external&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}</component>
<component name="RunManager" selected="npm.Next.js: server-side"> <component name="RunManager" selected="npm.Next.js: server-side">
<configuration name="Next.js: debug client-side" type="JavascriptDebugType" uri="http://localhost:3000/"> <configuration name="Next.js: debug client-side" type="JavascriptDebugType" uri="http://localhost:3000/">
<method v="2" /> <method v="2" />
@@ -16,4 +68,39 @@
<method v="2" /> <method v="2" />
</configuration> </configuration>
</component> </component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-31caf2ab9e3c-JavaScript-WS-261.23567.141" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="" />
<created>1785394346997</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1785394346997</updated>
<workItem from="1785394348169" duration="14851000" />
<workItem from="1785556493808" duration="2292000" />
</task>
<task id="LOCAL-00001" summary="update">
<option name="closed" value="true" />
<created>1785394671723</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1785394671723</updated>
</task>
<option name="localTasksCounter" value="2" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="update" />
<option name="LAST_COMMIT_MESSAGE" value="update" />
</component>
</project> </project>
+108 -114
View File
@@ -1,138 +1,132 @@
# روبات مارکت — Robot Market Landing # روبات مارکت — Vending Machine Showcase
A cinematic, RTL, Persian-language landing page for **Robot Market**, a پروژه Next.js (App Router) صفحه معرفی محصولات «روبات مارکت»، با انیمیشن‌های GSAP/ScrollTrigger،
smart refrigerated vending-machine brand. Built with Next.js (App Router), مدیریت سبد خرید با Redux Toolkit، حالت تیره/روشن، دو زبانه (فارسی/انگلیسی)، نقشه، فرم تیکت
Tailwind CSS, Ant Design, Redux Toolkit, Axios and GSAP + ScrollTrigger. پشتیبانی، و ویجت چت.
The centerpiece of the page is a scroll-driven hero animation: scattered ## اجرای پروژه
SVG pieces fly together into a fully assembled vending machine as the user
scrolls, followed by a three-act "scroll story" introducing each of the
three product models.
## Getting started
```bash ```bash
npm install npm install
npm run dev npm run dev
``` ```
Open http://localhost:3000. سپس `http://localhost:3000` را باز کنید.
```bash ## ساختار پروژه
npm run build # production build
npm run start # serve the production build
npm run lint # eslint
```
## Tech stack
| Concern | Library |
| ------------------- | -------------------------------- |
| Framework | Next.js 14 (App Router, JS only) |
| Styling | Tailwind CSS |
| Component library | Ant Design (RTL-configured) |
| State management | Redux Toolkit |
| HTTP client | Axios |
| Animation | GSAP + ScrollTrigger + @gsap/react |
| Icons | react-icons |
| Font | Vazirmatn (next/font/google) |
## Project structure
``` ```
src/ app/
app/ Routes (App Router): layout, page, dashboard, layout.jsx # پوسته HTML، فونت وزیرمتن
robots.js, sitemap.js, manifest.js, loading, 404 page.jsx # چیدمان کامل صفحه
components/ providers.jsx # Redux + Theme + Language + AntD ConfigProvider
common/ Container, Section, GlassCard, MagneticButton globals.css # توکن‌های رنگ دارک/لایت، جلوه اسپات‌لایت، تایپ‌رایتر
layout/ Navbar, Footer, About, ContactCta context/
hero/ Hero, HeroBackground, MachineAssembly, FloatingProducts ThemeContext.jsx # حالت دارک/لایت (ذخیره در localStorage)
products/ ProductsShowcase, ProductSection, MachineVisual LanguageContext.jsx # زبان فا/en (ذخیره در localStorage + جهت صفحه)
animations/ ScrollReveal (shared scroll-in wrapper) store/ # Redux (سبد خرید)
ui/ AntThemeProvider (Ant Design RTL + token bridge) data/
redux/ content.js # منبع واحد محتوای دوزبانه (fa/en) — همه‌ی متن‌ها اینجاست
store.js Store factory (per-request, App-Router-safe) components/
provider.jsx Client <StoreProvider> Header.jsx # هدر + سوییچ زبان/تم + دکمه ورود به پنل کاربری
slices/ auth, products, ui ThemeToggle.jsx / LanguageToggle.jsx
services/ HeroSection.jsx # هدلاین تایپ‌شونده + پس‌زمینه رنگی در حال چرخش
axios.js Shared Axios instance + interceptors HorizontalShowcase.jsx # اسکرول افقی پین‌شده + ویدیوی پس‌زمینه
auth.service.js // TODO: Connect Login API VendingCard.jsx # کارت دستگاه + جلوه اسپات‌لایت
product.service.js // TODO: Connect Product API FloatingItems.jsx
dashboard.service.js // TODO: Connect Dashboard API FeaturesSection.jsx
hooks/ useReducedMotion, useIsomorphicLayoutEffect AboutSection.jsx
utils/ cn, formatPrice, gsapConfig, breakpoints LocationsSection.jsx
constants/ theme tokens, nav links MapSection.jsx # نقشه Neshan (کلید API را خودتان اضافه می‌کنید)
data/ products.json, floatingProducts.json (mock data) CatalogSection.jsx # کاتالوگ محصولات قابل سفارش
IntroVideoSection.jsx # ویدیوی معرفی شرکت
TicketForm.jsx # فرم تیکت پشتیبانی
ChatWidget.jsx # آیکون شناور چت + مودال گفتگو
CartDrawer.jsx
Footer.jsx
public/ public/
svg/machines/machine35|48|60/ Assembly pieces: base, body, door, panel, screen, shelves images/, gifs/, video/ # دارایی‌های placeholder — در پیام قبلی توضیح داده شده
svg/floating/ chip, cola, drink, cookie, snack, juice
icons/ Brand icon + OG image placeholders (SVG)
``` ```
## Replacing placeholder assets ## ویژگی‌های اضافه‌شده در این نسخه
Every visual asset in `public/svg` is a deliberately simple placeholder — ### نقشه (Neshan)
swap the files in place (same filenames) and nothing else needs to change: `components/MapSection.jsx` از **Neshan Static Map API** استفاده می‌کند (یک تصویر ساده، بدون نیاز
به اسکریپت/SDK جاوااسکریپت). کلید API را اینجا وارد کنید:
- `public/svg/machines/<model>/*.svg` — the 6 pieces GSAP assembles/positions. 1. یک فایل `.env.local` در ریشه پروژه بسازید (از روی `.env.local.example` کپی کنید)
Keep the same `viewBox="0 0 400 700"` coordinate space so pieces still 2. مقدار `NEXT_PUBLIC_NESHAN_API_KEY` را با کلید واقعی خودتان از
line up when assembled, or update the piece list in `src/data/products.json`. [platform.neshan.org](https://platform.neshan.org) پر کنید
- `public/svg/floating/*.svg` — the floating product icons around the hero.
- `public/icons/*.svg` — brand icon and Open Graph placeholder. Replace the
OG placeholder with a real 1200×630 raster (`.png`/`.jpg`) before launch
and update the reference in `src/app/layout.js`.
## Connecting the real API تا وقتی کلید خالی باشد، به‌جای نقشه یک پیام راهنما («برای فعال شدن نقشه، کلید API نشان را در
تنظیمات پروژه قرار دهید») نمایش داده می‌شود — سایت خراب نمی‌شود. نقشه در حالت تیره/روشن هم استایل
متفاوتی می‌گیرد (`standard-night` / `standard-day`). لینک «مشاهده در نشان» کنار هر آدرس به نقشه
تعاملی neshan.org می‌رود و نیازی به کلید ندارد. مختصات فعلی در `data/content.js`
(`mapLocations`) تقریبی هستند — با پین دقیق جایگزین کنید.
Nothing here talks to a real backend yet. Each service method already has ### کاتالوگ محصولات
the exact call commented out — uncomment and remove the mock `Promise.resolve`: `components/CatalogSection.jsx` — گرید محصولاتی (زعفران، تنقلات، نوشیدنی، قهوه، چای و ...) که
مشتریان دستگاه می‌توانند با قیمت کمتر از بازار سفارش دهند. هر آیتم قیمت بازار (خط‌خورده) و قیمت
ویژه را نشان می‌دهد و دکمه «افزودن به سفارش» آن را به همان سبد خرید Redux اضافه می‌کند (در کنار
دستگاه‌ها). لیست محصولات، قیمت‌ها و تصاویر در `data/content.js` (کلید `catalog`) قابل ویرایش است؛
تصاویر placeholder در `public/images/catalog/` قرار دارند.
```js ### سکشن ویدیوی معرفی شرکت
// src/services/product.service.js `components/IntroVideoSection.jsx` — یک پلیر ویدیوی مستقل (جدا از ویدیوی پس‌زمینه اسکرول افقی) با
async getAll() { پوستر و دکمه پخش استاندارد مرورگر. برای جایگزینی، فایل واقعی را در
// TODO: Connect Product API `public/video/company-intro-placeholder.mp4` بگذارید (یا مسیر `introVideoSrc` را در
return apiClient.get("/products").then((res) => res.data); `data/content.js` عوض کنید)؛ پوستر هم از `public/images/video-poster.svg` می‌آید.
}
```
Set `NEXT_PUBLIC_API_BASE_URL` in `.env.local` (see `.env.example`). ### رنگ‌بندی متناوب سکشن‌ها
هر سکشن اصلی صفحه (به‌جز هیرو و بخش اسکرول افقی که پس‌زمینه مخصوص خودشان را دارند) به‌ترتیب بین سه
تن رنگی برند می‌چرخد: `bg-page` → `bg-surface` → `bg-elevated` → ... به‌همراه یک خط جداکننده ظریف
بالای هر بخش. این‌ها همان توکن‌های دارک/لایت هستند، پس با تغییر تم هم هماهنگ می‌مانند. برای تغییر
ترتیب یا اضافه کردن سکشن جدید، کافی‌ست یکی از این سه کلاس را روی `<section>` بگذارید.
## Motion & accessibility notes ### حالت دارک/لایت
دکمه سوییچ در هدر (آیکون خورشید/ماه). ترجیح در `localStorage` ذخیره می‌شود و در اولین بار بر اساس
تنظیمات سیستم کاربر تعیین می‌شود. رنگ‌بندی از طریق متغیرهای CSS در `app/globals.css`
(`--color-bg-page`, `--color-text-primary`, ...) و کلاس‌های کمکی `bg-page` / `bg-surface` /
`text-primary` / `text-secondary` / `border-subtle` مدیریت می‌شود؛ برای هر رنگ جدید فقط همین‌جا را
ویرایش کنید. تم Ant Design هم به‌صورت خودکار (`darkAlgorithm` / `defaultAlgorithm`) هماهنگ می‌شود.
- **Scroll-locked product story.** Each `ProductSection` reports its DOM ### دو زبانه (فارسی / انگلیسی)
nodes up to `ProductsShowcase`, which builds and pins all three دکمه «EN / فا» در هدر. تمام متن‌های سایت (منو، هیرو، محصولات، درباره ما، مکان‌های نصب، فرم تیکت،
machines' `ScrollTrigger` timelines together in one place (fixing a bug چت، فوتر) از `data/content.js` خوانده می‌شوند — یک آبجکت با کلیدهای `fa` و `en`. تغییر زبان همچنین
where wide/desktop viewports could fail to reveal machines after the جهت صفحه (`dir`) و `lang` تگ `html` را به‌روزرسانی می‌کند. برای اضافه یا اصلاح متن، فقط
first one — see the comment at the top of `ProductsShowcase.jsx`), then `data/content.js` را ویرایش کنید؛ کامپوننت‌ها نیازی به تغییر ندارند.
calls `ScrollTrigger.refresh()` once every measurement has settled.
Scrolling literally cannot advance past a pinned section until its
reveal finishes — only then does the next machine begin.
- **Vivid, temperature-coded background per machine.** Each product
carries its own `bgColor` in `src/data/products.json`, and
`ProductsShowcase` cross-fades a single fixed color layer to that color
over the *exact same* scroll range the section uses to pin — so the
backdrop finishes changing exactly as the machine settles into place.
The three models deliberately alternate cool → warm → cool: a vivid icy
cyan-blue (`#12C4E0`) for the entry model, a warm coral-orange
(`#FF7A45`) for the mid-tier, and a cool emerald-teal (`#12D6A8`) for
the flagship — all bright, cheerful tones rather than muted brand
neutrals, chosen specifically for this section.
- **Floating drinks/snacks per section.** `SectionFloatingItems.jsx`
places a small set of product icons (soda, mineral water, juice,
snacks) drifting gently around each machine — which items appear is
configurable per product via `floatingItemIds` in `products.json`.
- All GSAP animations check `prefers-reduced-motion` and degrade to simple
opacity fades (or skip entirely) when it's set.
- `gsap.matchMedia()` is used in both `MachineAssembly` (hero) and
`ProductSection` to give desktop/tablet/mobile different scroll
distances/intensity.
- Focus states, semantic landmarks (`<header>`, `<main>`, `<footer>`,
`<nav>`), and ARIA labels are included throughout.
## SEO / PWA > نکته فنی: بخش اسکرول افقی (`HorizontalShowcase`) به‌عمد همیشه `dir="ltr"` روی خود track دارد
> (چون محاسبات GSAP به آن نیاز دارد)، اما هر کارت داخلش جهت متن را بر اساس زبان فعال تنظیم می‌کند.
- Metadata API with OpenGraph, Twitter cards, and canonical URL in ### فرم تیکت پشتیبانی
`src/app/layout.js`. `components/TicketForm.jsx` — فرم Ant Design با اعتبارسنجی (نام، تلفن، ایمیل اختیاری، موضوع،
- JSON-LD `Organization` schema inlined in the root layout. اولویت، پیام). ارسال فعلاً فقط یک پیام موفقیت نمایش می‌دهد؛ بلوک کامنت‌شده
- `robots.js`, `sitemap.js`, and `manifest.js` are implemented as Next.js `// TODO: Connect to the real ticketing/support API` دقیقاً نشان می‌دهد کجا باید axios را وصل کنید.
route handlers (App Router convention) rather than static files.
### ویجت چت
دکمه شناور گوشه صفحه (`components/ChatWidget.jsx`) یک مودال گفتگوی ساده باز می‌کند. پیام‌ها فعلاً
در state محلی نگه‌داری می‌شوند و یک پاسخ خودکار نمایشی می‌دهند. بلوک
`// TODO: Connect to a real chat/support backend` محل اتصال به بک‌اند واقعی (یا وب‌سوکت) را مشخص
کرده.
### هیرو: تایپ‌رایتر + پس‌زمینه رنگی در حال تغییر
هدلاین با افکت تایپ‌رایتر بین چند جمله (`data/content.js` → `hero.typingPhrases`) می‌چرخد.
پس‌زمینه به‌صورت حلقه‌ای بین سبز برند (`#00a693`)، مشکی و سفید تغییر رنگ می‌دهد (با GSAP). یک لایه
scrim تیره روی آن قرار دارد تا متن در هر سه حالت خوانا بماند. وقتی ویدیوی هیرو را آماده کردید،
داخل `components/HeroSection.jsx` یک بلوک `TODO` کامنت‌شده دقیقاً نشان می‌دهد چطور به‌جای پس‌زمینه
رنگی، ویدیو را جایگزین کنید (فقط از حالت کامنت خارجش کنید).
### ورود به پنل کاربری
دکمه «ورود به پنل کاربری» / «Sign In» در هدر اضافه شده (در `components/Header.jsx`). چون گفتید
لینک‌دهی را خودتان انجام می‌دهید، فعلاً فقط یک `onClick` خالی با کامنت TODO دارد — مسیر واقعی پنل
کاربری را همان‌جا وصل کنید.
## جایگزینی دارایی‌های واقعی (تصاویر/گیف/ویدیو)
همان‌طور که قبلاً توضیح داده شد، فایل‌های داخل `public/images`, `public/gifs`, `public/video`
placeholder هستند. برای جایگزینی، فایل جدید را با همان نام/مسیر جایگزین کنید یا مسیر را در
`data/content.js` تغییر دهید.
## اتصال درگاه پرداخت
در `components/CartDrawer.jsx` داخل `handleCheckout` بلوک `// TODO: Connect to payment gateway API`
با نمونه‌کد axios کامنت‌شده وجود دارد.
+2 -10
View File
@@ -2,15 +2,7 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"], "@/*": ["./*"]
"@components/*": ["src/components/*"],
"@redux/*": ["src/redux/*"],
"@services/*": ["src/services/*"],
"@hooks/*": ["src/hooks/*"],
"@utils/*": ["src/utils/*"],
"@constants/*": ["src/constants/*"],
"@data/*": ["src/data/*"]
} }
}, }
"exclude": ["node_modules", ".next"]
} }
-13
View File
@@ -1,13 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
transpilePackages: ["antd", "@ant-design/icons"],
images: {
formats: ["image/avif", "image/webp"],
},
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
};
export default nextConfig;
+19 -4894
View File
File diff suppressed because it is too large Load Diff
+4 -10
View File
@@ -1,8 +1,7 @@
{ {
"name": "robot-market", "name": "robot-market-showcase",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"description": "Robot Market — cinematic vending machine landing page",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
@@ -14,20 +13,15 @@
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"antd": "5.19.1", "antd": "5.19.1",
"@ant-design/nextjs-registry": "1.0.1",
"@ant-design/icons": "5.3.7", "@ant-design/icons": "5.3.7",
"@reduxjs/toolkit": "2.2.6", "@reduxjs/toolkit": "2.2.7",
"react-redux": "9.1.2", "react-redux": "9.1.2",
"axios": "1.7.2", "axios": "1.7.2",
"gsap": "3.12.5", "gsap": "3.12.5"
"@gsap/react": "2.1.1",
"react-icons": "5.2.1"
}, },
"devDependencies": { "devDependencies": {
"autoprefixer": "10.4.19", "autoprefixer": "10.4.19",
"postcss": "8.4.39", "postcss": "8.4.39",
"tailwindcss": "3.4.6", "tailwindcss": "3.4.6"
"eslint": "8.57.0",
"eslint-config-next": "14.2.5"
} }
} }
-11
View File
@@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#063D39"/>
<rect x="140" y="96" width="232" height="320" rx="24" fill="#00A693"/>
<rect x="168" y="132" width="176" height="96" rx="12" fill="#D8FFF8"/>
<circle cx="196" cy="284" r="14" fill="#19D7C2"/>
<circle cx="256" cy="284" r="14" fill="#19D7C2"/>
<circle cx="316" cy="284" r="14" fill="#19D7C2"/>
<circle cx="196" cy="328" r="14" fill="#19D7C2"/>
<circle cx="256" cy="328" r="14" fill="#19D7C2"/>
<circle cx="316" cy="328" r="14" fill="#19D7C2"/>
</svg>

Before

Width:  |  Height:  |  Size: 586 B

-11
View File
@@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#063D39"/>
<rect x="140" y="96" width="232" height="320" rx="24" fill="#00A693"/>
<rect x="168" y="132" width="176" height="96" rx="12" fill="#D8FFF8"/>
<circle cx="196" cy="284" r="14" fill="#19D7C2"/>
<circle cx="256" cy="284" r="14" fill="#19D7C2"/>
<circle cx="316" cy="284" r="14" fill="#19D7C2"/>
<circle cx="196" cy="328" r="14" fill="#19D7C2"/>
<circle cx="256" cy="328" r="14" fill="#19D7C2"/>
<circle cx="316" cy="328" r="14" fill="#19D7C2"/>
</svg>

Before

Width:  |  Height:  |  Size: 586 B

-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#00A693"/>
<stop offset="50%" stop-color="#19D7C2"/>
<stop offset="100%" stop-color="#063D39"/>
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#bg)"/>
<text x="600" y="330" font-size="72" font-family="Tahoma, sans-serif" fill="#F7FBFB" text-anchor="middle" font-weight="700">روبات مارکت</text>
<text x="600" y="400" font-size="30" font-family="Tahoma, sans-serif" fill="#D8FFF8" text-anchor="middle">دستگاه‌های وندینگ ماشین هوشمند</text>
</svg>

Before

Width:  |  Height:  |  Size: 694 B

-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect x="14" y="10" width="72" height="80" rx="16" fill="#00A693"/>
<rect x="26" y="24" width="48" height="52" rx="8" fill="#D8FFF8"/>
<text x="50" y="56" font-size="14" text-anchor="middle" fill="#063D39" font-family="Tahoma">CHIP</text>
</svg>

Before

Width:  |  Height:  |  Size: 315 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path d="M32 8h36l-4 20 6 10v46a6 6 0 0 1-6 6H36a6 6 0 0 1-6-6V38l6-10z" fill="#063D39"/>
<rect x="30" y="46" width="40" height="18" fill="#19D7C2"/>
</svg>

Before

Width:  |  Height:  |  Size: 224 B

-7
View File
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="38" fill="#D8FFF8" stroke="#063D39" stroke-width="2"/>
<circle cx="36" cy="40" r="5" fill="#063D39"/>
<circle cx="60" cy="36" r="5" fill="#063D39"/>
<circle cx="66" cy="58" r="5" fill="#063D39"/>
<circle cx="42" cy="64" r="5" fill="#063D39"/>
</svg>

Before

Width:  |  Height:  |  Size: 350 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path d="M28 14h44l-6 72a6 6 0 0 1-6 6H40a6 6 0 0 1-6-6z" fill="#19D7C2"/>
<rect x="24" y="10" width="52" height="10" rx="4" fill="#063D39"/>
</svg>

Before

Width:  |  Height:  |  Size: 216 B

-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect x="24" y="18" width="52" height="66" rx="10" fill="#19D7C2"/>
<rect x="24" y="18" width="52" height="20" rx="10" fill="#063D39"/>
<rect x="42" y="6" width="16" height="14" rx="3" fill="#063D39"/>
</svg>

Before

Width:  |  Height:  |  Size: 278 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path d="M20 20 L80 20 L72 84 L28 84 Z" fill="#00A693"/>
<path d="M20 20 L80 20 L76 40 L24 40 Z" fill="#063D39"/>
</svg>

Before

Width:  |  Height:  |  Size: 188 B

-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path d="M40 6h20v10l6 8v10l-6 6v46a6 6 0 0 1-6 6H46a6 6 0 0 1-6-6V40l-6-6V24l6-8z" fill="#19D7C2" opacity="0.35"/>
<path d="M40 6h20v10l6 8v10l-6 6v46a6 6 0 0 1-6 6H46a6 6 0 0 1-6-6V40l-6-6V24l6-8z" fill="none" stroke="#063D39" stroke-width="2"/>
<rect x="38" y="56" width="24" height="16" rx="2" fill="#063D39"/>
</svg>

Before

Width:  |  Height:  |  Size: 391 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700">
<rect x="70" y="640" width="260" height="34" rx="10" fill="#063D39"/>
<rect x="100" y="674" width="200" height="14" rx="7" fill="#063D39" opacity="0.6"/>
</svg>

Before

Width:  |  Height:  |  Size: 228 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700">
<rect x="40" y="40" width="320" height="600" rx="28" fill="#00A693"/>
<rect x="40" y="40" width="320" height="600" rx="28" fill="none" stroke="#063D39" stroke-opacity="0.15" stroke-width="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 267 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700">
<rect x="66" y="70" width="268" height="470" rx="18" fill="#D8FFF8" opacity="0.85"/>
<rect x="66" y="70" width="268" height="470" rx="18" fill="none" stroke="#FFFFFF" stroke-width="2" opacity="0.7"/>
</svg>

Before

Width:  |  Height:  |  Size: 274 B

-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700">
<rect x="86" y="560" width="228" height="60" rx="14" fill="#063D39"/>
<circle cx="116" cy="590" r="10" fill="#19D7C2"/>
<circle cx="150" cy="590" r="10" fill="#19D7C2"/>
<circle cx="184" cy="590" r="10" fill="#19D7C2"/>
<circle cx="218" cy="590" r="10" fill="#19D7C2"/>
<circle cx="252" cy="590" r="10" fill="#19D7C2"/>
<circle cx="286" cy="590" r="10" fill="#19D7C2"/>
</svg>

Before

Width:  |  Height:  |  Size: 454 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700">
<rect x="120" y="90" width="160" height="90" rx="12" fill="#063D39"/>
<rect x="132" y="102" width="136" height="66" rx="8" fill="#19D7C2" opacity="0.85"/>
</svg>

Before

Width:  |  Height:  |  Size: 229 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700"><rect x="86" y="210" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="190" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="144" cy="190" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="188" cy="190" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="232" cy="190" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="276" cy="190" r="16" fill="#19D7C2" opacity="0.9"/><rect x="86" y="270" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="250" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="144" cy="250" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="188" cy="250" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="232" cy="250" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="276" cy="250" r="16" fill="#19D7C2" opacity="0.9"/><rect x="86" y="330" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="310" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="144" cy="310" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="188" cy="310" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="232" cy="310" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="276" cy="310" r="16" fill="#19D7C2" opacity="0.9"/><rect x="86" y="390" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="370" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="144" cy="370" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="188" cy="370" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="232" cy="370" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="276" cy="370" r="16" fill="#19D7C2" opacity="0.9"/><rect x="86" y="450" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="430" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="144" cy="430" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="188" cy="430" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="232" cy="430" r="16" fill="#19D7C2" opacity="0.9"/><circle cx="276" cy="430" r="16" fill="#19D7C2" opacity="0.9"/></svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700">
<rect x="70" y="640" width="260" height="34" rx="10" fill="#063D39"/>
<rect x="100" y="674" width="200" height="14" rx="7" fill="#063D39" opacity="0.6"/>
</svg>

Before

Width:  |  Height:  |  Size: 228 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700">
<rect x="40" y="40" width="320" height="600" rx="28" fill="#00A693"/>
<rect x="40" y="40" width="320" height="600" rx="28" fill="none" stroke="#063D39" stroke-opacity="0.15" stroke-width="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 267 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700">
<rect x="66" y="70" width="268" height="470" rx="18" fill="#D8FFF8" opacity="0.85"/>
<rect x="66" y="70" width="268" height="470" rx="18" fill="none" stroke="#FFFFFF" stroke-width="2" opacity="0.7"/>
</svg>

Before

Width:  |  Height:  |  Size: 274 B

-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700">
<rect x="86" y="560" width="228" height="60" rx="14" fill="#063D39"/>
<circle cx="116" cy="590" r="10" fill="#00A693"/>
<circle cx="150" cy="590" r="10" fill="#00A693"/>
<circle cx="184" cy="590" r="10" fill="#00A693"/>
<circle cx="218" cy="590" r="10" fill="#00A693"/>
<circle cx="252" cy="590" r="10" fill="#00A693"/>
<circle cx="286" cy="590" r="10" fill="#00A693"/>
</svg>

Before

Width:  |  Height:  |  Size: 454 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700">
<rect x="120" y="90" width="160" height="90" rx="12" fill="#063D39"/>
<rect x="132" y="102" width="136" height="66" rx="8" fill="#00A693" opacity="0.85"/>
</svg>

Before

Width:  |  Height:  |  Size: 229 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700"><rect x="86" y="210" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="190" r="16" fill="#00A693" opacity="0.9"/><circle cx="144" cy="190" r="16" fill="#00A693" opacity="0.9"/><circle cx="188" cy="190" r="16" fill="#00A693" opacity="0.9"/><circle cx="232" cy="190" r="16" fill="#00A693" opacity="0.9"/><circle cx="276" cy="190" r="16" fill="#00A693" opacity="0.9"/><rect x="86" y="270" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="250" r="16" fill="#00A693" opacity="0.9"/><circle cx="144" cy="250" r="16" fill="#00A693" opacity="0.9"/><circle cx="188" cy="250" r="16" fill="#00A693" opacity="0.9"/><circle cx="232" cy="250" r="16" fill="#00A693" opacity="0.9"/><circle cx="276" cy="250" r="16" fill="#00A693" opacity="0.9"/><rect x="86" y="330" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="310" r="16" fill="#00A693" opacity="0.9"/><circle cx="144" cy="310" r="16" fill="#00A693" opacity="0.9"/><circle cx="188" cy="310" r="16" fill="#00A693" opacity="0.9"/><circle cx="232" cy="310" r="16" fill="#00A693" opacity="0.9"/><circle cx="276" cy="310" r="16" fill="#00A693" opacity="0.9"/><rect x="86" y="390" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="370" r="16" fill="#00A693" opacity="0.9"/><circle cx="144" cy="370" r="16" fill="#00A693" opacity="0.9"/><circle cx="188" cy="370" r="16" fill="#00A693" opacity="0.9"/><circle cx="232" cy="370" r="16" fill="#00A693" opacity="0.9"/><circle cx="276" cy="370" r="16" fill="#00A693" opacity="0.9"/><rect x="86" y="450" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="430" r="16" fill="#00A693" opacity="0.9"/><circle cx="144" cy="430" r="16" fill="#00A693" opacity="0.9"/><circle cx="188" cy="430" r="16" fill="#00A693" opacity="0.9"/><circle cx="232" cy="430" r="16" fill="#00A693" opacity="0.9"/><circle cx="276" cy="430" r="16" fill="#00A693" opacity="0.9"/><rect x="86" y="510" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="490" r="16" fill="#00A693" opacity="0.9"/><circle cx="144" cy="490" r="16" fill="#00A693" opacity="0.9"/><circle cx="188" cy="490" r="16" fill="#00A693" opacity="0.9"/><circle cx="232" cy="490" r="16" fill="#00A693" opacity="0.9"/><circle cx="276" cy="490" r="16" fill="#00A693" opacity="0.9"/></svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700">
<rect x="70" y="640" width="260" height="34" rx="10" fill="#063D39"/>
<rect x="100" y="674" width="200" height="14" rx="7" fill="#063D39" opacity="0.6"/>
</svg>

Before

Width:  |  Height:  |  Size: 228 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700">
<rect x="40" y="40" width="320" height="600" rx="28" fill="#00A693"/>
<rect x="40" y="40" width="320" height="600" rx="28" fill="none" stroke="#063D39" stroke-opacity="0.15" stroke-width="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 267 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700">
<rect x="66" y="70" width="268" height="470" rx="18" fill="#D8FFF8" opacity="0.85"/>
<rect x="66" y="70" width="268" height="470" rx="18" fill="none" stroke="#FFFFFF" stroke-width="2" opacity="0.7"/>
</svg>

Before

Width:  |  Height:  |  Size: 274 B

-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700">
<rect x="86" y="560" width="228" height="60" rx="14" fill="#063D39"/>
<circle cx="116" cy="590" r="10" fill="#063D39"/>
<circle cx="150" cy="590" r="10" fill="#063D39"/>
<circle cx="184" cy="590" r="10" fill="#063D39"/>
<circle cx="218" cy="590" r="10" fill="#063D39"/>
<circle cx="252" cy="590" r="10" fill="#063D39"/>
<circle cx="286" cy="590" r="10" fill="#063D39"/>
</svg>

Before

Width:  |  Height:  |  Size: 454 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700">
<rect x="120" y="90" width="160" height="90" rx="12" fill="#063D39"/>
<rect x="132" y="102" width="136" height="66" rx="8" fill="#063D39" opacity="0.85"/>
</svg>

Before

Width:  |  Height:  |  Size: 229 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 700"><rect x="86" y="210" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="190" r="16" fill="#063D39" opacity="0.9"/><circle cx="144" cy="190" r="16" fill="#063D39" opacity="0.9"/><circle cx="188" cy="190" r="16" fill="#063D39" opacity="0.9"/><circle cx="232" cy="190" r="16" fill="#063D39" opacity="0.9"/><circle cx="276" cy="190" r="16" fill="#063D39" opacity="0.9"/><rect x="86" y="270" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="250" r="16" fill="#063D39" opacity="0.9"/><circle cx="144" cy="250" r="16" fill="#063D39" opacity="0.9"/><circle cx="188" cy="250" r="16" fill="#063D39" opacity="0.9"/><circle cx="232" cy="250" r="16" fill="#063D39" opacity="0.9"/><circle cx="276" cy="250" r="16" fill="#063D39" opacity="0.9"/><rect x="86" y="330" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="310" r="16" fill="#063D39" opacity="0.9"/><circle cx="144" cy="310" r="16" fill="#063D39" opacity="0.9"/><circle cx="188" cy="310" r="16" fill="#063D39" opacity="0.9"/><circle cx="232" cy="310" r="16" fill="#063D39" opacity="0.9"/><circle cx="276" cy="310" r="16" fill="#063D39" opacity="0.9"/><rect x="86" y="390" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="370" r="16" fill="#063D39" opacity="0.9"/><circle cx="144" cy="370" r="16" fill="#063D39" opacity="0.9"/><circle cx="188" cy="370" r="16" fill="#063D39" opacity="0.9"/><circle cx="232" cy="370" r="16" fill="#063D39" opacity="0.9"/><circle cx="276" cy="370" r="16" fill="#063D39" opacity="0.9"/><rect x="86" y="450" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="430" r="16" fill="#063D39" opacity="0.9"/><circle cx="144" cy="430" r="16" fill="#063D39" opacity="0.9"/><circle cx="188" cy="430" r="16" fill="#063D39" opacity="0.9"/><circle cx="232" cy="430" r="16" fill="#063D39" opacity="0.9"/><circle cx="276" cy="430" r="16" fill="#063D39" opacity="0.9"/><rect x="86" y="510" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="490" r="16" fill="#063D39" opacity="0.9"/><circle cx="144" cy="490" r="16" fill="#063D39" opacity="0.9"/><circle cx="188" cy="490" r="16" fill="#063D39" opacity="0.9"/><circle cx="232" cy="490" r="16" fill="#063D39" opacity="0.9"/><circle cx="276" cy="490" r="16" fill="#063D39" opacity="0.9"/><rect x="86" y="570" width="228" height="6" fill="#063D39" opacity="0.25"/><circle cx="100" cy="550" r="16" fill="#063D39" opacity="0.9"/><circle cx="144" cy="550" r="16" fill="#063D39" opacity="0.9"/><circle cx="188" cy="550" r="16" fill="#063D39" opacity="0.9"/><circle cx="232" cy="550" r="16" fill="#063D39" opacity="0.9"/><circle cx="276" cy="550" r="16" fill="#063D39" opacity="0.9"/></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

-49
View File
@@ -1,49 +0,0 @@
import { FiGrid, FiDollarSign, FiMapPin } from "react-icons/fi";
import Container from "@components/common/Container";
export const metadata = {
title: "داشبورد",
robots: { index: false, follow: false },
};
const STATS = [
{ icon: FiGrid, label: "تعداد دستگاه‌ها", value: "—" },
{ icon: FiDollarSign, label: "درآمد کل", value: "—" },
{ icon: FiMapPin, label: "مکان‌های فعال", value: "—" },
];
/**
* UI-only placeholder for the future dashboard, per the brief:
* "Dashboard button should only be placeholder." Wire this up to
* `dashboardService.getSummary()` (see src/services/dashboard.service.js)
* once the real dashboard API and auth flow exist.
*/
export default function DashboardPage() {
return (
<main className="min-h-screen bg-surface py-24">
<Container>
<h1 className="text-3xl font-bold text-primary-dark">داشبورد</h1>
<p className="mt-2 text-primary-dark/60">
این صفحه یک نمای اولیه است و به‌زودی به داده‌های واقعی متصل می‌شود.
</p>
<div className="mt-10 grid gap-6 sm:grid-cols-3">
{STATS.map((stat) => (
<div
key={stat.label}
className="glass-surface rounded-3xl p-6 shadow-glass"
>
<span className="grid h-11 w-11 place-items-center rounded-2xl bg-brand-gradient text-white">
<stat.icon size={18} aria-hidden="true" />
</span>
<p className="mt-4 text-2xl font-bold text-primary-dark">
{stat.value}
</p>
<p className="mt-1 text-sm text-primary-dark/60">{stat.label}</p>
</div>
))}
</div>
</Container>
</main>
);
}
-71
View File
@@ -1,71 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-primary: #00a693;
--color-dark: #063d39;
--color-accent: #19d7c2;
--color-light: #d8fff8;
--color-bg: #f7fbfb;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
background-color: var(--color-bg);
color: var(--color-dark);
font-family: var(--font-vazirmatn), Tahoma, sans-serif;
overflow-x: hidden;
}
/* Respect the user's motion preference across the whole site. */
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
/* Slim, brand-tinted scrollbar (progressive enhancement only). */
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: var(--color-bg);
}
::-webkit-scrollbar-thumb {
background: var(--color-primary);
border-radius: 999px;
}
/* Visible keyboard focus ring, brand-colored, never removed. */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
border-radius: 4px;
}
.glass-surface {
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.6);
}
.text-balance {
text-wrap: balance;
}
-101
View File
@@ -1,101 +0,0 @@
import { Vazirmatn } from "next/font/google";
import { AntdRegistry } from "@ant-design/nextjs-registry";
import StoreProvider from "@/redux/provider";
import AntThemeProvider from "@/components/ui/AntThemeProvider";
import "./globals.css";
const vazirmatn = Vazirmatn({
subsets: ["arabic", "latin"],
variable: "--font-vazirmatn",
display: "swap",
});
const SITE_URL = "https://robotmarket.example.com";
const SITE_NAME = "روبات مارکت";
const SITE_DESCRIPTION =
"روبات مارکت، تولیدکننده دستگاه‌های وندینگ ماشین یخچالدار هوشمند با مدل‌های ۳۵، ۴۸ و ۶۰ کانال برای کسب‌وکارهای پرتردد.";
export const metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: "روبات مارکت | دستگاه‌های وندینگ ماشین هوشمند",
template: "%s | روبات مارکت",
},
description: SITE_DESCRIPTION,
keywords: [
"وندینگ ماشین",
"دستگاه فروش خودکار",
"روبات مارکت",
"یخچال هوشمند",
"vending machine",
],
alternates: {
canonical: "/",
},
openGraph: {
type: "website",
locale: "fa_IR",
url: SITE_URL,
siteName: SITE_NAME,
title: "روبات مارکت | دستگاه‌های وندینگ ماشین هوشمند",
description: SITE_DESCRIPTION,
images: [
{
// TODO: Replace with a real 1200x630 OG raster image before launch.
url: "/icons/og-placeholder.svg",
width: 1200,
height: 630,
alt: SITE_NAME,
},
],
},
twitter: {
card: "summary_large_image",
title: "روبات مارکت | دستگاه‌های وندینگ ماشین هوشمند",
description: SITE_DESCRIPTION,
images: ["/icons/og-placeholder.svg"],
},
robots: {
index: true,
follow: true,
},
};
export const viewport = {
themeColor: "#00A693",
width: "device-width",
initialScale: 1,
};
export default function RootLayout({ children }) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "Organization",
name: SITE_NAME,
url: SITE_URL,
logo: `${SITE_URL}/icons/icon.svg`,
description: SITE_DESCRIPTION,
makesOffer: [
{ "@type": "Offer", itemOffered: { "@type": "Product", name: "روبات مارکت 35 کانال" } },
{ "@type": "Offer", itemOffered: { "@type": "Product", name: "روبات مارکت 48 کانال" } },
{ "@type": "Offer", itemOffered: { "@type": "Product", name: "روبات مارکت 60 کانال" } },
],
};
return (
<html lang="fa" dir="rtl" className={vazirmatn.variable}>
<body className="font-vazir bg-surface text-primary-dark antialiased">
<script
type="application/ld+json"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<StoreProvider>
<AntdRegistry>
<AntThemeProvider>{children}</AntThemeProvider>
</AntdRegistry>
</StoreProvider>
</body>
</html>
);
}
-11
View File
@@ -1,11 +0,0 @@
/**
* Route-level loading UI shown by Next.js while the page's data
* (and lazy-loaded below-the-fold components) are being prepared.
*/
export default function Loading() {
return (
<div className="grid min-h-screen place-items-center bg-surface">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-primary-light border-t-primary" />
</div>
);
}
-22
View File
@@ -1,22 +0,0 @@
export default function manifest() {
return {
name: "روبات مارکت",
short_name: "روبات مارکت",
description: "دستگاه‌های وندینگ ماشین یخچالدار هوشمند روبات مارکت",
start_url: "/",
display: "standalone",
background_color: "#F7FBFB",
theme_color: "#00A693",
lang: "fa",
dir: "rtl",
icons: [
{ src: "/icons/icon.svg", sizes: "any", type: "image/svg+xml" },
{
src: "/icons/icon-maskable.svg",
sizes: "any",
type: "image/svg+xml",
purpose: "maskable",
},
],
};
}
-23
View File
@@ -1,23 +0,0 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="grid min-h-screen place-items-center bg-surface px-6 text-center">
<div>
<p className="text-sm font-semibold text-primary">۴۰۴</p>
<h1 className="mt-2 text-3xl font-bold text-primary-dark md:text-4xl">
صفحه مورد نظر پیدا نشد
</h1>
<p className="mt-3 text-primary-dark/60">
ممکن است آدرس اشتباه باشد یا صفحه جابه‌جا شده باشد.
</p>
<Link
href="/"
className="mt-8 inline-flex items-center rounded-full bg-brand-gradient px-8 py-3 font-medium text-white shadow-glow"
>
بازگشت به صفحه اصلی
</Link>
</div>
</div>
);
}
-28
View File
@@ -1,28 +0,0 @@
import dynamic from "next/dynamic";
import Navbar from "@components/layout/Navbar";
import Hero from "@components/hero/Hero";
import Footer from "@components/layout/Footer";
// Below-the-fold sections are lazy-loaded: they're heavier
// (GSAP timelines, multiple SVG stacks) and never needed for the
// first paint, so splitting them keeps the hero's LCP fast.
const ProductsShowcase = dynamic(
() => import("@components/products/ProductsShowcase")
);
const About = dynamic(() => import("@components/layout/About"));
const ContactCta = dynamic(() => import("@components/layout/ContactCta"));
export default function HomePage() {
return (
<>
<Navbar />
<main>
<Hero />
<ProductsShowcase />
<About />
<ContactCta />
</main>
<Footer />
</>
);
}
-10
View File
@@ -1,10 +0,0 @@
export default function robots() {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/dashboard"],
},
sitemap: "https://robotmarket.example.com/sitemap.xml",
};
}
-30
View File
@@ -1,30 +0,0 @@
export default function sitemap() {
const baseUrl = "https://robotmarket.example.com";
return [
{
url: `${baseUrl}/`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
},
{
url: `${baseUrl}/#products`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${baseUrl}/#about`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.5,
},
{
url: `${baseUrl}/#contact`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.5,
},
];
}
@@ -1,65 +0,0 @@
"use client";
import { useRef } from "react";
import { useGSAP } from "@gsap/react";
import { gsap, ScrollTrigger } from "@/utils/gsapConfig";
import { EASE } from "@/constants/theme";
/**
* Fades + rises a block into view once it enters the viewport.
* Used for headings, paragraphs and cards that don't need a
* bespoke timeline of their own. Respects prefers-reduced-motion
* by skipping the transform and only cross-fading.
*
* @param {Object} props
* @param {React.ReactNode} props.children
* @param {number} [props.delay]
* @param {number} [props.y] - starting vertical offset in px
* @param {string} [props.className]
* @param {"div"|"span"} [props.as]
*/
export default function ScrollReveal({
children,
delay = 0,
y = 40,
className,
as = "div",
}) {
const ref = useRef(null);
const Tag = as;
useGSAP(
() => {
const el = ref.current;
if (!el) return;
const prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
gsap.set(el, { opacity: 0, y: prefersReduced ? 0 : y });
const anim = gsap.to(el, {
opacity: 1,
y: 0,
duration: 1,
delay,
ease: EASE.soft,
scrollTrigger: {
trigger: el,
start: "top 85%",
toggleActions: "play none none reverse",
},
});
return () => anim.scrollTrigger?.kill();
},
{ scope: ref }
);
return (
<Tag ref={ref} className={className}>
{children}
</Tag>
);
}
-13
View File
@@ -1,13 +0,0 @@
import { cn } from "@/utils/cn";
/**
* Consistent max-width + horizontal padding wrapper used by every
* section, so spacing never drifts between components.
*/
export default function Container({ children, className }) {
return (
<div className={cn("mx-auto w-full max-w-[1280px] px-6 md:px-10", className)}>
{children}
</div>
);
}
-41
View File
@@ -1,41 +0,0 @@
"use client";
import { cn } from "@/utils/cn";
/**
* Glassmorphism card used across product sections and feature
* grids. The animated border is pure CSS (a conic-gradient mask)
* so it stays smooth without needing a GSAP ticker.
*
* @param {Object} props
* @param {React.ReactNode} props.children
* @param {string} [props.className]
*/
export default function GlassCard({ children, className }) {
return (
<div
className={cn(
"group relative rounded-4xl p-[1px] transition-transform duration-500 ease-out",
"hover:-translate-y-2",
className
)}
>
<div
className="pointer-events-none absolute inset-0 rounded-4xl opacity-40 transition-opacity duration-500 group-hover:opacity-100"
style={{
background:
"conic-gradient(from 180deg, #19D7C2, #00A693, #D8FFF8, #19D7C2)",
}}
aria-hidden="true"
/>
<div
className={cn(
"glass-surface relative h-full rounded-4xl p-8 shadow-glass",
"transition-shadow duration-500 group-hover:shadow-glass-lg"
)}
>
{children}
</div>
</div>
);
}
-129
View File
@@ -1,129 +0,0 @@
"use client";
import { useRef } from "react";
import { useGSAP } from "@gsap/react";
import { gsap } from "@/utils/gsapConfig";
import { cn } from "@/utils/cn";
/**
* A button that gently follows the cursor within its bounds
* (magnetic hover) and emits a soft ripple on click. Degrades to a
* plain button with no motion when the user prefers reduced motion,
* or on touch devices where "hover" doesn't really apply.
*
* @param {Object} props
* @param {"primary"|"ghost"} [props.variant]
* @param {React.ReactNode} props.children
* @param {() => void} [props.onClick]
* @param {string} [props.className]
* @param {string} [props.as] - render as "a" for links, "button" otherwise
* @param {string} [props.href]
*/
export default function MagneticButton({
variant = "primary",
children,
onClick,
className,
as = "button",
href,
...rest
}) {
const buttonRef = useRef(null);
const quickX = useRef(null);
const quickY = useRef(null);
useGSAP(
() => {
const el = buttonRef.current;
if (!el) return;
const prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
const isTouch = window.matchMedia("(hover: none)").matches;
if (prefersReduced || isTouch) return;
quickX.current = gsap.quickTo(el, "x", { duration: 0.5, ease: "power3" });
quickY.current = gsap.quickTo(el, "y", { duration: 0.5, ease: "power3" });
const handleMove = (event) => {
const rect = el.getBoundingClientRect();
const relX = event.clientX - (rect.left + rect.width / 2);
const relY = event.clientY - (rect.top + rect.height / 2);
quickX.current(relX * 0.35);
quickY.current(relY * 0.35);
};
const handleLeave = () => {
quickX.current(0);
quickY.current(0);
};
el.addEventListener("mousemove", handleMove);
el.addEventListener("mouseleave", handleLeave);
return () => {
el.removeEventListener("mousemove", handleMove);
el.removeEventListener("mouseleave", handleLeave);
};
},
{ scope: buttonRef }
);
const handleClick = (event) => {
const el = buttonRef.current;
if (el) {
const rect = el.getBoundingClientRect();
const ripple = document.createElement("span");
const size = Math.max(rect.width, rect.height) * 1.6;
ripple.style.position = "absolute";
ripple.style.width = `${size}px`;
ripple.style.height = `${size}px`;
ripple.style.left = `${event.clientX - rect.left - size / 2}px`;
ripple.style.top = `${event.clientY - rect.top - size / 2}px`;
ripple.style.borderRadius = "9999px";
ripple.style.background =
variant === "primary"
? "rgba(255,255,255,0.35)"
: "rgba(0,166,147,0.25)";
ripple.style.pointerEvents = "none";
ripple.style.transform = "scale(0)";
ripple.style.opacity = "1";
el.appendChild(ripple);
gsap.to(ripple, {
scale: 1,
opacity: 0,
duration: 0.7,
ease: "power2.out",
onComplete: () => ripple.remove(),
});
}
onClick?.(event);
};
const Tag = as === "a" ? "a" : "button";
const baseClasses = cn(
"relative isolate inline-flex items-center justify-center gap-2 overflow-hidden",
"rounded-full px-8 py-3.5 text-base font-medium transition-colors duration-300",
"focus-visible:outline-none",
variant === "primary" &&
"bg-brand-gradient text-white shadow-glow hover:shadow-glass-lg",
variant === "ghost" &&
"border border-primary-dark/15 bg-white/60 text-primary-dark backdrop-blur-md hover:bg-white/90",
className
);
return (
<Tag
ref={buttonRef}
href={href}
onClick={handleClick}
className={baseClasses}
{...rest}
>
{children}
</Tag>
);
}
-17
View File
@@ -1,17 +0,0 @@
import { cn } from "@/utils/cn";
import Container from "./Container";
/**
* @param {Object} props
* @param {string} [props.id]
* @param {string} [props.className]
* @param {string} [props.containerClassName]
* @param {React.ReactNode} props.children
*/
export default function Section({ id, className, containerClassName, children }) {
return (
<section id={id} className={cn("relative py-24 md:py-32", className)}>
<Container className={containerClassName}>{children}</Container>
</section>
);
}
-113
View File
@@ -1,113 +0,0 @@
"use client";
import { useRef } from "react";
import Image from "next/image";
import { useGSAP } from "@gsap/react";
import { gsap } from "@/utils/gsapConfig";
import floatingProducts from "@data/floatingProducts.json";
// Fixed layout slots (percentage-based) so items are spread evenly
// around the machine without overlapping it. Values are intentionally
// varied to break any obvious grid pattern.
const POSITIONS = [
{ top: "10%", left: "8%" },
{ top: "18%", left: "82%" },
{ top: "68%", left: "6%" },
{ top: "74%", left: "86%" },
{ top: "38%", left: "2%" },
{ top: "44%", left: "92%" },
];
/**
* Continuously floating / rotating product placeholders that ring
* the hero machine. Each item drifts at its own speed (its "depth")
* so the whole cluster reads as a single believable 3D scene rather
* than uniform motion.
*/
export default function FloatingProducts() {
const scope = useRef(null);
useGSAP(
() => {
const prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReduced) return;
const items = gsap.utils.toArray(".floating-product");
items.forEach((item, i) => {
const depth = Number(item.dataset.depth || 0.5);
gsap.to(item, {
y: `+=${18 + depth * 26}`,
rotation: i % 2 === 0 ? 8 : -8,
duration: 4 + depth * 4,
ease: "sine.inOut",
repeat: -1,
yoyo: true,
delay: i * 0.3,
});
gsap.to(item, {
scale: 1 + depth * 0.08,
filter: depth > 0.55 ? "blur(0.5px)" : "blur(0px)",
duration: 5 + depth * 3,
ease: "sine.inOut",
repeat: -1,
yoyo: true,
});
});
// Subtle mouse-parallax: items with more "depth" move more.
const handlePointerMove = (event) => {
const { innerWidth, innerHeight } = window;
const relX = event.clientX / innerWidth - 0.5;
const relY = event.clientY / innerHeight - 0.5;
items.forEach((item) => {
const depth = Number(item.dataset.depth || 0.5);
gsap.to(item, {
x: relX * depth * 40,
duration: 1.2,
ease: "power2.out",
overwrite: "auto",
});
});
};
window.addEventListener("pointermove", handlePointerMove);
return () => window.removeEventListener("pointermove", handlePointerMove);
},
{ scope }
);
return (
<div ref={scope} aria-hidden="true" className="pointer-events-none absolute inset-0 hidden md:block">
{floatingProducts.map((product, index) => {
const position = POSITIONS[index % POSITIONS.length];
return (
<div
key={product.id}
className="floating-product absolute animate-float"
data-depth={product.depth}
style={{
top: position.top,
left: position.left,
width: product.size,
height: product.size,
animationDelay: `${index * 0.4}s`,
}}
>
<Image
src={product.src}
alt=""
width={product.size}
height={product.size}
className="drop-shadow-glass"
/>
</div>
);
})}
</div>
);
}
-63
View File
@@ -1,63 +0,0 @@
import { FiArrowLeft, FiPlay } from "react-icons/fi";
import Container from "@components/common/Container";
import MagneticButton from "@components/common/MagneticButton";
import ScrollReveal from "@components/animations/ScrollReveal";
import HeroBackground from "./HeroBackground";
import MachineAssembly from "./MachineAssembly";
import FloatingProducts from "./FloatingProducts";
/**
* The hero: an eyebrow, a large headline, two CTAs, and the
* scroll-driven machine assembly centerpiece with floating product
* placeholders around it. This is the single most important
* section on the page per the brief.
*/
export default function Hero() {
return (
<section id="home" className="relative isolate">
<HeroBackground />
<Container className="relative flex flex-col items-center gap-16 pt-40 pb-10 text-center md:pt-48">
<div className="max-w-3xl">
<ScrollReveal delay={0} y={16}>
<span className="inline-flex items-center rounded-full border border-primary-dark/10 bg-white/60 px-4 py-1.5 text-sm font-medium text-primary backdrop-blur-md">
نسل جدید وندینگ ماشین‌های هوشمند
</span>
</ScrollReveal>
<ScrollReveal delay={0.1}>
<h1 className="text-balance mt-6 text-4xl font-bold leading-[1.15] text-primary-dark md:text-6xl">
فروشگاهی که هرگز
<span className="bg-brand-gradient bg-clip-text text-transparent"> تعطیل نمی‌شود</span>
</h1>
</ScrollReveal>
<ScrollReveal delay={0.2}>
<p className="text-balance mx-auto mt-6 max-w-xl text-lg text-primary-dark/70 md:text-xl">
روبات مارکت دستگاه‌های وندینگ ماشین یخچالدار را طراحی می‌کند تا
کسب‌وکار شما بدون نیروی انسانی، ۲۴ ساعته و هوشمند درآمدزایی کند.
</p>
</ScrollReveal>
<ScrollReveal delay={0.3}>
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
<MagneticButton variant="primary" as="a" href="#products">
مشاهده محصولات
<FiArrowLeft aria-hidden="true" />
</MagneticButton>
<MagneticButton variant="ghost" as="a" href="#about">
<FiPlay aria-hidden="true" />
آشنایی با روبات مارکت
</MagneticButton>
</div>
</ScrollReveal>
</div>
<div className="relative w-full">
<FloatingProducts />
<MachineAssembly />
</div>
</Container>
</section>
);
}
-87
View File
@@ -1,87 +0,0 @@
"use client";
import { useRef } from "react";
import { useGSAP } from "@gsap/react";
import { gsap } from "@/utils/gsapConfig";
const PARTICLE_COUNT = 24;
/**
* The ambient, always-moving layer behind the hero: three soft
* blurred gradient blobs drifting on independent loops, a radial
* glow pulsing gently, and a scattering of tiny floating particles.
* Everything here is decorative (aria-hidden) and purely GSAP-driven
* so it can run at 60fps without layout thrash.
*/
export default function HeroBackground() {
const scope = useRef(null);
useGSAP(
() => {
const prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReduced) return;
const blobs = gsap.utils.toArray(".hero-blob");
blobs.forEach((blob, i) => {
gsap.to(blob, {
x: i % 2 === 0 ? 60 : -50,
y: i % 2 === 0 ? -40 : 50,
duration: 10 + i * 3,
ease: "sine.inOut",
repeat: -1,
yoyo: true,
});
});
gsap.to(".hero-glow", {
opacity: 0.9,
scale: 1.08,
duration: 5,
ease: "sine.inOut",
repeat: -1,
yoyo: true,
});
const particles = gsap.utils.toArray(".hero-particle");
particles.forEach((particle) => {
gsap.to(particle, {
y: `-=${gsap.utils.random(40, 120)}`,
x: `+=${gsap.utils.random(-30, 30)}`,
opacity: gsap.utils.random(0.2, 0.8),
duration: gsap.utils.random(4, 9),
ease: "sine.inOut",
repeat: -1,
yoyo: true,
delay: gsap.utils.random(0, 3),
});
});
},
{ scope }
);
return (
<div ref={scope} aria-hidden="true" className="absolute inset-0 -z-10 overflow-hidden">
<div className="hero-glow bg-brand-radial absolute left-1/2 top-1/3 h-[70vmax] w-[70vmax] -translate-x-1/2 -translate-y-1/2 opacity-60 blur-3xl" />
<div className="hero-blob absolute -right-24 top-10 h-[26rem] w-[26rem] rounded-full bg-primary-accent/40 blur-3xl" />
<div className="hero-blob absolute -left-16 top-1/2 h-[22rem] w-[22rem] rounded-full bg-primary/40 blur-3xl" />
<div className="hero-blob absolute bottom-0 right-1/3 h-[20rem] w-[20rem] rounded-full bg-primary-light/60 blur-3xl" />
{Array.from({ length: PARTICLE_COUNT }).map((_, index) => (
<span
key={index}
className="hero-particle absolute h-1.5 w-1.5 rounded-full bg-primary-accent"
style={{
left: `${(index * 137.5) % 100}%`,
top: `${(index * 71.3) % 100}%`,
opacity: 0.4,
}}
/>
))}
<div className="absolute inset-0 bg-surface/10" />
</div>
);
}
-141
View File
@@ -1,141 +0,0 @@
"use client";
import { useRef } from "react";
import Image from "next/image";
import { useGSAP } from "@gsap/react";
import { gsap, ScrollTrigger } from "@/utils/gsapConfig";
import { useDispatch } from "react-redux";
import { setHeroAssembled } from "@redux/slices/uiSlice";
import heroMachine from "@data/products.json";
// The hero uses the first product's piece set as the "signature"
// machine. Swap this constant to feature a different model.
const HERO_PRODUCT = heroMachine[0];
/**
* Renders the hero product's SVG pieces scattered around the
* viewport, then uses a scrubbed, pinned ScrollTrigger timeline to
* fly every piece back into its assembled position — the
* "satisfying" scroll moment the brief calls for.
*
* Piece order (back to front) is intentional: body sits behind the
* shelving and screen, the door glass sits on top, and the base
* anchors the whole thing visually.
*/
export default function MachineAssembly() {
const wrapperRef = useRef(null);
const piecesRef = useRef([]);
const dispatch = useDispatch();
useGSAP(
() => {
const prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
const pieces = piecesRef.current.filter(Boolean);
if (!pieces.length) return;
// Deterministic-feeling but varied scatter target per piece.
const scatter = pieces.map((_, i) => {
const angle = (i / pieces.length) * Math.PI * 2;
const radius = gsap.utils.random(260, 420);
return {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius * 0.6 - 40,
rotation: gsap.utils.random(-70, 70),
scale: gsap.utils.random(0.55, 0.85),
};
});
if (prefersReduced) {
// Reduced motion: pieces simply appear already assembled.
gsap.set(pieces, { x: 0, y: 0, rotation: 0, scale: 1, opacity: 1 });
dispatch(setHeroAssembled(true));
return;
}
gsap.set(pieces, (i) => ({
x: scatter[i].x,
y: scatter[i].y,
rotation: scatter[i].rotation,
scale: scatter[i].scale,
opacity: 0.9,
transformOrigin: "50% 50%",
}));
const mm = gsap.matchMedia();
mm.add(
{
isDesktop: "(min-width: 1025px)",
isTablet: "(min-width: 641px) and (max-width: 1024px)",
isMobile: "(max-width: 640px)",
},
(context) => {
const { isMobile } = context.conditions;
const tl = gsap.timeline({
scrollTrigger: {
trigger: wrapperRef.current,
start: "top top",
// Mobile gets a shorter, lighter scrub distance to
// keep the animation snappy on small screens.
end: isMobile ? "+=80%" : "+=140%",
scrub: 1,
pin: true,
anticipatePin: 1,
onLeave: () => dispatch(setHeroAssembled(true)),
onEnterBack: () => dispatch(setHeroAssembled(false)),
},
});
tl.to(pieces, {
x: 0,
y: 0,
rotation: 0,
scale: 1,
opacity: 1,
ease: "power2.inOut",
stagger: 0.06,
}).to(
wrapperRef.current,
{ scale: isMobile ? 1.02 : 1.05, duration: 0.3, ease: "power1.out" },
"-=0.15"
);
return () => tl.scrollTrigger?.kill();
}
);
return () => mm.revert();
},
{ scope: wrapperRef, dependencies: [] }
);
return (
<div
ref={wrapperRef}
className="relative mx-auto flex h-[70vh] max-h-[640px] w-full max-w-[420px] items-center justify-center md:h-[78vh]"
role="img"
aria-label={`تصویر دستگاه ${HERO_PRODUCT.title} در حال مونتاژ`}
>
{HERO_PRODUCT.pieces.map((piece, index) => (
<div
key={piece}
ref={(el) => (piecesRef.current[index] = el)}
className="absolute inset-0"
style={{ zIndex: index }}
>
<Image
src={`${HERO_PRODUCT.svgDir}/${piece}.svg`}
alt=""
fill
priority={index < 2}
className="object-contain drop-shadow-glass-lg"
sizes="(max-width: 768px) 320px, 420px"
/>
</div>
))}
</div>
);
}
-70
View File
@@ -1,70 +0,0 @@
import { FiZap, FiShield, FiTrendingUp } from "react-icons/fi";
import Section from "@components/common/Section";
import ScrollReveal from "@components/animations/ScrollReveal";
import GlassCard from "@components/common/GlassCard";
const HIGHLIGHTS = [
{
icon: FiZap,
title: "راه‌اندازی سریع",
text: "نصب و راه‌اندازی دستگاه در محل شما در کوتاه‌ترین زمان ممکن.",
},
{
icon: FiShield,
title: "پشتیبانی مطمئن",
text: "پایش آنلاین سلامت دستگاه و پشتیبانی فنی مستمر.",
},
{
icon: FiTrendingUp,
title: "درآمد پایدار",
text: "فروش ۲۴ ساعته بدون نیاز به نیروی انسانی ثابت.",
},
];
/**
* Brand-story section. Copy here is placeholder-quality but
* production-shaped — swap the paragraph and highlight text for
* real company messaging when available.
*/
export default function About() {
return (
<Section id="about">
<div className="grid gap-16 md:grid-cols-2 md:items-center">
<ScrollReveal>
<span className="text-sm font-semibold text-primary">درباره ما</span>
<h2 className="text-balance mt-3 text-3xl font-bold text-primary-dark md:text-5xl">
روبات مارکت، شریک فروش هوشمند شما
</h2>
<p className="mt-6 text-primary-dark/70 leading-8">
{/* TODO: Replace with real company story/copy */}
روبات مارکت با تمرکز بر طراحی دستگاه‌های وندینگ ماشین یخچالدار،
تجربه‌ای مدرن، سریع و بدون وقفه برای مصرف‌کننده نهایی و درآمدی
پایدار برای صاحبان کسب‌وکار فراهم می‌کند.
</p>
</ScrollReveal>
<div className="grid gap-5">
{HIGHLIGHTS.map((item, index) => (
<ScrollReveal key={item.title} delay={index * 0.1}>
<GlassCard className="!p-0">
<div className="flex items-start gap-4 p-6">
<span className="grid h-12 w-12 flex-shrink-0 place-items-center rounded-2xl bg-brand-gradient text-white">
<item.icon size={20} aria-hidden="true" />
</span>
<div>
<h3 className="font-semibold text-primary-dark">
{item.title}
</h3>
<p className="mt-1 text-sm text-primary-dark/60">
{item.text}
</p>
</div>
</div>
</GlassCard>
</ScrollReveal>
))}
</div>
</div>
</Section>
);
}
-33
View File
@@ -1,33 +0,0 @@
import { FiArrowLeft } from "react-icons/fi";
import Section from "@components/common/Section";
import ScrollReveal from "@components/animations/ScrollReveal";
import MagneticButton from "@components/common/MagneticButton";
import GlassCard from "@components/common/GlassCard";
/**
* Final call-to-action before the footer. The نav "تماس با ما"
* link points here; the footer below carries the detailed contact
* info, keeping this section focused on a single clear action.
*/
export default function ContactCta() {
return (
<Section id="contact" className="pb-16 md:pb-20">
<ScrollReveal>
<GlassCard className="mx-auto max-w-3xl text-center">
<h2 className="text-balance text-2xl font-bold text-primary-dark md:text-4xl">
آماده‌اید فروش بدون توقف را تجربه کنید؟
</h2>
<p className="mt-4 text-primary-dark/70">
کارشناسان روبات مارکت مدل مناسب کسب‌وکار شما را پیشنهاد می‌دهند.
</p>
<div className="mt-8 flex justify-center">
<MagneticButton variant="primary" as="a" href="#products">
درخواست مشاوره رایگان
<FiArrowLeft aria-hidden="true" />
</MagneticButton>
</div>
</GlassCard>
</ScrollReveal>
</Section>
);
}
-85
View File
@@ -1,85 +0,0 @@
import { FiInstagram, FiLinkedin, FiMessageCircle } from "react-icons/fi";
import Container from "@components/common/Container";
import { NAV_LINKS } from "@constants/nav";
const SOCIAL_LINKS = [
{ key: "instagram", label: "اینستاگرام", href: "#", icon: FiInstagram },
{ key: "linkedin", label: "لینکدین", href: "#", icon: FiLinkedin },
{ key: "telegram", label: "تلگرام", href: "#", icon: FiMessageCircle },
];
/**
* Placeholder footer content is intentionally generic — every
* string here is meant to be swapped for real company copy before
* launch, but the structure (about / quick links / contact /
* social / legal bar) is production-ready.
*/
export default function Footer() {
const year = new Date().getFullYear();
return (
<footer className="border-t border-primary-dark/10 bg-primary-dark text-primary-light">
<Container className="grid gap-12 py-16 md:grid-cols-4">
<div className="md:col-span-2">
<h2 className="text-xl font-bold text-white">روبات مارکت</h2>
<p className="mt-4 max-w-sm text-sm leading-7 text-primary-light/70">
طراحی و تولید دستگاه‌های وندینگ ماشین یخچالدار هوشمند برای
کسب‌وکارهایی که می‌خواهند بدون وقفه بفروشند.
</p>
<div className="mt-6 flex gap-3">
{SOCIAL_LINKS.map(({ key, label, href, icon: Icon }) => (
<a
key={key}
href={href}
aria-label={label}
className="grid h-10 w-10 place-items-center rounded-full bg-white/10 transition-colors hover:bg-primary-accent hover:text-primary-dark"
>
<Icon size={18} aria-hidden="true" />
</a>
))}
</div>
</div>
<nav aria-label="لینک‌های سریع">
<h3 className="text-sm font-semibold text-white">لینک‌های سریع</h3>
<ul className="mt-4 space-y-3">
{NAV_LINKS.map((link) => (
<li key={link.key}>
<a
href={link.href}
className="text-sm text-primary-light/70 transition-colors hover:text-primary-accent"
>
{link.label}
</a>
</li>
))}
</ul>
</nav>
<div>
<h3 className="text-sm font-semibold text-white">تماس با ما</h3>
<ul className="mt-4 space-y-3 text-sm text-primary-light/70">
{/* TODO: Replace with real company contact details */}
<li>تهران، ایران</li>
<li dir="ltr" className="text-right">+98 21 0000 0000</li>
<li dir="ltr" className="text-right">info@robotmarket.example.com</li>
</ul>
</div>
</Container>
<div className="border-t border-white/10 py-6">
<Container className="flex flex-col items-center justify-between gap-3 text-xs text-primary-light/60 md:flex-row">
<p>© {year} روبات مارکت. تمامی حقوق محفوظ است.</p>
<div className="flex gap-6">
<a href="#" className="hover:text-primary-accent">
حریم خصوصی
</a>
<a href="#" className="hover:text-primary-accent">
قوانین و مقررات
</a>
</div>
</Container>
</div>
</footer>
);
}
-127
View File
@@ -1,127 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { Drawer } from "antd";
import { FiMenu, FiX } from "react-icons/fi";
import { useDispatch, useSelector } from "react-redux";
import {
toggleMobileNav,
closeMobileNav,
setNavScrolled,
} from "@redux/slices/uiSlice";
import { NAV_LINKS, DASHBOARD_LINK } from "@constants/nav";
import Container from "@components/common/Container";
import MagneticButton from "@components/common/MagneticButton";
import { cn } from "@/utils/cn";
/**
* Sticky navigation. Starts fully transparent over the hero and
* morphs into a glass surface once the page scrolls past a small
* threshold — driven by a lightweight scroll listener rather than
* ScrollTrigger, since this is a simple boolean toggle, not a
* scrubbed animation.
*/
export default function Navbar() {
const dispatch = useDispatch();
const isScrolled = useSelector((state) => state.ui.isNavScrolled);
const isMobileNavOpen = useSelector((state) => state.ui.isMobileNavOpen);
const thresholdReached = useRef(false);
useEffect(() => {
const handleScroll = () => {
const scrolled = window.scrollY > 40;
if (scrolled !== thresholdReached.current) {
thresholdReached.current = scrolled;
dispatch(setNavScrolled(scrolled));
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => window.removeEventListener("scroll", handleScroll);
}, [dispatch]);
return (
<header
className={cn(
"fixed inset-x-0 top-0 z-50 transition-all duration-500",
isScrolled
? "glass-surface shadow-glass py-3"
: "border-transparent bg-transparent py-6"
)}
>
<Container className="flex items-center justify-between">
<a href="#home" className="text-lg font-bold text-primary-dark">
روبات مارکت
</a>
<nav
aria-label="پیمایش اصلی"
className="hidden items-center gap-8 md:flex"
>
{NAV_LINKS.map((link) => (
<a
key={link.key}
href={link.href}
className="text-sm font-medium text-primary-dark/80 transition-colors hover:text-primary"
>
{link.label}
</a>
))}
</nav>
<div className="hidden items-center gap-4 md:flex">
<MagneticButton
variant="ghost"
as="a"
href={DASHBOARD_LINK.href}
className="!px-6 !py-2 text-sm"
>
{DASHBOARD_LINK.label}
</MagneticButton>
</div>
<button
type="button"
aria-label="باز کردن منو"
onClick={() => dispatch(toggleMobileNav())}
className="grid h-11 w-11 place-items-center rounded-full bg-white/70 text-primary-dark shadow-glass md:hidden"
>
<FiMenu size={20} aria-hidden="true" />
</button>
</Container>
<Drawer
title="روبات مارکت"
placement="top"
height="auto"
open={isMobileNavOpen}
onClose={() => dispatch(closeMobileNav())}
closeIcon={<FiX size={18} />}
>
<nav
aria-label="پیمایش موبایل"
className="flex flex-col gap-5 pb-4"
>
{NAV_LINKS.map((link) => (
<a
key={link.key}
href={link.href}
onClick={() => dispatch(closeMobileNav())}
className="text-base font-medium text-primary-dark"
>
{link.label}
</a>
))}
<a
href={DASHBOARD_LINK.href}
onClick={() => dispatch(closeMobileNav())}
className="text-base font-medium text-primary"
>
{DASHBOARD_LINK.label}
</a>
</nav>
</Drawer>
</header>
);
}
-57
View File
@@ -1,57 +0,0 @@
"use client";
import { useRef } from "react";
import Image from "next/image";
import { useGSAP } from "@gsap/react";
import { gsap } from "@/utils/gsapConfig";
/**
* Renders a product's machine fully assembled (all pieces stacked
* in place) with a soft, continuous idle bob so it still feels
* alive while its section is pinned in view. The section-level
* "slide in / settle" entrance itself is handled by the parent
* `ProductSection`, which owns the pinned scroll timeline — this
* component only owns the small perpetual idle motion.
*
* @param {Object} props
* @param {{ svgDir: string, pieces: string[], title: string }} props.product
*/
export default function MachineVisual({ product }) {
const wrapperRef = useRef(null);
useGSAP(
() => {
const prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReduced) return;
const idle = gsap.to(wrapperRef.current, {
y: "-=14",
duration: 3.2,
ease: "sine.inOut",
repeat: -1,
yoyo: true,
});
return () => idle.kill();
},
{ scope: wrapperRef }
);
return (
<div ref={wrapperRef} className="relative mx-auto aspect-[4/7] w-full max-w-[340px]">
{product.pieces.map((piece, index) => (
<div key={piece} className="absolute inset-0" style={{ zIndex: index }}>
<Image
src={`${product.svgDir}/${piece}.svg`}
alt={index === 0 ? `دستگاه ${product.title}` : ""}
fill
className="object-contain drop-shadow-glass-lg"
sizes="(max-width: 768px) 280px, 340px"
/>
</div>
))}
</div>
);
}
-134
View File
@@ -1,134 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { FiCheck, FiArrowLeft } from "react-icons/fi";
import GlassCard from "@components/common/GlassCard";
import MagneticButton from "@components/common/MagneticButton";
import MachineVisual from "./MachineVisual";
import SectionFloatingItems from "./SectionFloatingItems";
import { cn } from "@/utils/cn";
const FEATURES = [
"سیستم خنک‌کننده قدرتمند",
"مکانیزم تحویل نرم و بی‌صدا",
"مدیریت و پایش آنلاین",
];
/**
* One presentational "chapter" of the product story. This
* component itself owns no ScrollTrigger/pin — it only renders the
* machine + info card and, once mounted, reports its three DOM
* nodes (section root, machine wrapper, card) up to
* `ProductsShowcase` via `onRefsReady`.
*
* Why: creating each section's pinned ScrollTrigger from its own
* sibling component led to a real bug on wide/desktop viewports —
* later sections' pins could be measured before earlier sections'
* pin-spacers were reliably in place, so scrolling past the first
* machine sometimes never revealed the next ones. Centralizing all
* pin/timeline creation in the parent (after every section has
* reported in, then calling `ScrollTrigger.refresh()` once)
* guarantees correct measurement regardless of viewport size.
*
* @param {Object} props
* @param {Object} props.product
* @param {number} props.index
* @param {(index: number, refs: { sectionEl: HTMLElement, machineEl: HTMLElement, cardEl: HTMLElement }) => void} props.onRefsReady
*/
export default function ProductSection({ product, index, onRefsReady }) {
const sectionRef = useRef(null);
const machineWrapRef = useRef(null);
const cardRef = useRef(null);
const isReversed = product.reverseLayout;
const isDark = product.bgColor && isColorDark(product.bgColor);
useEffect(() => {
onRefsReady?.(index, {
sectionEl: sectionRef.current,
machineEl: machineWrapRef.current,
cardEl: cardRef.current,
});
}, [index, onRefsReady]);
return (
<div
ref={sectionRef}
className="relative flex min-h-screen items-center overflow-hidden py-20"
>
<div className="relative mx-auto grid w-full max-w-6xl items-center gap-14 px-6 md:grid-cols-2 md:gap-20 md:px-10">
<div
ref={machineWrapRef}
className={cn("relative", isReversed ? "md:order-2" : "md:order-1")}
>
<SectionFloatingItems product={product} />
{isDark && (
<div
aria-hidden="true"
className="absolute inset-0 -z-10 rounded-full bg-white/20 blur-3xl"
/>
)}
<MachineVisual product={product} />
</div>
<div
ref={cardRef}
className={cn(isReversed ? "md:order-1" : "md:order-2")}
>
<GlassCard>
<span className="text-sm font-semibold text-primary">
{product.brand} · {product.channels} کانال
</span>
<h3 className="mt-3 text-3xl font-bold text-primary-dark md:text-4xl">
{product.title}
</h3>
<p className="mt-2 text-2xl font-bold text-primary-dark/90">
{product.price}
<span className="mr-1 text-sm font-normal text-primary-dark/50">
تومان
</span>
</p>
<p className="mt-5 text-sm leading-8 text-primary-dark/70">
{product.description.split("\n\n")[1] ?? product.description}
</p>
<ul className="mt-6 space-y-3">
{FEATURES.map((feature) => (
<li
key={feature}
className="flex items-center gap-2 text-sm text-primary-dark/80"
>
<span className="grid h-5 w-5 flex-shrink-0 place-items-center rounded-full bg-primary-light text-primary">
<FiCheck size={12} aria-hidden="true" />
</span>
{feature}
</li>
))}
</ul>
<MagneticButton variant="primary" className="mt-8 w-full md:w-auto">
استعلام قیمت و خرید
<FiArrowLeft aria-hidden="true" />
</MagneticButton>
</GlassCard>
</div>
</div>
</div>
);
}
/**
* Cheap luminance check used to decide whether a section's vivid
* `bgColor` still reads as dark enough to warrant a soft white glow
* behind the machine for extra contrast/drama.
* @param {string} hex
* @returns {boolean}
*/
function isColorDark(hex) {
const value = hex.replace("#", "");
const r = parseInt(value.substring(0, 2), 16);
const g = parseInt(value.substring(2, 4), 16);
const b = parseInt(value.substring(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance < 0.55;
}
@@ -1,190 +0,0 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { useSelector } from "react-redux";
import { useGSAP } from "@gsap/react";
import { gsap, ScrollTrigger } from "@/utils/gsapConfig";
import Container from "@components/common/Container";
import ScrollReveal from "@components/animations/ScrollReveal";
import ProductSection from "./ProductSection";
import { PIN_DISTANCE_DESKTOP, PIN_DISTANCE_MOBILE } from "@constants/motion";
/**
* The scroll-locked product story. All pin/timeline creation for
* every machine lives here — in one place, built only after every
* `ProductSection` has reported its DOM nodes in — rather than each
* section creating its own ScrollTrigger independently. That fixed
* a real bug: on wide/desktop viewports, later sections' pins could
* get measured before earlier ones' pin-spacers had settled, so
* scrolling past the first machine sometimes never revealed the
* next ones. A single `ScrollTrigger.refresh()` once everything is
* built (and again once images/fonts finish loading) guarantees
* correct measurements regardless of viewport size.
*
* A single fixed color layer sits behind the page; as the user
* scrolls through each pinned section, that layer cross-fades to
* the product's own vivid `bgColor` over the exact same scroll
* range the section uses to pin, so color and motion always finish
* together.
*/
export default function ProductsShowcase() {
const products = useSelector((state) => state.products.items);
const bgRef = useRef(null);
const storyRef = useRef(null);
const refsMap = useRef({});
const [readyCount, setReadyCount] = useState(0);
const handleRefsReady = useCallback((index, refs) => {
if (!refsMap.current[index]) {
refsMap.current[index] = refs;
setReadyCount((count) => count + 1);
} else {
refsMap.current[index] = refs;
}
}, []);
useGSAP(
() => {
const bg = bgRef.current;
const story = storyRef.current;
if (!bg || !story || !products.length) return;
if (readyCount < products.length) return; // wait for every section
const prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
gsap.set(bg, { backgroundColor: products[0].bgColor });
if (prefersReduced) {
gsap.set(bg, { opacity: 1 });
products.forEach((_, index) => {
const refs = refsMap.current[index];
if (!refs) return;
gsap.set([refs.machineEl, refs.cardEl], {
opacity: 1,
x: 0,
y: 0,
scale: 1,
});
});
return;
}
// Fade the color layer in/out around the story's own bounds.
gsap.timeline({
scrollTrigger: {
trigger: story,
start: "top 85%",
end: "bottom bottom",
scrub: 0.6,
},
})
.to(bg, { opacity: 1, duration: 0.15 })
.to(bg, { opacity: 1, duration: 0.7 })
.to(bg, { opacity: 0, duration: 0.15 });
const mm = gsap.matchMedia();
mm.add({ isMobile: "(max-width: 640px)" }, (context) => {
const { isMobile } = context.conditions;
const end = isMobile ? PIN_DISTANCE_MOBILE : PIN_DISTANCE_DESKTOP;
const triggers = [];
products.forEach((product, index) => {
const refs = refsMap.current[index];
if (!refs?.sectionEl) return;
const { sectionEl, machineEl, cardEl } = refs;
const fromX = product.reverseLayout ? -160 : 160;
gsap.set(machineEl, { x: fromX, opacity: 0, scale: 0.9 });
gsap.set(cardEl, { opacity: 0, y: 40 });
const tl = gsap.timeline({
scrollTrigger: {
trigger: sectionEl,
start: "top top",
end,
scrub: 1,
pin: true,
anticipatePin: 1,
invalidateOnRefresh: true,
},
});
tl.to(machineEl, { x: 0, opacity: 1, scale: 1, ease: "power2.out" }, 0).to(
cardEl,
{ opacity: 1, y: 0, ease: "power2.out" },
0.3
);
triggers.push(tl.scrollTrigger);
const colorTween = gsap.to(bg, {
backgroundColor: product.bgColor,
ease: "none",
scrollTrigger: {
trigger: sectionEl,
start: "top top",
end,
scrub: 1,
},
});
triggers.push(colorTween.scrollTrigger);
});
// Every trigger for every section now exists — recompute
// start/end positions once against the final settled layout.
requestAnimationFrame(() => ScrollTrigger.refresh());
return () => triggers.forEach((trigger) => trigger?.kill());
});
return () => mm.revert();
},
{ dependencies: [products, readyCount], scope: storyRef }
);
// Images (next/image) and the Vazirmatn font can both shift layout
// slightly after first paint — a final refresh once everything has
// truly settled prevents any pinned section from being measured
// against a stale (too-short) document height.
useGSAP(() => {
const handleLoad = () => ScrollTrigger.refresh();
window.addEventListener("load", handleLoad);
return () => window.removeEventListener("load", handleLoad);
}, []);
return (
<section id="products" className="relative">
<div
ref={bgRef}
aria-hidden="true"
className="pointer-events-none fixed inset-0 -z-20 opacity-0"
/>
<div className="relative py-24 md:py-28">
<Container>
<ScrollReveal className="mx-auto max-w-2xl text-center">
<span className="text-sm font-semibold text-primary">محصولات</span>
<h2 className="text-balance mt-3 text-3xl font-bold text-primary-dark md:text-5xl">
مدلی متناسب با هر فضای کسب‌وکار
</h2>
<p className="mt-4 text-primary-dark/70">
با هر اسکرول، یک دستگاه مقابل چشمان شما سرهم می‌شود.
</p>
</ScrollReveal>
</Container>
</div>
<div ref={storyRef} className="relative">
{products.map((product, index) => (
<ProductSection
key={product.id}
product={product}
index={index}
onRefsReady={handleRefsReady}
/>
))}
</div>
</section>
);
}
@@ -1,86 +0,0 @@
"use client";
import { useRef } from "react";
import Image from "next/image";
import { useGSAP } from "@gsap/react";
import { gsap } from "@/utils/gsapConfig";
import floatingCatalog from "@data/floatingProducts.json";
// Four slots positioned just outside the machine's bounding box so
// items read as "floating around the device" rather than on top of
// it. Reused cyclically if a product lists fewer/more item ids.
const SLOTS = [
{ top: "-6%", left: "68%" },
{ top: "42%", left: "-20%" },
{ top: "78%", left: "84%" },
{ top: "8%", left: "-16%" },
];
/**
* Looks up each id in `product.floatingItemIds` against the shared
* floating-item catalog and renders it drifting gently around the
* machine, with its own speed/scale derived from the catalog's
* `depth` value — mirroring the hero's floating products but scoped
* to a single section.
*
* @param {Object} props
* @param {{ floatingItemIds: string[] }} props.product
*/
export default function SectionFloatingItems({ product }) {
const scope = useRef(null);
const items = (product.floatingItemIds || [])
.map((id) => floatingCatalog.find((entry) => entry.id === id))
.filter(Boolean);
useGSAP(
() => {
const prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReduced) return;
const nodes = gsap.utils.toArray(".section-floating-item");
nodes.forEach((node, i) => {
const depth = Number(node.dataset.depth || 0.5);
gsap.to(node, {
y: `+=${16 + depth * 22}`,
rotation: i % 2 === 0 ? 10 : -10,
duration: 3.5 + depth * 3,
ease: "sine.inOut",
repeat: -1,
yoyo: true,
delay: i * 0.25,
});
});
},
{ scope }
);
return (
<div
ref={scope}
aria-hidden="true"
className="pointer-events-none absolute inset-0 hidden sm:block"
>
{items.map((item, index) => {
const slot = SLOTS[index % SLOTS.length];
return (
<div
key={item.id}
className="section-floating-item absolute"
data-depth={item.depth}
style={{ top: slot.top, left: slot.left, width: item.size, height: item.size }}
>
<Image
src={item.src}
alt=""
width={item.size}
height={item.size}
className="drop-shadow-glass"
/>
</div>
);
})}
</div>
);
}
-38
View File
@@ -1,38 +0,0 @@
"use client";
import { ConfigProvider } from "antd";
import { COLORS } from "@/constants/theme";
/**
* Single place where Ant Design's design tokens are mapped onto
* the Robot Market palette, and where the whole component library
* is switched to RTL. Any Ant component used anywhere in the app
* (Drawer, Button, Form, etc.) automatically inherits this.
*/
export default function AntThemeProvider({ children }) {
return (
<ConfigProvider
direction="rtl"
theme={{
token: {
colorPrimary: COLORS.primary,
colorLink: COLORS.primary,
colorInfo: COLORS.primary,
borderRadius: 14,
fontFamily: "var(--font-vazirmatn), Tahoma, sans-serif",
},
components: {
Button: {
controlHeight: 46,
borderRadius: 999,
},
Drawer: {
colorBgElevated: COLORS.background,
},
},
}}
>
{children}
</ConfigProvider>
);
}
-9
View File
@@ -1,9 +0,0 @@
/**
* Shared scroll-distance constants for the pinned, scroll-driven
* product story. Both `ProductSection` (which owns the pin +
* assembly timeline) and `ProductsShowcase` (which cross-fades the
* background color behind it) must use the exact same start/end so
* their two independent ScrollTriggers stay perfectly in sync.
*/
export const PIN_DISTANCE_DESKTOP = "+=140%";
export const PIN_DISTANCE_MOBILE = "+=90%";
-12
View File
@@ -1,12 +0,0 @@
export const NAV_LINKS = [
{ key: "home", label: "خانه", href: "#home" },
{ key: "products", label: "محصولات", href: "#products" },
{ key: "about", label: "درباره ما", href: "#about" },
{ key: "contact", label: "تماس با ما", href: "#contact" },
];
export const DASHBOARD_LINK = {
key: "dashboard",
label: "داشبورد",
href: "/dashboard",
};
-26
View File
@@ -1,26 +0,0 @@
/**
* Central design tokens for Robot Market.
* Keep this in sync with tailwind.config.js so JS-driven
* (GSAP / Ant Design) styling always matches the Tailwind palette.
*/
export const COLORS = {
primary: "#00A693",
dark: "#063D39",
accent: "#19D7C2",
light: "#D8FFF8",
background: "#F7FBFB",
};
export const FONT_FAMILY = "var(--font-vazirmatn), Tahoma, sans-serif";
export const BREAKPOINTS = {
mobile: 640,
tablet: 1024,
desktop: 1280,
};
export const EASE = {
soft: "power3.out",
softInOut: "power3.inOut",
expo: "expo.out",
};
-9
View File
@@ -1,9 +0,0 @@
[
{ "id": "chip", "src": "/svg/floating/chip.svg", "size": 64, "depth": 0.2 },
{ "id": "cola", "src": "/svg/floating/cola.svg", "size": 80, "depth": 0.4 },
{ "id": "drink", "src": "/svg/floating/drink.svg", "size": 70, "depth": 0.6 },
{ "id": "cookie", "src": "/svg/floating/cookie.svg", "size": 56, "depth": 0.3 },
{ "id": "snack", "src": "/svg/floating/snack.svg", "size": 68, "depth": 0.5 },
{ "id": "juice", "src": "/svg/floating/juice.svg", "size": 74, "depth": 0.7 },
{ "id": "water", "src": "/svg/floating/water.svg", "size": 66, "depth": 0.45 }
]
-79
View File
@@ -1,79 +0,0 @@
[
{
"id": "machine-35",
"slug": "machine35",
"title": "روبات مارکت 35 کانال",
"brand": "روبات مارکت",
"channels": 35,
"price": "ریال۳.۸۶۰.۰۰۰.۰۰۰",
"description": "دستگاه وندینگ ماشین یخچالدار روبات مارکت مدل ۳۵ کانال\n\nاین دستگاه با بهره‌گیری از سیستم خنک‌کننده قدرتمند و ۳۵ کانال اختصاصی، امکان عرضه طیف وسیعی از نوشیدنی‌ها و خوراکی‌های تازه را فراهم می‌کند. طراحی هوشمند، مکانیزم تحویل نرم و قابلیت مدیریت آنلاین، این مدل را به گزینه‌ای ایده‌آل برای کسب‌وکارهای پرتردد تبدیل کرده است تا بدون نیاز به نیروی انسانی، درآمدی پایدار و ۲۴ ساعته داشته باشید.",
"svgDir": "/svg/machines/machine35",
"pieces": [
"base",
"body",
"door",
"panel",
"screen",
"shelves"
],
"reverseLayout": false,
"bgColor": "#12C4E0",
"floatingItemIds": [
"cola",
"chip",
"cookie"
]
},
{
"id": "machine-48",
"slug": "machine48",
"title": "روبات مارکت 48 کانال",
"brand": "روبات مارکت",
"channels": 48,
"price": "ریال۴.۴۸۰.۰۰۰.۰۰۰",
"description": "دستگاه وندینگ ماشین یخچالدار روبات مارکت مدل ۴۸ کانال\n\nاین دستگاه با بهره‌گیری از سیستم خنک‌کننده قدرتمند و ۴۸ کانال اختصاصی، امکان عرضه طیف وسیعی از نوشیدنی‌ها و خوراکی‌های تازه را فراهم می‌کند. طراحی هوشمند، مکانیزم تحویل نرم و قابلیت مدیریت آنلاین، این مدل را به گزینه‌ای ایده‌آل برای کسب‌وکارهای پرتردد تبدیل کرده است تا بدون نیاز به نیروی انسانی، درآمدی پایدار و ۲۴ ساعته داشته باشید.",
"svgDir": "/svg/machines/machine48",
"pieces": [
"base",
"body",
"door",
"panel",
"screen",
"shelves"
],
"reverseLayout": true,
"bgColor": "#FF7A45",
"floatingItemIds": [
"drink",
"snack",
"juice",
"water"
]
},
{
"id": "machine-60",
"slug": "machine60",
"title": "روبات مارکت 60 کانال",
"brand": "روبات مارکت",
"channels": 60,
"price": "ریال۴.۹۸۰.۰۰۰.۰۰۰",
"description": "دستگاه وندینگ ماشین یخچالدار روبات مارکت مدل ۶۰ کانال\n\nاین دستگاه با بهره‌گیری از سیستم خنک‌کننده قدرتمند و ۶۰ کانال اختصاصی، امکان عرضه طیف وسیعی از نوشیدنی‌ها و خوراکی‌های تازه را فراهم می‌کند. طراحی هوشمند، مکانیزم تحویل نرم و قابلیت مدیریت آنلاین، این مدل را به گزینه‌ای ایده‌آل برای کسب‌وکارهای پرتردد تبدیل کرده است تا بدون نیاز به نیروی انسانی، درآمدی پایدار و ۲۴ ساعته داشته باشید.",
"svgDir": "/svg/machines/machine60",
"pieces": [
"base",
"body",
"door",
"panel",
"screen",
"shelves"
],
"reverseLayout": false,
"bgColor": "#12D6A8",
"floatingItemIds": [
"water",
"cola",
"juice",
"snack"
]
}
]
-11
View File
@@ -1,11 +0,0 @@
"use client";
import { useEffect, useLayoutEffect } from "react";
/**
* useLayoutEffect throws a warning during SSR. This swaps to a
* plain useEffect on the server so GSAP-driven components can be
* imported anywhere without triggering Next.js warnings.
*/
export const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
-24
View File
@@ -1,24 +0,0 @@
"use client";
import { useEffect, useState } from "react";
/**
* Tracks the user's prefers-reduced-motion setting so animation
* components can degrade to simple fades/opacity instead of large
* transforms, per the accessibility requirements of the project.
* @returns {boolean}
*/
export function useReducedMotion() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(query.matches);
const listener = (event) => setReduced(event.matches);
query.addEventListener("change", listener);
return () => query.removeEventListener("change", listener);
}, []);
return reduced;
}
-19
View File
@@ -1,19 +0,0 @@
"use client";
import { useRef } from "react";
import { Provider } from "react-redux";
import { makeStore } from "./store";
/**
* Client-side Redux provider. Wraps the app once in the root
* layout. Uses a ref so the store is created exactly once per
* client session, not on every render.
*/
export default function StoreProvider({ children }) {
const storeRef = useRef(null);
if (!storeRef.current) {
storeRef.current = makeStore();
}
return <Provider store={storeRef.current}>{children}</Provider>;
}
-45
View File
@@ -1,45 +0,0 @@
import { createSlice } from "@reduxjs/toolkit";
/**
* Auth slice — placeholder state shape until the real login API
* is connected. The dashboard nav link and any protected routes
* will read from `isAuthenticated`.
*/
const initialState = {
user: null,
token: null,
isAuthenticated: false,
status: "idle", // idle | loading | succeeded | failed
error: null,
};
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
loginStart(state) {
state.status = "loading";
state.error = null;
},
loginSuccess(state, action) {
state.status = "succeeded";
state.user = action.payload.user;
state.token = action.payload.token;
state.isAuthenticated = true;
},
loginFailure(state, action) {
state.status = "failed";
state.error = action.payload;
},
logout(state) {
state.user = null;
state.token = null;
state.isAuthenticated = false;
state.status = "idle";
},
},
});
export const { loginStart, loginSuccess, loginFailure, logout } =
authSlice.actions;
export default authSlice.reducer;
-44
View File
@@ -1,44 +0,0 @@
import { createSlice } from "@reduxjs/toolkit";
import productsData from "@/data/products.json";
/**
* Products slice — seeded with local JSON so the UI is fully
* functional before the real product API is wired up. Once
* connected, `fetchProducts` in product.service.js should dispatch
* these same reducers with live data.
*/
const initialState = {
items: productsData,
activeProductId: productsData[0]?.id ?? null,
status: "idle", // idle | loading | succeeded | failed
error: null,
};
const productsSlice = createSlice({
name: "products",
initialState,
reducers: {
fetchProductsStart(state) {
state.status = "loading";
},
fetchProductsSuccess(state, action) {
state.status = "succeeded";
state.items = action.payload;
},
fetchProductsFailure(state, action) {
state.status = "failed";
state.error = action.payload;
},
setActiveProduct(state, action) {
state.activeProductId = action.payload;
},
},
});
export const {
fetchProductsStart,
fetchProductsSuccess,
fetchProductsFailure,
setActiveProduct,
} = productsSlice.actions;
export default productsSlice.reducer;
-44
View File
@@ -1,44 +0,0 @@
import { createSlice } from "@reduxjs/toolkit";
/**
* UI slice — global interface state that doesn't belong to any
* single feature: nav appearance, mobile drawer, active section
* for scroll-spy highlighting, and the hero assembly progress.
*/
const initialState = {
isMobileNavOpen: false,
isNavScrolled: false,
activeSection: "home",
heroAssembled: false,
};
const uiSlice = createSlice({
name: "ui",
initialState,
reducers: {
toggleMobileNav(state) {
state.isMobileNavOpen = !state.isMobileNavOpen;
},
closeMobileNav(state) {
state.isMobileNavOpen = false;
},
setNavScrolled(state, action) {
state.isNavScrolled = action.payload;
},
setActiveSection(state, action) {
state.activeSection = action.payload;
},
setHeroAssembled(state, action) {
state.heroAssembled = action.payload;
},
},
});
export const {
toggleMobileNav,
closeMobileNav,
setNavScrolled,
setActiveSection,
setHeroAssembled,
} = uiSlice.actions;
export default uiSlice.reducer;
-20
View File
@@ -1,20 +0,0 @@
import { configureStore } from "@reduxjs/toolkit";
import authReducer from "./slices/authSlice";
import productsReducer from "./slices/productsSlice";
import uiReducer from "./slices/uiSlice";
/**
* A fresh store per request/render is required for Next.js App
* Router (no shared module-level singleton on the server), so this
* is a factory rather than an exported instance. `StoreProvider`
* calls it once per client mount via useRef.
*/
export function makeStore() {
return configureStore({
reducer: {
auth: authReducer,
products: productsReducer,
ui: uiReducer,
},
});
}
-31
View File
@@ -1,31 +0,0 @@
import apiClient from "./axios";
/**
* Auth service placeholder — wires up the shape the login/dashboard
* flow will eventually call. No real backend is connected yet.
*/
export const authService = {
/**
* @param {{ email: string, password: string }} credentials
* @returns {Promise<{ user: Object, token: string }>}
*/
async login(credentials) {
// TODO: Connect Login API
// return apiClient.post("/auth/login", credentials).then((res) => res.data);
return Promise.resolve({
user: { name: "کاربر مهمان", email: credentials?.email ?? "" },
token: "mock-token",
});
},
/**
* @returns {Promise<void>}
*/
async logout() {
// TODO: Connect Logout API
// return apiClient.post("/auth/logout");
return Promise.resolve();
},
};
export default authService;
-45
View File
@@ -1,45 +0,0 @@
import axios from "axios";
/**
* Single Axios instance for the whole app. Every service file
* imports this instead of calling axios directly, so base URL,
* headers, and interceptors only need to be configured once.
*/
const apiClient = axios.create({
baseURL:
process.env.NEXT_PUBLIC_API_BASE_URL ||
"https://api.robotmarket.example.com",
timeout: 15000,
headers: {
"Content-Type": "application/json",
},
});
// Attach the auth token (once real login exists) to every request.
apiClient.interceptors.request.use((config) => {
if (typeof window !== "undefined") {
const token = window.localStorage.getItem("rm_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});
// Centralized error normalization so services/components can rely
// on a consistent error shape regardless of what the backend sends.
apiClient.interceptors.response.use(
(response) => response,
(error) => {
const normalized = {
message:
error?.response?.data?.message ||
error?.message ||
"خطای غیرمنتظره‌ای رخ داد.",
status: error?.response?.status || 500,
};
return Promise.reject(normalized);
}
);
export default apiClient;
-23
View File
@@ -1,23 +0,0 @@
import apiClient from "./axios";
/**
* Dashboard service placeholder. The navbar's "داشبورد" link is a
* UI placeholder only — this file exists so the future dashboard
* page has a clean, ready-made data-fetching seam.
*/
export const dashboardService = {
/**
* @returns {Promise<Object>} summary metrics for the dashboard home
*/
async getSummary() {
// TODO: Connect Dashboard API
// return apiClient.get("/dashboard/summary").then((res) => res.data);
return Promise.resolve({
totalMachines: 0,
totalRevenue: 0,
activeLocations: 0,
});
},
};
export default dashboardService;
-30
View File
@@ -1,30 +0,0 @@
import apiClient from "./axios";
import productsData from "@/data/products.json";
/**
* Product service — every method returns a Promise so components
* and Redux thunks can call it the same way once the real backend
* exists. Until then, each method resolves with local mock data.
*/
export const productService = {
/**
* @returns {Promise<Array>} list of vending machine products
*/
async getAll() {
// TODO: Connect Product API
// return apiClient.get("/products").then((res) => res.data);
return Promise.resolve(productsData);
},
/**
* @param {string} id
* @returns {Promise<Object|undefined>}
*/
async getById(id) {
// TODO: Connect Product API
// return apiClient.get(`/products/${id}`).then((res) => res.data);
return Promise.resolve(productsData.find((product) => product.id === id));
},
};
export default productService;
-13
View File
@@ -1,13 +0,0 @@
import { BREAKPOINTS } from "@/constants/theme";
/**
* GSAP matchMedia query strings, kept in one place so every
* component that needs responsive-aware animation branches
* (desktop / tablet / mobile) stays consistent.
*/
export const MQ = {
desktop: `(min-width: ${BREAKPOINTS.tablet + 1}px)`,
tablet: `(min-width: ${BREAKPOINTS.mobile + 1}px) and (max-width: ${BREAKPOINTS.tablet}px)`,
mobile: `(max-width: ${BREAKPOINTS.mobile}px)`,
reducedMotion: "(prefers-reduced-motion: reduce)",
};
-9
View File
@@ -1,9 +0,0 @@
/**
* Tiny classNames combinator so we avoid a template-literal mess
* across components. Falsy values are filtered out.
* @param {...(string|false|null|undefined)} classes
* @returns {string}
*/
export function cn(...classes) {
return classes.filter(Boolean).join(" ");
}
-10
View File
@@ -1,10 +0,0 @@
/**
* Product prices already arrive pre-formatted in Rial from the
* data source (e.g. "ریال۳.۸۶۰.۰۰۰.۰۰۰"). This helper exists as a
* single seam to change formatting once real API data lands.
* @param {string} price
* @returns {string}
*/
export function formatPrice(price) {
return price;
}
-16
View File
@@ -1,16 +0,0 @@
"use client";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
/**
* GSAP plugins are registered exactly once here. Every animation
* component imports `gsap` from this file (re-exported below)
* instead of importing "gsap" directly, so registration always
* runs before any timeline is built.
*/
if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger);
}
export { gsap, ScrollTrigger };
+35 -36
View File
@@ -1,58 +1,57 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
darkMode: 'class',
content: [ content: [
"./src/app/**/*.{js,jsx}", './app/**/*.{js,jsx,ts,tsx}',
"./src/components/**/*.{js,jsx}", './components/**/*.{js,jsx,ts,tsx}',
], ],
theme: { theme: {
extend: { extend: {
colors: { colors: {
primary: { brand: {
DEFAULT: "#00A693", DEFAULT: '#00a693',
dark: "#063D39", 50: '#e6f7f4',
accent: "#19D7C2", 100: '#c0ebe3',
light: "#D8FFF8", 200: '#8fdccc',
300: '#5cccb4',
400: '#2eba9c',
500: '#00a693',
600: '#008c7c',
700: '#017065',
800: '#08554e',
900: '#0b3d38',
950: '#062522',
}, },
surface: { ink: {
DEFAULT: "#F7FBFB", 950: '#04120f',
900: '#071e19',
800: '#0c2b24',
}, },
}, },
fontFamily: { fontFamily: {
vazir: ["var(--font-vazirmatn)", "Tahoma", "sans-serif"], vazir: ['var(--font-vazirmatn)', 'Tahoma', 'sans-serif'],
},
backgroundImage: {
"brand-gradient":
"linear-gradient(135deg, #00A693 0%, #19D7C2 50%, #063D39 100%)",
"brand-radial":
"radial-gradient(60% 60% at 50% 40%, #19D7C2 0%, rgba(25,215,194,0) 70%)",
},
boxShadow: {
glass: "0 8px 32px 0 rgba(6, 61, 57, 0.12)",
"glass-lg": "0 20px 60px -10px rgba(6, 61, 57, 0.25)",
glow: "0 0 40px rgba(25, 215, 194, 0.35)",
},
backdropBlur: {
xs: "2px",
},
borderRadius: {
"4xl": "2rem",
"5xl": "2.5rem",
}, },
keyframes: { keyframes: {
float: { floaty: {
"0%, 100%": { transform: "translateY(0px)" }, '0%, 100%': { transform: 'translateY(0) rotate(0deg)' },
"50%": { transform: "translateY(-14px)" }, '50%': { transform: 'translateY(-18px) rotate(4deg)' },
}, },
"pulse-glow": { pulseSoft: {
"0%, 100%": { opacity: 0.5 }, '0%, 100%': { opacity: '0.6' },
"50%": { opacity: 1 }, '50%': { opacity: '1' },
}, },
}, },
animation: { animation: {
float: "float 6s ease-in-out infinite", floaty: 'floaty 6s ease-in-out infinite',
"pulse-glow": "pulse-glow 4s ease-in-out infinite", pulseSoft: 'pulseSoft 2.4s ease-in-out infinite',
},
boxShadow: {
brandGlow: '0 0 60px -10px rgba(0, 166, 147, 0.55)',
}, },
}, },
}, },
plugins: [], plugins: [],
corePlugins: {
preflight: true,
},
}; };