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
|
node_modules/
|
||||||
.next
|
.next/
|
||||||
out
|
out/
|
||||||
.env*
|
.env*.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
npm-debug.log*
|
||||||
|
|||||||
@@ -1,6 +1,58 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="PropertiesComponent">{}</component>
|
<component name="ChangeListManager">
|
||||||
|
<list default="true" id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="update">
|
||||||
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/jsconfig.json" beforeDir="false" afterPath="$PROJECT_DIR$/jsconfig.json" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/app/globals.css" beforeDir="false" afterPath="$PROJECT_DIR$/src/app/globals.css" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/app/page.js" beforeDir="false" afterPath="$PROJECT_DIR$/src/app/page.js" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/components/animations/ScrollReveal.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/animations/ScrollReveal.jsx" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/components/hero/FloatingProducts.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/hero/FloatingProducts.jsx" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/components/hero/Hero.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/hero/Hero.jsx" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/components/hero/MachineAssembly.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/hero/MachineAssembly.jsx" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/components/layout/Navbar.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/layout/Navbar.jsx" afterDir="false" />
|
||||||
|
</list>
|
||||||
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
|
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||||
|
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||||
|
</component>
|
||||||
|
<component name="Git.Settings">
|
||||||
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
|
</component>
|
||||||
|
<component name="JsbtTreeLayoutManager">
|
||||||
|
<layout place="tools.popupnpm">
|
||||||
|
<scroll-view-position x="0" y="0" />
|
||||||
|
</layout>
|
||||||
|
</component>
|
||||||
|
<component name="ProjectColorInfo">{
|
||||||
|
"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">
|
<component name="RunManager" selected="npm.Next.js: server-side">
|
||||||
<configuration name="Next.js: debug client-side" type="JavascriptDebugType" uri="http://localhost:3000/">
|
<configuration name="Next.js: debug client-side" type="JavascriptDebugType" uri="http://localhost:3000/">
|
||||||
<method v="2" />
|
<method v="2" />
|
||||||
@@ -16,4 +68,39 @@
|
|||||||
<method v="2" />
|
<method v="2" />
|
||||||
</configuration>
|
</configuration>
|
||||||
</component>
|
</component>
|
||||||
|
<component name="SharedIndexes">
|
||||||
|
<attachedChunks>
|
||||||
|
<set>
|
||||||
|
<option value="bundled-js-predefined-d6986cc7102b-31caf2ab9e3c-JavaScript-WS-261.23567.141" />
|
||||||
|
</set>
|
||||||
|
</attachedChunks>
|
||||||
|
</component>
|
||||||
|
<component name="TaskManager">
|
||||||
|
<task active="true" id="Default" summary="Default task">
|
||||||
|
<changelist id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="" />
|
||||||
|
<created>1785394346997</created>
|
||||||
|
<option name="number" value="Default" />
|
||||||
|
<option name="presentableId" value="Default" />
|
||||||
|
<updated>1785394346997</updated>
|
||||||
|
<workItem from="1785394348169" duration="14851000" />
|
||||||
|
<workItem from="1785556493808" duration="2292000" />
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00001" summary="update">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1785394671723</created>
|
||||||
|
<option name="number" value="00001" />
|
||||||
|
<option name="presentableId" value="LOCAL-00001" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1785394671723</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="2" />
|
||||||
|
<servers />
|
||||||
|
</component>
|
||||||
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
|
<option name="version" value="3" />
|
||||||
|
</component>
|
||||||
|
<component name="VcsManagerConfiguration">
|
||||||
|
<MESSAGE value="update" />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="update" />
|
||||||
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -1,138 +1,132 @@
|
|||||||
# روبات مارکت — Robot Market Landing
|
# روبات مارکت — Vending Machine Showcase
|
||||||
|
|
||||||
A cinematic, RTL, Persian-language landing page for **Robot Market**, a
|
پروژه Next.js (App Router) صفحه معرفی محصولات «روبات مارکت»، با انیمیشنهای GSAP/ScrollTrigger،
|
||||||
smart refrigerated vending-machine brand. Built with Next.js (App Router),
|
مدیریت سبد خرید با Redux Toolkit، حالت تیره/روشن، دو زبانه (فارسی/انگلیسی)، نقشه، فرم تیکت
|
||||||
Tailwind CSS, Ant Design, Redux Toolkit, Axios and GSAP + ScrollTrigger.
|
پشتیبانی، و ویجت چت.
|
||||||
|
|
||||||
The centerpiece of the page is a scroll-driven hero animation: scattered
|
## اجرای پروژه
|
||||||
SVG pieces fly together into a fully assembled vending machine as the user
|
|
||||||
scrolls, followed by a three-act "scroll story" introducing each of the
|
|
||||||
three product models.
|
|
||||||
|
|
||||||
## Getting started
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:3000.
|
سپس `http://localhost:3000` را باز کنید.
|
||||||
|
|
||||||
```bash
|
## ساختار پروژه
|
||||||
npm run build # production build
|
|
||||||
npm run start # serve the production build
|
|
||||||
npm run lint # eslint
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tech stack
|
|
||||||
|
|
||||||
| Concern | Library |
|
|
||||||
| ------------------- | -------------------------------- |
|
|
||||||
| Framework | Next.js 14 (App Router, JS only) |
|
|
||||||
| Styling | Tailwind CSS |
|
|
||||||
| Component library | Ant Design (RTL-configured) |
|
|
||||||
| State management | Redux Toolkit |
|
|
||||||
| HTTP client | Axios |
|
|
||||||
| Animation | GSAP + ScrollTrigger + @gsap/react |
|
|
||||||
| Icons | react-icons |
|
|
||||||
| Font | Vazirmatn (next/font/google) |
|
|
||||||
|
|
||||||
## Project structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
app/
|
||||||
app/ Routes (App Router): layout, page, dashboard,
|
layout.jsx # پوسته HTML، فونت وزیرمتن
|
||||||
robots.js, sitemap.js, manifest.js, loading, 404
|
page.jsx # چیدمان کامل صفحه
|
||||||
components/
|
providers.jsx # Redux + Theme + Language + AntD ConfigProvider
|
||||||
common/ Container, Section, GlassCard, MagneticButton
|
globals.css # توکنهای رنگ دارک/لایت، جلوه اسپاتلایت، تایپرایتر
|
||||||
layout/ Navbar, Footer, About, ContactCta
|
context/
|
||||||
hero/ Hero, HeroBackground, MachineAssembly, FloatingProducts
|
ThemeContext.jsx # حالت دارک/لایت (ذخیره در localStorage)
|
||||||
products/ ProductsShowcase, ProductSection, MachineVisual
|
LanguageContext.jsx # زبان فا/en (ذخیره در localStorage + جهت صفحه)
|
||||||
animations/ ScrollReveal (shared scroll-in wrapper)
|
store/ # Redux (سبد خرید)
|
||||||
ui/ AntThemeProvider (Ant Design RTL + token bridge)
|
data/
|
||||||
redux/
|
content.js # منبع واحد محتوای دوزبانه (fa/en) — همهی متنها اینجاست
|
||||||
store.js Store factory (per-request, App-Router-safe)
|
components/
|
||||||
provider.jsx Client <StoreProvider>
|
Header.jsx # هدر + سوییچ زبان/تم + دکمه ورود به پنل کاربری
|
||||||
slices/ auth, products, ui
|
ThemeToggle.jsx / LanguageToggle.jsx
|
||||||
services/
|
HeroSection.jsx # هدلاین تایپشونده + پسزمینه رنگی در حال چرخش
|
||||||
axios.js Shared Axios instance + interceptors
|
HorizontalShowcase.jsx # اسکرول افقی پینشده + ویدیوی پسزمینه
|
||||||
auth.service.js // TODO: Connect Login API
|
VendingCard.jsx # کارت دستگاه + جلوه اسپاتلایت
|
||||||
product.service.js // TODO: Connect Product API
|
FloatingItems.jsx
|
||||||
dashboard.service.js // TODO: Connect Dashboard API
|
FeaturesSection.jsx
|
||||||
hooks/ useReducedMotion, useIsomorphicLayoutEffect
|
AboutSection.jsx
|
||||||
utils/ cn, formatPrice, gsapConfig, breakpoints
|
LocationsSection.jsx
|
||||||
constants/ theme tokens, nav links
|
MapSection.jsx # نقشه Neshan (کلید API را خودتان اضافه میکنید)
|
||||||
data/ products.json, floatingProducts.json (mock data)
|
CatalogSection.jsx # کاتالوگ محصولات قابل سفارش
|
||||||
|
IntroVideoSection.jsx # ویدیوی معرفی شرکت
|
||||||
|
TicketForm.jsx # فرم تیکت پشتیبانی
|
||||||
|
ChatWidget.jsx # آیکون شناور چت + مودال گفتگو
|
||||||
|
CartDrawer.jsx
|
||||||
|
Footer.jsx
|
||||||
public/
|
public/
|
||||||
svg/machines/machine35|48|60/ Assembly pieces: base, body, door, panel, screen, shelves
|
images/, gifs/, video/ # داراییهای placeholder — در پیام قبلی توضیح داده شده
|
||||||
svg/floating/ chip, cola, drink, cookie, snack, juice
|
|
||||||
icons/ Brand icon + OG image placeholders (SVG)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Replacing placeholder assets
|
## ویژگیهای اضافهشده در این نسخه
|
||||||
|
|
||||||
Every visual asset in `public/svg` is a deliberately simple placeholder —
|
### نقشه (Neshan)
|
||||||
swap the files in place (same filenames) and nothing else needs to change:
|
`components/MapSection.jsx` از **Neshan Static Map API** استفاده میکند (یک تصویر ساده، بدون نیاز
|
||||||
|
به اسکریپت/SDK جاوااسکریپت). کلید API را اینجا وارد کنید:
|
||||||
|
|
||||||
- `public/svg/machines/<model>/*.svg` — the 6 pieces GSAP assembles/positions.
|
1. یک فایل `.env.local` در ریشه پروژه بسازید (از روی `.env.local.example` کپی کنید)
|
||||||
Keep the same `viewBox="0 0 400 700"` coordinate space so pieces still
|
2. مقدار `NEXT_PUBLIC_NESHAN_API_KEY` را با کلید واقعی خودتان از
|
||||||
line up when assembled, or update the piece list in `src/data/products.json`.
|
[platform.neshan.org](https://platform.neshan.org) پر کنید
|
||||||
- `public/svg/floating/*.svg` — the floating product icons around the hero.
|
|
||||||
- `public/icons/*.svg` — brand icon and Open Graph placeholder. Replace the
|
|
||||||
OG placeholder with a real 1200×630 raster (`.png`/`.jpg`) before launch
|
|
||||||
and update the reference in `src/app/layout.js`.
|
|
||||||
|
|
||||||
## Connecting the real API
|
تا وقتی کلید خالی باشد، بهجای نقشه یک پیام راهنما («برای فعال شدن نقشه، کلید API نشان را در
|
||||||
|
تنظیمات پروژه قرار دهید») نمایش داده میشود — سایت خراب نمیشود. نقشه در حالت تیره/روشن هم استایل
|
||||||
|
متفاوتی میگیرد (`standard-night` / `standard-day`). لینک «مشاهده در نشان» کنار هر آدرس به نقشه
|
||||||
|
تعاملی neshan.org میرود و نیازی به کلید ندارد. مختصات فعلی در `data/content.js`
|
||||||
|
(`mapLocations`) تقریبی هستند — با پین دقیق جایگزین کنید.
|
||||||
|
|
||||||
Nothing here talks to a real backend yet. Each service method already has
|
### کاتالوگ محصولات
|
||||||
the exact call commented out — uncomment and remove the mock `Promise.resolve`:
|
`components/CatalogSection.jsx` — گرید محصولاتی (زعفران، تنقلات، نوشیدنی، قهوه، چای و ...) که
|
||||||
|
مشتریان دستگاه میتوانند با قیمت کمتر از بازار سفارش دهند. هر آیتم قیمت بازار (خطخورده) و قیمت
|
||||||
|
ویژه را نشان میدهد و دکمه «افزودن به سفارش» آن را به همان سبد خرید Redux اضافه میکند (در کنار
|
||||||
|
دستگاهها). لیست محصولات، قیمتها و تصاویر در `data/content.js` (کلید `catalog`) قابل ویرایش است؛
|
||||||
|
تصاویر placeholder در `public/images/catalog/` قرار دارند.
|
||||||
|
|
||||||
```js
|
### سکشن ویدیوی معرفی شرکت
|
||||||
// src/services/product.service.js
|
`components/IntroVideoSection.jsx` — یک پلیر ویدیوی مستقل (جدا از ویدیوی پسزمینه اسکرول افقی) با
|
||||||
async getAll() {
|
پوستر و دکمه پخش استاندارد مرورگر. برای جایگزینی، فایل واقعی را در
|
||||||
// TODO: Connect Product API
|
`public/video/company-intro-placeholder.mp4` بگذارید (یا مسیر `introVideoSrc` را در
|
||||||
return apiClient.get("/products").then((res) => res.data);
|
`data/content.js` عوض کنید)؛ پوستر هم از `public/images/video-poster.svg` میآید.
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Set `NEXT_PUBLIC_API_BASE_URL` in `.env.local` (see `.env.example`).
|
### رنگبندی متناوب سکشنها
|
||||||
|
هر سکشن اصلی صفحه (بهجز هیرو و بخش اسکرول افقی که پسزمینه مخصوص خودشان را دارند) بهترتیب بین سه
|
||||||
|
تن رنگی برند میچرخد: `bg-page` → `bg-surface` → `bg-elevated` → ... بههمراه یک خط جداکننده ظریف
|
||||||
|
بالای هر بخش. اینها همان توکنهای دارک/لایت هستند، پس با تغییر تم هم هماهنگ میمانند. برای تغییر
|
||||||
|
ترتیب یا اضافه کردن سکشن جدید، کافیست یکی از این سه کلاس را روی `<section>` بگذارید.
|
||||||
|
|
||||||
## Motion & accessibility notes
|
### حالت دارک/لایت
|
||||||
|
دکمه سوییچ در هدر (آیکون خورشید/ماه). ترجیح در `localStorage` ذخیره میشود و در اولین بار بر اساس
|
||||||
|
تنظیمات سیستم کاربر تعیین میشود. رنگبندی از طریق متغیرهای CSS در `app/globals.css`
|
||||||
|
(`--color-bg-page`, `--color-text-primary`, ...) و کلاسهای کمکی `bg-page` / `bg-surface` /
|
||||||
|
`text-primary` / `text-secondary` / `border-subtle` مدیریت میشود؛ برای هر رنگ جدید فقط همینجا را
|
||||||
|
ویرایش کنید. تم Ant Design هم بهصورت خودکار (`darkAlgorithm` / `defaultAlgorithm`) هماهنگ میشود.
|
||||||
|
|
||||||
- **Scroll-locked product story.** Each `ProductSection` reports its DOM
|
### دو زبانه (فارسی / انگلیسی)
|
||||||
nodes up to `ProductsShowcase`, which builds and pins all three
|
دکمه «EN / فا» در هدر. تمام متنهای سایت (منو، هیرو، محصولات، درباره ما، مکانهای نصب، فرم تیکت،
|
||||||
machines' `ScrollTrigger` timelines together in one place (fixing a bug
|
چت، فوتر) از `data/content.js` خوانده میشوند — یک آبجکت با کلیدهای `fa` و `en`. تغییر زبان همچنین
|
||||||
where wide/desktop viewports could fail to reveal machines after the
|
جهت صفحه (`dir`) و `lang` تگ `html` را بهروزرسانی میکند. برای اضافه یا اصلاح متن، فقط
|
||||||
first one — see the comment at the top of `ProductsShowcase.jsx`), then
|
`data/content.js` را ویرایش کنید؛ کامپوننتها نیازی به تغییر ندارند.
|
||||||
calls `ScrollTrigger.refresh()` once every measurement has settled.
|
|
||||||
Scrolling literally cannot advance past a pinned section until its
|
|
||||||
reveal finishes — only then does the next machine begin.
|
|
||||||
- **Vivid, temperature-coded background per machine.** Each product
|
|
||||||
carries its own `bgColor` in `src/data/products.json`, and
|
|
||||||
`ProductsShowcase` cross-fades a single fixed color layer to that color
|
|
||||||
over the *exact same* scroll range the section uses to pin — so the
|
|
||||||
backdrop finishes changing exactly as the machine settles into place.
|
|
||||||
The three models deliberately alternate cool → warm → cool: a vivid icy
|
|
||||||
cyan-blue (`#12C4E0`) for the entry model, a warm coral-orange
|
|
||||||
(`#FF7A45`) for the mid-tier, and a cool emerald-teal (`#12D6A8`) for
|
|
||||||
the flagship — all bright, cheerful tones rather than muted brand
|
|
||||||
neutrals, chosen specifically for this section.
|
|
||||||
- **Floating drinks/snacks per section.** `SectionFloatingItems.jsx`
|
|
||||||
places a small set of product icons (soda, mineral water, juice,
|
|
||||||
snacks) drifting gently around each machine — which items appear is
|
|
||||||
configurable per product via `floatingItemIds` in `products.json`.
|
|
||||||
- All GSAP animations check `prefers-reduced-motion` and degrade to simple
|
|
||||||
opacity fades (or skip entirely) when it's set.
|
|
||||||
- `gsap.matchMedia()` is used in both `MachineAssembly` (hero) and
|
|
||||||
`ProductSection` to give desktop/tablet/mobile different scroll
|
|
||||||
distances/intensity.
|
|
||||||
- Focus states, semantic landmarks (`<header>`, `<main>`, `<footer>`,
|
|
||||||
`<nav>`), and ARIA labels are included throughout.
|
|
||||||
|
|
||||||
## SEO / PWA
|
> نکته فنی: بخش اسکرول افقی (`HorizontalShowcase`) بهعمد همیشه `dir="ltr"` روی خود track دارد
|
||||||
|
> (چون محاسبات GSAP به آن نیاز دارد)، اما هر کارت داخلش جهت متن را بر اساس زبان فعال تنظیم میکند.
|
||||||
|
|
||||||
- Metadata API with OpenGraph, Twitter cards, and canonical URL in
|
### فرم تیکت پشتیبانی
|
||||||
`src/app/layout.js`.
|
`components/TicketForm.jsx` — فرم Ant Design با اعتبارسنجی (نام، تلفن، ایمیل اختیاری، موضوع،
|
||||||
- JSON-LD `Organization` schema inlined in the root layout.
|
اولویت، پیام). ارسال فعلاً فقط یک پیام موفقیت نمایش میدهد؛ بلوک کامنتشده
|
||||||
- `robots.js`, `sitemap.js`, and `manifest.js` are implemented as Next.js
|
`// TODO: Connect to the real ticketing/support API` دقیقاً نشان میدهد کجا باید axios را وصل کنید.
|
||||||
route handlers (App Router convention) rather than static files.
|
|
||||||
|
### ویجت چت
|
||||||
|
دکمه شناور گوشه صفحه (`components/ChatWidget.jsx`) یک مودال گفتگوی ساده باز میکند. پیامها فعلاً
|
||||||
|
در state محلی نگهداری میشوند و یک پاسخ خودکار نمایشی میدهند. بلوک
|
||||||
|
`// TODO: Connect to a real chat/support backend` محل اتصال به بکاند واقعی (یا وبسوکت) را مشخص
|
||||||
|
کرده.
|
||||||
|
|
||||||
|
### هیرو: تایپرایتر + پسزمینه رنگی در حال تغییر
|
||||||
|
هدلاین با افکت تایپرایتر بین چند جمله (`data/content.js` → `hero.typingPhrases`) میچرخد.
|
||||||
|
پسزمینه بهصورت حلقهای بین سبز برند (`#00a693`)، مشکی و سفید تغییر رنگ میدهد (با GSAP). یک لایه
|
||||||
|
scrim تیره روی آن قرار دارد تا متن در هر سه حالت خوانا بماند. وقتی ویدیوی هیرو را آماده کردید،
|
||||||
|
داخل `components/HeroSection.jsx` یک بلوک `TODO` کامنتشده دقیقاً نشان میدهد چطور بهجای پسزمینه
|
||||||
|
رنگی، ویدیو را جایگزین کنید (فقط از حالت کامنت خارجش کنید).
|
||||||
|
|
||||||
|
### ورود به پنل کاربری
|
||||||
|
دکمه «ورود به پنل کاربری» / «Sign In» در هدر اضافه شده (در `components/Header.jsx`). چون گفتید
|
||||||
|
لینکدهی را خودتان انجام میدهید، فعلاً فقط یک `onClick` خالی با کامنت TODO دارد — مسیر واقعی پنل
|
||||||
|
کاربری را همانجا وصل کنید.
|
||||||
|
|
||||||
|
## جایگزینی داراییهای واقعی (تصاویر/گیف/ویدیو)
|
||||||
|
همانطور که قبلاً توضیح داده شد، فایلهای داخل `public/images`, `public/gifs`, `public/video`
|
||||||
|
placeholder هستند. برای جایگزینی، فایل جدید را با همان نام/مسیر جایگزین کنید یا مسیر را در
|
||||||
|
`data/content.js` تغییر دهید.
|
||||||
|
|
||||||
|
## اتصال درگاه پرداخت
|
||||||
|
در `components/CartDrawer.jsx` داخل `handleCheckout` بلوک `// TODO: Connect to payment gateway API`
|
||||||
|
با نمونهکد axios کامنتشده وجود دارد.
|
||||||
|
|||||||
@@ -2,15 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"],
|
"@/*": ["./*"]
|
||||||
"@components/*": ["src/components/*"],
|
|
||||||
"@redux/*": ["src/redux/*"],
|
|
||||||
"@services/*": ["src/services/*"],
|
|
||||||
"@hooks/*": ["src/hooks/*"],
|
|
||||||
"@utils/*": ["src/utils/*"],
|
|
||||||
"@constants/*": ["src/constants/*"],
|
|
||||||
"@data/*": ["src/data/*"]
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"exclude": ["node_modules", ".next"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Robot Market — cinematic vending machine landing page",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -14,20 +13,15 @@
|
|||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"antd": "5.19.1",
|
"antd": "5.19.1",
|
||||||
"@ant-design/nextjs-registry": "1.0.1",
|
|
||||||
"@ant-design/icons": "5.3.7",
|
"@ant-design/icons": "5.3.7",
|
||||||
"@reduxjs/toolkit": "2.2.6",
|
"@reduxjs/toolkit": "2.2.7",
|
||||||
"react-redux": "9.1.2",
|
"react-redux": "9.1.2",
|
||||||
"axios": "1.7.2",
|
"axios": "1.7.2",
|
||||||
"gsap": "3.12.5",
|
"gsap": "3.12.5"
|
||||||
"@gsap/react": "2.1.1",
|
|
||||||
"react-icons": "5.2.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"autoprefixer": "10.4.19",
|
"autoprefixer": "10.4.19",
|
||||||
"postcss": "8.4.39",
|
"postcss": "8.4.39",
|
||||||
"tailwindcss": "3.4.6",
|
"tailwindcss": "3.4.6"
|
||||||
"eslint": "8.57.0",
|
|
||||||
"eslint-config-next": "14.2.5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
darkMode: 'class',
|
||||||
content: [
|
content: [
|
||||||
"./src/app/**/*.{js,jsx}",
|
'./app/**/*.{js,jsx,ts,tsx}',
|
||||||
"./src/components/**/*.{js,jsx}",
|
'./components/**/*.{js,jsx,ts,tsx}',
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
primary: {
|
brand: {
|
||||||
DEFAULT: "#00A693",
|
DEFAULT: '#00a693',
|
||||||
dark: "#063D39",
|
50: '#e6f7f4',
|
||||||
accent: "#19D7C2",
|
100: '#c0ebe3',
|
||||||
light: "#D8FFF8",
|
200: '#8fdccc',
|
||||||
|
300: '#5cccb4',
|
||||||
|
400: '#2eba9c',
|
||||||
|
500: '#00a693',
|
||||||
|
600: '#008c7c',
|
||||||
|
700: '#017065',
|
||||||
|
800: '#08554e',
|
||||||
|
900: '#0b3d38',
|
||||||
|
950: '#062522',
|
||||||
},
|
},
|
||||||
surface: {
|
ink: {
|
||||||
DEFAULT: "#F7FBFB",
|
950: '#04120f',
|
||||||
|
900: '#071e19',
|
||||||
|
800: '#0c2b24',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
vazir: ["var(--font-vazirmatn)", "Tahoma", "sans-serif"],
|
vazir: ['var(--font-vazirmatn)', 'Tahoma', 'sans-serif'],
|
||||||
},
|
|
||||||
backgroundImage: {
|
|
||||||
"brand-gradient":
|
|
||||||
"linear-gradient(135deg, #00A693 0%, #19D7C2 50%, #063D39 100%)",
|
|
||||||
"brand-radial":
|
|
||||||
"radial-gradient(60% 60% at 50% 40%, #19D7C2 0%, rgba(25,215,194,0) 70%)",
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
glass: "0 8px 32px 0 rgba(6, 61, 57, 0.12)",
|
|
||||||
"glass-lg": "0 20px 60px -10px rgba(6, 61, 57, 0.25)",
|
|
||||||
glow: "0 0 40px rgba(25, 215, 194, 0.35)",
|
|
||||||
},
|
|
||||||
backdropBlur: {
|
|
||||||
xs: "2px",
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
"4xl": "2rem",
|
|
||||||
"5xl": "2.5rem",
|
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
float: {
|
floaty: {
|
||||||
"0%, 100%": { transform: "translateY(0px)" },
|
'0%, 100%': { transform: 'translateY(0) rotate(0deg)' },
|
||||||
"50%": { transform: "translateY(-14px)" },
|
'50%': { transform: 'translateY(-18px) rotate(4deg)' },
|
||||||
},
|
},
|
||||||
"pulse-glow": {
|
pulseSoft: {
|
||||||
"0%, 100%": { opacity: 0.5 },
|
'0%, 100%': { opacity: '0.6' },
|
||||||
"50%": { opacity: 1 },
|
'50%': { opacity: '1' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
float: "float 6s ease-in-out infinite",
|
floaty: 'floaty 6s ease-in-out infinite',
|
||||||
"pulse-glow": "pulse-glow 4s ease-in-out infinite",
|
pulseSoft: 'pulseSoft 2.4s ease-in-out infinite',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
brandGlow: '0 0 60px -10px rgba(0, 166, 147, 0.55)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
corePlugins: {
|
||||||
|
preflight: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||