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
|
node_modules
|
||||||
.next
|
.next
|
||||||
.env*.local
|
|
||||||
.DS_Store
|
|
||||||
out
|
out
|
||||||
build
|
.env*
|
||||||
|
.DS_Store
|
||||||
*.log
|
*.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"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ChangeListManager">
|
<component name="PropertiesComponent">{}</component>
|
||||||
<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="RunManager" selected="npm.Next.js: server-side">
|
<component name="RunManager" selected="npm.Next.js: server-side">
|
||||||
<configuration name="Next.js: debug client-side" type="JavascriptDebugType" uri="http://localhost:3000/">
|
<configuration name="Next.js: debug client-side" type="JavascriptDebugType" uri="http://localhost:3000/">
|
||||||
<method v="2" />
|
<method v="2" />
|
||||||
@@ -45,26 +16,4 @@
|
|||||||
<method v="2" />
|
<method v="2" />
|
||||||
</configuration>
|
</configuration>
|
||||||
</component>
|
</component>
|
||||||
<component name="SharedIndexes">
|
|
||||||
<attachedChunks>
|
|
||||||
<set>
|
|
||||||
<option value="bundled-js-predefined-d6986cc7102b-31caf2ab9e3c-JavaScript-WS-261.23567.141" />
|
|
||||||
</set>
|
|
||||||
</attachedChunks>
|
|
||||||
</component>
|
|
||||||
<component name="TaskManager">
|
|
||||||
<task active="true" id="Default" summary="Default task">
|
|
||||||
<changelist id="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>
|
</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
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
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/
|
src/
|
||||||
app/ # layout.js و page.js (App Router)
|
app/ Routes (App Router): layout, page, dashboard,
|
||||||
components/ # کامپوننتهای بخشهای سایت
|
robots.js, sitemap.js, manifest.js, loading, 404
|
||||||
data/ # محتوای متنی و محصولات (content.js)
|
components/
|
||||||
utils/api.js # نمونه Axios برای اتصال به API در آینده
|
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` تنظیم شده است.
|
Every visual asset in `public/svg` is a deliberately simple placeholder —
|
||||||
- فونت وزیرمتن بهصورت خودکار از `next/font/google` بارگذاری میشود.
|
swap the files in place (same filenames) and nothing else needs to change:
|
||||||
- برای جایگزینی عکس واقعی دستگاه بهجای طرح SVG، فایل
|
|
||||||
`src/components/VendingMachineParts.js` را ویرایش کنید.
|
- `public/svg/machines/<model>/*.svg` — the 6 pieces GSAP assembles/positions.
|
||||||
- برای اتصال فرم "سفارش و مشاوره" یا دکمه "افزودن به سبد" به بکاند واقعی،
|
Keep the same `viewBox="0 0 400 700"` coordinate space so pieces still
|
||||||
از توابع آماده در `src/utils/api.js` استفاده کنید.
|
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": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["src/*"],
|
||||||
|
"@components/*": ["src/components/*"],
|
||||||
|
"@redux/*": ["src/redux/*"],
|
||||||
|
"@services/*": ["src/services/*"],
|
||||||
|
"@hooks/*": ["src/hooks/*"],
|
||||||
|
"@utils/*": ["src/utils/*"],
|
||||||
|
"@constants/*": ["src/constants/*"],
|
||||||
|
"@data/*": ["src/data/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"jsx": "react"
|
"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",
|
"name": "robot-market",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"description": "Robot Market — cinematic vending machine landing page",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -10,14 +11,23 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "14.2.5",
|
"next": "14.2.5",
|
||||||
"react": "^18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"gsap": "^3.12.5",
|
"antd": "5.19.1",
|
||||||
"axios": "^1.7.2"
|
"@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": {
|
"devDependencies": {
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "10.4.19",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "8.4.39",
|
||||||
"tailwindcss": "^3.4.4"
|
"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 components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-primary: #00a693;
|
||||||
|
--color-dark: #063d39;
|
||||||
|
--color-accent: #19d7c2;
|
||||||
|
--color-light: #d8fff8;
|
||||||
|
--color-bg: #f7fbfb;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #f2fffd;
|
background-color: var(--color-bg);
|
||||||
color: #04211d;
|
color: var(--color-dark);
|
||||||
|
font-family: var(--font-vazirmatn), Tahoma, sans-serif;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
/* Respect the user's motion preference across the whole site. */
|
||||||
background-color: #00a693;
|
@media (prefers-reduced-motion: reduce) {
|
||||||
color: #ffffff;
|
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 {
|
::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #f2fffd;
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #00a693;
|
background: var(--color-primary);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blueprint-grid {
|
/* Visible keyboard focus ring, brand-colored, never removed. */
|
||||||
background-image:
|
:focus-visible {
|
||||||
linear-gradient(rgba(0, 166, 147, 0.09) 1px, transparent 1px),
|
outline: 2px solid var(--color-accent);
|
||||||
linear-gradient(90deg, rgba(0, 166, 147, 0.09) 1px, transparent 1px);
|
outline-offset: 3px;
|
||||||
background-size: 42px 42px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.corner-brackets {
|
.glass-surface {
|
||||||
position: relative;
|
background: rgba(255, 255, 255, 0.55);
|
||||||
}
|
backdrop-filter: blur(16px);
|
||||||
.corner-brackets::before,
|
-webkit-backdrop-filter: blur(16px);
|
||||||
.corner-brackets::after {
|
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-balance {
|
.text-balance {
|
||||||
text-wrap: 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 { 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";
|
import "./globals.css";
|
||||||
|
|
||||||
const vazir = Vazirmatn({
|
const vazirmatn = Vazirmatn({
|
||||||
subsets: ["arabic"],
|
subsets: ["arabic", "latin"],
|
||||||
weight: ["300", "400", "500", "600", "700", "800", "900"],
|
variable: "--font-vazirmatn",
|
||||||
variable: "--font-vazir",
|
|
||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const SITE_URL = "https://robotmarket.example.com";
|
||||||
|
const SITE_NAME = "روبات مارکت";
|
||||||
|
const SITE_DESCRIPTION =
|
||||||
|
"روبات مارکت، تولیدکننده دستگاههای وندینگ ماشین یخچالدار هوشمند با مدلهای ۳۵، ۴۸ و ۶۰ کانال برای کسبوکارهای پرتردد.";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "روبات مارکت | تولیدکننده دستگاههای وندینگ ماشین صنعتی",
|
metadataBase: new URL(SITE_URL),
|
||||||
description:
|
title: {
|
||||||
"روبات مارکت، تولیدکننده تخصصی دستگاههای خودپرداز (وندینگ ماشین) خنککننده با استانداردهای مهندسی روز و بازگشت سرمایه مطمئن. سفارش و مشاوره رایگان.",
|
default: "روبات مارکت | دستگاههای وندینگ ماشین هوشمند",
|
||||||
|
template: "%s | روبات مارکت",
|
||||||
|
},
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
keywords: [
|
keywords: [
|
||||||
"وندینگ ماشین",
|
"وندینگ ماشین",
|
||||||
"دستگاه خودپرداز",
|
|
||||||
"روبات مارکت",
|
|
||||||
"دستگاه فروش خودکار",
|
"دستگاه فروش خودکار",
|
||||||
|
"روبات مارکت",
|
||||||
|
"یخچال هوشمند",
|
||||||
"vending machine",
|
"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 }) {
|
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 (
|
return (
|
||||||
<html lang="fa" dir="rtl" className={vazir.variable}>
|
<html lang="fa" dir="rtl" className={vazirmatn.variable}>
|
||||||
<body className="font-vazir antialiased">{children}</body>
|
<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>
|
</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 dynamic from "next/dynamic";
|
||||||
import Hero from "@/components/Hero";
|
import Navbar from "@components/layout/Navbar";
|
||||||
import About from "@/components/About";
|
import Hero from "@components/hero/Hero";
|
||||||
import Locations from "@/components/Locations";
|
import Footer from "@components/layout/Footer";
|
||||||
import Products from "@/components/Products";
|
|
||||||
import Footer from "@/components/Footer";
|
|
||||||
import ScrollRefresh from "@/components/ScrollRefresh";
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
<main>
|
<main>
|
||||||
<ScrollRefresh />
|
|
||||||
<Header />
|
|
||||||
<Hero />
|
<Hero />
|
||||||
|
<ProductsShowcase />
|
||||||
<About />
|
<About />
|
||||||
<Locations />
|
<ContactCta />
|
||||||
<Products />
|
|
||||||
<Footer />
|
|
||||||
</main>
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
|
||||||