update
@@ -1,2 +0,0 @@
|
||||
# Base URL for the future backend API
|
||||
NEXT_PUBLIC_API_BASE_URL=https://api.robotmarket.example.com
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
.env*
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
.env*.local
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
@@ -1,6 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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">{
|
||||
"customColor": "",
|
||||
"associatedIndex": 2
|
||||
}</component>
|
||||
<component name="ProjectId" id="3HDEfy1HyzjDcELlwvnRRXxPltq" />
|
||||
<component name="ProjectViewState">
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent">{
|
||||
"keyToString": {
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||
"git-widget-placeholder": "main",
|
||||
"js.debugger.nextJs.config.created.client": "true",
|
||||
"js.debugger.nextJs.config.created.server": "true",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"settings.editor.selected.configurable": "editor.preferences.fonts.default",
|
||||
"ts.external.directory.path": "C:\\Program Files\\JetBrains\\WebStorm 2026.1.1\\plugins\\javascript-plugin\\jsLanguageServicesImpl\\external",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}</component>
|
||||
<component name="RunManager" selected="npm.Next.js: server-side">
|
||||
<configuration name="Next.js: debug client-side" type="JavascriptDebugType" uri="http://localhost:3000/">
|
||||
<method v="2" />
|
||||
@@ -16,4 +68,39 @@
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</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>
|
||||
@@ -1,138 +1,132 @@
|
||||
# روبات مارکت — Robot Market Landing
|
||||
# روبات مارکت — Vending Machine Showcase
|
||||
|
||||
A cinematic, RTL, Persian-language landing page for **Robot Market**, a
|
||||
smart refrigerated vending-machine brand. Built with Next.js (App Router),
|
||||
Tailwind CSS, Ant Design, Redux Toolkit, Axios and GSAP + ScrollTrigger.
|
||||
پروژه Next.js (App Router) صفحه معرفی محصولات «روبات مارکت»، با انیمیشنهای GSAP/ScrollTrigger،
|
||||
مدیریت سبد خرید با Redux Toolkit، حالت تیره/روشن، دو زبانه (فارسی/انگلیسی)، نقشه، فرم تیکت
|
||||
پشتیبانی، و ویجت چت.
|
||||
|
||||
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
|
||||
npm install
|
||||
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/ Routes (App Router): layout, page, dashboard,
|
||||
robots.js, sitemap.js, manifest.js, loading, 404
|
||||
components/
|
||||
common/ Container, Section, GlassCard, MagneticButton
|
||||
layout/ Navbar, Footer, About, ContactCta
|
||||
hero/ Hero, HeroBackground, MachineAssembly, FloatingProducts
|
||||
products/ ProductsShowcase, ProductSection, MachineVisual
|
||||
animations/ ScrollReveal (shared scroll-in wrapper)
|
||||
ui/ AntThemeProvider (Ant Design RTL + token bridge)
|
||||
redux/
|
||||
store.js Store factory (per-request, App-Router-safe)
|
||||
provider.jsx Client <StoreProvider>
|
||||
slices/ auth, products, ui
|
||||
services/
|
||||
axios.js Shared Axios instance + interceptors
|
||||
auth.service.js // TODO: Connect Login API
|
||||
product.service.js // TODO: Connect Product API
|
||||
dashboard.service.js // TODO: Connect Dashboard API
|
||||
hooks/ useReducedMotion, useIsomorphicLayoutEffect
|
||||
utils/ cn, formatPrice, gsapConfig, breakpoints
|
||||
constants/ theme tokens, nav links
|
||||
data/ products.json, floatingProducts.json (mock data)
|
||||
app/
|
||||
layout.jsx # پوسته HTML، فونت وزیرمتن
|
||||
page.jsx # چیدمان کامل صفحه
|
||||
providers.jsx # Redux + Theme + Language + AntD ConfigProvider
|
||||
globals.css # توکنهای رنگ دارک/لایت، جلوه اسپاتلایت، تایپرایتر
|
||||
context/
|
||||
ThemeContext.jsx # حالت دارک/لایت (ذخیره در localStorage)
|
||||
LanguageContext.jsx # زبان فا/en (ذخیره در localStorage + جهت صفحه)
|
||||
store/ # Redux (سبد خرید)
|
||||
data/
|
||||
content.js # منبع واحد محتوای دوزبانه (fa/en) — همهی متنها اینجاست
|
||||
components/
|
||||
Header.jsx # هدر + سوییچ زبان/تم + دکمه ورود به پنل کاربری
|
||||
ThemeToggle.jsx / LanguageToggle.jsx
|
||||
HeroSection.jsx # هدلاین تایپشونده + پسزمینه رنگی در حال چرخش
|
||||
HorizontalShowcase.jsx # اسکرول افقی پینشده + ویدیوی پسزمینه
|
||||
VendingCard.jsx # کارت دستگاه + جلوه اسپاتلایت
|
||||
FloatingItems.jsx
|
||||
FeaturesSection.jsx
|
||||
AboutSection.jsx
|
||||
LocationsSection.jsx
|
||||
MapSection.jsx # نقشه Neshan (کلید API را خودتان اضافه میکنید)
|
||||
CatalogSection.jsx # کاتالوگ محصولات قابل سفارش
|
||||
IntroVideoSection.jsx # ویدیوی معرفی شرکت
|
||||
TicketForm.jsx # فرم تیکت پشتیبانی
|
||||
ChatWidget.jsx # آیکون شناور چت + مودال گفتگو
|
||||
CartDrawer.jsx
|
||||
Footer.jsx
|
||||
public/
|
||||
svg/machines/machine35|48|60/ Assembly pieces: base, body, door, panel, screen, shelves
|
||||
svg/floating/ chip, cola, drink, cookie, snack, juice
|
||||
icons/ Brand icon + OG image placeholders (SVG)
|
||||
images/, gifs/, video/ # داراییهای placeholder — در پیام قبلی توضیح داده شده
|
||||
```
|
||||
|
||||
## Replacing placeholder assets
|
||||
## ویژگیهای اضافهشده در این نسخه
|
||||
|
||||
Every visual asset in `public/svg` is a deliberately simple placeholder —
|
||||
swap the files in place (same filenames) and nothing else needs to change:
|
||||
### نقشه (Neshan)
|
||||
`components/MapSection.jsx` از **Neshan Static Map API** استفاده میکند (یک تصویر ساده، بدون نیاز
|
||||
به اسکریپت/SDK جاوااسکریپت). کلید API را اینجا وارد کنید:
|
||||
|
||||
- `public/svg/machines/<model>/*.svg` — the 6 pieces GSAP assembles/positions.
|
||||
Keep the same `viewBox="0 0 400 700"` coordinate space so pieces still
|
||||
line up when assembled, or update the piece list in `src/data/products.json`.
|
||||
- `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`.
|
||||
1. یک فایل `.env.local` در ریشه پروژه بسازید (از روی `.env.local.example` کپی کنید)
|
||||
2. مقدار `NEXT_PUBLIC_NESHAN_API_KEY` را با کلید واقعی خودتان از
|
||||
[platform.neshan.org](https://platform.neshan.org) پر کنید
|
||||
|
||||
## 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
|
||||
async getAll() {
|
||||
// TODO: Connect Product API
|
||||
return apiClient.get("/products").then((res) => res.data);
|
||||
}
|
||||
```
|
||||
### سکشن ویدیوی معرفی شرکت
|
||||
`components/IntroVideoSection.jsx` — یک پلیر ویدیوی مستقل (جدا از ویدیوی پسزمینه اسکرول افقی) با
|
||||
پوستر و دکمه پخش استاندارد مرورگر. برای جایگزینی، فایل واقعی را در
|
||||
`public/video/company-intro-placeholder.mp4` بگذارید (یا مسیر `introVideoSrc` را در
|
||||
`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
|
||||
machines' `ScrollTrigger` timelines together in one place (fixing a bug
|
||||
where wide/desktop viewports could fail to reveal machines after the
|
||||
first one — see the comment at the top of `ProductsShowcase.jsx`), then
|
||||
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.
|
||||
### دو زبانه (فارسی / انگلیسی)
|
||||
دکمه «EN / فا» در هدر. تمام متنهای سایت (منو، هیرو، محصولات، درباره ما، مکانهای نصب، فرم تیکت،
|
||||
چت، فوتر) از `data/content.js` خوانده میشوند — یک آبجکت با کلیدهای `fa` و `en`. تغییر زبان همچنین
|
||||
جهت صفحه (`dir`) و `lang` تگ `html` را بهروزرسانی میکند. برای اضافه یا اصلاح متن، فقط
|
||||
`data/content.js` را ویرایش کنید؛ کامپوننتها نیازی به تغییر ندارند.
|
||||
|
||||
## SEO / PWA
|
||||
> نکته فنی: بخش اسکرول افقی (`HorizontalShowcase`) بهعمد همیشه `dir="ltr"` روی خود track دارد
|
||||
> (چون محاسبات GSAP به آن نیاز دارد)، اما هر کارت داخلش جهت متن را بر اساس زبان فعال تنظیم میکند.
|
||||
|
||||
- Metadata API with OpenGraph, Twitter cards, and canonical URL in
|
||||
`src/app/layout.js`.
|
||||
- JSON-LD `Organization` schema inlined in the root layout.
|
||||
- `robots.js`, `sitemap.js`, and `manifest.js` are implemented as Next.js
|
||||
route handlers (App Router convention) rather than static files.
|
||||
### فرم تیکت پشتیبانی
|
||||
`components/TicketForm.jsx` — فرم Ant Design با اعتبارسنجی (نام، تلفن، ایمیل اختیاری، موضوع،
|
||||
اولویت، پیام). ارسال فعلاً فقط یک پیام موفقیت نمایش میدهد؛ بلوک کامنتشده
|
||||
`// TODO: Connect to the real ticketing/support API` دقیقاً نشان میدهد کجا باید axios را وصل کنید.
|
||||
|
||||
### ویجت چت
|
||||
دکمه شناور گوشه صفحه (`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,15 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"name": "robot-market",
|
||||
"name": "robot-market-showcase",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Robot Market — cinematic vending machine landing page",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
@@ -14,20 +13,15 @@
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"antd": "5.19.1",
|
||||
"@ant-design/nextjs-registry": "1.0.1",
|
||||
"@ant-design/icons": "5.3.7",
|
||||
"@reduxjs/toolkit": "2.2.6",
|
||||
"@reduxjs/toolkit": "2.2.7",
|
||||
"react-redux": "9.1.2",
|
||||
"axios": "1.7.2",
|
||||
"gsap": "3.12.5",
|
||||
"@gsap/react": "2.1.1",
|
||||
"react-icons": "5.2.1"
|
||||
"gsap": "3.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "10.4.19",
|
||||
"postcss": "8.4.39",
|
||||
"tailwindcss": "3.4.6",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "14.2.5"
|
||||
"tailwindcss": "3.4.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export default function robots() {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/dashboard"],
|
||||
},
|
||||
sitemap: "https://robotmarket.example.com/sitemap.xml",
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%";
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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 }
|
||||
]
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)",
|
||||
};
|
||||
@@ -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(" ");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -1,58 +1,57 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
"./src/app/**/*.{js,jsx}",
|
||||
"./src/components/**/*.{js,jsx}",
|
||||
'./app/**/*.{js,jsx,ts,tsx}',
|
||||
'./components/**/*.{js,jsx,ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: "#00A693",
|
||||
dark: "#063D39",
|
||||
accent: "#19D7C2",
|
||||
light: "#D8FFF8",
|
||||
brand: {
|
||||
DEFAULT: '#00a693',
|
||||
50: '#e6f7f4',
|
||||
100: '#c0ebe3',
|
||||
200: '#8fdccc',
|
||||
300: '#5cccb4',
|
||||
400: '#2eba9c',
|
||||
500: '#00a693',
|
||||
600: '#008c7c',
|
||||
700: '#017065',
|
||||
800: '#08554e',
|
||||
900: '#0b3d38',
|
||||
950: '#062522',
|
||||
},
|
||||
surface: {
|
||||
DEFAULT: "#F7FBFB",
|
||||
ink: {
|
||||
950: '#04120f',
|
||||
900: '#071e19',
|
||||
800: '#0c2b24',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
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",
|
||||
vazir: ['var(--font-vazirmatn)', 'Tahoma', 'sans-serif'],
|
||||
},
|
||||
keyframes: {
|
||||
float: {
|
||||
"0%, 100%": { transform: "translateY(0px)" },
|
||||
"50%": { transform: "translateY(-14px)" },
|
||||
floaty: {
|
||||
'0%, 100%': { transform: 'translateY(0) rotate(0deg)' },
|
||||
'50%': { transform: 'translateY(-18px) rotate(4deg)' },
|
||||
},
|
||||
"pulse-glow": {
|
||||
"0%, 100%": { opacity: 0.5 },
|
||||
"50%": { opacity: 1 },
|
||||
pulseSoft: {
|
||||
'0%, 100%': { opacity: '0.6' },
|
||||
'50%': { opacity: '1' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
float: "float 6s ease-in-out infinite",
|
||||
"pulse-glow": "pulse-glow 4s ease-in-out infinite",
|
||||
floaty: 'floaty 6s 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: [],
|
||||
corePlugins: {
|
||||
preflight: true,
|
||||
},
|
||||
};
|
||||
|
||||