This commit is contained in:
Hamed
2026-07-30 10:57:49 +04:00
parent e03a4dfcb0
commit 31f261bf5f
102 changed files with 8782 additions and 1432 deletions
+2 -1
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
+2 -3
View File
@@ -1,7 +1,6 @@
node_modules node_modules
.next .next
.env*.local
.DS_Store
out out
build .env*
.DS_Store
*.log *.log
+6
View File
@@ -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>
+8
View File
@@ -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>
+8
View File
@@ -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>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+1 -52
View File
@@ -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">{
&quot;associatedIndex&quot;: 2,
&quot;fromUser&quot;: false
}</component>
<component name="ProjectId" id="3H7KB12mCznfw4hRrFCPWKMzxsB" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;js.debugger.nextJs.config.created.client&quot;: &quot;true&quot;,
&quot;js.debugger.nextJs.config.created.server&quot;: &quot;true&quot;,
&quot;last_opened_file_path&quot;: &quot;D:/Project/robot-market-landing/robot-market&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}</component>
<component name="RunManager" selected="npm.Next.js: server-side"> <component name="RunManager" selected="npm.Next.js: server-side">
<configuration name="Next.js: debug client-side" type="JavascriptDebugType" uri="http://localhost:3000/"> <configuration name="Next.js: debug client-side" type="JavascriptDebugType" uri="http://localhost:3000/">
<method v="2" /> <method v="2" />
@@ -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>
+124 -15
View File
@@ -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.
+10 -3
View File
@@ -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"]
}
} }
-14
View File
@@ -1,14 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**",
},
],
},
};
module.exports = nextConfig;
+13
View File
@@ -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;
+6104 -128
View File
File diff suppressed because it is too large Load Diff
+17 -7
View File
@@ -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"
} }
} }
+11
View File
@@ -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

+11
View File
@@ -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

+12
View File
@@ -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

+5
View File
@@ -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

+4
View File
@@ -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

+7
View File
@@ -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

+4
View File
@@ -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

+5
View File
@@ -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

+4
View File
@@ -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

+5
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+9
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+9
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+9
View File
@@ -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

+4
View File
@@ -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

+49
View File
@@ -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>
);
}
+42 -45
View File
@@ -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;
}
}
+82 -11
View File
@@ -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>
); );
} }
+11
View File
@@ -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>
);
}
+22
View File
@@ -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",
},
],
};
}
+23
View File
@@ -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>
);
}
+20 -13
View File
@@ -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 />
</>
); );
} }
+10
View File
@@ -0,0 +1,10 @@
export default function robots() {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/dashboard"],
},
sitemap: "https://robotmarket.example.com/sitemap.xml",
};
}
+30
View File
@@ -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,
},
];
}
-132
View File
@@ -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>
);
}
-89
View File
@@ -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>
);
}
-111
View File
@@ -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>
);
}
-216
View File
@@ -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>
);
}
-61
View File
@@ -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;
}
}
-64
View File
@@ -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>
);
}
-127
View File
@@ -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>
);
}
-35
View File
@@ -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;
}
-103
View File
@@ -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>
);
}
+13
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+129
View File
@@ -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>
);
}
+17
View File
@@ -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>
);
}
+113
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+87
View File
@@ -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>
);
}
+141
View File
@@ -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>
);
}
+70
View File
@@ -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>
);
}
+33
View File
@@ -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>
);
}
+85
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
}
+57
View File
@@ -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>
);
}
+134
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
+9
View File
@@ -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%";
+12
View File
@@ -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",
};
+26
View File
@@ -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",
};
-101
View File
@@ -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") + " ریال";
}
+9
View File
@@ -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 }
]
+79
View File
@@ -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"
]
}
]
+11
View File
@@ -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;
+24
View File
@@ -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;
}
+19
View File
@@ -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>;
}
+45
View File
@@ -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;
+44
View File
@@ -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;
+44
View File
@@ -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;
+20
View File
@@ -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,
},
});
}
+31
View File
@@ -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;
+45
View File
@@ -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;
+23
View File
@@ -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;
+30
View File
@@ -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;
-56
View File
@@ -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;
+13
View File
@@ -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)",
};
+9
View File
@@ -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(" ");
}
+10
View File
@@ -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;
}
-20
View File
@@ -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 };

Some files were not shown because too many files have changed in this diff Show More