feat: add cart and product pages, quantity controls, and first-visit development notice
Build & Deploy (develop) / build-and-deploy (push) Successful in 1m44s

This commit is contained in:
2026-09-15 07:14:37 +02:00
parent 4cd34106b0
commit 0390a35d01
23 changed files with 528 additions and 466 deletions
+107 -113
View File
@@ -4,38 +4,22 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="refactor: remove disabled home sections and their unused assets"> <list default="true" id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="refactor: migrate project to TypeScript and translate comments to English">
<change beforePath="$PROJECT_DIR$/.gitignore" beforeDir="false" afterPath="$PROJECT_DIR$/.gitignore" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" /> <change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/context/LanguageContext.jsx" beforeDir="false" /> <change beforePath="$PROJECT_DIR$/app/layout.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/app/layout.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/context/ThemeContext.jsx" beforeDir="false" /> <change beforePath="$PROJECT_DIR$/app/page.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/app/page.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/layout.jsx" beforeDir="false" /> <change beforePath="$PROJECT_DIR$/app/providers.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/app/providers.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/page.jsx" beforeDir="false" /> <change beforePath="$PROJECT_DIR$/components/AboutSection.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/AboutSection.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/providers.jsx" beforeDir="false" /> <change beforePath="$PROJECT_DIR$/components/CartDrawer.tsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/AboutSection.jsx" beforeDir="false" /> <change beforePath="$PROJECT_DIR$/components/Footer.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/Footer.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/CartDrawer.jsx" beforeDir="false" /> <change beforePath="$PROJECT_DIR$/components/Header.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/Header.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/FeaturesSection.jsx" beforeDir="false" /> <change beforePath="$PROJECT_DIR$/components/HorizontalShowcase.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/HorizontalShowcase.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/FloatingItems.jsx" beforeDir="false" /> <change beforePath="$PROJECT_DIR$/components/MobileShowcase.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/MobileShowcase.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/Footer.jsx" beforeDir="false" /> <change beforePath="$PROJECT_DIR$/components/VendingCard.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/VendingCard.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/Header.jsx" beforeDir="false" /> <change beforePath="$PROJECT_DIR$/data/content.ts" beforeDir="false" afterPath="$PROJECT_DIR$/data/content.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/HeroSection.jsx" beforeDir="false" /> <change beforePath="$PROJECT_DIR$/store/cartSlice.ts" beforeDir="false" afterPath="$PROJECT_DIR$/store/cartSlice.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/HorizontalShowcase.jsx" beforeDir="false" /> <change beforePath="$PROJECT_DIR$/types/domain.ts" beforeDir="false" afterPath="$PROJECT_DIR$/types/domain.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/LanguageToggle.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/LocationsSection.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/MobileShowcase.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/ShowcaseSection.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/ThemeToggle.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/VendingCard.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/data/content.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/jsconfig.json" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/lib/axios.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/next.config.js" beforeDir="false" afterPath="$PROJECT_DIR$/next.config.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/store/cartSlice.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/store/store.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/tailwind.config.js" beforeDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -57,61 +41,61 @@
</component> </component>
<component name="NextEditCompletionFeaturesState"> <component name="NextEditCompletionFeaturesState">
<decayedCancelled> <decayedCancelled>
<entry key="MS100" value="1.0105021478132723" /> <entry key="MS100" value="5.636161152320107E-5" />
<entry key="MS500" value="1.6326904583013002" /> <entry key="MS500" value="0.2302465263814015" />
<entry key="S2" value="3.200495115489667" /> <entry key="S2" value="1.9612794450498428" />
<entry key="S5" value="4.148160592733714" /> <entry key="S5" value="3.4102363921166443" />
<entry key="S10" value="4.983360398199462" /> <entry key="S10" value="4.5184216736456175" />
<entry key="S30" value="7.2170837140275825" /> <entry key="S30" value="6.985270524998366" />
<entry key="S60" value="9.732495501143132" /> <entry key="S60" value="9.574915616687461" />
<entry key="M2" value="14.025681297470134" /> <entry key="M2" value="13.91167227171946" />
<entry key="M5" value="22.17637857625762" /> <entry key="M5" value="22.10409689222133" />
<entry key="M10" value="27.702751951097298" /> <entry key="M10" value="27.65756793067821" />
<entry key="M15" value="30.22928327611057" /> <entry key="M15" value="30.196404421732346" />
<entry key="M30" value="34.21976380295617" /> <entry key="M30" value="34.20114919146407" />
<entry key="H1" value="38.60682034968035" /> <entry key="H1" value="38.596318396422" />
<entry key="H2" value="42.59562362296086" /> <entry key="H2" value="42.58982972864479" />
<entry key="H4" value="45.40713861912438" /> <entry key="H4" value="45.40405035457018" />
<entry key="D1" value="48.33720180411775" /> <entry key="D1" value="48.33665386421948" />
<entry key="W1" value="48.90360237164015" /> <entry key="W1" value="48.9035231768996" />
</decayedCancelled> </decayedCancelled>
<decayedSelected> <decayedSelected>
<entry key="MS100" value="0.0" /> <entry key="MS100" value="1.0" />
<entry key="MS500" value="8.86528819923573E-248" /> <entry key="MS500" value="1.0" />
<entry key="S2" value="1.7255327824639203E-62" /> <entry key="S2" value="1.0" />
<entry key="S5" value="1.9713751865934254E-25" /> <entry key="S5" value="1.0" />
<entry key="S10" value="4.440017101986686E-13" /> <entry key="S10" value="1.0000000000004026" />
<entry key="S30" value="7.628893420815487E-5" /> <entry key="S30" value="1.0000738385287775" />
<entry key="S60" value="0.008734353680047248" /> <entry key="S60" value="1.0085929348174816" />
<entry key="M2" value="0.09345776415069663" /> <entry key="M2" value="1.0926980842168899" />
<entry key="M5" value="0.387477201784943" /> <entry key="M5" value="1.3862142586684925" />
<entry key="M10" value="0.6224766676630884" /> <entry key="M10" value="1.6214613895235106" />
<entry key="M15" value="0.7290355699678522" /> <entry key="M15" value="1.7282426350470148" />
<entry key="M30" value="0.8538357980126227" /> <entry key="M30" value="1.853371334793368" />
<entry key="H1" value="0.9240323576653702" /> <entry key="H1" value="1.9237809993680148" />
<entry key="H2" value="0.9612660181580177" /> <entry key="H2" value="1.9611352659059051" />
<entry key="H4" value="0.9804417464378067" /> <entry key="H4" value="1.9803750638943771" />
<entry key="D1" value="0.9967134048485689" /> <entry key="D1" value="1.9967021063244244" />
<entry key="W1" value="0.9995298237292167" /> <entry key="W1" value="1.9995282050855745" />
</decayedSelected> </decayedSelected>
<decayedShown> <decayedShown>
<entry key="MS100" value="0.01712637149186048" /> <entry key="MS100" value="5.673108319866789E-5" />
<entry key="MS500" value="0.9540168467150943" /> <entry key="MS500" value="0.2755608628441334" />
<entry key="S2" value="2.9136793264313505" /> <entry key="S2" value="2.398322303413965" />
<entry key="S5" value="4.013649896020289" /> <entry key="S5" value="4.121762152287479" />
<entry key="S10" value="4.909460420460746" /> <entry key="S10" value="5.358118199319534" />
<entry key="S30" value="7.187579804050971" /> <entry key="S30" value="7.924594218734951" />
<entry key="S60" value="9.723843431274007" /> <entry key="S60" value="10.550212525549737" />
<entry key="M2" value="14.107987700307802" /> <entry key="M2" value="14.985181048549935" />
<entry key="M5" value="22.55725525698723" /> <entry key="M5" value="23.480472743765223" />
<entry key="M10" value="28.321163439518607" /> <entry key="M10" value="29.27333974135673" />
<entry key="M15" value="30.955381308867647" /> <entry key="M15" value="31.92062506542455" />
<entry key="M30" value="35.07198888146704" /> <entry key="M30" value="36.05236671041284" />
<entry key="H1" value="39.52999740754272" /> <entry key="H1" value="40.518972305385134" />
<entry key="H2" value="43.55644480884343" /> <entry key="H2" value="44.55038420189922" />
<entry key="H4" value="46.38735262517173" /> <entry key="H4" value="47.38412968080923" />
<entry key="D1" value="49.33387639135037" /> <entry key="D1" value="50.33330581758772" />
<entry key="W1" value="49.903126627030524" /> <entry key="W1" value="50.9030441942503" />
</decayedShown> </decayedShown>
</component> </component>
<component name="ProjectColorInfo">{ <component name="ProjectColorInfo">{
@@ -123,36 +107,36 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"ASKED_MARK_IGNORED_FILES_AS_EXCLUDED": "true", &quot;ASKED_MARK_IGNORED_FILES_AS_EXCLUDED&quot;: &quot;true&quot;,
"ModuleVcsDetector.initialDetectionPerformed": "true", &quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"RunOnceActivity.typescript.service.memoryLimit.init": "true", &quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
"codeWithMe.voiceChat.enabledByDefault": "false", &quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true", &quot;com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1&quot;: &quot;true&quot;,
"git-widget-placeholder": "develop", &quot;git-widget-placeholder&quot;: &quot;develop&quot;,
"javascript.nodejs.core.library.configured.version": "24.20.0", &quot;javascript.nodejs.core.library.configured.version&quot;: &quot;24.20.0&quot;,
"javascript.nodejs.core.library.typings.version": "24.13.3", &quot;javascript.nodejs.core.library.typings.version&quot;: &quot;24.13.3&quot;,
"javascript.preferred.runtime.type.id": "node", &quot;javascript.preferred.runtime.type.id&quot;: &quot;node&quot;,
"js.debugger.nextJs.config.created.client": "true", &quot;js.debugger.nextJs.config.created.client&quot;: &quot;true&quot;,
"js.debugger.nextJs.config.created.server": "true", &quot;js.debugger.nextJs.config.created.server&quot;: &quot;true&quot;,
"junie.onboarding.icon.badge.shown": "true", &quot;junie.onboarding.icon.badge.shown&quot;: &quot;true&quot;,
"kronk.content.tree.expanded.paths": "", &quot;kronk.content.tree.expanded.paths&quot;: &quot;&quot;,
"last_opened_file_path": "/home/hamed/Documents/Projects/GItLabRepo/device_web", &quot;last_opened_file_path&quot;: &quot;/home/hamed/Documents/Projects/GItLabRepo/device_web&quot;,
"node.js.detected.package.eslint": "true", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"node.js.detected.package.tslint": "true", &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"nodejs_package_manager_path": "npm", &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
"settings.editor.selected.configurable": "editor.preferences.fonts.default", &quot;settings.editor.selected.configurable&quot;: &quot;editor.preferences.fonts.default&quot;,
"to.speed.mode.migration.done": "true", &quot;to.speed.mode.migration.done&quot;: &quot;true&quot;,
"ts.external.directory.path": "/home/hamed/Documents/Projects/GIthubRepo/robot-market-landing/node_modules/typescript/lib", &quot;ts.external.directory.path&quot;: &quot;/home/hamed/Documents/Projects/GIthubRepo/robot-market-landing/node_modules/typescript/lib&quot;,
"vue.rearranger.settings.migration": "true" &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
} }
}]]></component> }</component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="MoveFile.RECENT_KEYS"> <key name="MoveFile.RECENT_KEYS">
<recent name="D:\Project\GitHubRepo\robot-market-landing\public\images\icons" /> <recent name="D:\Project\GitHubRepo\robot-market-landing\public\images\icons" />
@@ -234,7 +218,8 @@
<workItem from="1789362025574" duration="23000" /> <workItem from="1789362025574" duration="23000" />
<workItem from="1789362137185" duration="377000" /> <workItem from="1789362137185" duration="377000" />
<workItem from="1789362644534" duration="6512000" /> <workItem from="1789362644534" duration="6512000" />
<workItem from="1789383269194" duration="1732000" /> <workItem from="1789383269194" duration="2337000" />
<workItem from="1789445417779" duration="2914000" />
</task> </task>
<task id="LOCAL-00001" summary="update"> <task id="LOCAL-00001" summary="update">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -508,7 +493,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1789384147873</updated> <updated>1789384147873</updated>
</task> </task>
<option name="localTasksCounter" value="35" /> <task id="LOCAL-00035" summary="refactor: migrate project to TypeScript and translate comments to English">
<option name="closed" value="true" />
<created>1789385239281</created>
<option name="number" value="00035" />
<option name="presentableId" value="LOCAL-00035" />
<option name="project" value="LOCAL" />
<updated>1789385239281</updated>
</task>
<option name="localTasksCounter" value="36" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -530,6 +523,7 @@
<MESSAGE value="update" /> <MESSAGE value="update" />
<MESSAGE value="feat: add landing page device images and fix machine branding" /> <MESSAGE value="feat: add landing page device images and fix machine branding" />
<MESSAGE value="refactor: remove disabled home sections and their unused assets" /> <MESSAGE value="refactor: remove disabled home sections and their unused assets" />
<option name="LAST_COMMIT_MESSAGE" value="refactor: remove disabled home sections and their unused assets" /> <MESSAGE value="refactor: migrate project to TypeScript and translate comments to English" />
<option name="LAST_COMMIT_MESSAGE" value="refactor: migrate project to TypeScript and translate comments to English" />
</component> </component>
</project> </project>
+11 -4
View File
@@ -31,14 +31,20 @@ npm start
## ساختار و بخش‌های فعال ## ساختار و بخش‌های فعال
- `app/page.tsx`: صفحه اصلی شامل هدر، سبد خرید، هیرو، ویترین دستگاه‌ها، مزایا، درباره ما، محل‌های مناسب نصب و فوتر. - `app/page.tsx`: صفحه اصلی شامل هیرو، ویترین دستگاه‌ها، مزایا، درباره ما و محل‌های مناسب نصب.
- `app/layout.tsx`: هدر، فوتر و پیام توسعه مشترک بین صفحات.
- `app/cart/page.tsx`: صفحه مستقل سبد خرید.
- `app/products/[id]/page.tsx`: صفحه جزئیات هر دستگاه، تصاویر، ویژگی‌ها و کنترل تعداد.
- `components/HeroSection.tsx`: اسلایدر تصاویر دستگاه‌ها. - `components/HeroSection.tsx`: اسلایدر تصاویر دستگاه‌ها.
- `components/ShowcaseSection.tsx`: انتخاب نمایش موبایل یا ویترین افقی دسکتاپ. - `components/ShowcaseSection.tsx`: انتخاب نمایش موبایل یا ویترین افقی دسکتاپ.
- `components/HorizontalShowcase.tsx`: ویترین افقی با GSAP و ویدیوی پس‌زمینه وابسته به اسکرول. - `components/HorizontalShowcase.tsx`: ویترین افقی با GSAP و ویدیوی پس‌زمینه وابسته به اسکرول.
- `components/MobileShowcase.tsx`: فهرست دستگاه‌ها در موبایل. - `components/MobileShowcase.tsx`: فهرست دستگاه‌ها در موبایل.
- `components/VendingCard.tsx` و `components/FloatingItems.tsx`: نمایش دستگاه و جلوه‌های تصویری. - `components/VendingCard.tsx` و `components/FloatingItems.tsx`: نمایش دستگاه و جلوه‌های تصویری.
- `components/CartDrawer.tsx` و `store/`: سبد خرید و مدیریت تعداد دستگاه‌ها با Redux. - `components/CartPage.tsx`، `components/QuantityControl.tsx` و `store/`: سبد خرید و کنترل افزایش و کاهش تعداد با Redux.
- `components/CartPersistence.tsx`: حفظ شناسه و تعداد دستگاه‌ها در مرورگر.
- `components/DevelopmentNotice.tsx`: پیام اولین بازدید؛ پس از بسته‌شدن دوباره در همان مرورگر نمایش داده نمی‌شود.
- `data/content.ts`: محتوای فارسی و انگلیسی، مشخصات و قیمت دستگاه‌ها و مسیر رسانه‌ها. - `data/content.ts`: محتوای فارسی و انگلیسی، مشخصات و قیمت دستگاه‌ها و مسیر رسانه‌ها.
- `data/commerce.ts`: متن‌های دوزبانه سبد خرید، جزئیات محصول و پیام توسعه.
- `app/context/` و `app/providers.tsx`: زبان، جهت صفحه، تم و تنظیمات Ant Design. - `app/context/` و `app/providers.tsx`: زبان، جهت صفحه، تم و تنظیمات Ant Design.
- `app/globals.css`: رنگ‌ها و استایل‌های مشترک. - `app/globals.css`: رنگ‌ها و استایل‌های مشترک.
- `public/`: تصاویر دستگاه‌ها، اقلام شناور، گیف‌ها و ویدیوی ویترین. - `public/`: تصاویر دستگاه‌ها، اقلام شناور، گیف‌ها و ویدیوی ویترین.
@@ -51,8 +57,9 @@ npm start
## ورود و سبد خرید ## ورود و سبد خرید
دکمه ورود به https://panel.my-rm.com متصل است. دکمه ورود به https://panel.my-rm.com متصل است.
سبد خرید در حافظه نگه‌داری می‌شود و با بارگذاری مجدد صفحه پاک می‌شود. سبد خرید در مسیر `/cart` نمایش داده می‌شود و انتخاب‌ها پس از بارگذاری مجدد حفظ می‌شوند.
پرداخت هنوز متصل نیست؛ دکمه پرداخت در `CartDrawer.tsx` پیام نمایشی نشان می‌دهد. کنترل‌های مثبت و منفی در کارت‌ها، صفحه محصول و سبد هماهنگ‌اند؛ کاهش تعداد از یک به صفر دستگاه را از سبد حذف می‌کند.
پرداخت هنوز متصل نیست؛ دکمه پرداخت غیرفعال است و توضیح آن نمایش داده می‌شود.
## تنظیم API ## تنظیم API
+8
View File
@@ -0,0 +1,8 @@
import type {Metadata} from 'next';
import CartPage from '@/components/CartPage';
export const metadata: Metadata = {title: 'سبد خرید | روبات مارکت'};
export default function Page() {
return <CartPage/>;
}
+9 -1
View File
@@ -3,6 +3,9 @@ import type {ReactNode} from 'react';
import {Vazirmatn} from 'next/font/google'; import {Vazirmatn} from 'next/font/google';
import './globals.css'; import './globals.css';
import Providers from './providers'; import Providers from './providers';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import DevelopmentNotice from '@/components/DevelopmentNotice';
const vazirmatn = Vazirmatn({ const vazirmatn = Vazirmatn({
subsets: ['arabic'], subsets: ['arabic'],
@@ -28,7 +31,12 @@ export default function RootLayout({children}: {children: ReactNode}) {
return ( return (
<html lang="fa" dir="rtl" className={`${vazirmatn.variable} light`} suppressHydrationWarning> <html lang="fa" dir="rtl" className={`${vazirmatn.variable} light`} suppressHydrationWarning>
<body className="bg-page text-primary antialiased transition-colors duration-300"> <body className="bg-page text-primary antialiased transition-colors duration-300">
<Providers>{children}</Providers> <Providers>
<Header/>
{children}
<Footer/>
<DevelopmentNotice/>
</Providers>
</body> </body>
</html> </html>
); );
-6
View File
@@ -3,22 +3,16 @@ import ShowcaseSection from "@/components/ShowcaseSection";
import FeaturesSection from '@/components/FeaturesSection'; import FeaturesSection from '@/components/FeaturesSection';
import AboutSection from '@/components/AboutSection'; import AboutSection from '@/components/AboutSection';
import HeroSection from '@/components/HeroSection'; import HeroSection from '@/components/HeroSection';
import CartDrawer from '@/components/CartDrawer';
import Footer from '@/components/Footer';
import Header from '@/components/Header';
export default function HomePage() { export default function HomePage() {
return ( return (
<main className="relative bg-page"> <main className="relative bg-page">
<Header/>
<CartDrawer/>
<HeroSection/> <HeroSection/>
<ShowcaseSection/> <ShowcaseSection/>
<FeaturesSection/> <FeaturesSection/>
<AboutSection/> <AboutSection/>
<LocationsSection/> <LocationsSection/>
<Footer/>
</main> </main>
); );
} }
+21
View File
@@ -0,0 +1,21 @@
import type {Metadata} from 'next';
import {notFound} from 'next/navigation';
import {content} from '@/data/content';
import ProductDetails from '@/components/ProductDetails';
type Props = {params: {id: string}};
export function generateStaticParams() {
return content.fa.machines.map(machine => ({id: machine.id}));
}
export function generateMetadata({params}: Props): Metadata {
const machine = content.fa.machines.find(item => item.id === params.id);
if (!machine) return {};
return {title: `${machine.name} | روبات مارکت`, description: machine.description};
}
export default function ProductPage({params}: Props) {
if (!content.fa.machines.some(machine => machine.id === params.id)) notFound();
return <ProductDetails id={params.id}/>;
}
+2 -3
View File
@@ -11,6 +11,7 @@ import enUS from 'antd/locale/en_US';
import {store} from '@/store/store'; import {store} from '@/store/store';
import {ThemeProvider, useTheme} from './context/ThemeContext'; import {ThemeProvider, useTheme} from './context/ThemeContext';
import {LanguageProvider, useLanguage} from './context/LanguageContext'; import {LanguageProvider, useLanguage} from './context/LanguageContext';
import CartPersistence from '@/components/CartPersistence';
function AntdBridge({children}: {children: ReactNode}) { function AntdBridge({children}: {children: ReactNode}) {
const {theme} = useTheme(); const {theme} = useTheme();
@@ -37,9 +38,6 @@ function AntdBridge({children}: {children: ReactNode}) {
headerBg: theme === 'dark' ? '#0c2b24' : '#ffffff', headerBg: theme === 'dark' ? '#0c2b24' : '#ffffff',
titleColor: theme === 'dark' ? '#e6f7f4' : '#04231f', titleColor: theme === 'dark' ? '#e6f7f4' : '#04231f',
}, },
Drawer: {
colorBgElevated: theme === 'dark' ? '#0c2b24' : '#ffffff',
},
}, },
}; };
@@ -53,6 +51,7 @@ function AntdBridge({children}: {children: ReactNode}) {
export default function Providers({children}: {children: ReactNode}) { export default function Providers({children}: {children: ReactNode}) {
return ( return (
<ReduxProvider store={store}> <ReduxProvider store={store}>
<CartPersistence/>
<ThemeProvider> <ThemeProvider>
<LanguageProvider> <LanguageProvider>
<AntdBridge>{children}</AntdBridge> <AntdBridge>{children}</AntdBridge>
+8 -9
View File
@@ -7,7 +7,7 @@ import {useLanguage} from '@/app/context/LanguageContext';
export default function AboutSection() { export default function AboutSection() {
const {t} = useLanguage(); const {t} = useLanguage();
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(false);
const visibleParagraphs = expanded ? t.about.paragraphs : t.about.paragraphs.slice(0, 2); const visibleParagraphs = expanded ? t.about.paragraphs : t.about.paragraphs.slice(0, 2);
return ( return (
@@ -26,14 +26,13 @@ export default function AboutSection() {
))} ))}
</div> </div>
{/* faz 1 */} <button
{/* <button onClick={() => setExpanded((v) => !v)}
onClick={() => setExpanded((v) => !v)} className="mt-6 inline-flex items-center gap-2 text-brand-400 hover:text-brand-300 text-sm font-semibold transition-colors"
className="mt-6 inline-flex items-center gap-2 text-brand-400 hover:text-brand-300 text-sm font-semibold transition-colors" >
> {expanded ? t.about.less : t.about.more}
{expanded ? t.about.less : t.about.more} <span className={`transition-transform ${expanded ? 'rotate-180' : ''}`}>˅</span>
<span className={`transition-transform ${expanded ? 'rotate-180' : ''}`}>˅</span> </button>
</button>*/}
</div> </div>
</section> </section>
); );
-115
View File
@@ -1,115 +0,0 @@
'use client';
// Cart drawer with quantity editing, totals, and placeholder checkout.
import {Drawer, Button, Empty, InputNumber, message} from 'antd';
import {DeleteOutlined} from '@ant-design/icons';
import {useAppSelector, useAppDispatch} from '@/store/hooks';
import {
selectCartItems,
selectCartTotal,
selectIsDrawerOpen,
closeDrawer,
removeFromCart,
setQuantity,
} from '@/store/cartSlice';
import {useLanguage} from '@/app/context/LanguageContext';
import type {Locale} from '@/types/domain';
import api from "@/lib/axios";
function toLocaleNumber(n: number, locale: Locale) {
return n.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US');
}
export default function CartDrawer() {
const dispatch = useAppDispatch();
const isOpen = useAppSelector(selectIsDrawerOpen);
const items = useAppSelector(selectCartItems);
const total = useAppSelector(selectCartTotal);
const {t, locale, dir} = useLanguage();
const currencySuffix = locale === 'fa' ? ' ریال' : ' Rials';
const handleCheckout = async () => {
// -------------------------------------------------------------------
// TODO: Connect to payment gateway API..
// try {
// const { data } = await axios.post('/api/payment/create', {
// items: items.map((i) => ({ id: i.id, qty: i.qty })),
// totalAmount: total,
// });
// window.location.href = data.paymentRedirectUrl;
// } catch (err) {
// message.error('...');
// }
// api() <===> todo : use this func to fetch ...
// -------------------------------------------------------------------
await message.info(t.cart.checkoutToast);
};
return (
<Drawer
title={t.cart.title}
placement={dir === 'rtl' ? 'right' : 'left'}
onClose={() => dispatch(closeDrawer())}
open={isOpen}
width={400}
>
{items.length === 0 ? (
<Empty description={<span className="text-secondary">{t.cart.empty}</span>} className="mt-16"/>
) : (
<div className="flex flex-col gap-4">
{items.map((item) => (
<div key={item.id}
className="flex items-center gap-3 bg-surface border border-subtle rounded-xl p-3">
<img src={item.image} alt={item.name} className="w-14 h-20 object-contain shrink-0"/>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-primary truncate">{item.name}</p>
<p className="text-xs text-brand-400 mt-1">
{toLocaleNumber(item.priceNumeric, locale)}
{currencySuffix}
</p>
<div className="flex items-center gap-2 mt-2">
<InputNumber
size="small"
min={1}
precision={0}
value={item.qty}
onChange={(val) => {
if (val !== null) dispatch(setQuantity({id: item.id, qty: val}));
}}
className="w-16"
/>
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined/>}
onClick={() => dispatch(removeFromCart(item.id))}
/>
</div>
</div>
</div>
))}
<div className="border-t border-subtle pt-4 mt-2">
<div className="flex items-center justify-between text-sm text-secondary mb-4">
<span>{t.cart.total}</span>
<span className="text-lg font-bold text-primary">
{toLocaleNumber(total, locale)}
{currencySuffix}
</span>
</div>
<Button type="primary" block size="large" onClick={handleCheckout}>
{t.cart.checkout}
</Button>
</div>
</div>
)}
</Drawer>
);
}
+68
View File
@@ -0,0 +1,68 @@
'use client';
import Link from 'next/link';
import {Button, Empty} from 'antd';
import {DeleteOutlined} from '@ant-design/icons';
import {useAppDispatch, useAppSelector} from '@/store/hooks';
import {removeFromCart, selectCartItems, selectCartTotal} from '@/store/cartSlice';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
import QuantityControl from './QuantityControl';
export default function CartPage() {
const dispatch = useAppDispatch();
const items = useAppSelector(selectCartItems);
const total = useAppSelector(selectCartTotal);
const {t, locale} = useLanguage();
const copy = commerceCopy[locale];
const money = (value: number) => `${value.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')} ${copy.currency}`;
return (
<main className="mx-auto max-w-7xl min-h-[65vh] px-5 md:px-8 pt-28 pb-20">
<Link href="/#showcase" className="text-brand-400 hover:underline">{copy.continueShopping}</Link>
<h1 className="text-3xl md:text-4xl font-extrabold text-primary mt-6">{t.cart.title}</h1>
<p className="text-secondary mt-3 mb-10">{copy.cartDescription}</p>
{items.length === 0 ? (
<div className="rounded-3xl border border-subtle bg-surface p-10 text-center">
<Empty description={t.cart.empty}/>
<Link href="/#showcase" className="inline-block mt-6 text-brand-400 hover:underline">{copy.continueShopping}</Link>
</div>
) : (
<div className="grid lg:grid-cols-[minmax(0,1fr),340px] gap-8 items-start">
<div className="space-y-4">
{items.map(item => {
const product = t.machines.find(machine => machine.id === item.id) ?? item;
return (
<article key={item.id} className="rounded-2xl border border-subtle bg-surface p-5 flex flex-wrap sm:flex-nowrap gap-5 items-center">
<Link href={`/products/${item.id}`} className="shrink-0">
<img src={product.image} alt={product.name} className="w-24 h-32 object-contain"/>
</Link>
<div className="flex-1 min-w-0">
<Link href={`/products/${item.id}`} className="font-bold text-lg text-primary hover:text-brand-400">{product.name}</Link>
<p className="text-secondary text-sm mt-2">{copy.unitPrice}: {money(item.priceNumeric)}</p>
<div className="flex flex-wrap gap-3 items-center mt-4">
<QuantityControl product={product}/>
<Button danger type="text" icon={<DeleteOutlined/>}
aria-label={`${copy.remove}: ${product.name}`}
onClick={() => dispatch(removeFromCart(item.id))}/>
</div>
<p className="mt-4 text-primary font-semibold">{copy.lineTotal}: {money(item.qty * item.priceNumeric)}</p>
</div>
</article>
);
})}
</div>
<aside className="lg:sticky lg:top-28 border border-subtle bg-elevated rounded-2xl p-6">
<h2 className="font-bold text-xl text-primary">{copy.summary}</h2>
<div className="border-t border-subtle mt-5 pt-5 flex flex-wrap justify-between gap-3">
<span className="text-secondary">{t.cart.total}</span>
<strong className="text-primary">{money(total)}</strong>
</div>
<p className="text-secondary text-sm leading-7 my-5">{copy.checkoutUnavailable}</p>
<Button type="primary" size="large" disabled block>{t.cart.checkout}</Button>
</aside>
</div>
)}
</main>
);
}
+46
View File
@@ -0,0 +1,46 @@
'use client';
import {useEffect} from 'react';
import {useStore} from 'react-redux';
import type {AppStore} from '@/store/store';
import type {CartItem} from '@/types/domain';
import {restoreCart} from '@/store/cartSlice';
import {content} from '@/data/content';
const STORAGE_KEY = 'rm-cart';
const useAppStore = useStore.withTypes<AppStore>();
export default function CartPersistence() {
const store = useAppStore();
useEffect(() => {
try {
const saved: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]');
if (Array.isArray(saved)) {
const items: CartItem[] = [];
for (const entry of saved) {
if (!entry || typeof entry !== 'object' || typeof entry.id !== 'string'
|| !Number.isSafeInteger(entry.qty) || entry.qty < 1) continue;
const machine = content.fa.machines.find(item => item.id === entry.id);
if (!machine || items.some(item => item.id === machine.id)) continue;
// Restore identifiers and quantities; use current catalog prices and images.
items.push({id: machine.id, name: machine.name, image: machine.image,
priceNumeric: machine.priceNumeric, priceLabel: machine.priceLabel, qty: entry.qty});
}
store.dispatch(restoreCart(items));
}
} catch {
// Invalid or unavailable storage must not prevent shopping.
}
return store.subscribe(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(store.getState().cart.items.map(({id, qty}) => ({id, qty}))));
} catch {
// Keep the in-memory cart usable when storage is full or disabled.
}
});
}, [store]);
return null;
}
+41
View File
@@ -0,0 +1,41 @@
'use client';
import {useEffect, useState} from 'react';
import {Button, Modal} from 'antd';
import {ToolOutlined} from '@ant-design/icons';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
const STORAGE_KEY = 'rm-development-notice-seen';
export default function DevelopmentNotice() {
const [open, setOpen] = useState(false);
const {locale} = useLanguage();
const copy = commerceCopy[locale];
useEffect(() => {
try {
setOpen(localStorage.getItem(STORAGE_KEY) !== 'true');
} catch {
// Show the notice even when browser storage is unavailable.
setOpen(true);
}
}, []);
const dismiss = () => {
setOpen(false);
try {
localStorage.setItem(STORAGE_KEY, 'true');
} catch {
// Dismissing the notice must not depend on storage access.
}
};
return (
<Modal open={open} centered title={copy.developmentTitle} onCancel={dismiss}
footer={<Button type="primary" onClick={dismiss}>{copy.acknowledge}</Button>}>
<ToolOutlined className="text-brand-400 text-3xl my-4"/>
<p className="text-secondary leading-8">{copy.developmentMessage}</p>
</Modal>
);
}
+5 -4
View File
@@ -2,6 +2,7 @@
// Company information, quick links, social links, and contact details. // Company information, quick links, social links, and contact details.
import Link from 'next/link';
import { PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons'; import { PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons';
import { useLanguage } from '@/app/context/LanguageContext'; import { useLanguage } from '@/app/context/LanguageContext';
@@ -33,10 +34,10 @@ export default function Footer() {
<div> <div>
<h3 className="text-primary font-bold text-lg mb-3">{t.footer.quickLinks}</h3> <h3 className="text-primary font-bold text-lg mb-3">{t.footer.quickLinks}</h3>
<ul className="flex flex-col gap-2 text-sm text-secondary"> <ul className="flex flex-col gap-2 text-sm text-secondary">
<li><a href="#hero" className="hover:text-brand-400">{t.nav.home}</a></li> <li><Link href="/#hero" className="hover:text-brand-400">{t.nav.home}</Link></li>
<li><a href="#showcase" className="hover:text-brand-400">{t.nav.showcase}</a></li> <li><Link href="/#showcase" className="hover:text-brand-400">{t.nav.showcase}</Link></li>
<li><a href="#about" className="hover:text-brand-400">{t.nav.about}</a></li> <li><Link href="/#about" className="hover:text-brand-400">{t.nav.about}</Link></li>
<li><a href="#locations" className="hover:text-brand-400">{t.nav.locations}</a></li> <li><Link href="/#locations" className="hover:text-brand-400">{t.nav.locations}</Link></li>
</ul> </ul>
</div> </div>
+21 -21
View File
@@ -4,30 +4,33 @@
import {useState, useEffect} from 'react'; import {useState, useEffect} from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link';
import {usePathname} from 'next/navigation';
import {Badge, Button} from 'antd'; import {Badge, Button} from 'antd';
import {ShoppingCartOutlined, MenuOutlined, CloseOutlined, UserOutlined} from '@ant-design/icons'; import {ShoppingCartOutlined, MenuOutlined, CloseOutlined, UserOutlined} from '@ant-design/icons';
import {useAppSelector, useAppDispatch} from '@/store/hooks'; import {useAppSelector} from '@/store/hooks';
import {selectCartCount, openDrawer} from '@/store/cartSlice'; import {selectCartCount} from '@/store/cartSlice';
import {useLanguage} from '@/app/context/LanguageContext'; import {useLanguage} from '@/app/context/LanguageContext';
import ThemeToggle from './ThemeToggle'; import ThemeToggle from './ThemeToggle';
import LanguageToggle from './LanguageToggle'; import LanguageToggle from './LanguageToggle';
export default function Header() { export default function Header() {
const dispatch = useAppDispatch(); const pathname = usePathname();
const cartCount = useAppSelector(selectCartCount); const cartCount = useAppSelector(selectCartCount);
const {t} = useLanguage(); const {t} = useLanguage();
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const navLinks = [ const navLinks = [
{href: '#hero', label: t.nav.home}, {href: '/#hero', label: t.nav.home},
{href: '#showcase', label: t.nav.showcase}, {href: '/#showcase', label: t.nav.showcase},
{href: '#about', label: t.nav.about}, {href: '/#about', label: t.nav.about},
{href: '#locations', label: t.nav.locations}, {href: '/#locations', label: t.nav.locations},
]; ];
useEffect(() => { useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24); const onScroll = () => setScrolled(window.scrollY > 24);
onScroll();
window.addEventListener('scroll', onScroll); window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll); return () => window.removeEventListener('scroll', onScroll);
}, []); }, []);
@@ -40,23 +43,23 @@ export default function Header() {
return ( return (
<header <header
className={`fixed top-0 inset-x-0 z-50 transition-all duration-300 ${ className={`fixed top-0 inset-x-0 z-50 transition-all duration-300 ${
scrolled ? 'bg-page-85 backdrop-blur-md shadow-[0_1px_0_0_rgba(0,166,147,0.25)]' : 'bg-transparent' scrolled || pathname !== '/' ? 'bg-page-85 backdrop-blur-md shadow-[0_1px_0_0_rgba(0,166,147,0.25)]' : 'bg-transparent'
}`} }`}
> >
<div className="mx-auto max-w-7xl px-5 md:px-8 h-20 flex items-center justify-between gap-4"> <div className="mx-auto max-w-7xl px-5 md:px-8 h-20 flex items-center justify-between gap-4">
<a href="#hero" className="flex items-center gap-2 shrink-0"> <Link href="/#hero" onClick={() => setMobileOpen(false)} className="flex items-center gap-2 shrink-0">
<Image src="/images/icons/logo.png" alt="روبات مارکت" width={150} height={42} priority/> <Image src="/images/icons/logo.png" alt="روبات مارکت" width={150} height={42} priority/>
</a> </Link>
<nav className="hidden xl:flex items-center gap-5 2xl:gap-7"> <nav className="hidden xl:flex items-center gap-5 2xl:gap-7">
{navLinks.map((link) => ( {navLinks.map((link) => (
<a <Link
key={link.href} key={link.href}
href={link.href} href={link.href}
className="text-sm text-secondary hover:text-brand-300 transition-colors whitespace-nowrap" className="text-sm text-secondary hover:text-brand-300 transition-colors whitespace-nowrap"
> >
{link.label} {link.label}
</a> </Link>
))} ))}
</nav> </nav>
@@ -77,13 +80,10 @@ export default function Header() {
</Button> </Button>
<Badge count={cartCount} size="small" offset={[-2, 2]}> <Badge count={cartCount} size="small" offset={[-2, 2]}>
<Button <Link href="/cart" onClick={() => setMobileOpen(false)} aria-label={t.cart.title}
shape="circle" className="w-11 h-11 rounded-full border border-subtle bg-elevated inline-flex items-center justify-center text-xl text-primary hover:text-brand-400">
size="large" <ShoppingCartOutlined/>
icon={<ShoppingCartOutlined/>} </Link>
onClick={() => dispatch(openDrawer())}
aria-label={t.cart.title}
/>
</Badge> </Badge>
<button <button
@@ -100,14 +100,14 @@ export default function Header() {
<nav <nav
className="xl:hidden bg-elevated-95 backdrop-blur-md border-t border-subtle px-5 py-4 flex flex-col gap-4 max-h-[calc(100vh-5rem)] overflow-y-auto"> className="xl:hidden bg-elevated-95 backdrop-blur-md border-t border-subtle px-5 py-4 flex flex-col gap-4 max-h-[calc(100vh-5rem)] overflow-y-auto">
{navLinks.map((link) => ( {navLinks.map((link) => (
<a <Link
key={link.href} key={link.href}
href={link.href} href={link.href}
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
className="text-secondary text-base" className="text-secondary text-base"
> >
{link.label} {link.label}
</a> </Link>
))} ))}
<div className="flex items-center gap-3 pt-2 border-t border-subtle"> <div className="flex items-center gap-3 pt-2 border-t border-subtle">
<LanguageToggle/> <LanguageToggle/>
+1 -52
View File
@@ -1,16 +1,11 @@
'use client'; 'use client';
import type {Machine} from '@/types/domain';
import {useEffect, useRef, useState} from 'react'; import {useEffect, useRef, useState} from 'react';
import {gsap} from 'gsap'; import {gsap} from 'gsap';
import {ScrollTrigger} from 'gsap/ScrollTrigger'; import {ScrollTrigger} from 'gsap/ScrollTrigger';
import {Modal, Button, message} from 'antd';
import {ShoppingCartOutlined} from '@ant-design/icons';
import {useAppDispatch} from '@/store/hooks';
import VendingCard from './VendingCard'; import VendingCard from './VendingCard';
import {showcaseVideoSrc} from '@/data/content'; import {showcaseVideoSrc} from '@/data/content';
import {useLanguage} from '@/app/context/LanguageContext'; import {useLanguage} from '@/app/context/LanguageContext';
import {addToCart} from '@/store/cartSlice';
gsap.registerPlugin(ScrollTrigger); gsap.registerPlugin(ScrollTrigger);
@@ -18,9 +13,7 @@ export default function HorizontalShowcase() {
const sectionRef = useRef<HTMLElement>(null); const sectionRef = useRef<HTMLElement>(null);
const trackRef = useRef<HTMLDivElement>(null); const trackRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const dispatch = useAppDispatch();
const {t} = useLanguage(); const {t} = useLanguage();
const [quickViewMachine, setQuickViewMachine] = useState<Machine | null>(null);
const [videoAvailable, setVideoAvailable] = useState(true); const [videoAvailable, setVideoAvailable] = useState(true);
useEffect(() => { useEffect(() => {
@@ -74,23 +67,6 @@ export default function HorizontalShowcase() {
return () => ctx.revert(); return () => ctx.revert();
}, []); }, []);
const handleModalAddToCart = () => {
if (!quickViewMachine) return;
dispatch(
addToCart({
id: quickViewMachine.id,
name: quickViewMachine.name,
priceNumeric: quickViewMachine.priceNumeric,
priceLabel: quickViewMachine.priceLabel,
image: quickViewMachine.image,
})
);
message.success(t.cart.addToast(quickViewMachine.name));
setQuickViewMachine(null);
};
return ( return (
<section id="showcase" ref={sectionRef} className="relative w-full overflow-hidden"> <section id="showcase" ref={sectionRef} className="relative w-full overflow-hidden">
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
@@ -116,37 +92,10 @@ export default function HorizontalShowcase() {
<div ref={trackRef} className="horizontal-track relative z-10" dir="ltr"> <div ref={trackRef} className="horizontal-track relative z-10" dir="ltr">
{t.machines.map((machine) => ( {t.machines.map((machine) => (
<VendingCard key={machine.id} machine={machine} onQuickView={setQuickViewMachine}/> <VendingCard key={machine.id} machine={machine}/>
))} ))}
</div> </div>
<Modal
open={!!quickViewMachine}
onCancel={() => setQuickViewMachine(null)}
footer={null}
centered
title={quickViewMachine?.name}
>
{quickViewMachine && (
<div className="flex flex-col gap-4">
<img
src={quickViewMachine.image}
alt={quickViewMachine.name}
className="w-full h-64 object-contain"
/>
<p className="text-secondary leading-7">{quickViewMachine.description}</p>
<p className="text-xl font-bold text-primary">{quickViewMachine.priceLabel}</p>
<Button
type="primary"
size="large"
icon={<ShoppingCartOutlined/>}
onClick={handleModalAddToCart}
>
{t.machineActions.addToCart}
</Button>
</div>
)}
</Modal>
</section> </section>
); );
} }
+20 -92
View File
@@ -1,108 +1,36 @@
'use client'; 'use client';
import type {Machine} from '@/types/domain'; import Link from 'next/link';
import {useState} from 'react';
import {Modal, Button, message} from 'antd';
import {ShoppingCartOutlined, EyeOutlined} from '@ant-design/icons';
import {useAppDispatch} from '@/store/hooks';
import {useLanguage} from '@/app/context/LanguageContext'; import {useLanguage} from '@/app/context/LanguageContext';
import {addToCart} from '@/store/cartSlice'; import {commerceCopy} from '@/data/commerce';
import QuantityControl from './QuantityControl';
export default function MobileShowcase() { export default function MobileShowcase() {
const dispatch = useAppDispatch(); const {t, dir, locale} = useLanguage();
const {t, dir} = useLanguage();
const [quickViewMachine, setQuickViewMachine] = useState<Machine | null>(null);
const handleAddToCart = (machine: Machine) => {
dispatch(
addToCart({
id: machine.id,
name: machine.name,
priceNumeric: machine.priceNumeric,
priceLabel: machine.priceLabel,
image: machine.image,
})
);
message.success(t.cart.addToast(machine.name));
};
const handleModalAddToCart = () => {
if (!quickViewMachine) return;
handleAddToCart(quickViewMachine);
setQuickViewMachine(null);
};
return ( return (
<section id="showcase" className="relative w-full py-8 px-4" dir={dir}> <section id="showcase" className="relative w-full py-8 px-4" dir={dir}>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{t.machines.map((machine) => ( {t.machines.map(machine => (
<div <article key={machine.id} className="bg-surface border border-subtle rounded-2xl p-4">
key={machine.id} <Link href={`/products/${machine.id}`} className="flex gap-4 items-center">
className="flex gap-4 items-center bg-surface border border-subtle rounded-2xl p-4" <img src={machine.image} alt={machine.name} className="w-24 h-28 object-contain shrink-0"/>
> <div className="min-w-0">
<img <span className="text-brand-400 text-xs font-semibold">{machine.shortLabel}</span>
src={machine.image} <h3 className="text-lg font-bold text-primary mt-1">{machine.name}</h3>
alt={machine.name} <p className="text-sm text-secondary line-clamp-2 mt-2">{machine.description}</p>
className="w-24 h-24 object-contain shrink-0"
onClick={() => setQuickViewMachine(machine)}
/>
<div className="flex flex-col gap-1.5 min-w-0 flex-1">
<span className="text-brand-400 text-xs font-semibold tracking-wide">
{machine.shortLabel}
</span>
<h3 className="text-lg font-bold text-primary truncate">
{machine.name}
</h3>
<p className="text-sm text-secondary line-clamp-2">
{machine.description}
</p>
<p className="text-base font-bold text-primary mt-1">
{machine.priceLabel}
</p>
<div className="flex justify-start items-center mt-3">
<Button
type="primary"
size="middle"
icon={<ShoppingCartOutlined/>}
onClick={() => handleAddToCart(machine)}
>
{t.machineActions.addToCart}
</Button>
</div> </div>
</Link>
<p className="text-base font-bold text-primary my-4">{machine.priceLabel}</p>
<div className="flex flex-wrap justify-between items-center gap-3">
<QuantityControl product={machine}/>
<Link href={`/products/${machine.id}`} className="text-sm text-brand-400 hover:underline">
{commerceCopy[locale].details}
</Link>
</div> </div>
</div> </article>
))} ))}
</div> </div>
<Modal
open={!!quickViewMachine}
onCancel={() => setQuickViewMachine(null)}
footer={null}
centered
title={quickViewMachine?.name}
>
{quickViewMachine && (
<div className="flex flex-col gap-4">
<img
src={quickViewMachine.image}
alt={quickViewMachine.name}
className="w-full h-64 object-contain"
/>
<p className="text-secondary leading-7">{quickViewMachine.description}</p>
<p className="text-xl font-bold text-primary">{quickViewMachine.priceLabel}</p>
<Button
type="primary"
size="large"
icon={<ShoppingCartOutlined/>}
onClick={handleModalAddToCart}
>
{t.machineActions.addToCart}
</Button>
</div>
)}
</Modal>
</section> </section>
); );
} }
+61
View File
@@ -0,0 +1,61 @@
'use client';
import {useState} from 'react';
import Link from 'next/link';
import {CheckCircleOutlined} from '@ant-design/icons';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
import QuantityControl from './QuantityControl';
export default function ProductDetails({id}: {id: string}) {
const {t, locale} = useLanguage();
const copy = commerceCopy[locale];
const machine = t.machines.find(item => item.id === id);
const [alternate, setAlternate] = useState(false);
if (!machine) return null;
return (
<main className="max-w-7xl mx-auto px-5 md:px-8 pt-28 pb-20">
<nav aria-label={copy.details} className="text-sm text-secondary flex flex-wrap gap-3 mb-8">
<Link href="/" className="hover:text-brand-400">{t.nav.home}</Link>
<span aria-hidden="true">/</span>
<Link href="/#showcase" className="hover:text-brand-400">{t.nav.showcase}</Link>
<span aria-hidden="true">/</span><span>{machine.shortLabel}</span>
</nav>
<div className="grid md:grid-cols-2 gap-10 lg:gap-16 items-start">
<section aria-label={copy.gallery} className="rounded-3xl border border-subtle bg-surface p-6">
<img src={alternate ? machine.imageAlt : machine.image} alt={`${machine.name} — ${alternate ? copy.alternateImage : copy.baseImage}`}
className="w-full h-72 md:h-[450px] object-contain"/>
<div className="flex justify-center gap-3 mt-6">
{[false, true].map(value => (
<button key={String(value)} onClick={() => setAlternate(value)} aria-pressed={alternate === value}
aria-label={value ? copy.alternateImage : copy.baseImage}
className={`rounded-xl border p-2 ${alternate === value ? 'border-brand bg-brand/10' : 'border-subtle'}`}>
<img src={value ? machine.imageAlt : machine.image} alt="" className="h-20 w-20 object-contain"/>
</button>
))}
</div>
</section>
<section>
<p className="text-brand-400 font-semibold">{machine.shortLabel}</p>
<h1 className="text-3xl lg:text-4xl font-extrabold text-primary mt-3 leading-relaxed">{machine.name}</h1>
<p className="text-secondary mt-5 leading-8">{machine.description}</p>
<p className="text-sm text-secondary mt-4">{copy.model}: <span dir="ltr">{machine.id.toUpperCase()}</span></p>
<h2 className="font-bold text-primary text-xl mt-8 mb-4">{copy.specifications}</h2>
<ul className="space-y-3">
{machine.highlights.map(feature => <li key={feature} className="flex gap-3 text-secondary"><CheckCircleOutlined className="text-brand-400"/>{feature}</li>)}
</ul>
<div className="border-t border-subtle mt-8 pt-6">
<p className="text-sm text-secondary">{copy.unitPrice}</p>
<p className="text-2xl font-bold text-primary mt-2">{machine.priceLabel}</p>
<div className="mt-5 flex flex-wrap gap-4 items-center">
<QuantityControl product={machine}/>
<Link href="/cart" className="text-brand-400 font-semibold hover:underline">{copy.cartLink}</Link>
</div>
<p className="text-secondary text-sm leading-7 mt-5">{copy.checkoutUnavailable}</p>
</div>
</section>
</div>
</main>
);
}
+32
View File
@@ -0,0 +1,32 @@
'use client';
import {Button} from 'antd';
import {MinusOutlined, PlusOutlined} from '@ant-design/icons';
import type {CartProduct} from '@/types/domain';
import {useAppDispatch, useAppSelector} from '@/store/hooks';
import {addToCart, decrementQty} from '@/store/cartSlice';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
export default function QuantityControl({product}: {product: CartProduct}) {
const dispatch = useAppDispatch();
const {locale} = useLanguage();
const copy = commerceCopy[locale];
const quantity = useAppSelector(state => state.cart.items.find(item => item.id === product.id)?.qty ?? 0);
return (
<div role="group" aria-label={`${copy.quantity}: ${product.name}`}
className="inline-flex items-center gap-3 rounded-xl border border-subtle bg-page p-1" dir="ltr">
<Button icon={<MinusOutlined/>} disabled={quantity === 0}
aria-label={`${copy.decrease}: ${product.name}`}
onClick={() => dispatch(decrementQty(product.id))}/>
<span aria-live="polite" aria-atomic="true" className="min-w-8 text-center font-bold text-primary">
{quantity.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')}
</span>
<Button type="primary" icon={<PlusOutlined/>}
aria-label={`${copy.increase}: ${product.name}`}
onClick={() => dispatch(addToCart({id: product.id, name: product.name, image: product.image,
priceLabel: product.priceLabel, priceNumeric: product.priceNumeric}))}/>
</div>
);
}
+9 -26
View File
@@ -4,17 +4,15 @@
import type {Machine} from '@/types/domain'; import type {Machine} from '@/types/domain';
import type {MouseEvent} from 'react'; import type {MouseEvent} from 'react';
import {useRef} from 'react'; import {useRef} from 'react';
import {Button, message} from 'antd'; import Link from 'next/link';
import {ShoppingCartOutlined, EyeOutlined} from '@ant-design/icons'; import QuantityControl from './QuantityControl';
import {useAppDispatch} from '@/store/hooks'; import {commerceCopy} from '@/data/commerce';
import {addToCart} from '@/store/cartSlice';
import {useLanguage} from '@/app/context/LanguageContext'; import {useLanguage} from '@/app/context/LanguageContext';
import FloatingItems from './FloatingItems'; import FloatingItems from './FloatingItems';
import {floatingSnacks} from '@/data/content'; import {floatingSnacks} from '@/data/content';
export default function VendingCard({machine, onQuickView}: {machine: Machine; onQuickView?: (machine: Machine) => void}) { export default function VendingCard({machine}: {machine: Machine}) {
const dispatch = useAppDispatch(); const {t, dir, locale} = useLanguage();
const {t, dir} = useLanguage();
const stageRef = useRef<HTMLDivElement>(null); const stageRef = useRef<HTMLDivElement>(null);
const lensRef = useRef<HTMLDivElement>(null); const lensRef = useRef<HTMLDivElement>(null);
const revealRef = useRef<HTMLImageElement>(null); const revealRef = useRef<HTMLImageElement>(null);
@@ -38,19 +36,6 @@ export default function VendingCard({machine, onQuickView}: {machine: Machine; o
if (revealRef.current) revealRef.current.style.clipPath = 'circle(0px at 50% 50%)'; if (revealRef.current) revealRef.current.style.clipPath = 'circle(0px at 50% 50%)';
}; };
const handleAddToCart = () => {
dispatch(
addToCart({
id: machine.id,
name: machine.name,
priceNumeric: machine.priceNumeric,
priceLabel: machine.priceLabel,
image: machine.image,
})
);
message.success(t.cart.addToast(machine.name));
};
return ( return (
<div className="horizontal-panel flex items-center justify-center px-6 md:px-16" dir={dir}> <div className="horizontal-panel flex items-center justify-center px-6 md:px-16" dir={dir}>
<div className="relative z-10 max-w-6xl w-full grid md:grid-cols-2 gap-0 md:gap-10 items-center"> <div className="relative z-10 max-w-6xl w-full grid md:grid-cols-2 gap-0 md:gap-10 items-center">
@@ -103,12 +88,10 @@ export default function VendingCard({machine, onQuickView}: {machine: Machine; o
<p className="text-xl md:text-2xl font-bold text-primary mt-2">{machine.priceLabel}</p> <p className="text-xl md:text-2xl font-bold text-primary mt-2">{machine.priceLabel}</p>
<div className="flex flex-wrap items-center gap-2 mt-2 "> <div className="flex flex-wrap items-center gap-2 mt-2 ">
{/* <Button type="primary" size="large" icon={<ShoppingCartOutlined/>} onClick={handleAddToCart}> <QuantityControl product={machine}/>
{t.machineActions.addToCart} <Link href={`/products/${machine.id}`} className="px-5 py-3 rounded-xl border border-subtle text-primary hover:text-brand-400">
</Button>*/} {commerceCopy[locale].details}
<Button size="large" icon={<EyeOutlined/>} onClick={() => onQuickView?.(machine)}> </Link>
{t.machineActions.quickView}
</Button>
</div> </div>
</div> </div>
</div> </div>
+52
View File
@@ -0,0 +1,52 @@
import type {Locale} from '@/types/domain';
const fa = {
details: 'جزئیات محصول',
increase: 'افزایش تعداد',
decrease: 'کاهش تعداد',
quantity: 'تعداد',
remove: 'حذف از سبد',
continueShopping: 'مشاهده دستگاه‌ها',
cartDescription: 'دستگاه‌های انتخاب‌شده و تعداد آن‌ها را اینجا بررسی کنید.',
summary: 'خلاصه سبد خرید',
unitPrice: 'قیمت هر دستگاه',
lineTotal: 'جمع این دستگاه',
currency: 'ریال',
cartLink: 'مشاهده سبد خرید',
specifications: 'ویژگی‌های دستگاه',
model: 'مدل دستگاه',
gallery: 'تصاویر دستگاه',
baseImage: 'نمای اصلی',
alternateImage: 'نمای جایگزین',
developmentTitle: 'روبات مارکت در حال توسعه است',
developmentMessage: 'به روبات مارکت خوش آمدید. سایت در حال توسعه است و هنوز تمام بخش‌های آن تکمیل نشده‌اند. در حال حاضر می‌توانید دستگاه‌ها را بررسی و به سبد خرید اضافه کنید، اما ثبت نهایی سفارش و پرداخت آنلاین فعال نیست.',
acknowledge: 'متوجه شدم، ادامه می‌دهم',
checkoutUnavailable: 'ثبت نهایی سفارش و پرداخت آنلاین هنوز فعال نیست.',
};
export const commerceCopy: Record<Locale, Record<keyof typeof fa, string>> = {
fa,
en: {
details: 'Product details',
increase: 'Increase quantity',
decrease: 'Decrease quantity',
quantity: 'Quantity',
remove: 'Remove from cart',
continueShopping: 'Browse machines',
cartDescription: 'Review your selected machines and adjust their quantities.',
summary: 'Cart summary',
unitPrice: 'Unit price',
lineTotal: 'Item total',
currency: 'Rials',
cartLink: 'View cart',
specifications: 'Machine features',
model: 'Machine model',
gallery: 'Machine images',
baseImage: 'Main view',
alternateImage: 'Alternate view',
developmentTitle: 'Robot Market is under development',
developmentMessage: 'Welcome to Robot Market. Our website is still under development and some features are not yet complete. You can explore machines and add them to your cart, but order submission and online payment are not available yet.',
acknowledge: 'Got it, continue',
checkoutUnavailable: 'Order submission and online payment are not available yet.',
},
};
-2
View File
@@ -138,7 +138,6 @@ export const content: Record<Locale, SiteContent> = {
}, },
machineActions: { machineActions: {
addToCart: 'افزودن به سبد خرید', addToCart: 'افزودن به سبد خرید',
quickView: 'مشاهده سریع',
wishlistAdd: 'افزودن به علاقه‌مندی‌ها', wishlistAdd: 'افزودن به علاقه‌مندی‌ها',
wishlistRemove: 'حذف از علاقه‌مندی‌ها', wishlistRemove: 'حذف از علاقه‌مندی‌ها',
hoverHint: 'نشانگر را روی دستگاه حرکت دهید', hoverHint: 'نشانگر را روی دستگاه حرکت دهید',
@@ -304,7 +303,6 @@ export const content: Record<Locale, SiteContent> = {
}, },
machineActions: { machineActions: {
addToCart: 'Add to Cart', addToCart: 'Add to Cart',
quickView: 'Quick View',
wishlistAdd: 'Add to Wishlist', wishlistAdd: 'Add to Wishlist',
wishlistRemove: 'Remove from Wishlist', wishlistRemove: 'Remove from Wishlist',
hoverHint: 'Move your cursor over the machine', hoverHint: 'Move your cursor over the machine',
+5 -16
View File
@@ -1,17 +1,19 @@
import type {PayloadAction} from '@reduxjs/toolkit'; import type {PayloadAction} from '@reduxjs/toolkit';
import type {CartProduct, CartState} from '@/types/domain'; import type {CartItem, CartProduct, CartState} from '@/types/domain';
import type {RootState} from './store'; import type {RootState} from './store';
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
const initialState: CartState = { const initialState: CartState = {
items: [], // { id, name, priceNumeric, priceLabel, qty, image } items: [], // { id, name, priceNumeric, priceLabel, qty, image }
isDrawerOpen: false,
}; };
const cartSlice = createSlice({ const cartSlice = createSlice({
name: 'cart', name: 'cart',
initialState, initialState,
reducers: { reducers: {
restoreCart: (state, action: PayloadAction<CartItem[]>) => {
state.items = action.payload;
},
addToCart: (state, action: PayloadAction<CartProduct>) => { addToCart: (state, action: PayloadAction<CartProduct>) => {
const product = action.payload; const product = action.payload;
const existing = state.items.find((item) => item.id === product.id); const existing = state.items.find((item) => item.id === product.id);
@@ -20,7 +22,6 @@ const cartSlice = createSlice({
} else { } else {
state.items.push({ ...product, qty: 1 }); state.items.push({ ...product, qty: 1 });
} }
state.isDrawerOpen = true;
}, },
removeFromCart: (state, action: PayloadAction<string>) => { removeFromCart: (state, action: PayloadAction<string>) => {
state.items = state.items.filter((item) => item.id !== action.payload); state.items = state.items.filter((item) => item.id !== action.payload);
@@ -46,28 +47,17 @@ const cartSlice = createSlice({
clearCart: (state) => { clearCart: (state) => {
state.items = []; state.items = [];
}, },
openDrawer: (state) => {
state.isDrawerOpen = true;
},
closeDrawer: (state) => {
state.isDrawerOpen = false;
},
toggleDrawer: (state) => {
state.isDrawerOpen = !state.isDrawerOpen;
},
}, },
}); });
export const { export const {
restoreCart,
addToCart, addToCart,
removeFromCart, removeFromCart,
setQuantity, setQuantity,
incrementQty, incrementQty,
decrementQty, decrementQty,
clearCart, clearCart,
openDrawer,
closeDrawer,
toggleDrawer,
} = cartSlice.actions; } = cartSlice.actions;
// Selectors // Selectors
@@ -76,6 +66,5 @@ export const selectCartCount = (state: RootState) =>
state.cart.items.reduce((sum, item) => sum + item.qty, 0); state.cart.items.reduce((sum, item) => sum + item.qty, 0);
export const selectCartTotal = (state: RootState) => export const selectCartTotal = (state: RootState) =>
state.cart.items.reduce((sum, item) => sum + item.qty * item.priceNumeric, 0); state.cart.items.reduce((sum, item) => sum + item.qty * item.priceNumeric, 0);
export const selectIsDrawerOpen = (state: RootState) => state.cart.isDrawerOpen;
export default cartSlice.reducer; export default cartSlice.reducer;
+1 -2
View File
@@ -17,7 +17,7 @@ export interface Machine {
export type CartProduct = Pick<Machine, 'id' | 'name' | 'priceNumeric' | 'priceLabel' | 'image'>; export type CartProduct = Pick<Machine, 'id' | 'name' | 'priceNumeric' | 'priceLabel' | 'image'>;
export interface CartItem extends CartProduct { qty: number } export interface CartItem extends CartProduct { qty: number }
export interface CartState { items: CartItem[]; isDrawerOpen: boolean } export interface CartState { items: CartItem[] }
export interface FloatingSnack { export interface FloatingSnack {
id: string; id: string;
@@ -59,7 +59,6 @@ export interface SiteContent {
}; };
machineActions: { machineActions: {
addToCart: string; addToCart: string;
quickView: string;
wishlistAdd: string; wishlistAdd: string;
wishlistRemove: string; wishlistRemove: string;
hoverHint: string; hoverHint: string;