update
@@ -1 +1,2 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=https://api.robotmarket.ir/v1
|
||||
# Base URL for the future backend API
|
||||
NEXT_PUBLIC_API_BASE_URL=https://api.robotmarket.example.com
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
.env*.local
|
||||
.DS_Store
|
||||
out
|
||||
build
|
||||
.env*
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/robot-market-landing.iml" filepath="$PROJECT_DIR$/.idea/robot-market-landing.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,35 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="01b90d18-2bd3-488f-bee5-27ff6a73414d" name="Changes" comment="" />
|
||||
<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="ProjectColorInfo">{
|
||||
"associatedIndex": 2,
|
||||
"fromUser": false
|
||||
}</component>
|
||||
<component name="ProjectId" id="3H7KB12mCznfw4hRrFCPWKMzxsB" />
|
||||
<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.typescript.service.memoryLimit.init": "true",
|
||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||
"js.debugger.nextJs.config.created.client": "true",
|
||||
"js.debugger.nextJs.config.created.server": "true",
|
||||
"last_opened_file_path": "D:/Project/robot-market-landing/robot-market",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}</component>
|
||||
<component name="PropertiesComponent">{}</component>
|
||||
<component name="RunManager" selected="npm.Next.js: server-side">
|
||||
<configuration name="Next.js: debug client-side" type="JavascriptDebugType" uri="http://localhost:3000/">
|
||||
<method v="2" />
|
||||
@@ -45,26 +16,4 @@
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
<component name="SharedIndexes">
|
||||
<attachedChunks>
|
||||
<set>
|
||||
<option value="bundled-js-predefined-d6986cc7102b-31caf2ab9e3c-JavaScript-WS-261.23567.141" />
|
||||
</set>
|
||||
</attachedChunks>
|
||||
</component>
|
||||
<component name="TaskManager">
|
||||
<task active="true" id="Default" summary="Default task">
|
||||
<changelist id="01b90d18-2bd3-488f-bee5-27ff6a73414d" name="Changes" comment="" />
|
||||
<created>1785213528048</created>
|
||||
<option name="number" value="Default" />
|
||||
<option name="presentableId" value="Default" />
|
||||
<updated>1785213528048</updated>
|
||||
<workItem from="1785213529281" duration="2289000" />
|
||||
<workItem from="1785300366173" duration="6178000" />
|
||||
</task>
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
<option name="version" value="3" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,29 +1,138 @@
|
||||
# روبات مارکت — سایت نمایش محصولات
|
||||
# روبات مارکت — Robot Market Landing
|
||||
|
||||
## نصب و اجرا
|
||||
A cinematic, RTL, Persian-language landing page for **Robot Market**, a
|
||||
smart refrigerated vending-machine brand. Built with Next.js (App Router),
|
||||
Tailwind CSS, Ant Design, Redux Toolkit, Axios and GSAP + ScrollTrigger.
|
||||
|
||||
The centerpiece of the page is a scroll-driven hero animation: scattered
|
||||
SVG pieces fly together into a fully assembled vending machine as the user
|
||||
scrolls, followed by a three-act "scroll story" introducing each of the
|
||||
three product models.
|
||||
|
||||
## Getting started
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
سپس آدرس `http://localhost:3000` را در مرورگر باز کنید.
|
||||
Open 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/ # layout.js و page.js (App Router)
|
||||
components/ # کامپوننتهای بخشهای سایت
|
||||
data/ # محتوای متنی و محصولات (content.js)
|
||||
utils/api.js # نمونه Axios برای اتصال به API در آینده
|
||||
app/ Routes (App Router): layout, page, dashboard,
|
||||
robots.js, sitemap.js, manifest.js, loading, 404
|
||||
components/
|
||||
common/ Container, Section, GlassCard, MagneticButton
|
||||
layout/ Navbar, Footer, About, ContactCta
|
||||
hero/ Hero, HeroBackground, MachineAssembly, FloatingProducts
|
||||
products/ ProductsShowcase, ProductSection, MachineVisual
|
||||
animations/ ScrollReveal (shared scroll-in wrapper)
|
||||
ui/ AntThemeProvider (Ant Design RTL + token bridge)
|
||||
redux/
|
||||
store.js Store factory (per-request, App-Router-safe)
|
||||
provider.jsx Client <StoreProvider>
|
||||
slices/ auth, products, ui
|
||||
services/
|
||||
axios.js Shared Axios instance + interceptors
|
||||
auth.service.js // TODO: Connect Login API
|
||||
product.service.js // TODO: Connect Product API
|
||||
dashboard.service.js // TODO: Connect Dashboard API
|
||||
hooks/ useReducedMotion, useIsomorphicLayoutEffect
|
||||
utils/ cn, formatPrice, gsapConfig, breakpoints
|
||||
constants/ theme tokens, nav links
|
||||
data/ products.json, floatingProducts.json (mock data)
|
||||
public/
|
||||
svg/machines/machine35|48|60/ Assembly pieces: base, body, door, panel, screen, shelves
|
||||
svg/floating/ chip, cola, drink, cookie, snack, juice
|
||||
icons/ Brand icon + OG image placeholders (SVG)
|
||||
```
|
||||
|
||||
## نکات
|
||||
## Replacing placeholder assets
|
||||
|
||||
- رنگ اصلی برند در `tailwind.config.js` روی `#00a693` تنظیم شده است.
|
||||
- فونت وزیرمتن بهصورت خودکار از `next/font/google` بارگذاری میشود.
|
||||
- برای جایگزینی عکس واقعی دستگاه بهجای طرح SVG، فایل
|
||||
`src/components/VendingMachineParts.js` را ویرایش کنید.
|
||||
- برای اتصال فرم "سفارش و مشاوره" یا دکمه "افزودن به سبد" به بکاند واقعی،
|
||||
از توابع آماده در `src/utils/api.js` استفاده کنید.
|
||||
Every visual asset in `public/svg` is a deliberately simple placeholder —
|
||||
swap the files in place (same filenames) and nothing else needs to change:
|
||||
|
||||
- `public/svg/machines/<model>/*.svg` — the 6 pieces GSAP assembles/positions.
|
||||
Keep the same `viewBox="0 0 400 700"` coordinate space so pieces still
|
||||
line up when assembled, or update the piece list in `src/data/products.json`.
|
||||
- `public/svg/floating/*.svg` — the floating product icons around the hero.
|
||||
- `public/icons/*.svg` — brand icon and Open Graph placeholder. Replace the
|
||||
OG placeholder with a real 1200×630 raster (`.png`/`.jpg`) before launch
|
||||
and update the reference in `src/app/layout.js`.
|
||||
|
||||
## Connecting the real API
|
||||
|
||||
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`:
|
||||
|
||||
```js
|
||||
// src/services/product.service.js
|
||||
async getAll() {
|
||||
// TODO: Connect Product API
|
||||
return apiClient.get("/products").then((res) => res.data);
|
||||
}
|
||||
```
|
||||
|
||||
Set `NEXT_PUBLIC_API_BASE_URL` in `.env.local` (see `.env.example`).
|
||||
|
||||
## Motion & accessibility notes
|
||||
|
||||
- **Scroll-locked product story.** Each `ProductSection` reports its DOM
|
||||
nodes up to `ProductsShowcase`, which builds and pins all three
|
||||
machines' `ScrollTrigger` timelines together in one place (fixing a bug
|
||||
where wide/desktop viewports could fail to reveal machines after the
|
||||
first one — see the comment at the top of `ProductsShowcase.jsx`), then
|
||||
calls `ScrollTrigger.refresh()` once every measurement has settled.
|
||||
Scrolling literally cannot advance past a pinned section until its
|
||||
reveal finishes — only then does the next machine begin.
|
||||
- **Vivid, temperature-coded background per machine.** Each product
|
||||
carries its own `bgColor` in `src/data/products.json`, and
|
||||
`ProductsShowcase` cross-fades a single fixed color layer to that color
|
||||
over the *exact same* scroll range the section uses to pin — so the
|
||||
backdrop finishes changing exactly as the machine settles into place.
|
||||
The three models deliberately alternate cool → warm → cool: a vivid icy
|
||||
cyan-blue (`#12C4E0`) for the entry model, a warm coral-orange
|
||||
(`#FF7A45`) for the mid-tier, and a cool emerald-teal (`#12D6A8`) for
|
||||
the flagship — all bright, cheerful tones rather than muted brand
|
||||
neutrals, chosen specifically for this section.
|
||||
- **Floating drinks/snacks per section.** `SectionFloatingItems.jsx`
|
||||
places a small set of product icons (soda, mineral water, juice,
|
||||
snacks) drifting gently around each machine — which items appear is
|
||||
configurable per product via `floatingItemIds` in `products.json`.
|
||||
- All GSAP animations check `prefers-reduced-motion` and degrade to simple
|
||||
opacity fades (or skip entirely) when it's set.
|
||||
- `gsap.matchMedia()` is used in both `MachineAssembly` (hero) and
|
||||
`ProductSection` to give desktop/tablet/mobile different scroll
|
||||
distances/intensity.
|
||||
- Focus states, semantic landmarks (`<header>`, `<main>`, `<footer>`,
|
||||
`<nav>`), and ARIA labels are included throughout.
|
||||
|
||||
## SEO / PWA
|
||||
|
||||
- Metadata API with OpenGraph, Twitter cards, and canonical URL in
|
||||
`src/app/layout.js`.
|
||||
- JSON-LD `Organization` schema inlined in the root layout.
|
||||
- `robots.js`, `sitemap.js`, and `manifest.js` are implemented as Next.js
|
||||
route handlers (App Router convention) rather than static files.
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"jsx": "react"
|
||||
}
|
||||
"@/*": ["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,14 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "**",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,13 @@
|
||||
/** @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;
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "robot-market",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Robot Market — cinematic vending machine landing page",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
@@ -10,14 +11,23 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"gsap": "^3.12.5",
|
||||
"axios": "^1.7.2"
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"antd": "5.19.1",
|
||||
"@ant-design/nextjs-registry": "1.0.1",
|
||||
"@ant-design/icons": "5.3.7",
|
||||
"@reduxjs/toolkit": "2.2.6",
|
||||
"react-redux": "9.1.2",
|
||||
"axios": "1.7.2",
|
||||
"gsap": "3.12.5",
|
||||
"@gsap/react": "2.1.1",
|
||||
"react-icons": "5.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4"
|
||||
"autoprefixer": "10.4.19",
|
||||
"postcss": "8.4.39",
|
||||
"tailwindcss": "3.4.6",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "14.2.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 586 B |
@@ -0,0 +1,11 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 586 B |
@@ -0,0 +1,12 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 694 B |
@@ -0,0 +1,5 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 315 B |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 224 B |
@@ -0,0 +1,7 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 350 B |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 216 B |
@@ -0,0 +1,5 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 278 B |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 188 B |
@@ -0,0 +1,5 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 228 B |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 267 B |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 274 B |
@@ -0,0 +1,9 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 454 B |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 229 B |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 228 B |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 267 B |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 274 B |
@@ -0,0 +1,9 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 454 B |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 229 B |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 228 B |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 267 B |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 274 B |
@@ -0,0 +1,9 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 454 B |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 229 B |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,49 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,73 +2,70 @@
|
||||
@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: #f2fffd;
|
||||
color: #04211d;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-dark);
|
||||
font-family: var(--font-vazirmatn), Tahoma, sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: #00a693;
|
||||
color: #ffffff;
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Thin scrollbar, brand-tinted */
|
||||
/* Slim, brand-tinted scrollbar (progressive enhancement only). */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f2fffd;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #00a693;
|
||||
background: var(--color-primary);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.blueprint-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 166, 147, 0.09) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 166, 147, 0.09) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
/* Visible keyboard focus ring, brand-colored, never removed. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 3px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.corner-brackets {
|
||||
position: relative;
|
||||
}
|
||||
.corner-brackets::before,
|
||||
.corner-brackets::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-color: #00a693;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.corner-brackets::before {
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
border-top: 2px solid;
|
||||
border-right: 2px solid;
|
||||
}
|
||||
.corner-brackets::after {
|
||||
bottom: -6px;
|
||||
left: -6px;
|
||||
border-bottom: 2px solid;
|
||||
border-left: 2px solid;
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,101 @@
|
||||
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 vazir = Vazirmatn({
|
||||
subsets: ["arabic"],
|
||||
weight: ["300", "400", "500", "600", "700", "800", "900"],
|
||||
variable: "--font-vazir",
|
||||
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 = {
|
||||
title: "روبات مارکت | تولیدکننده دستگاههای وندینگ ماشین صنعتی",
|
||||
description:
|
||||
"روبات مارکت، تولیدکننده تخصصی دستگاههای خودپرداز (وندینگ ماشین) خنککننده با استانداردهای مهندسی روز و بازگشت سرمایه مطمئن. سفارش و مشاوره رایگان.",
|
||||
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={vazir.variable}>
|
||||
<body className="font-vazir antialiased">{children}</body>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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,21 +1,28 @@
|
||||
import Header from "@/components/Header";
|
||||
import Hero from "@/components/Hero";
|
||||
import About from "@/components/About";
|
||||
import Locations from "@/components/Locations";
|
||||
import Products from "@/components/Products";
|
||||
import Footer from "@/components/Footer";
|
||||
import ScrollRefresh from "@/components/ScrollRefresh";
|
||||
import dynamic from "next/dynamic";
|
||||
import Navbar from "@components/layout/Navbar";
|
||||
import Hero from "@components/hero/Hero";
|
||||
import Footer from "@components/layout/Footer";
|
||||
|
||||
export default function Home() {
|
||||
// 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 (
|
||||
<main>
|
||||
<ScrollRefresh />
|
||||
<Header />
|
||||
<Hero />
|
||||
<About />
|
||||
<Locations />
|
||||
<Products />
|
||||
<>
|
||||
<Navbar />
|
||||
<main>
|
||||
<Hero />
|
||||
<ProductsShowcase />
|
||||
<About />
|
||||
<ContactCta />
|
||||
</main>
|
||||
<Footer />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export default function robots() {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/dashboard"],
|
||||
},
|
||||
sitemap: "https://robotmarket.example.com/sitemap.xml",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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,132 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {useEffect, useRef} from "react";
|
||||
import {gsap} from "@/utils/gsap";
|
||||
|
||||
const TIMELINE = [
|
||||
{
|
||||
year: "۱۳۹۶",
|
||||
title: "آغاز فعالیت در تجهیزات زعفرانکوبی",
|
||||
desc: "شروع کار با طراحی و تولید ماشینآلات فرآوری زعفران با تمرکز بر دقت مهندسی و کیفیت ساخت داخلی.",
|
||||
},
|
||||
{
|
||||
year: "۱۴۰۰",
|
||||
title: "ورود به صنعت اتوماسیون فروش",
|
||||
desc: "توسعه دانش فنی تیم مهندسی به سمت سامانههای خردهفروشی خودکار و مکانیزمهای دیسپنس هوشمند.",
|
||||
},
|
||||
{
|
||||
year: "۱۴۰۲",
|
||||
title: "تولید نخستین وندینگ ماشین خنککننده",
|
||||
desc: "معرفی نسل اول دستگاههای خودپرداز خنککننده روبات مارکت با استاندارد سرمایش صنعتی.",
|
||||
},
|
||||
{
|
||||
year: "۱۴۰۳",
|
||||
title: "توسعه خط تولید و شبکه فروش سراسری",
|
||||
desc: "افزایش ظرفیت تولید، تنوع مدلها (۳۵ تا ۶۰ کانال) و پشتیبانی فنی در سراسر کشور.",
|
||||
},
|
||||
];
|
||||
|
||||
const STANDARDS = [
|
||||
{label: "استاندارد مهندسی برق و ایمنی صنعتی"},
|
||||
{label: "بدنه فولادی ضدضربه با پوشش ضدزنگ"},
|
||||
{label: "سیستم سرمایش کمپرسوری استاندارد"},
|
||||
{label: "بازگشت سرمایه (ROI) مطمئن و قابل پیشبینی"},
|
||||
];
|
||||
|
||||
export default function About() {
|
||||
const rootRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.utils.toArray(".timeline-item").forEach((item, i) => {
|
||||
gsap.fromTo(
|
||||
item,
|
||||
{opacity: 0, y: 30},
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.7,
|
||||
ease: "power2.out",
|
||||
scrollTrigger: {
|
||||
trigger: item,
|
||||
start: "top 85%",
|
||||
},
|
||||
delay: i * 0.05,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
gsap.fromTo(
|
||||
".timeline-line",
|
||||
{scaleY: 0},
|
||||
{
|
||||
scaleY: 1,
|
||||
transformOrigin: "top",
|
||||
ease: "none",
|
||||
scrollTrigger: {
|
||||
trigger: ".timeline-wrap",
|
||||
start: "top 70%",
|
||||
end: "bottom 80%",
|
||||
scrub: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
}, rootRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section id="about" ref={rootRef} className="relative py-24 md:py-32 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-5 md:px-8">
|
||||
<div className="grid lg:grid-cols-[0.9fr_1.1fr] gap-16">
|
||||
{/* Left: intro + standards */}
|
||||
<div>
|
||||
<span className="text-xs font-bold text-primary tracking-wide">معرفی شرکت</span>
|
||||
<h2 className="mt-3 text-3xl md:text-4xl font-black text-ink-900 leading-snug">
|
||||
مسیری مهندسیشده از صنعت زعفران تا اتوماسیون فروش
|
||||
</h2>
|
||||
<p className="mt-5 text-ink-500 leading-8">
|
||||
روبات مارکت با پیشینهای در طراحی ماشینآلات دقیق صنعتی، امروز یکی از تولیدکنندگان
|
||||
تخصصی وندینگ ماشینهای خنککننده در ایران است. تیم مهندسی ما هر دستگاه را با
|
||||
استانداردهای برق صنعتی، ایمنی مصرفکننده و دوام بلندمدت طراحی میکند تا سرمایهگذاری
|
||||
شما با کمترین ریسک و بیشترین بازده همراه باشد.
|
||||
</p>
|
||||
|
||||
<ul className="mt-8 space-y-4">
|
||||
{STANDARDS.map((s) => (
|
||||
<li key={s.label} className="flex items-center gap-3">
|
||||
<span
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary-light text-primary">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-sm md:text-base text-ink-700 font-medium">{s.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Right: timeline */}
|
||||
<div className="timeline-wrap relative pr-8">
|
||||
<div className="timeline-line absolute right-[7px] top-1 bottom-1 w-[2px] bg-primary/25"/>
|
||||
<div className="space-y-10">
|
||||
{TIMELINE.map((t) => (
|
||||
<div key={t.year} className="timeline-item relative opacity-0">
|
||||
<span
|
||||
className="absolute right-[-25px] top-1 h-4 w-4 rounded-full bg-primary ring-4 ring-primary-light"/>
|
||||
<span className="inline-block text-primary font-black text-lg mb-1">
|
||||
{t.year}
|
||||
</span>
|
||||
<h3 className="font-bold text-ink-900 text-base md:text-lg">{t.title}</h3>
|
||||
<p className="text-sm text-ink-500 mt-1.5 leading-7">{t.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { CONTACT, NAV_LINKS } from "@/data/content";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer id="contact" className="relative bg-ink-900 text-white pt-20 pb-8">
|
||||
<div className="max-w-7xl mx-auto px-5 md:px-8">
|
||||
<div className="grid md:grid-cols-[1.2fr_1fr_1fr] gap-12 pb-14 border-b border-white/10">
|
||||
{/* Brand + CTA */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary text-white font-black text-sm">
|
||||
RM
|
||||
</span>
|
||||
<span className="font-extrabold text-lg">روبات مارکت</span>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-white/60 leading-7 max-w-sm">
|
||||
تولیدکننده تخصصی دستگاههای خودپرداز خنککننده با استانداردهای مهندسی روز، برای
|
||||
فروش هوشمند و بیوقفه در هر مکان.
|
||||
</p>
|
||||
<a
|
||||
href={`tel:${CONTACT.phoneHref}`}
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-full bg-primary px-6 py-3 text-sm font-bold hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
سفارش و مشاوره رایگان
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<div>
|
||||
<h4 className="font-bold text-sm text-white/80 mb-4">دسترسی سریع</h4>
|
||||
<ul className="space-y-3">
|
||||
{NAV_LINKS.map((l) => (
|
||||
<li key={l.href}>
|
||||
<a href={l.href} className="text-sm text-white/60 hover:text-primary transition-colors">
|
||||
{l.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div>
|
||||
<h4 className="font-bold text-sm text-white/80 mb-4">اطلاعات تماس</h4>
|
||||
<ul className="space-y-3 text-sm text-white/60 leading-7">
|
||||
<li>{CONTACT.factory}</li>
|
||||
<li>{CONTACT.office}</li>
|
||||
<li dir="ltr" className="text-right">
|
||||
<a href={`tel:${CONTACT.phoneHref}`} className="hover:text-primary transition-colors">
|
||||
{CONTACT.phone}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<a
|
||||
href={CONTACT.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="اینستاگرام روبات مارکت"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 hover:bg-primary transition-colors"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<rect x="3" y="3" width="18" height="18" rx="5" />
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href={CONTACT.bale}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="بله روبات مارکت"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 hover:bg-primary transition-colors text-xs font-bold"
|
||||
>
|
||||
بله
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 flex flex-col md:flex-row items-center justify-between gap-3 text-xs text-white/40">
|
||||
<span>© {new Date().getFullYear()} روبات مارکت. تمامی حقوق محفوظ است.</span>
|
||||
<span>طراحی و توسعه با ❤ در مشهد</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { NAV_LINKS, CONTACT } from "@/data/content";
|
||||
|
||||
export default function Header() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 24);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed top-0 inset-x-0 z-50 transition-all duration-300 ${
|
||||
scrolled
|
||||
? "bg-white/90 backdrop-blur-md shadow-[0_1px_0_0_rgba(0,166,147,0.15)]"
|
||||
: "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 flex items-center justify-between h-14 md:h-20">
|
||||
{/* Logo */}
|
||||
<a href="#top" className="flex items-center gap-2 group min-w-0">
|
||||
<span className="relative flex h-7 w-7 md:h-9 md:w-9 shrink-0 items-center justify-center rounded-lg bg-primary text-white font-black text-[10px] md:text-sm">
|
||||
RM
|
||||
<span className="absolute inset-0 rounded-lg ring-2 ring-primary/30 scale-110 group-hover:scale-125 transition-transform" />
|
||||
</span>
|
||||
<span className="flex flex-col leading-tight min-w-0">
|
||||
<span className="font-extrabold text-ink-900 text-xs sm:text-sm md:text-lg truncate">
|
||||
روبات مارکت
|
||||
</span>
|
||||
<span className="hidden sm:block text-[10px] md:text-xs text-ink-500 tracking-wide truncate">
|
||||
تولیدکننده وندینگ ماشین صنعتی
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden lg:flex items-center gap-8">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm font-medium text-ink-700 hover:text-primary transition-colors relative after:absolute after:-bottom-1 after:right-0 after:h-[2px] after:w-0 after:bg-primary after:transition-all hover:after:w-full"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="hidden lg:flex items-center gap-4">
|
||||
<a
|
||||
href={`tel:${CONTACT.phoneHref}`}
|
||||
dir="ltr"
|
||||
className="text-sm font-semibold text-ink-700 hover:text-primary transition-colors"
|
||||
>
|
||||
{CONTACT.phone}
|
||||
</a>
|
||||
<a
|
||||
href="#contact"
|
||||
className="rounded-full bg-primary px-5 py-2.5 text-sm font-bold text-white shadow-lg shadow-primary/20 hover:bg-primary-hover hover:-translate-y-0.5 transition-all"
|
||||
>
|
||||
سفارش و مشاوره
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Mobile toggle */}
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="lg:hidden flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-primary/20 text-primary"
|
||||
aria-label="باز کردن منو"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
{open ? (
|
||||
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
|
||||
) : (
|
||||
<path d="M3 6h18M3 12h18M3 18h18" strokeLinecap="round" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{open && (
|
||||
<div className="lg:hidden bg-white border-t border-primary/10 px-4 py-4 flex flex-col gap-3">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-sm font-medium text-ink-700"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
<a
|
||||
href="#contact"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-full bg-primary px-5 py-2.5 text-sm font-bold text-white text-center"
|
||||
>
|
||||
سفارش و مشاوره
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {useEffect, useRef} from "react";
|
||||
import {VMTop, VMMiddle, VMBottom} from "./VendingMachineParts";
|
||||
import {gsap, ScrollTrigger} from "@/utils/gsap";
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
id: "ui",
|
||||
label: "نمایشگر لمسی هوشمند",
|
||||
desc: "رابط کاربری فارسی و انتخاب سریع کالا",
|
||||
top: "8%",
|
||||
side: "left",
|
||||
},
|
||||
{
|
||||
id: "cooling",
|
||||
label: "سیستم خنککننده صنعتی",
|
||||
desc: "کمپرسور مستقل با کنترل دمای دقیق",
|
||||
top: "42%",
|
||||
side: "right",
|
||||
},
|
||||
{
|
||||
id: "payment",
|
||||
label: "ترمینال پرداخت هوشمند",
|
||||
desc: "کارتخوان بانکی و پرداخت آنلاین",
|
||||
top: "78%",
|
||||
side: "left",
|
||||
},
|
||||
{
|
||||
id: "body",
|
||||
label: "بدنه فولادی ضدضربه",
|
||||
desc: "مقاوم در برابر سرقت و ضربه",
|
||||
top: "60%",
|
||||
side: "right",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Hero() {
|
||||
const sectionRef = useRef(null);
|
||||
const topRef = useRef(null);
|
||||
const midRef = useRef(null);
|
||||
const botRef = useRef(null);
|
||||
const calloutsRef = useRef([]);
|
||||
const eyebrowRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const mm = gsap.matchMedia();
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
mm.add(
|
||||
{
|
||||
isDesktop: "(min-width: 1024px)",
|
||||
isMobile: "(max-width: 1023px)",
|
||||
},
|
||||
(context) => {
|
||||
const {isMobile} = context.conditions;
|
||||
const distance = isMobile ? 120 : 220;
|
||||
const scrollLength = isMobile ? 1200 : 1800;
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: "top top",
|
||||
end: `+=${scrollLength}`,
|
||||
scrub: 0.8,
|
||||
pin: true,
|
||||
anticipatePin: 1,
|
||||
invalidateOnRefresh: true,
|
||||
},
|
||||
});
|
||||
|
||||
tl.fromTo(
|
||||
topRef.current,
|
||||
{y: -distance, opacity: 0.4},
|
||||
{y: 0, opacity: 1, duration: 1, ease: "power2.out"},
|
||||
0
|
||||
)
|
||||
.fromTo(
|
||||
botRef.current,
|
||||
{y: distance, opacity: 0.4},
|
||||
{y: 0, opacity: 1, duration: 1, ease: "power2.out"},
|
||||
0
|
||||
)
|
||||
.fromTo(
|
||||
midRef.current,
|
||||
{opacity: 0, scale: 0.92},
|
||||
{opacity: 1, scale: 1, duration: 1, ease: "power2.out"},
|
||||
0.15
|
||||
)
|
||||
.to({}, {duration: 0.35}) // brief hold once assembled (pin breathing room)
|
||||
.fromTo(
|
||||
calloutsRef.current,
|
||||
{opacity: 0, x: (i) => (i % 2 === 0 ? -24 : 24)},
|
||||
{opacity: 1, x: 0, stagger: 0.15, duration: 0.6, ease: "power2.out"},
|
||||
">-0.1"
|
||||
)
|
||||
.fromTo(
|
||||
eyebrowRef.current,
|
||||
{opacity: 1},
|
||||
{opacity: 0.5, duration: 0.3},
|
||||
"<"
|
||||
);
|
||||
|
||||
return () => tl.scrollTrigger?.kill();
|
||||
}
|
||||
);
|
||||
}, sectionRef);
|
||||
|
||||
// Fonts/SVG layout can settle a beat after this effect runs — refresh
|
||||
// once more on the next frame so the pin/scrub distances are accurate
|
||||
// on the very first load, not just after a manual scroll-refresh.
|
||||
const raf = requestAnimationFrame(() => ScrollTrigger.refresh());
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ctx.revert();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
id="top"
|
||||
ref={sectionRef}
|
||||
className="relative min-h-screen bg-primary-light blueprint-grid pt-16 sm:pt-20 md:pt-28"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-5 md:px-8 grid lg:grid-cols-2 gap-10 items-center h-full">
|
||||
{/* Copy */}
|
||||
<div className="order-2 lg:order-none relative z-10 pb-10 lg:pb-0 text-center lg:text-right">
|
||||
<span
|
||||
ref={eyebrowRef}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-primary/30 bg-white px-3 md:px-4 py-1.5 text-[11px] md:text-xs font-bold text-primary"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse"/>
|
||||
از تجهیزات زعفرانکوبی ۱۳۹۶ تا وندینگ ماشین هوشمند
|
||||
</span>
|
||||
<h1 className="mt-6 text-3xl sm:text-4xl md:text-6xl font-black text-ink-900 leading-[1.2] text-balance">
|
||||
روبات مارکت،
|
||||
<span className="text-primary"> مهندسی فروش خودکار</span> برای کسبوکار شما
|
||||
</h1>
|
||||
<p className="mt-5 text-sm sm:text-base md:text-lg text-ink-500 leading-7 md:leading-8 max-w-xl mx-auto lg:mx-0">
|
||||
تولیدکننده وندینگ ماشینهای خنککننده صنعتی با استانداردهای مهندسی روز، طراحیشده
|
||||
برای فروش بدونوقفه، ۲۴ ساعته و بازگشت سرمایه مطمئن.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap justify-center lg:justify-start gap-4">
|
||||
<a
|
||||
href="#products"
|
||||
className="rounded-full bg-primary px-6 md:px-7 py-3 md:py-3.5 text-sm font-bold text-white shadow-lg shadow-primary/25 hover:bg-primary-hover hover:-translate-y-0.5 transition-all"
|
||||
>
|
||||
مشاهده محصولات
|
||||
</a>
|
||||
<a
|
||||
href="#contact"
|
||||
className="rounded-full border-2 border-primary/30 px-6 md:px-7 py-3 md:py-3.5 text-sm font-bold text-ink-900 hover:border-primary hover:text-primary transition-all"
|
||||
>
|
||||
سفارش و مشاوره رایگان
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-6 text-xs text-ink-500">
|
||||
برای مشاهده اسکرول کنید — دستگاه بهصورت زنده در برابر چشمان شما مونتاژ میشود
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* GSAP Assembly Machine */}
|
||||
<div
|
||||
className="order-1 lg:order-none relative h-[46vh] sm:h-[55vh] lg:h-[80vh] flex items-center justify-center">
|
||||
<div className="relative w-[170px] sm:w-[220px] md:w-[260px] lg:w-[300px]">
|
||||
<div ref={topRef} className="relative z-30 will-change-transform">
|
||||
<VMTop/>
|
||||
</div>
|
||||
<div ref={midRef} className="relative z-20 -mt-px will-change-transform">
|
||||
<VMMiddle/>
|
||||
</div>
|
||||
<div ref={botRef} className="relative z-30 -mt-px will-change-transform">
|
||||
<VMBottom/>
|
||||
</div>
|
||||
|
||||
{/* Feature callouts */}
|
||||
{FEATURES.map((f, i) => (
|
||||
<div
|
||||
key={f.id}
|
||||
ref={(el) => (calloutsRef.current[i] = el)}
|
||||
className={`hidden sm:flex absolute items-center gap-2 w-40 md:w-48 opacity-0 ${
|
||||
f.side === "left" ? "right-full ml-3 flex-row-reverse text-right" : "left-full mr-3 text-right"
|
||||
}`}
|
||||
style={{top: f.top}}
|
||||
>
|
||||
{f.side === "left" && (
|
||||
<span className="h-px w-6 md:w-10 bg-primary/60 shrink-0"/>
|
||||
)}
|
||||
<span>
|
||||
<span className="block text-xs md:text-sm font-bold text-ink-900">
|
||||
{f.label}
|
||||
</span>
|
||||
<span className="block text-[10px] md:text-xs text-ink-500">{f.desc}</span>
|
||||
</span>
|
||||
{f.side === "right" && (
|
||||
<span className="h-px w-6 md:w-10 bg-primary/60 shrink-0"/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile feature list (callout lines are desktop-only) */}
|
||||
<div className="sm:hidden max-w-md mx-auto px-5 pb-10 grid grid-cols-2 gap-3">
|
||||
{FEATURES.map((f) => (
|
||||
<div key={f.id} className="rounded-xl bg-white/70 border border-primary/15 p-3">
|
||||
<p className="text-xs font-bold text-ink-900">{f.label}</p>
|
||||
<p className="text-[10px] text-ink-500 mt-1">{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
export function Icon({ name, className = "h-6 w-6" }) {
|
||||
const common = {
|
||||
className,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
};
|
||||
|
||||
switch (name) {
|
||||
case "hotel":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M3 21V7a1 1 0 0 1 1-1h4v15" />
|
||||
<path d="M8 21V3h9a1 1 0 0 1 1 1v17" />
|
||||
<path d="M3 21h18" />
|
||||
<path d="M11 7h4M11 11h4M11 15h4" />
|
||||
</svg>
|
||||
);
|
||||
case "school":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M2 9l10-5 10 5-10 5-10-5z" />
|
||||
<path d="M6 11v5c0 1.5 2.5 3 6 3s6-1.5 6-3v-5" />
|
||||
<path d="M22 9v6" />
|
||||
</svg>
|
||||
);
|
||||
case "pharmacy":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect x="3" y="7" width="18" height="14" rx="2" />
|
||||
<path d="M12 11v6M9 14h6" />
|
||||
<path d="M8 7V5a4 4 0 0 1 8 0v2" />
|
||||
</svg>
|
||||
);
|
||||
case "hospital":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect x="4" y="3" width="16" height="18" rx="2" />
|
||||
<path d="M12 8v6M9 11h6" />
|
||||
<path d="M9 21v-3M15 21v-3" />
|
||||
</svg>
|
||||
);
|
||||
case "moon":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
||||
</svg>
|
||||
);
|
||||
case "brand":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M12 2l2.4 6.6L21 11l-6.6 2.4L12 20l-2.4-6.6L3 11l6.6-2.4z" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { LOCATIONS } from "@/data/content";
|
||||
import { Icon } from "./Icons";
|
||||
import { gsap } from "@/utils/gsap";
|
||||
|
||||
export default function Locations() {
|
||||
const rootRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.fromTo(
|
||||
".location-card",
|
||||
{ opacity: 0, y: 36 },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.6,
|
||||
ease: "power2.out",
|
||||
stagger: 0.08,
|
||||
scrollTrigger: {
|
||||
trigger: ".location-grid",
|
||||
start: "top 82%",
|
||||
},
|
||||
}
|
||||
);
|
||||
}, rootRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section id="locations" ref={rootRef} className="relative py-24 md:py-32 bg-primary-light">
|
||||
<div className="max-w-7xl mx-auto px-5 md:px-8">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<span className="text-xs font-bold text-primary tracking-wide">مکانهای مناسب نصب</span>
|
||||
<h2 className="mt-3 text-3xl md:text-4xl font-black text-ink-900">
|
||||
هرجا نیاز به فروش بیوقفه باشد، جای وندینگ ماشین است
|
||||
</h2>
|
||||
<p className="mt-4 text-ink-500 leading-7">
|
||||
دستگاههای روبات مارکت برای انطباق با فضاهای مختلف طراحی شدهاند؛ از فضاهای پرتردد تا
|
||||
نصب پشت ویترین برای فروش شبانه.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="location-grid mt-14 grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{LOCATIONS.map((loc) => (
|
||||
<div
|
||||
key={loc.id}
|
||||
className="location-card group relative rounded-2xl bg-white p-6 border border-primary/10 shadow-sm hover:shadow-xl hover:shadow-primary/10 hover:-translate-y-1 transition-all duration-300 opacity-0"
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-light text-primary group-hover:bg-primary group-hover:text-white transition-colors">
|
||||
<Icon name={loc.icon} />
|
||||
</div>
|
||||
<h3 className="mt-5 font-bold text-ink-900 text-lg">{loc.title}</h3>
|
||||
<p className="mt-2 text-sm text-ink-500 leading-7">{loc.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { PRODUCTS, formatRial } from "@/data/content";
|
||||
import { gsap } from "@/utils/gsap";
|
||||
|
||||
export default function Products() {
|
||||
const rootRef = useRef(null);
|
||||
const [added, setAdded] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.fromTo(
|
||||
".product-card",
|
||||
{ opacity: 0, y: 40 },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.7,
|
||||
ease: "power2.out",
|
||||
stagger: 0.12,
|
||||
scrollTrigger: {
|
||||
trigger: ".product-grid",
|
||||
start: "top 80%",
|
||||
},
|
||||
}
|
||||
);
|
||||
}, rootRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
const handleAdd = (id) => {
|
||||
setAdded((prev) => ({ ...prev, [id]: true }));
|
||||
// Static demo action — wire this to `submitConsultationRequest` in
|
||||
// src/utils/api.js once a cart/order API is available.
|
||||
setTimeout(() => setAdded((prev) => ({ ...prev, [id]: false })), 2200);
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="products" ref={rootRef} className="relative py-24 md:py-32 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-5 md:px-8">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<span className="text-xs font-bold text-primary tracking-wide">محصولات تخصصی</span>
|
||||
<h2 className="mt-3 text-3xl md:text-4xl font-black text-ink-900">
|
||||
مدلهای وندینگ ماشین روبات مارکت
|
||||
</h2>
|
||||
<p className="mt-4 text-ink-500 leading-7">
|
||||
سه مدل با ظرفیتهای مختلف، متناسب با ترافیک و فضای محل نصب شما.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="product-grid mt-14 grid md:grid-cols-3 gap-6">
|
||||
{PRODUCTS.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`product-card opacity-0 relative rounded-3xl p-7 flex flex-col border transition-all duration-300 ${
|
||||
p.highlight
|
||||
? "bg-ink-900 border-primary text-white lg:-translate-y-4 shadow-2xl shadow-primary/20"
|
||||
: "bg-primary-light border-primary/10 text-ink-900 hover:-translate-y-2 hover:shadow-xl"
|
||||
}`}
|
||||
>
|
||||
{p.highlight && (
|
||||
<span className="absolute -top-3 right-7 rounded-full bg-primary px-4 py-1 text-[11px] font-bold text-white">
|
||||
پرفروشترین
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={`text-xs font-bold tracking-wide ${
|
||||
p.highlight ? "text-info" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
{p.channels} کانال
|
||||
</span>
|
||||
<h3 className="mt-2 text-xl font-black">{p.name}</h3>
|
||||
<p className={`mt-1 text-sm ${p.highlight ? "text-white/60" : "text-ink-500"}`}>
|
||||
{p.tagline}
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<span className="text-2xl md:text-3xl font-black" dir="ltr">
|
||||
{formatRial(p.price)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="mt-6 space-y-3 flex-1">
|
||||
{p.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2.5 text-sm">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
className={`mt-0.5 shrink-0 ${p.highlight ? "text-info" : "text-primary"}`}
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span className={p.highlight ? "text-white/85" : "text-ink-700"}>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
onClick={() => handleAdd(p.id)}
|
||||
className={`mt-8 w-full rounded-full py-3.5 text-sm font-bold transition-all ${
|
||||
p.highlight
|
||||
? "bg-primary text-white hover:bg-primary-hover"
|
||||
: "bg-ink-900 text-white hover:bg-primary"
|
||||
} ${added[p.id] ? "!bg-success" : ""}`}
|
||||
>
|
||||
{added[p.id] ? "به سبد سفارش اضافه شد ✓" : "افزودن به سبد سفارش"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-center text-xs text-ink-500">
|
||||
قیمتها بهروزرسانی دورهای دارند؛ برای استعلام نهایی و امکان پرداخت اقساطی با کارشناسان
|
||||
فروش تماس بگیرید.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { ScrollTrigger } from "@/utils/gsap";
|
||||
|
||||
/**
|
||||
* Mounted once near the root of the page. Fonts (Vazirmatn) and the
|
||||
* inline SVG artwork can finish loading/laying out slightly after
|
||||
* ScrollTrigger has already taken its initial measurements, which makes
|
||||
* pinned sections (like the Hero assembly) start blank until the user
|
||||
* scrolls back up and down again. Forcing a refresh once everything has
|
||||
* settled fixes that on the very first load.
|
||||
*/
|
||||
export default function ScrollRefresh() {
|
||||
useEffect(() => {
|
||||
const refresh = () => ScrollTrigger.refresh();
|
||||
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
document.fonts.ready.then(refresh);
|
||||
}
|
||||
|
||||
window.addEventListener("load", refresh);
|
||||
// Fallback in case fonts/images settle without firing the above events.
|
||||
const t1 = setTimeout(refresh, 300);
|
||||
const t2 = setTimeout(refresh, 1000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("load", refresh);
|
||||
clearTimeout(t1);
|
||||
clearTimeout(t2);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
// Schematic / blueprint-style artwork for the vending machine, split into
|
||||
// three physically-accurate parts so the Hero section can animate them
|
||||
// together with GSAP. Replace with real product photography later by
|
||||
// swapping the <svg> content inside each part — the wrapper sizes/ids stay.
|
||||
|
||||
export function VMTop() {
|
||||
return (
|
||||
<svg viewBox="0 0 400 150" className="w-full block" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 150 V40 Q20 10 50 10 H350 Q380 10 380 40 V150 Z" fill="#0c3a34" />
|
||||
<path d="M20 150 V40 Q20 10 50 10 H350 Q380 10 380 40 V150 Z" fill="url(#topGrad)" opacity="0.9" />
|
||||
<defs>
|
||||
<linearGradient id="topGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#0f4a42" />
|
||||
<stop offset="1" stopColor="#0c3a34" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* brand plate */}
|
||||
<rect x="150" y="28" width="100" height="16" rx="4" fill="#00a693" />
|
||||
<text x="200" y="40" textAnchor="middle" fontSize="10" fill="#f2fffd" fontFamily="sans-serif" fontWeight="700">
|
||||
ROBOT MARKET
|
||||
</text>
|
||||
{/* smart UI screen */}
|
||||
<rect x="130" y="58" width="140" height="80" rx="8" fill="#04211d" stroke="#00bba6" strokeWidth="2" />
|
||||
<rect x="140" y="68" width="120" height="46" rx="4" fill="#00a693" opacity="0.25" />
|
||||
<rect x="140" y="68" width="70" height="6" rx="3" fill="#00bba6" />
|
||||
<rect x="140" y="80" width="90" height="4" rx="2" fill="#00bba6" opacity="0.6" />
|
||||
<rect x="140" y="90" width="60" height="4" rx="2" fill="#00bba6" opacity="0.6" />
|
||||
<circle cx="250" cy="126" r="6" fill="#00bba6" />
|
||||
{/* cooling vents */}
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<rect key={i} x={36} y={34 + i * 8} width="60" height="3" rx="1.5" fill="#00bba6" opacity="0.5" />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function VMMiddle() {
|
||||
return (
|
||||
<svg viewBox="0 0 400 300" className="w-full block" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="20" y="0" width="360" height="300" fill="#0e4841" />
|
||||
{/* glass front */}
|
||||
<rect x="42" y="18" width="316" height="264" rx="6" fill="#04211d" stroke="#00a693" strokeWidth="2" />
|
||||
{/* product coils - 3 rows x 4 columns */}
|
||||
{[0, 1, 2].map((row) => (
|
||||
<g key={row}>
|
||||
{[0, 1, 2, 3].map((col) => (
|
||||
<g key={col}>
|
||||
<rect
|
||||
x={58 + col * 74}
|
||||
y={34 + row * 84}
|
||||
width="60"
|
||||
height="70"
|
||||
rx="4"
|
||||
fill="#0c3a34"
|
||||
stroke="#00bba6"
|
||||
strokeOpacity="0.5"
|
||||
/>
|
||||
{[0, 1, 2, 3].map((c) => (
|
||||
<circle
|
||||
key={c}
|
||||
cx={58 + col * 74 + 12 + c * 12}
|
||||
cy={34 + row * 84 + 35}
|
||||
r="9"
|
||||
fill="#00a693"
|
||||
opacity="0.65"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
))}
|
||||
{/* LED strip */}
|
||||
<rect x="20" y="0" width="8" height="300" fill="#00bba6" opacity="0.8" />
|
||||
<rect x="372" y="0" width="8" height="300" fill="#00bba6" opacity="0.8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function VMBottom() {
|
||||
return (
|
||||
<svg viewBox="0 0 400 150" className="w-full block" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 0 H380 V110 Q380 140 350 140 H50 Q20 140 20 110 Z" fill="#0c3a34" />
|
||||
{/* payment terminal */}
|
||||
<rect x="70" y="20" width="90" height="60" rx="6" fill="#04211d" stroke="#00a693" strokeWidth="2" />
|
||||
<rect x="80" y="30" width="70" height="16" rx="3" fill="#00a693" opacity="0.4" />
|
||||
<rect x="80" y="52" width="30" height="18" rx="3" fill="#00bba6" />
|
||||
<text x="95" y="65" textAnchor="middle" fontSize="9" fill="#04211d" fontWeight="700">
|
||||
POS
|
||||
</text>
|
||||
{/* coin slot + card reader */}
|
||||
<rect x="150" y="52" width="4" height="16" rx="2" fill="#00bba6" />
|
||||
{/* dispense flap */}
|
||||
<rect x="240" y="30" width="110" height="60" rx="8" fill="#04211d" stroke="#00bba6" strokeWidth="2" />
|
||||
<rect x="255" y="70" width="80" height="8" rx="4" fill="#00a693" />
|
||||
<text x="295" y="52" textAnchor="middle" fontSize="9" fill="#00bba6" fontFamily="sans-serif">
|
||||
محل تحویل کالا
|
||||
</text>
|
||||
{/* legs */}
|
||||
<rect x="60" y="132" width="16" height="16" rx="2" fill="#0c3a34" stroke="#00a693" />
|
||||
<rect x="324" y="132" width="16" height="16" rx="2" fill="#0c3a34" stroke="#00a693" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"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;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 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%";
|
||||
@@ -0,0 +1,12 @@
|
||||
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",
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 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,101 +0,0 @@
|
||||
export const NAV_LINKS = [
|
||||
{ label: "معرفی شرکت", href: "#about" },
|
||||
{ label: "مکانهای نصب", href: "#locations" },
|
||||
{ label: "محصولات", href: "#products" },
|
||||
{ label: "تماس با ما", href: "#contact" },
|
||||
];
|
||||
|
||||
export const LOCATIONS = [
|
||||
{
|
||||
id: "hotels",
|
||||
title: "هتلها و اقامتگاهها",
|
||||
desc: "دسترسی شبانهروزی مهمانان به نوشیدنی و تنقلات، بدون نیاز به نیروی انسانی اضافه در لابی.",
|
||||
icon: "hotel",
|
||||
},
|
||||
{
|
||||
id: "education",
|
||||
title: "مراکز آموزشی و دانشگاهها",
|
||||
desc: "پاسخگویی سریع به تراکم بالای دانشآموزان و دانشجویان در زنگهای تفریح و فاصله کلاسها.",
|
||||
icon: "school",
|
||||
},
|
||||
{
|
||||
id: "pharmacy",
|
||||
title: "داروخانهها و سوپرمارکتها",
|
||||
desc: "افزایش ساعات فروش فعال بدون افزایش هزینه پرسنل، مکمل فروش حضوری فروشگاه.",
|
||||
icon: "pharmacy",
|
||||
},
|
||||
{
|
||||
id: "hospital",
|
||||
title: "بیمارستانها و مراکز درمانی",
|
||||
desc: "تامین نیاز همراهان بیمار در تمام ساعات شبانهروز در راهروها و بخشهای انتظار.",
|
||||
icon: "hospital",
|
||||
},
|
||||
{
|
||||
id: "night-shift",
|
||||
title: "فروش شبانه پشت ویترین",
|
||||
desc: "نصب پشت شیشه مغازه برای فروش ۲۴ ساعته حتی در زمان تعطیلی فروشگاه، بدون ریسک امنیتی.",
|
||||
icon: "moon",
|
||||
},
|
||||
{
|
||||
id: "branding",
|
||||
title: "برندسازی اختصاصی",
|
||||
desc: "امکان طراحی نما و رابط کاربری دستگاه مطابق هویت بصری برند شما برای فضاهای تجاری.",
|
||||
icon: "brand",
|
||||
},
|
||||
];
|
||||
|
||||
export const PRODUCTS = [
|
||||
{
|
||||
id: "vm-35",
|
||||
channels: 35,
|
||||
name: "وندینگ ماشین ۳۵ کانال",
|
||||
tagline: "مناسب فضاهای کوچک و متوسط",
|
||||
price: 3860000000,
|
||||
features: [
|
||||
"ظرفیت ۳۵ کانال فروش مستقل",
|
||||
"سیستم خنککننده کمپرسوری",
|
||||
"نمایشگر لمسی هوشمند",
|
||||
"ترمینال پرداخت کارتخوان و آنلاین",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "vm-48",
|
||||
channels: 48,
|
||||
name: "وندینگ ماشین ۴۸ کانال",
|
||||
tagline: "پرفروشترین مدل، تعادل ظرفیت و ابعاد",
|
||||
price: 4480000000,
|
||||
features: [
|
||||
"ظرفیت ۴۸ کانال فروش مستقل",
|
||||
"کنترل دما دیجیتال دقیق",
|
||||
"پنل کاربری فارسی با گزارش فروش",
|
||||
"بدنه فولادی ضدضربه و ضدسرقت",
|
||||
],
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
id: "vm-60",
|
||||
channels: 60,
|
||||
name: "وندینگ ماشین ۶۰ کانال",
|
||||
tagline: "بیشترین ظرفیت، مناسب ترافیک بالا",
|
||||
price: 4980000000,
|
||||
features: [
|
||||
"ظرفیت ۶۰ کانال فروش مستقل",
|
||||
"دو کمپرسور مستقل برای پایداری دما",
|
||||
"اتصال اینترنتی و مانیتورینگ از راه دور",
|
||||
"طراحی مدولار برای شارژ سریع کالا",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const CONTACT = {
|
||||
factory: "کارخانه: مشهد، شهرک صنعتی توس",
|
||||
office: "دفتر مرکزی: مشهد، برج نخل",
|
||||
phone: "۰۹۱۵۱۶۰۱۸۴۷",
|
||||
phoneHref: "+989151601847",
|
||||
instagram: "https://instagram.com/robot.market",
|
||||
bale: "https://ble.ir/robot.market",
|
||||
};
|
||||
|
||||
export function formatRial(amount) {
|
||||
return amount.toLocaleString("fa-IR") + " ریال";
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{ "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 }
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
"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;
|
||||
@@ -0,0 +1,24 @@
|
||||
"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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"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>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
@@ -0,0 +1,44 @@
|
||||
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;
|
||||
@@ -0,0 +1,44 @@
|
||||
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;
|
||||
@@ -0,0 +1,20 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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;
|
||||
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
@@ -0,0 +1,30 @@
|
||||
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,56 +0,0 @@
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Central Axios instance for Robot Market.
|
||||
* Every future API call (orders, consultation requests, product sync, ...)
|
||||
* should go through this instance so headers, base URL and error
|
||||
* handling only need to be configured in one place.
|
||||
*/
|
||||
const api = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.robotmarket.ir/v1",
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Attach an auth token automatically, if one is available client-side.
|
||||
api.interceptors.request.use((config) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const token = window.localStorage.getItem("rm_token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Normalize errors so components can rely on a consistent shape.
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const message =
|
||||
error?.response?.data?.message || error?.message || "خطای ناشناخته در ارتباط با سرور";
|
||||
return Promise.reject({ ...error, message });
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Example future usage:
|
||||
* submitConsultationRequest({ name, phone, city })
|
||||
*/
|
||||
export async function submitConsultationRequest(payload) {
|
||||
const { data } = await api.post("/leads/consultation", payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Example future usage:
|
||||
* fetchProducts()
|
||||
*/
|
||||
export async function fetchProducts() {
|
||||
const { data } = await api.get("/products");
|
||||
return data;
|
||||
}
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,13 @@
|
||||
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)",
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 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(" ");
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 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,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
|
||||
// Registering the plugin synchronously at module scope (guarded for SSR)
|
||||
// avoids the race condition where ScrollTrigger measures the page before
|
||||
// fonts/images have finished loading, which is what caused the "blank on
|
||||
// first load, fixed after scrolling back up" bug.
|
||||
if (typeof window !== "undefined") {
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
// Mobile browsers fire resize events when the address bar shows/hides
|
||||
// while scrolling. ScrollTrigger would otherwise treat that as a real
|
||||
// viewport change and re-run its (expensive, sometimes wrong) pin
|
||||
// calculations mid-scroll, which is what made the assembled machine
|
||||
// disappear or freeze on phones.
|
||||
ScrollTrigger.config({ ignoreMobileResize: true });
|
||||
}
|
||||
|
||||
export { gsap, ScrollTrigger };
|
||||