Compare commits

...
21 Commits
Author SHA1 Message Date
Hamed bf6cb67fcf Merge branch 'develop' of ssh://git.my-rm.com:2222/Robot-Market/static-front into develop
Build & Deploy (develop) / build-and-deploy (push) Successful in 3m7s
2026-10-13 14:00:37 +03:30
Hamed 54c387bf73 chore: update device prices on website 2026-10-13 13:58:36 +03:30
Hamed 57b0cfeac8 refactor dashboard feature boundaries and add test setup 2026-09-30 12:27:57 +03:30
Hamed fb2c394433 fix(styles): include feature modules in Tailwind content scan 2026-09-30 12:02:08 +03:30
Ryan 0381c8b73c refac:updated ci/cd pipeline to create docker images with timestamp and commit sha
Build & Deploy (develop) / build-and-deploy (push) Successful in 2m11s
2026-09-29 11:05:36 +03:30
Ryan b93c780fea refac:updated ci/cd pipeline to create docker images with timestamp and commit sha
Build & Deploy (develop) / build-and-deploy (push) Successful in 2m17s
2026-09-29 10:59:09 +03:30
Hamed 92a3f014a6 fix(styles): include feature modules in Tailwind content scan
Build & Deploy (develop) / build-and-deploy (push) Successful in 2m13s
2026-09-29 10:00:19 +03:30
Hamed 4c8da3765d refactor(products): organize product showcase into feature module 2026-09-29 09:46:35 +03:30
Hamed ae6018a1f0 refactor(products): organize product showcase into feature module 2026-09-29 09:44:00 +03:30
Hamed fcded6f139 Replace desktop showcase video with scroll-driven color backgrounds 2026-09-29 09:22:23 +03:30
Hamed 5d9fe204e7 Replace desktop showcase video with scroll-driven color backgrounds
Build & Deploy (develop) / build-and-deploy (push) Successful in 2m22s
2026-09-27 10:35:12 +03:30
Hamed 1ad215e1bf Improve featured products layout and add support CTA 2026-09-22 06:19:01 +02:00
Hamed 217fe01de8 Improve featured products layout and add support CTA
Build & Deploy (develop) / build-and-deploy (push) Successful in 2m13s
2026-09-21 06:42:52 +02:00
Hamed 9c54fb1cea FAZ1
Build & Deploy (develop) / build-and-deploy (push) Successful in 2m17s
2026-09-20 12:42:11 +02:00
Hamed a4e3634176 FAZ1 2026-09-20 12:41:53 +02:00
Hamed a7aa278608 FAZ1
Build & Deploy (develop) / build-and-deploy (push) Successful in 2m15s
2026-09-20 11:58:08 +02:00
Hamed 6cfeff85b6 FAZ1
Build & Deploy (develop) / build-and-deploy (push) Successful in 2m28s
2026-09-17 08:02:24 +03:30
Hamed e6103ef6fd FAZ1
Build & Deploy (develop) / build-and-deploy (push) Successful in 2m46s
2026-09-16 15:04:19 +03:30
Hamed dc64b41c80 chore: configure prettier for consistent WebStorm formatting 2026-09-16 14:22:16 +03:30
Hamed 812029fe84 chore: format project automatically before starting dev server 2026-09-16 14:14:04 +03:30
Hamed 7c55b3916c feat: add customer authentication and order dashboard 2026-09-16 09:06:48 +03:30
107 changed files with 11517 additions and 4266 deletions
+9 -6
View File
@@ -20,17 +20,20 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Resolve commit SHA
- name: Resolve image tag
id: vars
run: |
# For PRs, gitea.sha is a temporary merge commit — use the real
# PR head commit instead so tags map to actual git history.
FULL_SHA="${{ gitea.event.pull_request.head.sha || gitea.sha }}"
echo "sha=${FULL_SHA:0:7}" >> "$GITHUB_OUTPUT"
SHORT_SHA="${FULL_SHA:0:7}"
BUILD_TIME=$(date -u +%Y-%m-%d_%H-%M-%S)
echo "sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "tag=${BUILD_TIME}-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
- name: Build Docker image (tagged by commit SHA)
- name: Build Docker image (tagged by UTC build time and commit SHA)
run: |
docker build -t robot-market:${{ steps.vars.outputs.sha }} .
docker build -t robot-market:${{ steps.vars.outputs.tag }} .
- name: Save previous tag for rollback
id: backup
@@ -42,7 +45,7 @@ jobs:
- name: Point compose at this build
run: |
echo "IMAGE_TAG=${{ steps.vars.outputs.sha }}" > /opt/robot-market-front/.env
echo "IMAGE_TAG=${{ steps.vars.outputs.tag }}" > /opt/robot-market-front/.env
- name: Deploy via compose
id: deploy
@@ -85,7 +88,7 @@ jobs:
- name: Clean up dangling (untagged) layers
run: docker image prune -f
- name: Prune old SHA-tagged images (keep last 5)
- name: Prune old build-tagged images (keep last 5)
run: |
KEEP=5
CURRENT_TAG=$(grep IMAGE_TAG /opt/robot-market-front/.env 2>/dev/null | cut -d= -f2)
+13 -4
View File
@@ -2,14 +2,15 @@ name: Rollback (develop)
# Trigger manually from the Gitea Actions UI.
# Leave "sha" empty to roll back to the previous deploy.
# Fill "sha" in (short 7-char form, e.g. a3f9c21) to roll back to a specific past build.
# Fill "sha" in (short 7-char form, e.g. a3f9c21) to roll back to the newest
# build for that commit. Build tags include a UTC timestamp and commit SHA.
on:
workflow_dispatch:
inputs:
sha:
description: "Short commit SHA to roll back to (empty = previous deploy)"
description: 'Short commit SHA to roll back to (empty = previous deploy)'
required: false
default: ""
default: ''
jobs:
rollback:
@@ -18,7 +19,15 @@ jobs:
- name: Determine target tag
run: |
if [ -n "${{ gitea.event.inputs.sha }}" ]; then
TAG="${{ gitea.event.inputs.sha }}"
SHA="${{ gitea.event.inputs.sha }}"
TAG=$(docker images robot-market --format '{{.Tag}}' \
| grep -E "-${SHA}$" \
| sort -r \
| head -n 1)
# Keep accepting legacy SHA-only tags created before timestamped tags.
if [ -z "$TAG" ]; then
TAG="$SHA"
fi
else
TAG=$(grep IMAGE_TAG /opt/robot-market-front/.env.previous | cut -d= -f2)
fi
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="MANUAL" />
<option name="myRunOnSave" value="true" />
<option name="myFilesPattern" value="{**/*,*}.{js,jsx,ts,tsx,json,css,md,yml,yaml}" />
</component>
</project>
+171 -67
View File
@@ -4,20 +4,9 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="feat: add cart and product pages, quantity controls, and first-visit development notice">
<list default="true" id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="fix(styles): include feature modules in Tailwind content scan">
<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$/app/products/[id]/page.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/app/products/[id]/page.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/CartPage.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/CartPage.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/Footer.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/Footer.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/Header.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/Header.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/HorizontalShowcase.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/HorizontalShowcase.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/MobileShowcase.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/MobileShowcase.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/ProductDetails.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/ProductDetails.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/ShowcaseSection.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/ShowcaseSection.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/data/commerce.ts" beforeDir="false" afterPath="$PROJECT_DIR$/data/commerce.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/data/content.ts" beforeDir="false" afterPath="$PROJECT_DIR$/data/content.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/types/domain.ts" beforeDir="false" afterPath="$PROJECT_DIR$/types/domain.ts" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -27,7 +16,7 @@
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="develop" />
<entry key="$PROJECT_DIR$" value="Implement" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -39,61 +28,61 @@
</component>
<component name="NextEditCompletionFeaturesState">
<decayedCancelled>
<entry key="MS100" value="5.636161152320107E-5" />
<entry key="MS500" value="0.2302465263814015" />
<entry key="S2" value="1.9612794450498428" />
<entry key="S5" value="3.4102363921166443" />
<entry key="S10" value="4.5184216736456175" />
<entry key="S30" value="6.985270524998366" />
<entry key="S60" value="9.574915616687461" />
<entry key="M2" value="13.91167227171946" />
<entry key="M5" value="22.10409689222133" />
<entry key="M10" value="27.65756793067821" />
<entry key="M15" value="30.196404421732346" />
<entry key="M30" value="34.20114919146407" />
<entry key="H1" value="38.596318396422" />
<entry key="H2" value="42.58982972864479" />
<entry key="H4" value="45.40405035457018" />
<entry key="D1" value="48.33665386421948" />
<entry key="W1" value="48.9035231768996" />
</decayedCancelled>
<decayedSelected>
<entry key="MS100" value="1.0" />
<entry key="MS500" value="1.0" />
<entry key="S2" value="1.0" />
<entry key="S5" value="1.0" />
<entry key="S10" value="1.0000000000004026" />
<entry key="S30" value="1.0000738385287775" />
<entry key="S60" value="1.0085929348174816" />
<entry key="M2" value="1.0926980842168899" />
<entry key="M5" value="1.3862142586684925" />
<entry key="M10" value="1.6214613895235106" />
<entry key="M15" value="1.7282426350470148" />
<entry key="M30" value="1.853371334793368" />
<entry key="H1" value="1.9237809993680148" />
<entry key="H2" value="1.9611352659059051" />
<entry key="H4" value="1.9803750638943771" />
<entry key="D1" value="1.9967021063244244" />
<entry key="W1" value="1.9995282050855745" />
<entry key="S2" value="1.000087252972038" />
<entry key="S5" value="1.0245081193731238" />
<entry key="S10" value="1.2033303929041042" />
<entry key="S30" value="2.526416819401647" />
<entry key="S60" value="4.055177645414961" />
<entry key="M2" value="6.613842117661979" />
<entry key="M5" value="13.292786284149477" />
<entry key="M10" value="19.047637193847063" />
<entry key="M15" value="21.88400324113337" />
<entry key="M30" value="25.442136814414752" />
<entry key="H1" value="27.80466751680738" />
<entry key="H2" value="32.05099449690078" />
<entry key="H4" value="42.01119466369274" />
<entry key="D1" value="68.95561233852308" />
<entry key="W1" value="77.42240766540701" />
</decayedCancelled>
<decayedSelected>
<entry key="MS100" value="0.0" />
<entry key="MS500" value="6.173165109562985E-278" />
<entry key="S2" value="4.98456166633934E-70" />
<entry key="S5" value="1.9013006178680956E-28" />
<entry key="S10" value="1.3788900274450676E-14" />
<entry key="S30" value="2.4491942588613115E-5" />
<entry key="S60" value="0.00561270246753653" />
<entry key="M2" value="0.09687308384134666" />
<entry key="M5" value="0.6088965925604509" />
<entry key="M10" value="1.2419655618123104" />
<entry key="M15" value="1.625109194802163" />
<entry key="M30" value="2.1768432348200197" />
<entry key="H1" value="2.557178317988167" />
<entry key="H2" value="2.9129118025914487" />
<entry key="H4" value="3.4288442997263706" />
<entry key="D1" value="4.592699670680046" />
<entry key="W1" value="4.936597530104908" />
</decayedSelected>
<decayedShown>
<entry key="MS100" value="5.673108319866789E-5" />
<entry key="MS500" value="0.2755608628441334" />
<entry key="S2" value="2.398322303413965" />
<entry key="S5" value="4.121762152287479" />
<entry key="S10" value="5.358118199319534" />
<entry key="S30" value="7.924594218734951" />
<entry key="S60" value="10.550212525549737" />
<entry key="M2" value="14.985181048549935" />
<entry key="M5" value="23.480472743765223" />
<entry key="M10" value="29.27333974135673" />
<entry key="M15" value="31.92062506542455" />
<entry key="M30" value="36.05236671041284" />
<entry key="H1" value="40.518972305385134" />
<entry key="H2" value="44.55038420189922" />
<entry key="H4" value="47.38412968080923" />
<entry key="D1" value="50.33330581758772" />
<entry key="W1" value="50.9030441942503" />
<entry key="MS100" value="0.4444213405832851" />
<entry key="MS500" value="0.8502741600641959" />
<entry key="S2" value="0.9603446084215367" />
<entry key="S5" value="1.0078897996704945" />
<entry key="S10" value="1.1930999599005467" />
<entry key="S30" value="2.5186943108989044" />
<entry key="S60" value="4.054093611688289" />
<entry key="M2" value="6.702385865993853" />
<entry key="M5" value="13.890469482072719" />
<entry key="M10" value="20.28012126997275" />
<entry key="M15" value="23.50144975042241" />
<entry key="M30" value="27.614279851343763" />
<entry key="H1" value="30.359221991616337" />
<entry key="H2" value="34.96247820400055" />
<entry key="H4" value="45.43924535528764" />
<entry key="D1" value="73.5481501056019" />
<entry key="W1" value="82.35898081352131" />
</decayedShown>
</component>
<component name="ProjectColorInfo">{
@@ -101,6 +90,9 @@
&quot;associatedIndex&quot;: 2
}</component>
<component name="ProjectId" id="3HDEfy1HyzjDcELlwvnRRXxPltq" />
<component name="ProjectLevelVcsManager">
<ConfirmationsSetting value="2" id="Add" />
</component>
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
@@ -125,11 +117,14 @@
&quot;kronk.content.tree.expanded.paths&quot;: &quot;&quot;,
&quot;last_opened_file_path&quot;: &quot;/home/hamed/Documents/Projects/GItLabRepo/device_web&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.jshint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.jshint&quot;: &quot;&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;editor.preferences.fonts.default&quot;,
&quot;prettierjs.PrettierConfiguration.Package&quot;: &quot;/home/hamed/Documents/Projects/GIthubRepo/robot-market-landing/node_modules/prettier&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.lookFeel&quot;,
&quot;to.speed.mode.migration.done&quot;: &quot;true&quot;,
&quot;ts.external.directory.path&quot;: &quot;/home/hamed/Documents/Projects/GIthubRepo/robot-market-landing/node_modules/typescript/lib&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
@@ -137,6 +132,7 @@
}</component>
<component name="RecentsManager">
<key name="MoveFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$" />
<recent name="D:\Project\GitHubRepo\robot-market-landing\public\images\icons" />
<recent name="D:\Project\GitHubRepo\robot-market-landing\public\images" />
<recent name="D:\Project\GitHubRepo\robot-market-landing\app" />
@@ -219,6 +215,18 @@
<workItem from="1789383269194" duration="2337000" />
<workItem from="1789445417779" duration="6709000" />
<workItem from="1789456504019" duration="1703000" />
<workItem from="1789618327035" duration="326000" />
<workItem from="1789618878095" duration="14000" />
<workItem from="1789618912128" duration="2222000" />
<workItem from="1789622601965" duration="2006000" />
<workItem from="1789973458932" duration="1450000" />
<workItem from="1789981347522" duration="4504000" />
<workItem from="1790050380142" duration="326000" />
<workItem from="1790568898974" duration="91000" />
<workItem from="1790569032481" duration="788000" />
<workItem from="1790573950198" duration="16000" />
<workItem from="1790660862579" duration="6418000" />
<workItem from="1790756879141" duration="396000" />
</task>
<task id="LOCAL-00001" summary="update">
<option name="closed" value="true" />
@@ -516,7 +524,95 @@
<option name="project" value="LOCAL" />
<updated>1789451442320</updated>
</task>
<option name="localTasksCounter" value="38" />
<task id="LOCAL-00038" summary="feat: add customer authentication and order dashboard">
<option name="closed" value="true" />
<created>1789537009629</created>
<option name="number" value="00038" />
<option name="presentableId" value="LOCAL-00038" />
<option name="project" value="LOCAL" />
<updated>1789537009629</updated>
</task>
<task id="LOCAL-00039" summary="chore: configure prettier for consistent WebStorm formatting">
<option name="closed" value="true" />
<created>1789555937414</created>
<option name="number" value="00039" />
<option name="presentableId" value="LOCAL-00039" />
<option name="project" value="LOCAL" />
<updated>1789555937414</updated>
</task>
<task id="LOCAL-00040" summary="FAZ1">
<option name="closed" value="true" />
<created>1789558460777</created>
<option name="number" value="00040" />
<option name="presentableId" value="LOCAL-00040" />
<option name="project" value="LOCAL" />
<updated>1789558460777</updated>
</task>
<task id="LOCAL-00041" summary="FAZ1">
<option name="closed" value="true" />
<created>1789619546219</created>
<option name="number" value="00041" />
<option name="presentableId" value="LOCAL-00041" />
<option name="project" value="LOCAL" />
<updated>1789619546219</updated>
</task>
<task id="LOCAL-00042" summary="Improve featured products layout and add support CTA">
<option name="closed" value="true" />
<created>1789974962919</created>
<option name="number" value="00042" />
<option name="presentableId" value="LOCAL-00042" />
<option name="project" value="LOCAL" />
<updated>1789974962919</updated>
</task>
<task id="LOCAL-00043" summary="Replace desktop showcase video with scroll-driven color backgrounds">
<option name="closed" value="true" />
<created>1790492715355</created>
<option name="number" value="00043" />
<option name="presentableId" value="LOCAL-00043" />
<option name="project" value="LOCAL" />
<updated>1790492715355</updated>
</task>
<task id="LOCAL-00044" summary="Replace desktop showcase video with scroll-driven color backgrounds">
<option name="closed" value="true" />
<created>1790661145102</created>
<option name="number" value="00044" />
<option name="presentableId" value="LOCAL-00044" />
<option name="project" value="LOCAL" />
<updated>1790661145102</updated>
</task>
<task id="LOCAL-00045" summary="refactor(products): organize product showcase into feature module">
<option name="closed" value="true" />
<created>1790662441910</created>
<option name="number" value="00045" />
<option name="presentableId" value="LOCAL-00045" />
<option name="project" value="LOCAL" />
<updated>1790662441910</updated>
</task>
<task id="LOCAL-00046" summary="refactor(products): organize product showcase into feature module">
<option name="closed" value="true" />
<created>1790662596270</created>
<option name="number" value="00046" />
<option name="presentableId" value="LOCAL-00046" />
<option name="project" value="LOCAL" />
<updated>1790662596270</updated>
</task>
<task id="LOCAL-00047" summary="fix(styles): include feature modules in Tailwind content scan">
<option name="closed" value="true" />
<created>1790663421347</created>
<option name="number" value="00047" />
<option name="presentableId" value="LOCAL-00047" />
<option name="project" value="LOCAL" />
<updated>1790663421347</updated>
</task>
<task id="LOCAL-00048" summary="fix(styles): include feature modules in Tailwind content scan">
<option name="closed" value="true" />
<created>1790757130578</created>
<option name="number" value="00048" />
<option name="presentableId" value="LOCAL-00048" />
<option name="project" value="LOCAL" />
<updated>1790757130578</updated>
</task>
<option name="localTasksCounter" value="49" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -540,6 +636,14 @@
<MESSAGE value="refactor: remove disabled home sections and their unused assets" />
<MESSAGE value="refactor: migrate project to TypeScript and translate comments to English" />
<MESSAGE value="feat: add cart and product pages, quantity controls, and first-visit development notice" />
<option name="LAST_COMMIT_MESSAGE" value="feat: add cart and product pages, quantity controls, and first-visit development notice" />
<MESSAGE value="feat: add customer authentication and order dashboard" />
<MESSAGE value="chore: format project automatically before starting dev server" />
<MESSAGE value="chore: configure prettier for consistent WebStorm formatting" />
<MESSAGE value="FAZ1" />
<MESSAGE value="Improve featured products layout and add support CTA" />
<MESSAGE value="Replace desktop showcase video with scroll-driven color backgrounds" />
<MESSAGE value="refactor(products): organize product showcase into feature module" />
<MESSAGE value="fix(styles): include feature modules in Tailwind content scan" />
<option name="LAST_COMMIT_MESSAGE" value="fix(styles): include feature modules in Tailwind content scan" />
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
.next
node_modules
docs
public
.idea
.agents
.codex
*.tsbuildinfo
package-lock.json
+11
View File
@@ -0,0 +1,11 @@
{
"singleQuote": true,
"semi": true,
"tabWidth": 4,
"useTabs": false,
"printWidth": 120,
"bracketSpacing": false,
"trailingComma": "all",
"arrowParens": "avoid",
"endOfLine": "lf"
}
@@ -0,0 +1,991 @@
# Next.js Project Development Rules
You are working on a production Next.js application using the App Router.
The codebase must remain:
- simple
- readable
- maintainable
- testable
- scalable
- junior-developer friendly
Always prefer the simplest maintainable solution.
Do not introduce Clean Architecture, repositories, use-cases, factories, dependency-injection systems, adapters, presenters, interactors, or unnecessary architectural layers unless they solve a real current problem.
Avoid over-engineering.
---
# Technology Stack
Use:
- Next.js
- App Router
- React
- JavaScript and TypeScript
- Tailwind CSS
- Redux Toolkit
- Vitest
- React Testing Library
- Playwright
Do not introduce additional libraries without a clear reason.
Before adding a dependency:
1. Check whether Next.js, React, JavaScript, TypeScript, or the existing stack can solve the problem.
2. Prefer existing project dependencies.
3. Add a dependency only when it clearly improves maintainability, reliability, or developer experience.
4. Briefly explain why it is necessary.
Do not introduce Zod or another validation library unless explicitly requested.
---
# App Router Only
Use the Next.js App Router.
Do not introduce Pages Router patterns into a new App Router project.
Use:
```text
app/
layout.tsx
page.tsx
loading.tsx
error.tsx
not-found.tsx
route.ts
```
when appropriate.
Use route groups for organization where helpful.
Example:
```text
app/
├── (public)/
├── (auth)/
└── (dashboard)/
```
Remember that route groups should organize the project without changing the URL.
---
# Server Components by Default
Server Components are the default.
Do not add:
```ts
'use client';
```
unless the component actually requires client-side functionality.
Client Components are appropriate when using things such as:
- useState
- useEffect
- browser APIs
- localStorage
- event handlers
- interactive client-side libraries
- Redux hooks
Keep Client Component boundaries as small as practical.
Do not convert an entire page into a Client Component just because one small child component needs interactivity.
Prefer:
```text
Server Page
↓
Server Components
↓
Small Client Component
```
rather than:
```text
Entire Page
↓
"use client"
```
---
# General Project Structure
Recommended structure:
```text
src/
├── app/
├── features/
├── components/
│ ├── ui/
│ └── layout/
├── hooks/
├── lib/
├── config/
├── constants/
├── types/
├── utils/
└── styles/
```
Keep routing in `app/`.
Keep business and feature-specific code in `features/`.
Keep generic reusable UI in `components/ui/`.
Keep infrastructure and reusable technical utilities in `lib/`.
Do not force every project to have every directory.
Create directories only when useful.
---
# Keep Pages Thin
`page.tsx` should primarily:
- define the route entry
- load route-level data when appropriate
- compose feature components
- pass necessary data
Avoid putting hundreds of lines of business logic inside `page.tsx`.
Prefer:
```tsx
import {ProductList} from '@/features/products/components/ProductList';
/**
* Displays the product-management page.
*/
export default async function ProductsPage() {
return <ProductList />;
}
```
over a very large page containing:
- data fetching
- filtering
- business rules
- forms
- modals
- API calls
- transformations
- UI rendering
all together.
---
# Feature-Based Architecture
Organize business functionality by feature.
Example:
```text
features/
├── auth/
├── products/
├── users/
├── machines/
├── sales/
└── reports/
```
A feature may contain:
```text
features/products/
├── components/
├── hooks/
├── services/
├── store/
├── utils/
├── types/
└── tests/
```
Do not create empty folders.
Only introduce a folder when there is actual code that belongs there.
---
# Shared UI vs Feature UI
Generic reusable components belong in:
```text
components/ui/
```
Examples:
```text
Button
Input
Modal
Table
Card
Badge
Spinner
```
Feature-specific components stay inside their feature.
Examples:
```text
features/products/components/ProductCard.tsx
features/machines/components/MachineStatus.tsx
features/sales/components/SalesTable.tsx
```
Generic UI components must not contain feature-specific business logic.
---
# Components
Each component should have one clear responsibility.
Keep components small enough to understand easily.
Split a component when it:
- handles unrelated responsibilities
- contains large amounts of business logic
- contains several independent UI sections
- becomes difficult to test
- becomes difficult to read
Do not split components unnecessarily.
Avoid creating wrappers that provide no meaningful behavior.
---
# Functions
Functions should be:
- focused
- descriptive
- short enough to understand
- easy to test
Prefer meaningful names:
```ts
calculateSaleTotal();
formatMachineStatus();
canUserEditProduct();
getAvailableProducts();
```
Avoid vague names:
```ts
handleData();
processStuff();
doWork();
helper();
func1();
```
---
# Function Documentation
Write English documentation comments for important and non-trivial functions.
Explain:
- what the function does
- important assumptions
- important business behavior
- non-obvious side effects
Example:
```ts
/**
* Returns true when a product is enabled and still has stock available.
*/
function canSellProduct(product: Product): boolean {
return product.enabled && product.stock > 0;
}
```
Do not comment obvious syntax or trivial statements.
Comments should explain intent, not translate code into English.
---
# Business Logic
Keep important business rules outside JSX when practical.
Bad:
```tsx
{
machine.enabled &&
machine.status === 'online' &&
machine.products.length > 0 &&
user.permissions.includes('manage_machine') && <ManageButton />;
}
```
Prefer:
```ts
/**
* Determines whether the current user can manage the given machine.
*/
function canManageMachine(machine: Machine, user: User): boolean {
return (
machine.enabled &&
machine.status === 'online' &&
machine.products.length > 0 &&
user.permissions.includes('manage_machine')
);
}
```
Then:
```tsx
{
canManageMachine(machine, user) && <ManageButton />;
}
```
This improves:
- readability
- testing
- reuse
- maintainability
---
# Redux Toolkit
Use Redux Toolkit as the project's global state-management solution.
Do not store every value in Redux.
Use local React state for local UI concerns.
Examples:
```text
modal open/close
dropdown state
form input
selected tab
temporary UI state
```
Use Redux for meaningful shared application state.
Examples:
```text
authentication
current user
shared settings
cross-page feature state
global workflow state
```
Keep Redux state feature-oriented.
Example:
```text
features/auth/store/authSlice.ts
features/machines/store/machineSlice.ts
```
Avoid a single giant application slice.
---
# Redux in Next.js
Redux requires Client Components.
Keep Redux Provider boundaries explicit.
Do not turn unrelated Server Components into Client Components just to access Redux.
Prefer passing server-fetched data into client boundaries when practical.
Example:
```text
Server Page
↓
Server data loading
↓
Client Feature Component
↓
Redux / interactive state
```
Do not unnecessarily duplicate server data into Redux.
Use Redux mainly for client-side shared state.
---
# Data Fetching
Prefer Server Components for server-side data fetching when appropriate.
Example:
```tsx
/**
* Loads the available products and renders the product list.
*/
export default async function ProductsPage() {
const products = await getProducts();
return <ProductList products={products} />;
}
```
Do not automatically use `useEffect` for every API request.
Use client-side fetching only when the feature requires client-side re-fetching, polling, user-triggered requests, or interactive behavior.
---
# Service Layer
Centralize backend communication.
Example:
```text
features/products/services/productService.ts
```
Example:
```ts
/**
* Loads all products from the backend.
*/
export async function getProducts(): Promise<Product[]> {
const response = await fetch(`${API_URL}/products`);
if (!response.ok) {
throw new Error('Failed to load products.');
}
return response.json();
}
```
Do not scatter backend URLs throughout pages and components.
Keep HTTP concerns away from presentation components when possible.
---
# Server and Client Boundaries
Be deliberate about the boundary between server and client code.
Server-side code may handle:
- server-side data loading
- secure environment variables
- server-only APIs
- protected backend communication
- route handlers
- server actions when appropriate
Client-side code may handle:
- interaction
- browser APIs
- local UI state
- Redux
- event listeners
- client-only libraries
Never expose secrets to Client Components.
---
# Environment Variables
Do not access environment variables randomly throughout the project.
Centralize configuration when useful.
Example:
```text
config/env.ts
```
Never expose secrets using:
```text
NEXT_PUBLIC_*
```
Only values intentionally accessible in the browser should use `NEXT_PUBLIC_`.
---
# Error Handling
Use Next.js error boundaries where appropriate.
Examples:
```text
error.tsx
not-found.tsx
```
Handle expected failures explicitly.
Do not silently swallow errors.
Provide user-friendly messages for recoverable failures.
Log technical information where appropriate without exposing sensitive data to end users.
---
# Loading States
Use:
```text
loading.tsx
```
when route-level loading UI is appropriate.
For local interactive operations, use explicit loading state.
Avoid blocking unrelated parts of the UI unnecessarily.
---
# State Derived from Existing State
Do not duplicate values that can be calculated.
Bad:
```ts
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
```
Prefer:
```ts
const fullName = `${firstName} ${lastName}`;
```
Keep state minimal.
---
# useEffect
Do not use `useEffect` to calculate values that can be derived during rendering.
Use `useEffect` primarily for synchronizing with external systems.
Examples:
- WebSocket
- browser APIs
- subscriptions
- external widgets
- timers
- event listeners
Always clean up resources when required.
---
# Avoid Deep Nesting
Prefer guard clauses and early returns.
Bad:
```ts
if (session) {
if (session.user) {
if (session.user.active) {
// logic
}
}
}
```
Prefer:
```ts
if (!session?.user) return;
if (!session.user.active) return;
// Main logic
```
Keep the happy path visible.
---
# Avoid Magic Values
Bad:
```ts
if (status === 4) {
}
```
Prefer:
```ts
if (status === MachineStatus.Offline) {
}
```
Bad:
```ts
if (retryCount > 3) {
}
```
Prefer:
```ts
const MAX_RETRY_COUNT = 3;
if (retryCount > MAX_RETRY_COUNT) {
}
```
Use descriptive constants where they improve understanding.
---
# Naming
Names should explain intent.
Prefer:
```ts
selectedProduct;
currentUser;
isMachineOnline;
canEditSale;
hasAdminPermission;
shouldReloadProducts;
```
Avoid vague names such as:
```ts
data;
info;
item2;
temp;
res2;
obj;
value1;
stuff;
```
unless the surrounding context makes the meaning completely obvious.
---
# TypeScript and JavaScript
JavaScript and TypeScript may coexist.
Prefer TypeScript for new important logic where practical.
Do not perform unnecessary project-wide TypeScript migrations during unrelated work.
Avoid `any` where useful types can be defined.
Keep feature-specific types close to their feature.
Use global/shared types only when genuinely shared.
---
# Tailwind CSS
Use Tailwind CSS for styling.
Keep styles close to components.
Avoid creating excessive abstraction only to hide Tailwind classes.
If a visual pattern is reused meaningfully, extract a reusable component.
Maintain readable class structures.
Avoid unnecessarily complex conditional class expressions.
---
# Testing
Tests are required for meaningful behavior.
Use:
```text
Vitest
React Testing Library
Playwright
```
Use Vitest for:
- utilities
- business logic
- selectors
- reducers
- non-UI modules
Use React Testing Library for:
- interactive components
- forms
- user behavior
- conditional rendering
Use Playwright for critical user flows.
Examples:
```text
login
logout
create product
edit machine
view report
critical dashboard navigation
```
Test behavior rather than implementation details.
---
# Comments
All project comments must be written in English.
Use comments to explain:
- intent
- business rules
- important decisions
- limitations
- unusual behavior
- non-obvious workarounds
Do not use excessive comments for obvious code.
Important functions should have short English documentation comments.
---
# File Organization
Keep related code close together.
Avoid giant generic folders containing hundreds of unrelated files.
Bad:
```text
components/
ProductCard
LoginForm
SalesChart
MachineStatus
UserAvatar
ReportFilter
...
```
Prefer:
```text
features/products/components/
features/auth/components/
features/sales/components/
features/machines/components/
```
Use global component directories only for truly shared components.
---
# Refactoring Rules
When refactoring an existing Next.js project:
1. First understand the existing behavior.
2. Preserve functionality unless a behavior change is requested.
3. Identify duplicated logic.
4. Identify large pages and components.
5. Identify unclear naming.
6. Identify unnecessary Client Components.
7. Identify unnecessary `useEffect` usage.
8. Identify duplicated state.
9. Identify business logic buried in JSX.
10. Identify API calls scattered across UI code.
11. Refactor incrementally.
12. Add or update tests.
13. Remove dead code when safe.
14. Do not rewrite working code only to match a theoretical architecture.
15. Do not introduce unnecessary layers.
16. Keep the final result understandable to junior developers.
---
# New Features
When implementing a feature:
1. Find or create the correct feature directory.
2. Reuse existing shared components.
3. Keep the page thin.
4. Prefer Server Components by default.
5. Create Client Components only where needed.
6. Put API communication into a service when it improves separation.
7. Keep Redux only for meaningful shared client state.
8. Keep business rules testable.
9. Add appropriate tests.
10. Keep names explicit.
11. Avoid introducing abstractions before they are needed.
---
# New Projects
For a new Next.js project:
- use App Router
- use Tailwind CSS
- configure Redux Toolkit
- configure Vitest
- configure React Testing Library
- configure Playwright
- configure linting
- establish useful path aliases
- create only necessary directories
- keep Server Components as the default
- create a clear README
- avoid empty architecture folders
- avoid unnecessary dependencies
---
# Junior-Friendly Development Rules
Always ask:
"Can a junior developer understand this file without knowing hidden architectural conventions?"
Prefer explicit code.
Prefer:
```ts
const product = await getProduct(productId);
if (!product) {
return null;
}
return product;
```
over clever compressed expressions.
Avoid:
- excessive abstractions
- unnecessary generics
- complex type tricks
- deeply nested callbacks
- deeply nested conditions
- complicated ternaries
- hidden side effects
- unnecessary factories
- unnecessary inheritance
- excessive indirection
- premature reusable frameworks
A small amount of duplication is better than a confusing abstraction.
Refactor duplication when a clear reusable concept actually emerges.
---
# Final Decision Rule
When multiple implementations are valid, prefer the one that is:
1. simplest
2. easiest to read
3. easiest to test
4. easiest to debug
5. easiest to extend
6. easiest for a junior developer to understand
Do not optimize for impressive architecture.
Optimize for clarity, predictability, and maintainability.
+6 -1
View File
@@ -1,8 +1,13 @@
import type {Metadata} from 'next';
import CartPage from '@/components/CartPage';
import ProtectedPage from '@/components/auth/ProtectedPage';
export const metadata: Metadata = {title: 'سبد خرید | روبات مارکت'};
export default function Page() {
return <CartPage/>;
return (
<ProtectedPage>
<CartPage />
</ProtectedPage>
);
}
+107
View File
@@ -0,0 +1,107 @@
'use client';
import type {ReactNode} from 'react';
import type {AuthSession, CustomerProfile} from '@/types/account';
import {createContext, useCallback, useContext, useEffect, useRef, useState} from 'react';
import {AUTH_STORAGE_KEY, loginWithOtp, loginWithPassword, requestOtp} from '@/services/auth';
import {saveProfile} from '@/services/profile';
import AuthModal from '@/components/auth/AuthModal';
interface AuthContextValue {
session: AuthSession | null;
isAuthenticated: boolean;
ready: boolean;
loginOpen: boolean;
openLogin: (onSuccess?: () => void) => void;
closeLogin: () => void;
sendOtp: (phone: string) => Promise<void>;
verifyOtp: (phone: string, code: string) => Promise<void>;
loginPassword: (phone: string, password: string) => Promise<void>;
updateProfile: (profile: CustomerProfile) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({children}: {children: ReactNode}) {
const [session, setSession] = useState<AuthSession | null>(null);
const [ready, setReady] = useState(false);
const [loginOpen, setLoginOpen] = useState(false);
const successCallback = useRef<(() => void) | undefined>();
useEffect(() => {
try {
const saved = window.localStorage.getItem(AUTH_STORAGE_KEY);
if (saved) setSession(JSON.parse(saved) as AuthSession);
} catch {
window.localStorage.removeItem(AUTH_STORAGE_KEY);
} finally {
setReady(true);
}
}, []);
const persist = useCallback((next: AuthSession | null) => {
setSession(next);
if (next) {
window.localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(next));
window.localStorage.setItem('rm-auth-token', next.accessToken);
} else {
window.localStorage.removeItem(AUTH_STORAGE_KEY);
window.localStorage.removeItem('rm-auth-token');
}
}, []);
const finishLogin = useCallback(
(next: AuthSession) => {
persist(next);
setLoginOpen(false);
const callback = successCallback.current;
successCallback.current = undefined;
callback?.();
},
[persist],
);
const openLogin = useCallback((onSuccess?: () => void) => {
successCallback.current = onSuccess;
setLoginOpen(true);
}, []);
const closeLogin = useCallback(() => {
successCallback.current = undefined;
setLoginOpen(false);
}, []);
const value: AuthContextValue = {
session,
isAuthenticated: Boolean(session),
ready,
loginOpen,
openLogin,
closeLogin,
sendOtp: async phone => {
await requestOtp(phone);
},
verifyOtp: async (phone, code) => finishLogin(await loginWithOtp(phone, code)),
loginPassword: async (phone, password) => finishLogin(await loginWithPassword(phone, password)),
updateProfile: async profile => {
if (!session) return;
const saved = await saveProfile(profile);
persist({...session, profile: saved});
},
logout: () => persist(null),
};
return (
<AuthContext.Provider value={value}>
{children}
<AuthModal />
</AuthContext.Provider>
);
}
export function useAuth() {
const value = useContext(AuthContext);
if (!value) throw new Error('useAuth must be used within AuthProvider');
return value;
}
+1 -1
View File
@@ -33,7 +33,7 @@ export function LanguageProvider({children}: {children: ReactNode}) {
window.localStorage.setItem('rm-locale', locale);
}, [locale]);
const toggleLocale = () => setLocale((l) => (l === 'fa' ? 'en' : 'fa'));
const toggleLocale = () => setLocale(l => (l === 'fa' ? 'en' : 'fa'));
return (
<LanguageContext.Provider
+2 -9
View File
@@ -14,11 +14,9 @@ interface ThemeContextValue {
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({children}: {children: ReactNode}) {
const [theme, setTheme] = useState<Theme>('light');
const [mounted, setMounted] = useState(false);
useEffect(() => {
const saved = window.localStorage.getItem('rm-theme');
if (saved === 'light' || saved === 'dark') {
@@ -29,7 +27,6 @@ export function ThemeProvider({children}: {children: ReactNode}) {
setMounted(true);
}, []);
useEffect(() => {
const root = document.documentElement;
root.classList.remove('light', 'dark');
@@ -37,13 +34,9 @@ export function ThemeProvider({children}: {children: ReactNode}) {
window.localStorage.setItem('rm-theme', theme);
}, [theme]);
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
const toggleTheme = () => setTheme(t => (t === 'dark' ? 'light' : 'dark'));
return (
<ThemeContext.Provider value={{theme, setTheme, toggleTheme, mounted}}>
{children}
</ThemeContext.Provider>
);
return <ThemeContext.Provider value={{theme, setTheme, toggleTheme, mounted}}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
+11
View File
@@ -0,0 +1,11 @@
import type {ReactNode} from 'react';
import ProtectedPage from '@/components/auth/ProtectedPage';
import DashboardShell from '@/components/dashboard/DashboardShell';
export default function Layout({children}: {children: ReactNode}) {
return (
<ProtectedPage>
<DashboardShell>{children}</DashboardShell>
</ProtectedPage>
);
}
+165
View File
@@ -0,0 +1,165 @@
'use client';
import {useEffect, useRef, useState} from 'react';
import Link from 'next/link';
import {Alert, App, Button, Empty, Skeleton, Steps} from 'antd';
import {DownloadOutlined, FileExcelOutlined, PrinterOutlined} from '@ant-design/icons';
import type {CustomerOrder} from '@/types/account';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy, orderStatusLabels, paymentStatusLabels} from '@/data/account';
import {getOrder} from '@/services/orders';
import {downloadInvoiceExcel, downloadInvoicePdf} from '@/lib/invoice';
import OrderStatusTag from '@/components/dashboard/OrderStatusTag';
import InvoiceDocument from '@/components/dashboard/InvoiceDocument';
export default function OrderDetailsPage({params}: {params: {id: string}}) {
const auth = useAuth();
const {message} = App.useApp();
const {locale} = useLanguage();
const copy = accountCopy[locale];
const [order, setOrder] = useState<CustomerOrder | null | undefined>(null);
const [exporting, setExporting] = useState<'pdf' | 'excel' | null>(null);
const invoiceRef = useRef<HTMLDivElement>(null);
const numberLocale = locale === 'fa' ? 'fa-IR' : 'en-US';
const money = (value: number) => `${value.toLocaleString(numberLocale)} ${locale === 'fa' ? 'ریال' : 'Rials'}`;
useEffect(() => {
if (auth.session) getOrder(params.id, auth.session.profile).then(setOrder);
}, [auth.session, params.id]);
if (order === null) return <Skeleton active />;
if (!order)
return (
<div>
<Empty description={copy.notFound} />
<Link href="/dashboard/orders" className="text-brand-400 block text-center mt-5">
{copy.backToOrders}
</Link>
</div>
);
const exportPdf = async () => {
if (!invoiceRef.current) return;
setExporting('pdf');
try {
await downloadInvoicePdf(invoiceRef.current, order.invoice!.number);
} catch {
await message.error('ساخت فایل PDF انجام نشد. دوباره تلاش کنید.');
} finally {
setExporting(null);
}
};
const exportExcel = async () => {
setExporting('excel');
try {
await downloadInvoiceExcel(order, order.invoice!.number);
} catch {
await message.error('ساخت فایل Excel انجام نشد. دوباره تلاش کنید.');
} finally {
setExporting(null);
}
};
return (
<>
<Link href="/dashboard/orders" className="text-brand-400 hover:underline">
{copy.backToOrders}
</Link>
<div className="flex flex-wrap justify-between gap-4 items-center mt-5">
<div>
<p className="text-secondary">{copy.orderNumber}</p>
<h1 className="text-3xl font-extrabold text-primary" dir="ltr">
{order.number}
</h1>
</div>
<OrderStatusTag status={order.status} />
</div>
<div className="grid sm:grid-cols-3 gap-4 mt-7">
<div className="rounded-xl border border-subtle bg-elevated p-4">
<span className="text-sm text-secondary">{copy.orderDate}</span>
<strong className="block text-primary mt-1">{order.createdAt}</strong>
</div>
<div className="rounded-xl border border-subtle bg-elevated p-4">
<span className="text-sm text-secondary">{copy.lastUpdate}</span>
<strong className="block text-primary mt-1">{order.updatedAt}</strong>
</div>
<div className="rounded-xl border border-subtle bg-elevated p-4">
<span className="text-sm text-secondary">{copy.paymentStatus}</span>
<strong className="block text-primary mt-1">
{paymentStatusLabels[locale][order.paymentStatus]}
</strong>
</div>
</div>
<section className="rounded-2xl border border-subtle bg-elevated p-5 md:p-7 mt-7">
<h2 className="text-xl font-bold text-primary mb-6">{copy.timeline}</h2>
<Steps
direction="vertical"
current={order.events.length - 1}
items={order.events.map(event => ({
title: orderStatusLabels[locale][event.status],
description: `${event.date} — ${event.note}`,
status: event.completed ? 'finish' : 'wait',
}))}
/>
</section>
<section className="rounded-2xl border border-subtle bg-elevated p-5 md:p-7 mt-7">
<h2 className="text-xl font-bold text-primary mb-5">{copy.orderItems}</h2>
<div className="space-y-5">
{order.items.map(item => (
<article
key={item.id}
className="flex flex-wrap sm:flex-nowrap gap-5 border-b border-subtle last:border-0 pb-5 last:pb-0"
>
<img
src={item.image}
alt={item.name}
className="w-24 h-28 object-contain rounded-xl bg-surface"
/>
<div className="flex-1">
<h3 className="font-bold text-primary">{item.name}</h3>
<p className="text-sm text-secondary mt-1" dir="ltr">
{item.model}
</p>
<p className="text-secondary mt-3">
{copy.modules}: {item.modules.join('، ')}
</p>
<p className="text-primary mt-3">
{copy.quantity}: {item.quantity.toLocaleString(numberLocale)} · {copy.total}:{' '}
{money(item.quantity * item.unitPrice)}
</p>
</div>
</article>
))}
</div>
</section>
<section className="mt-7">
<h2 className="text-xl font-bold text-primary mb-5">{copy.invoice}</h2>
{!order.invoice ? (
<Alert type="info" showIcon message={copy.invoicePending} />
) : (
<>
<div className="flex flex-wrap gap-3 mb-5 invoice-actions">
<Button
type="primary"
icon={<DownloadOutlined />}
loading={exporting === 'pdf'}
onClick={exportPdf}
>
{copy.downloadPdf}
</Button>
<Button icon={<FileExcelOutlined />} loading={exporting === 'excel'} onClick={exportExcel}>
{copy.downloadExcel}
</Button>
<Button icon={<PrinterOutlined />} onClick={() => window.print()}>
{copy.print}
</Button>
</div>
<div className="overflow-x-auto rounded-2xl border border-subtle">
<InvoiceDocument ref={invoiceRef} order={order} />
</div>
</>
)}
</section>
</>
);
}
+5
View File
@@ -0,0 +1,5 @@
import OrdersList from '@/features/dashboard/components/OrdersList';
export default function OrdersPage() {
return <OrdersList />;
}
+5
View File
@@ -0,0 +1,5 @@
import DashboardOverview from '@/features/dashboard/components/DashboardOverview';
export default function DashboardPage() {
return <DashboardOverview />;
}
+105
View File
@@ -0,0 +1,105 @@
'use client';
import {useEffect, useState} from 'react';
import {Alert, App, Button, Form, Input, Radio} from 'antd';
import type {CustomerProfile} from '@/types/account';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy} from '@/data/account';
export default function ProfilePage() {
const auth = useAuth();
const {message} = App.useApp();
const {locale} = useLanguage();
const copy = accountCopy[locale];
const [form] = Form.useForm<CustomerProfile>();
const [saving, setSaving] = useState(false);
const customerType = Form.useWatch('type', form);
useEffect(() => {
if (auth.session) form.setFieldsValue(auth.session.profile);
}, [auth.session, form]);
const submit = async (values: CustomerProfile) => {
setSaving(true);
try {
await auth.updateProfile(values);
message.success(copy.saved);
} finally {
setSaving(false);
}
};
return (
<>
<h1 className="text-3xl font-extrabold text-primary">{copy.profile}</h1>
<p className="text-secondary mt-3 mb-7">{copy.invoicePending}</p>
<Form
form={form}
layout="vertical"
onFinish={submit}
className="rounded-2xl border border-subtle bg-elevated p-5 md:p-7"
>
<Form.Item name="type" label={copy.customerType} rules={[{required: true}]}>
<Radio.Group
optionType="button"
buttonStyle="solid"
options={[
{
label: copy.individual,
value: 'individual',
},
{
label: copy.corporate,
value: 'corporate',
},
]}
/>
</Form.Item>
<div className="grid md:grid-cols-2 gap-x-5">
<Form.Item name="firstName" label={copy.firstName} rules={[{required: true}]}>
<Input size="large" />
</Form.Item>
<Form.Item name="lastName" label={copy.lastName} rules={[{required: true}]}>
<Input size="large" />
</Form.Item>
<Form.Item name="phone" label={copy.phone}>
<Input size="large" dir="ltr" disabled />
</Form.Item>
<Form.Item name="email" label={copy.email} rules={[{type: 'email'}]}>
<Input size="large" dir="ltr" />
</Form.Item>
{customerType === 'corporate' && (
<>
<Form.Item name="companyName" label={copy.companyName} rules={[{required: true}]}>
<Input size="large" />
</Form.Item>
<Form.Item name="nationalId" label={copy.nationalId} rules={[{required: true}]}>
<Input size="large" dir="ltr" />
</Form.Item>
<Form.Item name="economicCode" label={copy.economicCode}>
<Input size="large" dir="ltr" />
</Form.Item>
</>
)}
<Form.Item name="province" label={copy.province} rules={[{required: true}]}>
<Input size="large" />
</Form.Item>
<Form.Item name="city" label={copy.city} rules={[{required: true}]}>
<Input size="large" />
</Form.Item>
<Form.Item name="postalCode" label={copy.postalCode} rules={[{required: true}]}>
<Input size="large" dir="ltr" maxLength={10} />
</Form.Item>
<Form.Item name="preferredCallTime" label={copy.preferredCallTime}>
<Input size="large" />
</Form.Item>
</div>
<Form.Item name="address" label={copy.address} rules={[{required: true}]}>
<Input.TextArea rows={4} />
</Form.Item>
<Alert type="info" showIcon message={copy.verifiedPhone} className="mb-6" />
<Button type="primary" htmlType="submit" size="large" loading={saving}>
{copy.save}
</Button>
</Form>
</>
);
}
+128 -3
View File
@@ -169,7 +169,8 @@ html[dir='rtl'] body {
height: 190px;
border-radius: 999px;
border: 2px solid rgba(0, 166, 147, 0.9);
box-shadow: 0 0 0 4px rgba(0, 166, 147, 0.15),
box-shadow:
0 0 0 4px rgba(0, 166, 147, 0.15),
0 0 40px 6px rgba(0, 166, 147, 0.45);
pointer-events: none;
transform: translate(-50%, -50%);
@@ -211,11 +212,135 @@ html[dir='rtl'] body {
animation: caretBlink 0.9s steps(1) infinite;
}
.invoice-document {
width: 900px;
min-height: 1120px;
padding: 48px;
background: #ffffff;
color: #12332e;
font-family: var(--font-vazirmatn), Tahoma, sans-serif;
}
.invoice-head,
.invoice-meta {
display: flex;
justify-content: space-between;
gap: 28px;
}
.invoice-head {
align-items: center;
padding-bottom: 24px;
border-bottom: 2px solid #00a693;
}
.invoice-head img {
width: 150px;
height: auto;
}
.invoice-head h2 {
margin: 0 0 8px;
font-size: 26px;
}
.invoice-head p,
.invoice-meta p {
margin: 4px 0;
}
.invoice-meta {
margin: 28px 0;
}
.invoice-meta > div {
flex: 1;
line-height: 1.9;
}
.invoice-document table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.invoice-document th {
background: #e9f8f5;
}
.invoice-document th,
.invoice-document td {
border: 1px solid #b9ded7;
padding: 12px 9px;
text-align: start;
vertical-align: top;
}
.invoice-document td small {
display: block;
color: #55756f;
margin-top: 5px;
}
.invoice-totals {
width: 380px;
margin-inline-start: auto;
margin-top: 24px;
}
.invoice-totals p {
display: flex;
justify-content: space-between;
gap: 20px;
margin: 0;
padding: 9px 0;
border-bottom: 1px solid #dcebe8;
}
.invoice-totals .grand {
color: #006d61;
font-size: 16px;
border-bottom: 2px solid #00a693;
}
.invoice-note {
margin-top: 40px;
padding: 16px;
background: #f2faf8;
border-radius: 10px;
line-height: 2;
}
@media print {
body * {
visibility: hidden !important;
}
.invoice-document,
.invoice-document * {
visibility: visible !important;
}
.invoice-document {
position: absolute;
inset: 0;
width: 100%;
min-height: auto;
padding: 20px;
}
.invoice-actions {
display: none !important;
}
}
@keyframes caretBlink {
0%, 49% {
0%,
49% {
opacity: 1;
}
50%, 100% {
50%,
100% {
opacity: 0;
}
}
+1 -6
View File
@@ -13,21 +13,16 @@ const vazirmatn = Vazirmatn({
display: 'swap',
});
export const metadata: Metadata = {
title: 'روبات مارکت | دستگاه‌های وندینگ هوشمند',
description:
'روبات مارکت؛ دستگاه‌های فروش خودکار هوشمند با مدیریت آنلاین، تحویل نرم و طراحی مدرن.',
description: 'روبات مارکت؛ دستگاه‌های فروش خودکار هوشمند با مدیریت آنلاین، تحویل نرم و طراحی مدرن.',
};
export const viewport: Viewport = {
themeColor: '#00a693',
};
export default function RootLayout({children}: {children: ReactNode}) {
return (
<html lang="fa" dir="rtl" className={`${vazirmatn.variable} light`} suppressHydrationWarning>
<body className="bg-page text-primary antialiased transition-colors duration-300">
+30
View File
@@ -0,0 +1,30 @@
'use client';
import {useEffect} from 'react';
import {useRouter} from 'next/navigation';
import {Button} from 'antd';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy} from '@/data/account';
export default function LoginPage() {
const auth = useAuth();
const router = useRouter();
const {locale} = useLanguage();
const copy = accountCopy[locale];
useEffect(() => {
if (auth.isAuthenticated) router.replace('/dashboard');
else if (auth.ready) auth.openLogin(() => router.replace('/dashboard'));
}, [auth.isAuthenticated, auth.ready, auth.openLogin, router]);
return (
<main className="max-w-2xl mx-auto min-h-[70vh] px-5 pt-32 pb-20 text-center">
<div className="rounded-3xl border border-subtle bg-elevated p-10">
<h1 className="text-3xl font-extrabold text-primary">{copy.login}</h1>
<p className="text-secondary mt-3 mb-7">{copy.protectedDescription}</p>
<Button type="primary" size="large" onClick={() => auth.openLogin(() => router.replace('/dashboard'))}>
{copy.openLogin}
</Button>
</div>
</main>
);
}
+1 -2
View File
@@ -1,11 +1,10 @@
import LocationsSection from '@/components/LocationsSection';
import ShowcaseSection from "@/components/ShowcaseSection";
import {ShowcaseSection} from '@/features/products';
import FeaturesSection from '@/components/FeaturesSection';
import AboutSection from '@/components/AboutSection';
import HeroSection from '@/components/HeroSection';
export default function HomePage() {
return (
<main className="relative bg-page">
<HeroSection />
+7 -2
View File
@@ -1,7 +1,8 @@
import type {Metadata} from 'next';
import {notFound} from 'next/navigation';
import {content} from '@/data/content';
import ProductDetails from '@/components/ProductDetails';
import {ProductDetails} from '@/features/products';
import ProtectedPage from '@/components/auth/ProtectedPage';
type Props = {params: {id: string}};
@@ -17,5 +18,9 @@ export function generateMetadata({params}: Props): Metadata {
export default function ProductPage({params}: Props) {
if (!content.fa.machines.some(machine => machine.id === params.id)) notFound();
return <ProductDetails id={params.id}/>;
return (
<ProtectedPage>
<ProductDetails id={params.id} />
</ProtectedPage>
);
}
+8 -2
View File
@@ -1,12 +1,18 @@
import {Suspense} from 'react';
import type {Metadata} from 'next';
import ProductsPage from '@/components/ProductsPage';
import {ProductsPage} from '@/features/products';
export const metadata: Metadata = {title: 'همه محصولات | روبات مارکت'};
export default function Page() {
return (
<Suspense fallback={<div className="min-h-screen pt-28 px-6" aria-busy="true"><div className="h-60 rounded-3xl bg-surface animate-pulse"/></div>}>
<Suspense
fallback={
<div className="min-h-screen pt-28 px-6" aria-busy="true">
<div className="h-60 rounded-3xl bg-surface animate-pulse" />
</div>
}
>
<ProductsPage />
</Suspense>
);
+6 -3
View File
@@ -5,13 +5,14 @@ import type {ReactNode} from 'react';
import {Provider as ReduxProvider} from 'react-redux';
import type {ThemeConfig} from 'antd';
import {ConfigProvider, theme as antdThemeApi} from 'antd';
import {App as AntdApp, ConfigProvider, theme as antdThemeApi} from 'antd';
import faIR from 'antd/locale/fa_IR';
import enUS from 'antd/locale/en_US';
import {store} from '@/store/store';
import {ThemeProvider, useTheme} from './context/ThemeContext';
import {LanguageProvider, useLanguage} from './context/LanguageContext';
import CartPersistence from '@/components/CartPersistence';
import {AuthProvider} from './context/AuthContext';
function AntdBridge({children}: {children: ReactNode}) {
const {theme} = useTheme();
@@ -43,7 +44,7 @@ function AntdBridge({children}: {children: ReactNode}) {
return (
<ConfigProvider direction={dir} locale={locale === 'fa' ? faIR : enUS} theme={antdTheme}>
{children}
<AntdApp>{children}</AntdApp>
</ConfigProvider>
);
}
@@ -54,7 +55,9 @@ export default function Providers({children}: {children: ReactNode}) {
<CartPersistence />
<ThemeProvider>
<LanguageProvider>
<AntdBridge>{children}</AntdBridge>
<AntdBridge>
<AuthProvider>{children}</AuthProvider>
</AntdBridge>
</LanguageProvider>
</ThemeProvider>
</ReduxProvider>
+1 -1
View File
@@ -27,7 +27,7 @@ export default function AboutSection() {
</div>
<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"
>
{expanded ? t.about.less : t.about.more}
+36 -10
View File
@@ -19,13 +19,17 @@ export default function CartPage() {
return (
<main className="mx-auto max-w-7xl min-h-[65vh] px-5 md:px-8 pt-28 pb-20">
<Link href="/products" className="text-brand-400 hover:underline">{copy.continueShopping}</Link>
<Link href="/products" 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="/products" className="inline-block mt-6 text-brand-400 hover:underline">{copy.continueShopping}</Link>
<Link href="/products" 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">
@@ -33,20 +37,40 @@ export default function CartPage() {
{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">
<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"/>
<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>
<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/>}
<Button
danger
type="text"
icon={<DeleteOutlined />}
aria-label={`${copy.remove}: ${product.name}`}
onClick={() => dispatch(removeFromCart(item.id))}/>
onClick={() => dispatch(removeFromCart(item.id))}
/>
</div>
<p className="mt-4 text-primary font-semibold">{copy.lineTotal}: {money(item.qty * item.priceNumeric)}</p>
<p className="mt-4 text-primary font-semibold">
{copy.lineTotal}: {money(item.qty * item.priceNumeric)}
</p>
</div>
</article>
);
@@ -59,7 +83,9 @@ export default function CartPage() {
<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>
<Button type="primary" size="large" disabled block>
{t.cart.checkout}
</Button>
</aside>
</div>
)}
+20 -5
View File
@@ -19,13 +19,25 @@ export default function CartPersistence() {
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;
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});
items.push({
id: machine.id,
name: machine.name,
image: machine.image,
priceNumeric: machine.priceNumeric,
priceLabel: machine.priceLabel,
qty: entry.qty,
});
}
store.dispatch(restoreCart(items));
}
@@ -35,7 +47,10 @@ export default function CartPersistence() {
return store.subscribe(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(store.getState().cart.items.map(({id, qty}) => ({id, qty}))));
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.
}
+11 -2
View File
@@ -32,8 +32,17 @@ export default function DevelopmentNotice() {
};
return (
<Modal open={open} centered title={copy.developmentTitle} onCancel={dismiss}
footer={<Button type="primary" onClick={dismiss}>{copy.acknowledge}</Button>}>
<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>
+2 -7
View File
@@ -2,12 +2,7 @@
// Service benefits: warranty, consultation, installments, and packaging.
import {
SafetyCertificateOutlined,
CustomerServiceOutlined,
CreditCardOutlined,
InboxOutlined,
} from '@ant-design/icons';
import {SafetyCertificateOutlined, CustomerServiceOutlined, CreditCardOutlined, InboxOutlined} from '@ant-design/icons';
import {useLanguage} from '@/app/context/LanguageContext';
const ICONS = {
@@ -23,7 +18,7 @@ export default function FeaturesSection() {
return (
<section className="relative px-6 md:px-16 py-16 md:py-20 bg-surface border-t border-subtle">
<div className="max-w-6xl mx-auto grid sm:grid-cols-2 lg:grid-cols-4 gap-5">
{t.features.map((feature) => {
{t.features.map(feature => {
const Icon = ICONS[feature.icon];
return (
<div
+21 -5
View File
@@ -18,7 +18,7 @@ export default function Footer() {
<h3 className="text-primary font-bold text-lg mb-3">{t.footer.aboutLabel}</h3>
<p className="text-secondary text-sm leading-7">{t.footer.aboutText}</p>
<div className="flex flex-col gap-2 mt-4">
{c.socialLinks.map((link) => (
{c.socialLinks.map(link => (
<a
key={link.id}
href={link.href}
@@ -35,10 +35,26 @@ export default function Footer() {
<div>
<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">
<li><Link href="/#hero" className="hover:text-brand-400">{t.nav.home}</Link></li>
<li><Link href="/products" className="hover:text-brand-400">{commerceCopy[locale].allProducts}</Link></li>
<li><Link href="/#about" className="hover:text-brand-400">{t.nav.about}</Link></li>
<li><Link href="/#locations" className="hover:text-brand-400">{t.nav.locations}</Link></li>
<li>
<Link href="/#hero" className="hover:text-brand-400">
{t.nav.home}
</Link>
</li>
<li>
<Link href="/products" className="hover:text-brand-400">
{commerceCopy[locale].allProducts}
</Link>
</li>
<li>
<Link href="/#about" className="hover:text-brand-400">
{t.nav.about}
</Link>
</li>
<li>
<Link href="/#locations" className="hover:text-brand-400">
{t.nav.locations}
</Link>
</li>
</ul>
</div>
+61 -36
View File
@@ -12,6 +12,9 @@ import {useAppSelector} from '@/store/hooks';
import {selectCartCount} from '@/store/cartSlice';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
import {accountCopy} from '@/data/account';
import {useAuth} from '@/app/context/AuthContext';
import ProtectedLink from './auth/ProtectedLink';
import ThemeToggle from './ThemeToggle';
import LanguageToggle from './LanguageToggle';
@@ -19,6 +22,8 @@ export default function Header() {
const pathname = usePathname();
const cartCount = useAppSelector(selectCartCount);
const {t, locale} = useLanguage();
const auth = useAuth();
const account = accountCopy[locale];
const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
@@ -28,6 +33,9 @@ export default function Header() {
{href: '/#about', label: t.nav.about},
{href: '/#locations', label: t.nav.locations},
];
const accountLabel = auth.session?.profile.firstName
? `${auth.session.profile.firstName} ${auth.session.profile.lastName}`.trim()
: auth.session?.profile.phone || account.login;
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24);
@@ -36,15 +44,14 @@ export default function Header() {
return () => window.removeEventListener('scroll', onScroll);
}, []);
console.log(
"%c👋 Hamed Abdollahzade · Frontend Developer",
"font-size:14px;font-weight:600;"
);
console.log('%c👋 Hamed Abdollahzade · Frontend Developer', 'font-size:14px;font-weight:600;');
return (
<header
className={`fixed top-0 inset-x-0 z-50 transition-all duration-300 ${
scrolled || pathname !== '/' ? '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">
@@ -53,7 +60,7 @@ export default function Header() {
</Link>
<nav className="hidden xl:flex items-center gap-5 2xl:gap-7">
{navLinks.map((link) => (
{navLinks.map(link => (
<Link
key={link.href}
href={link.href}
@@ -69,27 +76,34 @@ export default function Header() {
<LanguageToggle />
<ThemeToggle />
</div>
{/*FAZ1*/}
{/*{auth.isAuthenticated ? (*/}
{/* <Button className="hidden sm:inline-flex" icon={<UserOutlined />} href="/dashboard">*/}
{/* {accountLabel}*/}
{/* </Button>*/}
{/*) : (*/}
{/* <Button*/}
{/* className="hidden sm:inline-flex"*/}
{/* icon={<UserOutlined />}*/}
{/* onClick={() => auth.openLogin()}*/}
{/* >*/}
{/* {account.login}*/}
{/* </Button>*/}
{/*)}*/}
<Button
className="hidden sm:inline-flex"
icon={<UserOutlined/>}
href="https://panel.my-rm.com"
target="_blank"
rel="noopener noreferrer"
>
{t.nav.login}
</Button>
<Badge count={cartCount} size="small" offset={[-2, 2]}>
<Link href="/cart" onClick={() => setMobileOpen(false)} aria-label={t.cart.title}
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">
<ShoppingCartOutlined/>
</Link>
</Badge>
{/*<Badge count={cartCount} size="small" offset={[-2, 2]}>*/}
{/* <ProtectedLink*/}
{/* href="/cart"*/}
{/* onClick={() => setMobileOpen(false)}*/}
{/* 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"*/}
{/* >*/}
{/* <ShoppingCartOutlined />*/}
{/* </ProtectedLink>*/}
{/*</Badge>*/}
<button
className="xl:hidden text-brand-400 text-2xl"
onClick={() => setMobileOpen((v) => !v)}
onClick={() => setMobileOpen(v => !v)}
aria-label="menu"
>
{mobileOpen ? <CloseOutlined /> : <MenuOutlined />}
@@ -98,9 +112,8 @@ export default function Header() {
</div>
{mobileOpen && (
<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">
{navLinks.map((link) => (
<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">
{navLinks.map(link => (
<Link
key={link.href}
href={link.href}
@@ -113,16 +126,28 @@ export default function Header() {
<div className="flex items-center gap-3 pt-2 border-t border-subtle">
<LanguageToggle />
<ThemeToggle />
<Button
icon={<UserOutlined/>}
className="flex-1"
href="https://panel.my-rm.com"
target="_blank"
rel="noopener noreferrer"
onClick={() => setMobileOpen(false)}
>
{t.nav.login}
</Button>
{/*FAZ1*/}
{/*{auth.isAuthenticated ? (*/}
{/* <Button*/}
{/* icon={<UserOutlined />}*/}
{/* className="flex-1"*/}
{/* href="/dashboard"*/}
{/* onClick={() => setMobileOpen(false)}*/}
{/* >*/}
{/* {accountLabel}*/}
{/* </Button>*/}
{/*) : (*/}
{/* <Button*/}
{/* icon={<UserOutlined />}*/}
{/* className="flex-1"*/}
{/* onClick={() => {*/}
{/* setMobileOpen(false);*/}
{/* auth.openLogin();*/}
{/* }}*/}
{/* >*/}
{/* {account.login}*/}
{/* </Button>*/}
{/*)}*/}
</div>
</nav>
)}
+16 -6
View File
@@ -5,22 +5,23 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {LeftOutlined, RightOutlined} from '@ant-design/icons';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy} from '@/data/account';
const AUTOPLAY_DELAY = 5000; // Advance every five seconds.
export default function HeroSection() {
const {t, dir} = useLanguage();
const {t, dir, locale} = useLanguage();
const slides = t.hero.slides;
const [activeIndex, setActiveIndex] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Wrap around after the last slide.
const goToNext = useCallback(() => {
setActiveIndex((prev) => (prev + 1) % slides.length);
setActiveIndex(prev => (prev + 1) % slides.length);
}, [slides.length]);
const goToPrev = useCallback(() => {
setActiveIndex((prev) => (prev - 1 + slides.length) % slides.length);
setActiveIndex(prev => (prev - 1 + slides.length) % slides.length);
}, [slides.length]);
// Restart the autoplay timer after manual navigation.
@@ -93,8 +94,7 @@ export default function HeroSection() {
{/* ------------------------------------------------------------------ */}
{/* Active slide headline, description, and call to action. */}
{/* ------------------------------------------------------------------ */}
<div
className="relative z-10 h-full flex flex-col justify-end md:justify-end px-6 md:px-16 pb-28 md:pb-72">
<div className="relative z-10 h-full flex flex-col justify-end md:justify-end px-6 md:px-16 pb-28 md:pb-72">
<div className="max-w-2xl">
<span className="text-brand-400 font-semibold tracking-widest text-sm uppercase">
{slide.kicker}
@@ -107,12 +107,22 @@ export default function HeroSection() {
))}
</h1>
<p className="mt-5 text-white/90 text-base md:text-lg max-w-xl drop-shadow-lg">{slide.sub}</p>
<div className="mt-8 flex flex-wrap gap-3">
<a
href="#showcase"
className="mt-8 inline-flex items-center gap-2 bg-brand hover:bg-brand-400 text-ink-950 font-bold px-8 py-3.5 rounded-full transition-colors shadow-brandGlow"
className="inline-flex items-center gap-2 bg-brand hover:bg-brand-400 text-ink-950 font-bold px-8 py-3.5 rounded-full transition-colors shadow-brandGlow"
>
{t.hero.cta}
</a>
<a
href="https://panel.my-rm.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white border border-white/30 font-bold px-8 py-3.5 rounded-full transition-colors backdrop-blur-sm"
>
{accountCopy[locale].robotMasterPanel}
</a>
</div>
</div>
</div>
-114
View File
@@ -1,114 +0,0 @@
'use client';
import {useEffect, useRef, useState} from 'react';
import Link from 'next/link';
import {gsap} from 'gsap';
import {ScrollTrigger} from 'gsap/ScrollTrigger';
import VendingCard from './VendingCard';
import {showcaseVideoSrc} from '@/data/content';
import {featuredMachines} from '@/data/products';
import {commerceCopy} from '@/data/commerce';
import {useLanguage} from '@/app/context/LanguageContext';
gsap.registerPlugin(ScrollTrigger);
export default function HorizontalShowcase() {
const sectionRef = useRef<HTMLElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const {t, locale} = useLanguage();
const copy = commerceCopy[locale];
const [videoAvailable, setVideoAvailable] = useState(true);
useEffect(() => {
const ctx = gsap.context(() => {
const track = trackRef.current;
const section = sectionRef.current;
if (!track || !section) return;
const scrollDistance = () => (
Math.max(track.scrollWidth - window.innerWidth, 0)
);
let animationFrameId: number | null = null;
let latestProgress = 0;
const syncVideo = (progress: number) => {
latestProgress = progress;
if (animationFrameId !== null) return;
animationFrameId = window.requestAnimationFrame(() => {
const video = videoRef.current;
if (video && Number.isFinite(video.duration) && video.duration > 0) {
video.currentTime = latestProgress * video.duration;
}
animationFrameId = null;
});
};
const horizontalTween = gsap.to(track, {
x: () => -scrollDistance(),
ease: 'none',
scrollTrigger: {
trigger: section,
start: 'top top',
end: () => `+=${scrollDistance()}`,
scrub: 1,
pin: true,
invalidateOnRefresh: true,
onUpdate: (self) => syncVideo(self.progress),
},
});
return () => {
if (animationFrameId !== null) window.cancelAnimationFrame(animationFrameId);
horizontalTween.scrollTrigger?.kill();
horizontalTween.kill();
};
}, sectionRef);
return () => ctx.revert();
}, []);
return (
<section id="showcase" ref={sectionRef} className="relative w-full overflow-hidden">
<div className="absolute inset-0 z-0">
{videoAvailable && (
<video
ref={videoRef}
src={showcaseVideoSrc}
muted
playsInline
preload="auto"
className="w-full h-full object-cover opacity-60"
onError={() => setVideoAvailable(false)}
/>
)}
<div
className="absolute inset-0"
style={{
background:
'linear-gradient(180deg, rgba(4,18,15,0.55) 0%, rgba(7,30,25,0.3) 50%, rgba(4,18,15,0.6) 100%)',
}}
/>
</div>
<div className="relative z-20 h-40 pt-20 bg-page-85 backdrop-blur-md">
<div className="max-w-7xl h-full mx-auto px-8 flex justify-between items-center gap-4">
<h2 className="text-2xl font-bold text-primary">{copy.featuredProducts}</h2>
<Link href="/products" className="text-brand-400 hover:underline">{copy.allProducts}</Link>
</div>
</div>
<div ref={trackRef}
className="horizontal-track relative z-10 [&_.horizontal-panel]:h-full [&_.spotlight-stage]:max-h-[calc(100vh-12rem)]"
style={{height: 'calc(100vh - 10rem)'}} dir="ltr">
{featuredMachines(t.machines).map((machine) => (
<VendingCard key={machine.id} machine={machine}/>
))}
</div>
</section>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export default function LocationsSection() {
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
{t.locations.items.map((loc) => {
{t.locations.items.map(loc => {
const Icon = ICONS[loc.icon];
return (
<div
-37
View File
@@ -1,37 +0,0 @@
'use client';
import Link from 'next/link';
import {featuredMachines} from '@/data/products';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
import QuantityControl from './QuantityControl';
export default function MobileShowcase() {
const {t, dir, locale} = useLanguage();
return (
<section id="showcase" className="relative w-full py-8 px-4" dir={dir}>
<div className="flex gap-4 overflow-x-auto snap-x snap-mandatory pb-4">
{featuredMachines(t.machines).map(machine => (
<article key={machine.id} className="w-[90%] shrink-0 snap-start bg-surface border border-subtle rounded-2xl p-4">
<Link href={`/products/${machine.id}`} className="flex gap-4 items-center">
<img src={machine.image} alt={machine.name} className="w-24 h-28 object-contain shrink-0"/>
<div className="min-w-0">
<span className="text-brand-400 text-xs font-semibold">{machine.shortLabel}</span>
<h3 className="text-lg font-bold text-primary mt-1">{machine.name}</h3>
<p className="text-sm text-secondary line-clamp-2 mt-2">{machine.description}</p>
</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>
</article>
))}
</div>
</section>
);
}
-69
View File
@@ -1,69 +0,0 @@
'use client';
import Link from 'next/link';
import {usePathname, useRouter, useSearchParams} from 'next/navigation';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
import {filterMachines, productCategories} from '@/data/products';
import QuantityControl from './QuantityControl';
export default function ProductsPage() {
const {t, locale} = useLanguage();
const copy = commerceCopy[locale];
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const category = searchParams.get('category') ?? '';
const products = filterMachines(t.machines, category);
const knownCategory = productCategories.some(item => item.id === category);
const setCategory = (value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) params.set('category', value);
else params.delete('category');
// Preserve other query parameters and allow browser Back to restore filters.
const query = params.toString();
router.push(query ? `${pathname}?${query}` : pathname, {scroll: false});
};
return (
<main className="max-w-7xl mx-auto px-5 md:px-8 pt-28 pb-20 min-h-[70vh]">
<h1 className="text-3xl md:text-4xl font-extrabold text-primary">{copy.allProducts}</h1>
<p className="text-secondary mt-4 leading-7">{copy.productsDescription}</p>
<div className="mt-8 mb-8 rounded-2xl border border-subtle bg-surface p-5 flex flex-wrap items-end gap-4">
<div className="flex flex-col gap-2 w-full sm:w-72">
<label htmlFor="product-category" className="text-sm font-semibold text-primary">{copy.category}</label>
<select id="product-category" value={category} onChange={event => setCategory(event.target.value)}
className="w-full border border-subtle bg-elevated text-primary rounded-xl px-4 py-3 focus:outline-brand">
<option value="">{copy.allCategories}</option>
{category && !knownCategory && <option value={category}>{copy.unknownCategory}</option>}
{productCategories.map(item => <option key={item.id} value={item.id}>{item.label[locale]}</option>)}
</select>
</div>
{category && <button onClick={() => setCategory('')} className="text-brand-400 py-3 hover:underline">{copy.clearFilters}</button>}
<p aria-live="polite" aria-atomic="true" className="text-secondary text-sm py-3 sm:ms-auto">
{products.length.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')} {copy.results}
</p>
</div>
{products.length ? (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map(machine => (
<article key={machine.id} className="flex flex-col rounded-3xl border border-subtle bg-surface p-5">
<Link href={`/products/${machine.id}`} className="rounded-2xl bg-page p-5">
<img src={machine.image} alt={machine.name} className="w-full h-60 object-contain"/>
</Link>
<span className="text-brand-400 text-xs mt-5">{productCategories.find(item => item.id === machine.category)?.label[locale]}</span>
<h2 className="text-xl font-bold text-primary mt-2"><Link href={`/products/${machine.id}`}>{machine.name}</Link></h2>
<p className="text-secondary text-sm leading-7 mt-3 mb-5">{machine.description}</p>
<p className="text-primary text-lg font-bold mt-auto mb-4">{machine.priceLabel}</p>
<div className="flex flex-wrap items-center justify-between gap-3">
<QuantityControl product={machine}/>
<Link href={`/products/${machine.id}`} className="text-brand-400 text-sm hover:underline">{copy.details}</Link>
</div>
</article>
))}
</div>
) : <div className="text-center text-secondary border border-subtle rounded-2xl p-12">{copy.noProducts}</div>}
</main>
);
}
+31 -7
View File
@@ -7,26 +7,50 @@ import {useAppDispatch, useAppSelector} from '@/store/hooks';
import {addToCart, decrementQty} from '@/store/cartSlice';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
import {useAuth} from '@/app/context/AuthContext';
export default function QuantityControl({product}: {product: CartProduct}) {
const dispatch = useAppDispatch();
const {locale} = useLanguage();
const copy = commerceCopy[locale];
const auth = useAuth();
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}
<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))}/>
onClick={() => (auth.isAuthenticated ? dispatch(decrementQty(product.id)) : auth.openLogin())}
/>
<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/>}
<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}))}/>
onClick={() => {
const add = () =>
dispatch(
addToCart({
id: product.id,
name: product.name,
image: product.image,
priceLabel: product.priceLabel,
priceNumeric: product.priceNumeric,
}),
);
if (auth.isAuthenticated) add();
else auth.openLogin(add);
}}
/>
</div>
);
}
-42
View File
@@ -1,42 +0,0 @@
'use client';
import {useEffect, useState} from 'react';
import Link from 'next/link';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
import HorizontalShowcase from './HorizontalShowcase';
import MobileShowcase from './MobileShowcase';
const MOBILE_BREAKPOINT = 768;
export default function ShowcaseSection() {
const {locale} = useLanguage();
const copy = commerceCopy[locale];
const [isMobile, setIsMobile] = useState<boolean | null>(null);
useEffect(() => {
const mediaQuery = window.matchMedia(
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`
);
const handleChange = (event: MediaQueryListEvent) => setIsMobile(event.matches);
setIsMobile(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
if (isMobile === null) return null;
if (!isMobile) return <HorizontalShowcase/>;
return (
<>
<div className="max-w-7xl mx-auto px-5 md:px-8 py-8 flex flex-wrap justify-between items-center gap-4">
<h2 className="text-2xl font-bold text-primary">{copy.featuredProducts}</h2>
<Link href="/products" className="text-brand-400 hover:underline">{copy.allProducts}</Link>
</div>
<MobileShowcase/>
</>
);
}
-1
View File
@@ -1,6 +1,5 @@
'use client';
import {Button, Tooltip} from 'antd';
import {SunOutlined, MoonOutlined} from '@ant-design/icons';
import {useTheme} from '@/app/context/ThemeContext';
+131
View File
@@ -0,0 +1,131 @@
'use client';
import {useEffect, useState} from 'react';
import {Alert, App, Button, Form, Input, Modal, Segmented, Typography} from 'antd';
import {LockOutlined, MobileOutlined, SafetyCertificateOutlined} from '@ant-design/icons';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy} from '@/data/account';
type LoginMode = 'otp' | 'password';
export default function AuthModal() {
const auth = useAuth();
const {message} = App.useApp();
const {locale} = useLanguage();
const copy = accountCopy[locale];
const [mode, setMode] = useState<LoginMode>('otp');
const [step, setStep] = useState<'phone' | 'code'>('phone');
const [phone, setPhone] = useState('09123456789');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (auth.loginOpen) {
setMode('otp');
setStep('phone');
setError('');
}
}, [auth.loginOpen]);
const submit = async (values: {phone?: string; code?: string; password?: string}) => {
setLoading(true);
setError('');
try {
const currentPhone = (values.phone || phone).replace(/\s/g, '');
if (mode === 'otp' && step === 'phone') {
await auth.sendOtp(currentPhone);
setPhone(currentPhone);
setStep('code');
message.success(copy.otpSent);
} else if (mode === 'otp') {
await auth.verifyOtp(currentPhone, values.code || '');
} else {
await auth.loginPassword(currentPhone, values.password || '');
}
} catch (caught) {
setError(
caught instanceof Error && caught.message === 'INVALID_PHONE'
? copy.invalidPhone
: copy.invalidCredentials,
);
} finally {
setLoading(false);
}
};
return (
<Modal
open={auth.loginOpen}
onCancel={auth.closeLogin}
footer={null}
centered
title={copy.login}
destroyOnClose
>
<Segmented<LoginMode>
block
value={mode}
onChange={value => {
setMode(value);
setStep('phone');
setError('');
}}
options={[
{label: copy.otpLogin, value: 'otp'},
{
label: copy.passwordLogin,
value: 'password',
},
]}
/>
<Alert
className="mt-5"
type="info"
showIcon
message={copy.demoTitle}
description={
<div>
<div>{copy.demoOtp}</div>
<div>{copy.demoPassword}</div>
</div>
}
/>
{error && <Alert className="mt-4" type="error" showIcon message={error} />}
<Form
layout="vertical"
className="mt-5"
onFinish={submit}
initialValues={{phone: '09123456789', code: '12345', password: '123456'}}
>
{(mode === 'password' || step === 'phone') && (
<Form.Item
name="phone"
label={copy.phone}
rules={[{required: true, pattern: /^09\d{9}$/, message: copy.invalidPhone}]}
>
<Input dir="ltr" size="large" maxLength={11} prefix={<MobileOutlined />} />
</Form.Item>
)}
{mode === 'password' && (
<Form.Item name="password" label={copy.password} rules={[{required: true}]}>
<Input.Password dir="ltr" size="large" prefix={<LockOutlined />} />
</Form.Item>
)}
{mode === 'otp' && step === 'code' && (
<>
<Typography.Paragraph type="secondary">
{copy.phone}: <span dir="ltr">{phone}</span>
</Typography.Paragraph>
<Form.Item name="code" label={copy.otp} rules={[{required: true}]}>
<Input dir="ltr" size="large" maxLength={5} prefix={<SafetyCertificateOutlined />} />
</Form.Item>
</>
)}
<Button htmlType="submit" type="primary" size="large" block loading={loading}>
{mode === 'otp' ? (step === 'phone' ? copy.sendOtp : copy.verifyAndLogin) : copy.loginButton}
</Button>
</Form>
</Modal>
);
}
+33
View File
@@ -0,0 +1,33 @@
'use client';
import type {MouseEvent, ReactNode} from 'react';
import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useAuth} from '@/app/context/AuthContext';
export default function ProtectedLink({
href,
children,
className,
onClick,
}: {
href: string;
children: ReactNode;
className?: string;
onClick?: () => void;
}) {
const auth = useAuth();
const router = useRouter();
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
onClick?.();
if (!auth.isAuthenticated) {
event.preventDefault();
auth.openLogin(() => router.push(href));
}
};
return (
<Link href={href} className={className} onClick={handleClick}>
{children}
</Link>
);
}
+44
View File
@@ -0,0 +1,44 @@
'use client';
import type {ReactNode} from 'react';
import {useEffect, useRef} from 'react';
import {Button, Skeleton} from 'antd';
import {LockOutlined} from '@ant-design/icons';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy} from '@/data/account';
export default function ProtectedPage({children}: {children: ReactNode}) {
const auth = useAuth();
const {locale} = useLanguage();
const copy = accountCopy[locale];
const prompted = useRef(false);
useEffect(() => {
if (auth.ready && !auth.isAuthenticated && !prompted.current) {
prompted.current = true;
auth.openLogin();
}
}, [auth]);
if (!auth.ready)
return (
<main className="max-w-5xl mx-auto min-h-[70vh] px-5 pt-32">
<Skeleton active />
</main>
);
if (!auth.isAuthenticated)
return (
<main className="max-w-3xl mx-auto min-h-[70vh] px-5 pt-32 pb-20 text-center">
<div className="rounded-3xl border border-subtle bg-surface p-10">
<LockOutlined className="text-4xl text-brand-400" />
<h1 className="text-2xl font-extrabold text-primary mt-5">{copy.protectedTitle}</h1>
<p className="text-secondary mt-3 mb-7">{copy.protectedDescription}</p>
<Button type="primary" size="large" onClick={() => auth.openLogin()}>
{copy.openLogin}
</Button>
</div>
</main>
);
return children;
}
+64
View File
@@ -0,0 +1,64 @@
'use client';
import type {ReactNode} from 'react';
import Link from 'next/link';
import {usePathname, useRouter} from 'next/navigation';
import {Button} from 'antd';
import {DashboardOutlined, FileTextOutlined, LogoutOutlined, UserOutlined} from '@ant-design/icons';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy} from '@/data/account';
export default function DashboardShell({children}: {children: ReactNode}) {
const pathname = usePathname();
const router = useRouter();
const auth = useAuth();
const {locale} = useLanguage();
const copy = accountCopy[locale];
const links = [
{href: '/dashboard', label: copy.dashboard, icon: <DashboardOutlined />},
{href: '/dashboard/orders', label: copy.orders, icon: <FileTextOutlined />},
{href: '/dashboard/profile', label: copy.profile, icon: <UserOutlined />},
];
const active = (href: string) => (href === '/dashboard' ? pathname === href : pathname.startsWith(href));
return (
<main className="max-w-7xl mx-auto min-h-[75vh] px-5 md:px-8 pt-28 pb-20">
<div className="grid lg:grid-cols-[240px,minmax(0,1fr)] gap-7 items-start">
<aside className="lg:sticky lg:top-28 rounded-2xl border border-subtle bg-elevated p-4">
<div className="px-3 py-3 border-b border-subtle mb-3">
<p className="font-bold text-primary">{auth.session?.profile.firstName || copy.account}</p>
<p className="text-sm text-secondary mt-1" dir="ltr">
{auth.session?.profile.phone}
</p>
</div>
<nav className="flex lg:flex-col gap-2 overflow-x-auto no-scrollbar">
{links.map(link => (
<Link
key={link.href}
href={link.href}
className={`flex items-center gap-2 whitespace-nowrap px-4 py-3 rounded-xl transition-colors ${active(link.href) ? 'bg-brand text-ink-950 font-bold' : 'text-secondary hover:bg-surface-strong hover:text-primary'}`}
>
{link.icon}
{link.label}
</Link>
))}
</nav>
<Button
className="mt-3"
type="text"
danger
icon={<LogoutOutlined />}
onClick={() => {
auth.logout();
router.push('/');
}}
block
>
{copy.logout}
</Button>
</aside>
<section className="min-w-0">{children}</section>
</div>
</main>
);
}
+108
View File
@@ -0,0 +1,108 @@
'use client';
import {forwardRef} from 'react';
import type {CustomerOrder} from '@/types/account';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy} from '@/data/account';
const InvoiceDocument = forwardRef<HTMLDivElement, {order: CustomerOrder}>(({order}, ref) => {
const {locale} = useLanguage();
const copy = accountCopy[locale];
const invoice = order.invoice!;
const numberLocale = locale === 'fa' ? 'fa-IR' : 'en-US';
const money = (value: number) => `${value.toLocaleString(numberLocale)} ${locale === 'fa' ? 'ریال' : 'Rials'}`;
const subtotal = order.items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
const total = subtotal - invoice.discount + invoice.shipping + invoice.tax;
const buyerName =
order.customer.type === 'corporate'
? order.customer.companyName
: `${order.customer.firstName} ${order.customer.lastName}`;
return (
<div ref={ref} className="invoice-document" dir={locale === 'fa' ? 'rtl' : 'ltr'}>
<div className="invoice-head">
<img src="/images/icons/logo.png" alt="Robot Market" />
<div>
<h2>{copy.invoice}</h2>
<p>
{copy.invoiceNumber}: <span dir="ltr">{invoice.number}</span>
</p>
</div>
</div>
<div className="invoice-meta">
<div>
<strong>{copy.seller}</strong>
<p>{copy.sellerName}</p>
<p>{copy.sellerPhone}</p>
</div>
<div>
<strong>{copy.buyer}</strong>
<p>{buyerName}</p>
<p dir="ltr">{order.customer.phone}</p>
<p>
{order.customer.city}، {order.customer.address}
</p>
</div>
<div>
<p>
{copy.issueDate}: {invoice.issueDate}
</p>
<p>
{copy.validUntil}: {invoice.validUntil}
</p>
</div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>{copy.orderItems}</th>
<th>{copy.modules}</th>
<th>{copy.quantity}</th>
<th>{copy.unitPrice}</th>
<th>{copy.total}</th>
</tr>
</thead>
<tbody>
{order.items.map((item, index) => (
<tr key={item.id}>
<td>{index + 1}</td>
<td>
{item.name}
<small>{item.model}</small>
</td>
<td>{item.modules.join('، ')}</td>
<td>{item.quantity}</td>
<td>{money(item.unitPrice)}</td>
<td>{money(item.unitPrice * item.quantity)}</td>
</tr>
))}
</tbody>
</table>
<div className="invoice-totals">
<p>
<span>{copy.subtotal}</span>
<strong>{money(subtotal)}</strong>
</p>
<p>
<span>{copy.discount}</span>
<strong>{money(invoice.discount)}</strong>
</p>
<p>
<span>{copy.shipping}</span>
<strong>{money(invoice.shipping)}</strong>
</p>
<p>
<span>{copy.tax}</span>
<strong>{money(invoice.tax)}</strong>
</p>
<p className="grand">
<span>{copy.finalTotal}</span>
<strong>{money(total)}</strong>
</p>
</div>
<p className="invoice-note">این پیش‌فاکتور پس از هماهنگی تلفنی و تأیید ادمین روبات مارکت صادر شده است.</p>
</div>
);
});
InvoiceDocument.displayName = 'InvoiceDocument';
export default InvoiceDocument;
+48
View File
@@ -0,0 +1,48 @@
'use client';
import Link from 'next/link';
import type {CustomerOrder} from '@/types/account';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy, paymentStatusLabels} from '@/data/account';
import {getOrderItemQuantity} from '@/features/dashboard/lib/orderSummary';
import OrderStatusTag from './OrderStatusTag';
export default function OrderCard({order}: {order: CustomerOrder}) {
const {locale} = useLanguage();
const copy = accountCopy[locale];
return (
<article className="rounded-2xl border border-subtle bg-elevated p-5">
<div className="flex flex-wrap justify-between gap-4">
<div>
<p className="text-sm text-secondary">{copy.orderNumber}</p>
<strong className="text-primary" dir="ltr">
{order.number}
</strong>
</div>
<OrderStatusTag status={order.status} />
</div>
<div className="grid sm:grid-cols-3 gap-4 mt-5 pt-5 border-t border-subtle text-sm">
<div>
<span className="text-secondary block">{copy.orderDate}</span>
<span className="text-primary">{order.createdAt}</span>
</div>
<div>
<span className="text-secondary block">{copy.paymentStatus}</span>
<span className="text-primary">{paymentStatusLabels[locale][order.paymentStatus]}</span>
</div>
<div>
<span className="text-secondary block">{copy.orderItems}</span>
<span className="text-primary">
{getOrderItemQuantity(order).toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')}
</span>
</div>
</div>
<Link
href={`/dashboard/orders/${order.id}`}
className="inline-block text-brand-400 font-semibold mt-5 hover:underline"
>
{copy.details}
</Link>
</article>
);
}
+19
View File
@@ -0,0 +1,19 @@
'use client';
import {Tag} from 'antd';
import type {OrderStatus} from '@/types/account';
import {useLanguage} from '@/app/context/LanguageContext';
import {orderStatusLabels} from '@/data/account';
export default function OrderStatusTag({status}: {status: OrderStatus}) {
const {locale} = useLanguage();
const color =
status === 'cancelled'
? 'red'
: status === 'delivered'
? 'green'
: status === 'proforma_issued'
? 'cyan'
: 'gold';
return <Tag color={color}>{orderStatusLabels[locale][status]}</Tag>;
}
+205
View File
@@ -0,0 +1,205 @@
import type {Locale} from '@/types/domain';
import type {OrderStatus, PaymentStatus} from '@/types/account';
export const orderStatusLabels: Record<Locale, Record<OrderStatus, string>> = {
fa: {
registered: 'ثبت‌شده',
under_review: 'در حال بررسی',
needs_information: 'نیازمند تکمیل اطلاعات',
approved: 'تأییدشده',
proforma_issued: 'پیش‌فاکتور صادرشده',
awaiting_payment: 'در انتظار پرداخت',
preparing: 'در حال آماده‌سازی',
ready_to_ship: 'آماده ارسال',
shipped: 'ارسال‌شده',
delivered: 'تحویل‌شده',
cancelled: 'لغوشده',
},
en: {
registered: 'Registered',
under_review: 'Under review',
needs_information: 'More information required',
approved: 'Approved',
proforma_issued: 'Proforma issued',
awaiting_payment: 'Awaiting payment',
preparing: 'Preparing',
ready_to_ship: 'Ready to ship',
shipped: 'Shipped',
delivered: 'Delivered',
cancelled: 'Cancelled',
},
};
export const paymentStatusLabels: Record<Locale, Record<PaymentStatus, string>> = {
fa: {not_payable: 'در انتظار تأیید', awaiting_payment: 'در انتظار پرداخت', paid: 'پرداخت‌شده', failed: 'ناموفق'},
en: {not_payable: 'Pending approval', awaiting_payment: 'Awaiting payment', paid: 'Paid', failed: 'Failed'},
};
const fa = {
login: 'ورود',
account: 'حساب کاربری',
dashboard: 'داشبورد',
profile: 'اطلاعات حساب',
orders: 'سفارش‌ها',
logout: 'خروج',
robotMasterPanel: 'پنل روبات مستر',
otpLogin: 'ورود با کد یک‌بارمصرف',
passwordLogin: 'ورود با رمز عبور',
phone: 'شماره موبایل',
password: 'رمز عبور',
otp: 'کد تأیید',
sendOtp: 'دریافت کد',
verifyAndLogin: 'تأیید و ورود',
loginButton: 'ورود به حساب',
demoTitle: 'اطلاعات ورود آزمایشی',
demoOtp: 'کد نمونه: ۱۲۳۴۵',
demoPassword: 'شماره ۰۹۱۲۳۴۵۶۷۸۹ و رمز ۱۲۳۴۵۶',
otpSent: 'کد آزمایشی برای این شماره آماده است.',
invalidPhone: 'شماره موبایل معتبر وارد کنید.',
invalidCredentials: 'شماره یا رمز عبور صحیح نیست.',
protectedTitle: 'برای ادامه وارد حساب شوید',
protectedDescription: 'مشاهده جزئیات دستگاه، سبد خرید و داشبورد فقط پس از ورود امکان‌پذیر است.',
openLogin: 'ورود به حساب',
welcome: 'خوش آمدید',
dashboardDescription: 'اطلاعات حساب و روند سفارش‌های خود را از اینجا مدیریت کنید.',
profileCompletion: 'تکمیل اطلاعات',
activeOrders: 'سفارش فعال',
totalOrders: 'همه سفارش‌ها',
recentOrders: 'آخرین سفارش‌ها',
viewAll: 'مشاهده همه',
save: 'ذخیره اطلاعات',
saved: 'اطلاعات با موفقیت ذخیره شد.',
individual: 'شخص حقیقی',
corporate: 'شخص حقوقی',
customerType: 'نوع خریدار',
firstName: 'نام',
lastName: 'نام خانوادگی',
email: 'ایمیل',
province: 'استان',
city: 'شهر',
address: 'نشانی کامل',
postalCode: 'کد پستی',
preferredCallTime: 'زمان مناسب تماس',
companyName: 'نام شرکت',
nationalId: 'شناسه ملی',
economicCode: 'کد اقتصادی',
verifiedPhone: 'شماره تأییدشده و قابل ویرایش نیست.',
orderNumber: 'شماره سفارش',
orderDate: 'تاریخ ثبت',
lastUpdate: 'آخرین به‌روزرسانی',
orderStatus: 'وضعیت سفارش',
paymentStatus: 'وضعیت پرداخت',
details: 'مشاهده جزئیات',
orderItems: 'اقلام سفارش',
quantity: 'تعداد',
unitPrice: 'قیمت واحد',
total: 'جمع کل',
modules: 'ماژول‌ها و امکانات',
timeline: 'روند سفارش',
invoice: 'پیش‌فاکتور',
invoicePending: 'پیش‌فاکتور پس از بررسی تلفنی و تأیید ادمین در این بخش قرار می‌گیرد.',
downloadPdf: 'دانلود PDF',
downloadExcel: 'دانلود Excel',
print: 'چاپ',
invoiceNumber: 'شماره پیش‌فاکتور',
issueDate: 'تاریخ صدور',
validUntil: 'اعتبار تا',
buyer: 'خریدار',
seller: 'فروشنده',
subtotal: 'جمع اقلام',
discount: 'تخفیف',
shipping: 'هزینه ارسال',
tax: 'مالیات',
finalTotal: 'مبلغ نهایی',
sellerName: 'شرکت نگین سازان پیشگام رادمهر آتوسا (روبات مارکت)',
sellerPhone: 'تلفن سفارش: ۰۹۱۵۱۶۰۱۸۴۷',
emptyOrders: 'هنوز سفارشی ثبت نشده است.',
backToOrders: 'بازگشت به سفارش‌ها',
notFound: 'سفارش پیدا نشد.',
};
export const accountCopy: Record<Locale, typeof fa> = {
fa,
en: {
login: 'Login',
account: 'Account',
dashboard: 'Dashboard',
profile: 'Profile',
orders: 'Orders',
logout: 'Log out',
robotMasterPanel: 'Robot Master Panel',
otpLogin: 'Login with OTP',
passwordLogin: 'Login with password',
phone: 'Mobile number',
password: 'Password',
otp: 'Verification code',
sendOtp: 'Send code',
verifyAndLogin: 'Verify and login',
loginButton: 'Login',
demoTitle: 'Demo credentials',
demoOtp: 'Demo code: 12345',
demoPassword: 'Phone 09123456789 and password 123456',
otpSent: 'The demo code is ready for this number.',
invalidPhone: 'Enter a valid Iranian mobile number.',
invalidCredentials: 'Incorrect phone number or password.',
protectedTitle: 'Log in to continue',
protectedDescription: 'Product details, cart, and dashboard are available after login.',
openLogin: 'Login',
welcome: 'Welcome',
dashboardDescription: 'Manage your account details and follow your orders.',
profileCompletion: 'Profile completion',
activeOrders: 'Active orders',
totalOrders: 'All orders',
recentOrders: 'Recent orders',
viewAll: 'View all',
save: 'Save profile',
saved: 'Your profile was saved.',
individual: 'Individual',
corporate: 'Company',
customerType: 'Customer type',
firstName: 'First name',
lastName: 'Last name',
email: 'Email',
province: 'Province',
city: 'City',
address: 'Full address',
postalCode: 'Postal code',
preferredCallTime: 'Preferred call time',
companyName: 'Company name',
nationalId: 'National ID',
economicCode: 'Economic code',
verifiedPhone: 'This verified number cannot be edited.',
orderNumber: 'Order number',
orderDate: 'Created',
lastUpdate: 'Last update',
orderStatus: 'Order status',
paymentStatus: 'Payment status',
details: 'View details',
orderItems: 'Order items',
quantity: 'Quantity',
unitPrice: 'Unit price',
total: 'Total',
modules: 'Modules and options',
timeline: 'Order progress',
invoice: 'Proforma invoice',
invoicePending: 'The invoice appears here after phone review and admin approval.',
downloadPdf: 'Download PDF',
downloadExcel: 'Download Excel',
print: 'Print',
invoiceNumber: 'Invoice number',
issueDate: 'Issue date',
validUntil: 'Valid until',
buyer: 'Buyer',
seller: 'Seller',
subtotal: 'Subtotal',
discount: 'Discount',
shipping: 'Shipping',
tax: 'Tax',
finalTotal: 'Grand total',
sellerName: 'Negin Sazan Pishgam Radmehr Atousa (Robot Market)',
sellerPhone: 'Sales: 09151601847',
emptyOrders: 'No orders have been registered.',
backToOrders: 'Back to orders',
notFound: 'Order not found.',
},
};
+9 -1
View File
@@ -25,6 +25,9 @@ const fa = {
specifications: 'ویژگی‌های دستگاه',
model: 'مدل دستگاه',
gallery: 'تصاویر دستگاه',
supportOrderTitle: 'تماس با پشتیبانی و ثبت سفارش',
supportOrderDescription: 'برای مشاوره خرید، بررسی ظرفیت دستگاه و ثبت سفارش مستقیم با تیم فروش تماس بگیرید.',
callSupport: 'تماس برای خرید',
baseImage: 'نمای اصلی',
alternateImage: 'نمای جایگزین',
developmentTitle: 'روبات مارکت در حال توسعه است',
@@ -60,10 +63,15 @@ export const commerceCopy: Record<Locale, Record<keyof typeof fa, string>> = {
specifications: 'Machine features',
model: 'Machine model',
gallery: 'Machine images',
supportOrderTitle: 'Support and ordering',
supportOrderDescription:
'Call the sales team for purchase guidance, machine capacity review, and direct ordering.',
callSupport: 'Call to order',
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.',
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.',
},
+60 -46
View File
@@ -12,8 +12,8 @@ const machinesFa: Machine[] = [
category: 'warm-vending',
name: 'روبات مارکت ۳۵ کانال',
shortLabel: '۳۵ کانال',
priceLabel: '4,180,000,000 ریال',
priceNumeric: 4180000000,
priceLabel: '4,680,000,000 ریال',
priceNumeric: 4680000000,
description: 'یخچالدار، ۳۵ کانال اختصاصی، سیستم خنک‌کننده قدرتمند، تحویل نرم، مدیریت آنلاین ۲۴ ساعته.',
highlights: ['یخچالدار', '۳۵ کانال اختصاصی', 'تحویل نرم', 'مدیریت آنلاین ۲۴ ساعته'],
image: '/images/machine-35-base.webp',
@@ -25,8 +25,8 @@ const machinesFa: Machine[] = [
category: 'warm-vending',
name: 'روبات مارکت ۴۸ کانال',
shortLabel: '۴۸ کانال',
priceLabel: '4,980,000,000 ریال',
priceNumeric: 4980000000,
priceLabel: '5,480,000,000 ریال',
priceNumeric: 5480000000,
description: 'یخچالدار، ۴۸ کانال اختصاصی، قابلیت‌های پیشرفته مدیریت آنلاین و تحویل کالا.',
highlights: ['یخچالدار', '۴۸ کانال اختصاصی', 'مدیریت آنلاین پیشرفته', 'تحویل هوشمند کالا'],
image: '/images/machine-48-base.webp',
@@ -38,8 +38,8 @@ const machinesFa: Machine[] = [
category: 'warm-vending',
name: 'روبات مارکت ۶۰ کانال',
shortLabel: '۶۰ کانال',
priceLabel: '5,480,000,000 ریال',
priceNumeric: 5480000000,
priceLabel: '5,980,000,000 ریال',
priceNumeric: 5980000000,
description: 'یخچالدار، ۶۰ کانال اختصاصی برای کسب‌وکارهای پرتردد.',
highlights: ['یخچالدار', '۶۰ کانال اختصاصی', 'مناسب اماکن پرتردد', 'ظرفیت بالا'],
image: '/images/machine-60-base.webp',
@@ -54,9 +54,10 @@ const machinesEn: Machine[] = [
category: 'warm-vending',
name: 'Robot Market RM-35',
shortLabel: '35 Channels',
priceLabel: '4,180,000,000 Rials',
priceNumeric: 4180000000,
description: 'Refrigerated, 35 dedicated channels, powerful cooling system, soft delivery, 24/7 online management.',
priceLabel: '4,680,000,000 Rials',
priceNumeric: 4680000000,
description:
'Refrigerated, 35 dedicated channels, powerful cooling system, soft delivery, 24/7 online management.',
highlights: ['Refrigerated', '35 dedicated channels', 'Soft delivery', '24/7 online management'],
image: '/images/machine-35-base.webp',
imageAlt: '/images/machine-35-white.webp',
@@ -67,8 +68,8 @@ const machinesEn: Machine[] = [
category: 'warm-vending',
name: 'Robot Market RM-48',
shortLabel: '48 Channels',
priceLabel: '4,980,000,000 Rials',
priceNumeric: 4980000000,
priceLabel: '5,480,000,000 Rials',
priceNumeric: 5480000000,
description: 'Refrigerated, 48 dedicated channels, advanced online management and delivery capabilities.',
highlights: ['Refrigerated', '48 dedicated channels', 'Advanced online management', 'Smart delivery'],
image: '/images/machine-48-base.webp',
@@ -80,8 +81,8 @@ const machinesEn: Machine[] = [
category: 'warm-vending',
name: 'Robot Market RM-60',
shortLabel: '60 Channels',
priceLabel: '5,480,000,000 Rials',
priceNumeric: 5480000000,
priceLabel: '5,980,000,000 Rials',
priceNumeric: 5980000000,
description: 'Refrigerated, 60 dedicated channels for high-traffic businesses.',
highlights: ['Refrigerated', '60 dedicated channels', 'Ideal for high-traffic venues', 'High capacity'],
image: '/images/machine-60-base.webp',
@@ -96,8 +97,7 @@ export const content: Record<Locale, SiteContent> = {
lang: 'fa',
meta: {
title: 'روبات مارکت | دستگاه‌های وندینگ هوشمند',
description:
'روبات مارکت؛ دستگاه‌های فروش خودکار هوشمند با مدیریت آنلاین، تحویل نرم و طراحی مدرن.',
description: 'روبات مارکت؛ دستگاه‌های فروش خودکار هوشمند با مدیریت آنلاین، تحویل نرم و طراحی مدرن.',
},
nav: {
home: 'خانه',
@@ -140,7 +140,7 @@ export const content: Record<Locale, SiteContent> = {
total: 'جمع کل',
checkout: 'پرداخت / تسویه حساب',
checkoutToast: 'درگاه پرداخت به‌زودی متصل می‌شود.',
addToast: (name) => `${name} به سبد خرید اضافه شد`,
addToast: name => `${name} به سبد خرید اضافه شد`,
},
machineActions: {
addToCart: 'افزودن به سبد خرید',
@@ -148,7 +148,7 @@ export const content: Record<Locale, SiteContent> = {
wishlistRemove: 'حذف از علاقه‌مندی‌ها',
hoverHint: 'نشانگر را روی دستگاه حرکت دهید',
altVariant: 'رنگ جایگزین',
gifAlt: (name) => `نمایش عملکرد ${name}`,
gifAlt: name => `نمایش عملکرد ${name}`,
},
machines: machinesFa,
features: [
@@ -156,25 +156,25 @@ export const content: Record<Locale, SiteContent> = {
id: 'warranty',
icon: 'safety',
title: 'ضمانت کالا',
description: 'کلیه محصولات شرکت دارای یک سال خدمات پس از فروش می‌باشند.'
description: 'کلیه محصولات شرکت دارای یک سال خدمات پس از فروش می‌باشند.',
},
{
id: 'consulting',
icon: 'consulting',
title: 'مشاوره تخصصی و رایگان',
description: 'خدمات مشاوره رایگان در مورد نوع دستگاه و چیدمان محصولات.'
description: 'خدمات مشاوره رایگان در مورد نوع دستگاه و چیدمان محصولات.',
},
{
id: 'installment',
icon: 'installment',
title: 'امکان خرید اقساطی',
description: 'امکان خرید اقساط با شرایط آسان و مناسب جهت کلیه مشاغل.'
description: 'امکان خرید اقساط با شرایط آسان و مناسب جهت کلیه مشاغل.',
},
{
id: 'packaging',
icon: 'packaging',
title: 'بسته‌بندی ایمن',
description: 'با درخواست شما، محصولات با بسته‌بندی باکس پالت با هزینه‌ای اندک ارسال می‌شود.'
description: 'با درخواست شما، محصولات با بسته‌بندی باکس پالت با هزینه‌ای اندک ارسال می‌شود.',
},
],
about: {
@@ -200,43 +200,48 @@ export const content: Record<Locale, SiteContent> = {
id: 'hotels',
icon: 'hotel',
title: 'هتل‌ها و مراکز اقامتی',
description: 'هر جایی که جمعیت زیادی در صف انتظار قرار دارند، به‌جز درآمد، روبات مارکت جزو امکانات رفاهی آن مکان محسوب شده و میزان رضایت مراجعان را نیز افزایش می‌دهد.'
description:
'هر جایی که جمعیت زیادی در صف انتظار قرار دارند، به‌جز درآمد، روبات مارکت جزو امکانات رفاهی آن مکان محسوب شده و میزان رضایت مراجعان را نیز افزایش می‌دهد.',
},
{
id: 'schools',
icon: 'school',
title: 'مراکز آموزشی و مدارس',
description: 'در صورتی که هر یک از دانش‌آموزان و دانشجویان برای خرید مایحتاج خود به خارج از مجموعه مراجعه کنند و خدای‌ناکرده اتفاقی بیفتد، مسئولیت آن مستقیماً به عهده مدیر آموزشگاه است. در نتیجه وجود یک روبات مارکت در هر آموزشگاه ضروری است.'
description:
'در صورتی که هر یک از دانش‌آموزان و دانشجویان برای خرید مایحتاج خود به خارج از مجموعه مراجعه کنند و خدای‌ناکرده اتفاقی بیفتد، مسئولیت آن مستقیماً به عهده مدیر آموزشگاه است. در نتیجه وجود یک روبات مارکت در هر آموزشگاه ضروری است.',
},
{
id: 'pharmacies',
icon: 'pharmacy',
title: 'داروخانه‌ها و سوپرمارکت‌ها',
description: 'برای فروش محصولات جنسی و پیشگیری، ویژه مشتریانی که برای خرید آن معذوریت دارند.'
description: 'برای فروش محصولات جنسی و پیشگیری، ویژه مشتریانی که برای خرید آن معذوریت دارند.',
},
{
id: 'hospitals',
icon: 'hospital',
title: 'بیمارستان‌ها و مراکز درمانی',
description: 'برای فروش محصولات جنسی و پیشگیری، ویژه مشتریانی که برای خرید آن معذوریت دارند.'
description: 'برای فروش محصولات جنسی و پیشگیری، ویژه مشتریانی که برای خرید آن معذوریت دارند.',
},
{
id: 'night-shift',
icon: 'night',
title: 'کشیک شیفت شب',
description: 'امکان تعبیه روبات مارکت پشت شیشه سکوریت مغازه‌ها نیز وجود دارد تا در زمان تعطیلی فروشگاه و حتی نیمه‌شب‌ها، روبات زحمتکش در حال خدمت‌دهی به مشتریان عزیز شما باشد.'
description:
'امکان تعبیه روبات مارکت پشت شیشه سکوریت مغازه‌ها نیز وجود دارد تا در زمان تعطیلی فروشگاه و حتی نیمه‌شب‌ها، روبات زحمتکش در حال خدمت‌دهی به مشتریان عزیز شما باشد.',
},
{
id: 'specialty',
icon: 'specialty',
title: 'محصولات تخصصی',
description: 'اگر تولیدکننده هستید یا نماینده تولیدکننده محصولی خاص هستید، یک روبات مارکت می‌تواند مسئولیت معرفی و عرضه محصولات تخصصی شما را نیز بر عهده بگیرد.'
description:
'اگر تولیدکننده هستید یا نماینده تولیدکننده محصولی خاص هستید، یک روبات مارکت می‌تواند مسئولیت معرفی و عرضه محصولات تخصصی شما را نیز بر عهده بگیرد.',
},
],
},
footer: {
aboutLabel: 'روبات مارکت',
aboutText: 'ارائه‌دهنده دستگاه‌های وندینگ هوشمند برای کسب‌وکارهای مدرن — محصولی از شرکت نگین سازان پیشگام رادمهر آتوسا.',
aboutText:
'ارائه‌دهنده دستگاه‌های وندینگ هوشمند برای کسب‌وکارهای مدرن — محصولی از شرکت نگین سازان پیشگام رادمهر آتوسا.',
quickLinks: 'دسترسی سریع',
contactTitle: 'تماس با ما',
factoryLabel: 'کارخانه',
@@ -246,7 +251,8 @@ export const content: Record<Locale, SiteContent> = {
contactInfo: {
orderPhone: '۰۹۱۵۱۶۰۱۸۴۷',
orderPhoneHref: '09151601847',
factoryAddress: 'مشهد، کیلومتر ۸ جاده مشهد - چناران، دور برگردان اصلی شهرک صنعتی توس به سمت مشهد، بعد از نیروگاه، اولین فرعی سمت راست (خیابان کاله)، اولین کوچه سمت راست، پلاک ۱۱۰، شرکت نگین سازان پیشگام رادمهر آتوسا',
factoryAddress:
'مشهد، کیلومتر ۸ جاده مشهد - چناران، دور برگردان اصلی شهرک صنعتی توس به سمت مشهد، بعد از نیروگاه، اولین فرعی سمت راست (خیابان کاله)، اولین کوچه سمت راست، پلاک ۱۱۰، شرکت نگین سازان پیشگام رادمهر آتوسا',
salesOfficeAddress: 'مشهد، نبش پیامبر اعظم ۷، جنب بیمارستان رضوی، برج نخل، واحد ۴۰۱',
socialLinks: [
{id: 'instagram', label: 'صفحه اینستاگرام', href: 'http://instagram.com/vending_machin_life'},
@@ -305,7 +311,7 @@ export const content: Record<Locale, SiteContent> = {
total: 'Total',
checkout: 'Checkout',
checkoutToast: 'Payment gateway will be connected soon.',
addToast: (name) => `${name} added to cart`,
addToast: name => `${name} added to cart`,
},
machineActions: {
addToCart: 'Add to Cart',
@@ -313,7 +319,7 @@ export const content: Record<Locale, SiteContent> = {
wishlistRemove: 'Remove from Wishlist',
hoverHint: 'Move your cursor over the machine',
altVariant: 'Alternate color',
gifAlt: (name) => `${name} in action`,
gifAlt: name => `${name} in action`,
},
machines: machinesEn,
features: [
@@ -321,25 +327,25 @@ export const content: Record<Locale, SiteContent> = {
id: 'warranty',
icon: 'safety',
title: 'Product Warranty',
description: 'All our products come with one year of after-sales service.'
description: 'All our products come with one year of after-sales service.',
},
{
id: 'consulting',
icon: 'consulting',
title: 'Free Expert Consulting',
description: 'Free consulting on the right machine type and product layout.'
description: 'Free consulting on the right machine type and product layout.',
},
{
id: 'installment',
icon: 'installment',
title: 'Installment Purchase',
description: 'Flexible, easy installment plans suitable for any business.'
description: 'Flexible, easy installment plans suitable for any business.',
},
{
id: 'packaging',
icon: 'packaging',
title: 'Secure Packaging',
description: 'On request, products ship in pallet-box packaging for a small fee.'
description: 'On request, products ship in pallet-box packaging for a small fee.',
},
],
about: {
@@ -350,12 +356,12 @@ export const content: Record<Locale, SiteContent> = {
paragraphs: [
'Negin Sazan Pishgam Radmehr Atousa Co. (registration No. 84094), also known as "Ideh Avaran Pishgam Atousa" (No. 63097) and "Pishgam Gostar Negin Atousa" (No. 73416), began operations in 2017 producing precision saffron-processing machines. After building a nationwide saffron distribution network, in 2022 the company entered the vending-machine industry with saffron-dedicated machines under the "Robot Market" brand.',
'In 2023, cooling capability was added to preserve saffron quality, and Robot Market gained the ability to sell snacks and cold drinks as well. By spring 2024, with the wide rollout of "Robot Market" machines, these robots were introduced as an official retailer of snacks and cold drinks in Iran.',
'Robot Market is not just a name — it is a symbol of a modern, forward-looking way of thinking in the vending industry. What happens inside Robot Market is the result of our engineers\' technical expertise combined with today\'s technology. Every Robot Market machine is a tireless, intelligent assistant that bridges consumer needs and your business goals with precision and speed.',
"Robot Market is not just a name — it is a symbol of a modern, forward-looking way of thinking in the vending industry. What happens inside Robot Market is the result of our engineers' technical expertise combined with today's technology. Every Robot Market machine is a tireless, intelligent assistant that bridges consumer needs and your business goals with precision and speed.",
'Our commitment is to build high-quality machines with modern design and flawless performance, delivering a different experience of shopping and revenue generation. Robot Market stands for trust, innovation, and continuous progress in the vending industry.',
'Hundreds of hours of engineering work and operational testing go into every Robot Market unit. We believe durability and machine lifespan are the most important measures of our customers\' investment. That is why, from selecting the best materials and electronic components to final assembly, we follow strict standards so our machines perform reliably in any condition.',
"Hundreds of hours of engineering work and operational testing go into every Robot Market unit. We believe durability and machine lifespan are the most important measures of our customers' investment. That is why, from selecting the best materials and electronic components to final assembly, we follow strict standards so our machines perform reliably in any condition.",
'Our smart machines offer a simple yet advanced user interface for an enjoyable experience. Secure payment systems and internet connectivity for online management and price monitoring set Robot Market apart from competitors. We have made the purchase process as simple as a few taps, so your customers can access what they need, any time of day, in the shortest time possible. Robot Market is not just a manufacturer — it is a trusted advisor for growing your business, designing automated sales solutions tailored to your needs.',
'Whether you run a large retail chain or manage a small office, our machines let you increase sales without time or location limits, and without worrying about staffing issues.',
'Behind Robot Market\'s specialized, forward-looking products lies technology that replaces foreign alternatives — meeting domestic needs today, and poised to compete meaningfully in export markets in the near future. We take pride in building an Iranian product that competes with global standards — a small but firm step toward self-sufficiency and economic growth.',
"Behind Robot Market's specialized, forward-looking products lies technology that replaces foreign alternatives — meeting domestic needs today, and poised to compete meaningfully in export markets in the near future. We take pride in building an Iranian product that competes with global standards — a small but firm step toward self-sufficiency and economic growth.",
],
},
locations: {
@@ -365,43 +371,50 @@ export const content: Record<Locale, SiteContent> = {
id: 'hotels',
icon: 'hotel',
title: 'Hotels & Accommodations',
description: 'Anywhere large crowds wait in line — beyond the revenue, Robot Market becomes an amenity that increases guest satisfaction.'
description:
'Anywhere large crowds wait in line — beyond the revenue, Robot Market becomes an amenity that increases guest satisfaction.',
},
{
id: 'schools',
icon: 'school',
title: 'Schools & Educational Centers',
description: 'If a student has to leave campus to buy something and, God forbid, an incident occurs, responsibility falls directly on the school administrator. A Robot Market on-site is essential.'
description:
'If a student has to leave campus to buy something and, God forbid, an incident occurs, responsibility falls directly on the school administrator. A Robot Market on-site is essential.',
},
{
id: 'pharmacies',
icon: 'pharmacy',
title: 'Pharmacies & Supermarkets',
description: 'For selling personal/health products discreetly, for customers who prefer not to purchase them in person.'
description:
'For selling personal/health products discreetly, for customers who prefer not to purchase them in person.',
},
{
id: 'hospitals',
icon: 'hospital',
title: 'Hospitals & Medical Centers',
description: 'For selling personal/health products discreetly, for customers who prefer not to purchase them in person.'
description:
'For selling personal/health products discreetly, for customers who prefer not to purchase them in person.',
},
{
id: 'night-shift',
icon: 'night',
title: 'Night-Shift Coverage',
description: 'Robot Market can be installed behind a shop\'s security glass, serving customers even after closing time and in the middle of the night.'
description:
"Robot Market can be installed behind a shop's security glass, serving customers even after closing time and in the middle of the night.",
},
{
id: 'specialty',
icon: 'specialty',
title: 'Specialty Products',
description: 'If you manufacture or represent a specific product line, a Robot Market machine can take on introducing and offering your specialty products too.'
description:
'If you manufacture or represent a specific product line, a Robot Market machine can take on introducing and offering your specialty products too.',
},
],
},
footer: {
aboutLabel: 'Robot Market',
aboutText: 'Smart vending machines for modern businesses — a product of Negin Sazan Pishgam Radmehr Atousa Co.',
aboutText:
'Smart vending machines for modern businesses — a product of Negin Sazan Pishgam Radmehr Atousa Co.',
quickLinks: 'Quick Links',
contactTitle: 'Contact Us',
factoryLabel: 'Factory',
@@ -411,7 +424,8 @@ export const content: Record<Locale, SiteContent> = {
contactInfo: {
orderPhone: '09151601847',
orderPhoneHref: '09151601847',
factoryAddress: 'Mashhad, Mashhad–Chenaran Rd. km 8, main roundabout of Toos Industrial Town toward Mashhad, after the power plant, 1st street on the right (Kaleh St.), 1st alley on the right, No. 110 — Negin Sazan Pishgam Radmehr Atousa Co.',
factoryAddress:
'Mashhad, Mashhad–Chenaran Rd. km 8, main roundabout of Toos Industrial Town toward Mashhad, after the power plant, 1st street on the right (Kaleh St.), 1st alley on the right, No. 110 — Negin Sazan Pishgam Radmehr Atousa Co.',
salesOfficeAddress: 'Mashhad, corner of Payambar-e Azam 7, next to Razavi Hospital, Nakhl Tower, Unit 401',
socialLinks: [
{id: 'instagram', label: 'Instagram', href: 'http://instagram.com/vending_machin_life'},
+1 -1
View File
@@ -4,7 +4,7 @@ services:
context: .
container_name: robot-market
ports:
- "3000:3000"
- '3000:3000'
environment:
NODE_ENV: production
HOSTNAME: 0.0.0.0
-1
View File
@@ -1 +0,0 @@
[{"eventName":"NEXT_CLI_SESSION_STOPPED","payload":{"nextVersion":"14.2.5","nodeVersion":"v26.3.0","cliCommand":"dev","durationMilliseconds":263824,"turboFlag":false,"pagesDir":false,"appDir":true}}]
-3
View File
@@ -1,3 +0,0 @@
{
"pages": {}
}
-16
View File
@@ -1,16 +0,0 @@
{
"polyfillFiles": [
"static/chunks/polyfills.js"
],
"devFiles": [],
"ampDevFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js"
],
"rootMainFiles": [],
"pages": {
"/_app": []
},
"ampFirstPages": []
}
Binary file not shown.
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
{"type": "commonjs"}
-1
View File
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
-1
View File
@@ -1 +0,0 @@
self.__BUILD_MANIFEST={"polyfillFiles":["static/chunks/polyfills.js"],"devFiles":[],"ampDevFiles":[],"lowPriorityFiles":["static/development/_buildManifest.js","static/development/_ssgManifest.js"],"rootMainFiles":[],"pages":{"/_app":[]},"ampFirstPages":[]}
-6
View File
@@ -1,6 +0,0 @@
{
"version": 3,
"middleware": {},
"functions": {},
"sortedMiddleware": []
}
@@ -1 +0,0 @@
self.__REACT_LOADABLE_MANIFEST="{}"
-1
View File
@@ -1 +0,0 @@
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"
-1
View File
@@ -1 +0,0 @@
{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}
-1
View File
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"OV7lo+xPvS/sDjLEddseUuhg7MfbZYxNg/IFGVNi66w=\"\n}"
@@ -1,5 +0,0 @@
{
"node": {},
"edge": {},
"encryptionKey": "OV7lo+xPvS/sDjLEddseUuhg7MfbZYxNg/IFGVNi66w="
}
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
self.__BUILD_MANIFEST = {__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},sortedPages:["\u002F_app"]};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
-1
View File
@@ -1 +0,0 @@
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
-2
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"type": "module"}
@@ -0,0 +1,56 @@
import {describe, expect, it} from 'vitest';
import type {CustomerOrder, CustomerProfile} from '@/types/account';
import {getActiveOrderCount, getOrderItemQuantity, getProfileCompletion} from '../lib/orderSummary';
const completeProfile: CustomerProfile = {
type: 'individual',
phone: '09123456789',
firstName: 'Sara',
lastName: 'Ahmadi',
email: 'sara@example.com',
province: 'Razavi Khorasan',
city: 'Mashhad',
address: 'Main street',
postalCode: '1234567890',
preferredCallTime: '',
companyName: '',
nationalId: '',
economicCode: '',
};
const makeOrder = (status: CustomerOrder['status'], quantities: number[] = [1]): CustomerOrder => ({
id: status,
number: `RM-${status}`,
createdAt: '1405/06/12',
updatedAt: '1405/06/15',
status,
paymentStatus: 'not_payable',
customer: completeProfile,
items: quantities.map((quantity, index) => ({
id: `item-${index}`,
name: 'Robot Market vending machine',
model: 'RM-48',
image: '/images/machine-48-base.webp',
quantity,
unitPrice: 100,
modules: [],
})),
events: [],
});
describe('dashboard order summary helpers', () => {
it('calculates required profile completion percentage', () => {
expect(getProfileCompletion(completeProfile)).toBe(100);
expect(getProfileCompletion({...completeProfile, city: '', postalCode: ''})).toBe(67);
});
it('counts active orders and excludes terminal statuses', () => {
const orders = [makeOrder('under_review'), makeOrder('delivered'), makeOrder('cancelled')];
expect(getActiveOrderCount(orders)).toBe(1);
});
it('totals item quantities for an order card', () => {
expect(getOrderItemQuantity(makeOrder('registered', [1, 2, 4]))).toBe(7);
});
});
@@ -0,0 +1,73 @@
'use client';
import Link from 'next/link';
import {CheckCircleOutlined, ClockCircleOutlined, FileTextOutlined} from '@ant-design/icons';
import {Progress, Skeleton} from 'antd';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import OrderCard from '@/components/dashboard/OrderCard';
import {accountCopy} from '@/data/account';
import {useCustomerOrders} from '../hooks/useCustomerOrders';
import {getActiveOrderCount, getProfileCompletion} from '../lib/orderSummary';
export default function DashboardOverview() {
const auth = useAuth();
const {locale} = useLanguage();
const copy = accountCopy[locale];
const numberLocale = locale === 'fa' ? 'fa-IR' : 'en-US';
const profile = auth.session?.profile;
const orders = useCustomerOrders(profile);
if (!profile || !orders) return <Skeleton active />;
const completion = getProfileCompletion(profile);
const activeOrders = getActiveOrderCount(orders);
const cards = [
{
label: copy.profileCompletion,
value: `${completion.toLocaleString(numberLocale)}٪`,
icon: <CheckCircleOutlined />,
extra: <Progress percent={completion} showInfo={false} size="small" />,
},
{
label: copy.activeOrders,
value: activeOrders.toLocaleString(numberLocale),
icon: <ClockCircleOutlined />,
},
{
label: copy.totalOrders,
value: orders.length.toLocaleString(numberLocale),
icon: <FileTextOutlined />,
},
];
return (
<>
<h1 className="text-3xl font-extrabold text-primary">
{copy.welcome} {profile.firstName}
</h1>
<p className="text-secondary mt-3">{copy.dashboardDescription}</p>
<div className="grid sm:grid-cols-3 gap-4 mt-8">
{cards.map(card => (
<div key={card.label} className="rounded-2xl border border-subtle bg-elevated p-5">
<div className="text-brand-400 text-2xl">{card.icon}</div>
<p className="text-secondary text-sm mt-4">{card.label}</p>
<strong className="text-2xl text-primary block mt-1">{card.value}</strong>
{card.extra && <div className="mt-3">{card.extra}</div>}
</div>
))}
</div>
<div className="flex items-center justify-between gap-4 mt-10 mb-5">
<h2 className="text-xl font-bold text-primary">{copy.recentOrders}</h2>
<Link href="/dashboard/orders" className="text-brand-400">
{copy.viewAll}
</Link>
</div>
<div className="space-y-4">
{orders.slice(0, 2).map(order => (
<OrderCard key={order.id} order={order} />
))}
</div>
</>
);
}
@@ -0,0 +1,34 @@
'use client';
import {Empty, Skeleton} from 'antd';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import OrderCard from '@/components/dashboard/OrderCard';
import {accountCopy} from '@/data/account';
import {useCustomerOrders} from '../hooks/useCustomerOrders';
export default function OrdersList() {
const auth = useAuth();
const {locale} = useLanguage();
const copy = accountCopy[locale];
const profile = auth.session?.profile;
const orders = useCustomerOrders(profile);
return (
<>
<h1 className="text-3xl font-extrabold text-primary">{copy.orders}</h1>
<p className="text-secondary mt-3 mb-7">{copy.dashboardDescription}</p>
{!orders ? (
<Skeleton active />
) : orders.length ? (
<div className="space-y-4">
{orders.map(order => (
<OrderCard key={order.id} order={order} />
))}
</div>
) : (
<Empty description={copy.emptyOrders} />
)}
</>
);
}
@@ -0,0 +1,28 @@
'use client';
import {useEffect, useState} from 'react';
import {getOrders} from '@/services/orders';
import type {CustomerOrder, CustomerProfile} from '@/types/account';
/**
* Loads the signed-in customer's orders after the browser auth profile is available.
*/
export function useCustomerOrders(profile: CustomerProfile | undefined) {
const [orders, setOrders] = useState<CustomerOrder[] | null>(null);
useEffect(() => {
if (!profile) return;
let active = true;
getOrders(profile).then(nextOrders => {
if (active) setOrders(nextOrders);
});
return () => {
active = false;
};
}, [profile]);
return orders;
}
+35
View File
@@ -0,0 +1,35 @@
import type {CustomerOrder, CustomerProfile} from '@/types/account';
const COMPLETE_PROFILE_FIELDS: Array<keyof CustomerProfile> = [
'firstName',
'lastName',
'province',
'city',
'address',
'postalCode',
];
const INACTIVE_ORDER_STATUSES = new Set<CustomerOrder['status']>(['delivered', 'cancelled']);
/**
* Calculates how much of the required buyer profile is ready for order processing.
*/
export function getProfileCompletion(profile: CustomerProfile): number {
const filledFields = COMPLETE_PROFILE_FIELDS.filter(field => Boolean(profile[field]));
return Math.round((filledFields.length / COMPLETE_PROFILE_FIELDS.length) * 100);
}
/**
* Counts orders that still need operational follow-up from the customer or sales team.
*/
export function getActiveOrderCount(orders: CustomerOrder[]): number {
return orders.filter(order => !INACTIVE_ORDER_STATUSES.has(order.status)).length;
}
/**
* Counts all units across an order, regardless of how many product lines it contains.
*/
export function getOrderItemQuantity(order: CustomerOrder): number {
return order.items.reduce((sum, item) => sum + item.quantity, 0);
}
@@ -32,12 +32,12 @@ export default function FloatingItems({items}: {items: FloatingSnack[]}) {
});
});
return () => tweens.forEach((t) => t.kill());
return () => tweens.forEach(t => t.kill());
}, [items]);
return (
<div ref={containerRef} className="absolute inset-0 pointer-events-none hidden md:block">
{items.map((item) => (
{items.map(item => (
<img
key={item.id}
data-float-item
@@ -0,0 +1,92 @@
'use client';
import {useEffect, useRef} from 'react';
import {gsap} from 'gsap';
import {ScrollTrigger} from 'gsap/ScrollTrigger';
import VendingCard from './VendingCard';
import {featuredMachines} from '@/features/products/data/catalog';
import {useLanguage} from '@/app/context/LanguageContext';
gsap.registerPlugin(ScrollTrigger);
export default function HorizontalShowcase() {
const sectionRef = useRef<HTMLElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const backgroundRef = useRef<HTMLDivElement>(null);
const {t} = useLanguage();
useEffect(() => {
const ctx = gsap.context(() => {
const track = trackRef.current;
const section = sectionRef.current;
const background = backgroundRef.current;
if (!track || !section) return;
const scrollDistance = () => Math.max(track.scrollWidth - window.innerWidth, 0);
const backgroundColors = ['#062522', '#0b3d38', '#123d67', '#45235e', '#4a2c18'];
const colorAtProgress = (progress: number) => gsap.utils.interpolate(backgroundColors, progress);
const horizontalTween = gsap.to(track, {
x: () => -scrollDistance(),
ease: 'none',
scrollTrigger: {
trigger: section,
start: 'top top',
end: () => `+=${scrollDistance()}`,
scrub: 1,
pin: true,
invalidateOnRefresh: true,
onUpdate: self => {
if (!background) return;
background.style.backgroundColor = colorAtProgress(self.progress);
background.style.backgroundPosition = `${self.progress * 100}% ${50 + self.progress * 30}%`;
},
},
});
return () => {
horizontalTween.scrollTrigger?.kill();
horizontalTween.kill();
};
}, sectionRef);
return () => ctx.revert();
}, []);
return (
<section id="showcase" ref={sectionRef} className="relative w-full overflow-hidden">
<div
ref={backgroundRef}
className="absolute inset-0 z-0 bg-[#062522] transition-[background-color] duration-300"
style={{
backgroundImage:
'radial-gradient(circle at 18% 22%, rgba(92, 204, 180, 0.42), transparent 31%), radial-gradient(circle at 78% 72%, rgba(255, 177, 92, 0.36), transparent 36%), linear-gradient(125deg, rgba(255,255,255,0.06), transparent 55%)',
backgroundSize: '145% 145%, 145% 145%, 100% 100%',
backgroundPosition: '0% 50%',
}}
>
<div
className="absolute inset-0"
style={{
background:
'linear-gradient(180deg, rgba(4,18,15,0.55) 0%, rgba(7,30,25,0.3) 50%, rgba(4,18,15,0.6) 100%)',
}}
/>
</div>
<div
ref={trackRef}
className="horizontal-track relative z-10 [&_.horizontal-panel]:h-full [&_.spotlight-stage]:max-h-[calc(100vh-4rem)]"
style={{height: '100vh'}}
dir="ltr"
>
{featuredMachines(t.machines).map(machine => (
<VendingCard key={machine.id} machine={machine} />
))}
</div>
</section>
);
}
@@ -0,0 +1,54 @@
'use client';
import {PhoneOutlined} from '@ant-design/icons';
import {featuredMachines} from '@/features/products/data/catalog';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
export default function MobileShowcase() {
const {t, dir, locale} = useLanguage();
const copy = commerceCopy[locale];
const contact = t.contactInfo;
return (
<section id="showcase" className="relative w-full py-8 px-4" dir={dir}>
<div className="flex flex-col gap-4 pb-4">
{featuredMachines(t.machines).map(machine => (
<article key={machine.id} className="w-full bg-surface border border-subtle rounded-2xl p-4">
<div className="flex gap-4 items-center">
{/*<Link href={`/products/${machine.id}`} className="flex gap-4 items-center">*/}
<img src={machine.image} alt={machine.name} className="w-24 h-28 object-contain shrink-0" />
<div className="min-w-0">
<span className="text-brand-400 text-xs font-semibold">{machine.shortLabel}</span>
<h3 className="text-lg font-bold text-primary mt-1">{machine.name}</h3>
<p className="text-sm text-secondary line-clamp-2 mt-2">{machine.description}</p>
</div>
{/*</Link>*/}
</div>
<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">
{copy.details}
</Link>
</div>*/}
</article>
))}
</div>
<div className="mt-3 rounded-2xl border border-subtle bg-elevated p-5 text-center">
<h3 className="text-xl font-bold text-primary">{copy.supportOrderTitle}</h3>
<p className="mt-3 text-sm leading-7 text-secondary">{copy.supportOrderDescription}</p>
<a
href={`tel:${contact.orderPhoneHref}`}
className="mt-5 inline-flex w-full items-center justify-center gap-2 rounded-xl bg-brand px-5 py-3 font-bold text-ink-950 transition-colors hover:bg-brand-300"
dir="ltr"
>
<PhoneOutlined />
<span>{copy.callSupport}</span>
<span>{contact.orderPhone}</span>
</a>
</div>
</section>
);
}
@@ -5,7 +5,7 @@ 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';
import QuantityControl from '@/components/QuantityControl';
export default function ProductDetails({id}: {id: string}) {
const {t, locale} = useLanguage();
@@ -17,40 +17,67 @@ export default function ProductDetails({id}: {id: string}) {
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>
<Link href="/" className="hover:text-brand-400">
{t.nav.home}
</Link>
<span aria-hidden="true">/</span>
<Link href="/products" className="hover:text-brand-400">{t.nav.showcase}</Link>
<span aria-hidden="true">/</span><span>{machine.shortLabel}</span>
<Link href="/products" 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"/>
<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}
<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"/>
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>
<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>
<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>)}
{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>
<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>
@@ -0,0 +1,105 @@
'use client';
import Link from 'next/link';
import {usePathname, useRouter, useSearchParams} from 'next/navigation';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
import {filterMachines, productCategories} from '@/features/products/data/catalog';
import QuantityControl from '@/components/QuantityControl';
export default function ProductsPage() {
const {t, locale} = useLanguage();
const copy = commerceCopy[locale];
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const category = searchParams.get('category') ?? '';
const products = filterMachines(t.machines, category);
const knownCategory = productCategories.some(item => item.id === category);
const setCategory = (value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) params.set('category', value);
else params.delete('category');
// Preserve other query parameters and allow browser Back to restore filters.
const query = params.toString();
router.push(query ? `${pathname}?${query}` : pathname, {scroll: false});
};
return (
<main className="max-w-7xl mx-auto px-5 md:px-8 pt-28 pb-20 min-h-[70vh]">
<h1 className="text-3xl md:text-4xl font-extrabold text-primary">{copy.allProducts}</h1>
<p className="text-secondary mt-4 leading-7">{copy.productsDescription}</p>
<div className="mt-8 mb-8 rounded-2xl border border-subtle bg-surface p-5 flex flex-wrap items-end gap-4">
<div className="flex flex-col gap-2 w-full sm:w-72">
<label htmlFor="product-category" className="text-sm font-semibold text-primary">
{copy.category}
</label>
<select
id="product-category"
value={category}
onChange={event => setCategory(event.target.value)}
className="w-full border border-subtle bg-elevated text-primary rounded-xl px-4 py-3 focus:outline-brand"
>
<option value="">{copy.allCategories}</option>
{category && !knownCategory && <option value={category}>{copy.unknownCategory}</option>}
{productCategories.map(item => (
<option key={item.id} value={item.id}>
{item.label[locale]}
</option>
))}
</select>
</div>
{category && (
<button onClick={() => setCategory('')} className="text-brand-400 py-3 hover:underline">
{copy.clearFilters}
</button>
)}
<p aria-live="polite" aria-atomic="true" className="text-secondary text-sm py-3 sm:ms-auto">
{products.length.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')} {copy.results}
</p>
</div>
{products.length ? (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map(machine => (
<article
key={machine.id}
className="flex flex-col rounded-3xl border border-subtle bg-surface p-5"
>
{/*<Link href={`/products/${machine.id}`} className="rounded-2xl bg-page p-5">*/}
{/* <img src={machine.image} alt={machine.name} className="w-full h-60 object-contain" />*/}
{/*</Link>*/}
<div className="rounded-2xl bg-page p-5">
<img src={machine.image} alt={machine.name} className="w-full h-60 object-contain" />
</div>
<span className="text-brand-400 text-xs mt-5">
{productCategories.find(item => item.id === machine.category)?.label[locale]}
</span>
<h2 className="text-xl font-bold text-primary mt-2">
{/*<Link href={`/products/${machine.id}`}>{machine.name}</Link>*/}
<div>{machine.name}</div>
</h2>
<p className="text-secondary text-sm leading-7 mt-3 mb-5">{machine.description}</p>
<p className="text-primary text-lg font-bold mt-auto mb-4">{machine.priceLabel}</p>
{/*FAZ1*/}
{/*<div className="flex flex-wrap items-center justify-between gap-3">*/}
{/* <QuantityControl product={machine} />*/}
{/* <Link*/}
{/* href={`/products/${machine.id}`}*/}
{/* className="text-brand-400 text-sm hover:underline"*/}
{/* >*/}
{/* {copy.details}*/}
{/* </Link>*/}
{/*</div>*/}
</article>
))}
</div>
) : (
<div className="text-center text-secondary border border-subtle rounded-2xl p-12">
{copy.noProducts}
</div>
)}
</main>
);
}
@@ -0,0 +1,65 @@
'use client';
import {useEffect, useState} from 'react';
import Link from 'next/link';
import {PhoneOutlined} from '@ant-design/icons';
import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce';
import HorizontalShowcase from './HorizontalShowcase';
import MobileShowcase from './MobileShowcase';
const MOBILE_BREAKPOINT = 768;
export default function ShowcaseSection() {
const {t, locale} = useLanguage();
const copy = commerceCopy[locale];
const [isMobile, setIsMobile] = useState<boolean | null>(null);
useEffect(() => {
const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const handleChange = (event: MediaQueryListEvent) => setIsMobile(event.matches);
setIsMobile(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
if (isMobile === null) return null;
if (!isMobile)
return (
<>
<HorizontalShowcase />
<section className="px-6 md:px-16 py-14 bg-surface border-t border-subtle">
<div className="max-w-5xl mx-auto flex flex-wrap items-center justify-between gap-6 rounded-2xl border border-subtle bg-elevated p-7">
<div className="max-w-2xl">
<h3 className="text-2xl font-bold text-primary">{copy.supportOrderTitle}</h3>
<p className="mt-3 text-secondary leading-7">{copy.supportOrderDescription}</p>
</div>
<a
href={`tel:${t.contactInfo.orderPhoneHref}`}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-brand px-6 py-3 font-bold text-ink-950 transition-colors hover:bg-brand-300"
dir="ltr"
>
<PhoneOutlined />
<span>{copy.callSupport}</span>
<span>{t.contactInfo.orderPhone}</span>
</a>
</div>
</section>
</>
);
return (
<>
<div className="max-w-7xl mx-auto px-5 md:px-8 py-8 flex flex-wrap justify-between items-center gap-4">
<h2 className="text-2xl font-bold text-primary">{copy.featuredProducts}</h2>
<Link href="/products" className="text-brand-400 hover:underline">
{copy.allProducts}
</Link>
</div>
<MobileShowcase />
</>
);
}
@@ -1,12 +1,8 @@
'use client';
import type {Machine} from '@/types/domain';
import type {MouseEvent} from 'react';
import {useRef} from 'react';
import Link from 'next/link';
import QuantityControl from './QuantityControl';
import {commerceCopy} from '@/data/commerce';
import {useLanguage} from '@/app/context/LanguageContext';
import FloatingItems from './FloatingItems';
import {floatingSnacks} from '@/data/content';
@@ -56,8 +52,7 @@ export default function VendingCard({machine}: {machine: Machine}) {
/>
<div ref={lensRef} className="spotlight-lens" />
<span
className="absolute hidden md:block top-3 z-30 text-[11px] px-3 py-1 rounded-full bg-brand/15 border border-subtle text-brand-400 start-3">
<span className="absolute hidden md:block top-3 z-30 text-[11px] px-3 py-1 rounded-full bg-brand/15 border border-subtle text-brand-400 start-3">
{t.machineActions.hoverHint}
</span>
</div>
@@ -66,8 +61,7 @@ export default function VendingCard({machine}: {machine: Machine}) {
<span className="text-brand-400 text-sm font-semibold tracking-wide">{machine.shortLabel}</span>
<h3 className="text-2xl md:text-4xl font-extrabold text-primary">{machine.name}</h3>
<div
className="grid grid-cols-[auto,1fr] gap-4 items-start bg-surface border border-subtle rounded-2xl p-4">
<div className="grid grid-cols-[auto,1fr] gap-4 items-start bg-surface border border-subtle rounded-2xl p-4">
<img
src={machine.gif}
alt={t.machineActions.gifAlt(machine.name)}
@@ -77,9 +71,11 @@ export default function VendingCard({machine}: {machine: Machine}) {
</div>
<ul className="flex flex-wrap gap-2">
{machine.highlights.map((h) => (
<li key={h}
className="text-xs md:text-sm text-brand-400 bg-brand/10 border border-subtle rounded-full px-3 py-1">
{machine.highlights.map(h => (
<li
key={h}
className="text-xs md:text-sm text-brand-400 bg-brand/10 border border-subtle rounded-full px-3 py-1"
>
{h}
</li>
))}
@@ -87,12 +83,16 @@ export default function VendingCard({machine}: {machine: Machine}) {
<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 ">
<QuantityControl product={machine}/>
<Link href={`/products/${machine.id}`} className="px-5 py-3 rounded-xl border border-subtle text-primary hover:text-brand-400">
{commerceCopy[locale].details}
</Link>
</div>
{/*FAZ1*/}
{/*<div className="flex flex-wrap items-center gap-2 mt-2 ">*/}
{/* <QuantityControl product={machine} />*/}
{/* <ProtectedLink*/}
{/* href={`/products/${machine.id}`}*/}
{/* className="px-5 py-3 rounded-xl border border-subtle text-primary hover:text-brand-400"*/}
{/* >*/}
{/* {commerceCopy[locale].details}*/}
{/* </ProtectedLink>*/}
{/*</div>*/}
</div>
</div>
</div>
+3
View File
@@ -0,0 +1,3 @@
export {default as ProductDetails} from './components/ProductDetails';
export {default as ProductsPage} from './components/ProductsPage';
export {default as ShowcaseSection} from './components/ShowcaseSection';
+6 -15
View File
@@ -1,6 +1,5 @@
import axios from 'axios';
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'https://api.example.com',
timeout: 15000,
@@ -9,11 +8,9 @@ const api = axios.create({
},
});
api.interceptors.request.use(
(config) => {
const token =
typeof window !== 'undefined' ? window.localStorage.getItem('rm-auth-token') : null;
config => {
const token = typeof window !== 'undefined' ? window.localStorage.getItem('rm-auth-token') : null;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
@@ -22,19 +19,15 @@ api.interceptors.request.use(
// console.log('[API request]', config.method?.toUpperCase(), config.url);
return config;
},
(error) => Promise.reject(error)
error => Promise.reject(error),
);
api.interceptors.response.use(
(response) => response,
response => response,
(error) => {
error => {
if (error.response) {
const status = error.response.status;
if (status === 401) {
@@ -50,10 +43,8 @@ api.interceptors.response.use(
// TODO: Display a network connection error.
}
return Promise.reject(error);
}
},
);
export default api;
+63
View File
@@ -0,0 +1,63 @@
import type {CustomerOrder} from '@/types/account';
export async function downloadInvoicePdf(element: HTMLElement, filename: string) {
const [{default: html2canvas}, {jsPDF}] = await Promise.all([import('html2canvas'), import('jspdf')]);
const canvas = await html2canvas(element, {scale: 2, backgroundColor: '#ffffff', useCORS: true});
const image = canvas.toDataURL('image/jpeg', 0.95);
const pdf = new jsPDF({orientation: 'portrait', unit: 'mm', format: 'a4'});
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const imageHeight = (canvas.height * pageWidth) / canvas.width;
let remaining = imageHeight;
let position = 0;
pdf.addImage(image, 'JPEG', 0, position, pageWidth, imageHeight);
remaining -= pageHeight;
while (remaining > 0) {
position = remaining - imageHeight;
pdf.addPage();
pdf.addImage(image, 'JPEG', 0, position, pageWidth, imageHeight);
remaining -= pageHeight;
}
pdf.save(`${filename}.pdf`);
}
export async function downloadInvoiceExcel(order: CustomerOrder, filename: string) {
const ExcelJS = await import('exceljs');
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('پیش فاکتور', {views: [{rightToLeft: true}]});
sheet.columns = [
{header: 'نام دستگاه', key: 'name', width: 34},
{header: 'مدل', key: 'model', width: 14},
{header: 'امکانات', key: 'modules', width: 44},
{header: 'تعداد', key: 'quantity', width: 10},
{header: 'قیمت واحد (ریال)', key: 'unitPrice', width: 22},
{header: 'جمع (ریال)', key: 'total', width: 22},
];
order.items.forEach(item =>
sheet.addRow({
name: item.name,
model: item.model,
modules: item.modules.join('، '),
quantity: item.quantity,
unitPrice: item.unitPrice,
total: item.unitPrice * item.quantity,
}),
);
const subtotal = order.items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
const total =
subtotal - (order.invoice?.discount || 0) + (order.invoice?.shipping || 0) + (order.invoice?.tax || 0);
sheet.addRow({name: 'مبلغ نهایی', total});
sheet.getRow(1).font = {bold: true, color: {argb: 'FFFFFFFF'}};
sheet.getRow(1).fill = {type: 'pattern', pattern: 'solid', fgColor: {argb: 'FF00A693'}};
sheet.getRow(sheet.rowCount).font = {bold: true, color: {argb: 'FF006D61'}};
sheet.getColumn('unitPrice').numFmt = '#,##0';
sheet.getColumn('total').numFmt = '#,##0';
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${filename}.xlsx`;
link.click();
URL.revokeObjectURL(url);
}
+1 -7
View File
@@ -5,13 +5,7 @@ const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
const nextConfig = {
reactStrictMode: true,
transpilePackages: [
'antd',
'@ant-design/icons',
'rc-util',
'rc-pagination',
'rc-picker'
],
transpilePackages: ['antd', '@ant-design/icons', 'rc-util', 'rc-pagination', 'rc-picker'],
output: 'standalone',
+4127 -6
View File
File diff suppressed because it is too large Load Diff
+19 -2
View File
@@ -3,30 +3,47 @@
"version": "1.0.0",
"private": true,
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check .",
"predev": "npm run format",
"dev": "next dev -H 0.0.0.0",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@ant-design/icons": "5.3.7",
"@reduxjs/toolkit": "2.2.7",
"antd": "5.19.1",
"axios": "1.7.2",
"exceljs": "^4.4.0",
"gsap": "3.12.5",
"html2canvas": "^1.4.1",
"jspdf": "^2.5.2",
"next": "14.2.5",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-redux": "9.1.2"
},
"devDependencies": {
"@playwright/test": "^1.49.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "20.19.43",
"@types/react": "18.3.31",
"@types/react-dom": "18.3.7",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "10.4.19",
"jsdom": "^25.0.1",
"postcss": "8.4.39",
"prettier": "3.9.6",
"tailwindcss": "3.4.6",
"typescript": "5.9.3"
"typescript": "5.9.3",
"vitest": "^3.2.4"
}
}
+20
View File
@@ -0,0 +1,20 @@
import {defineConfig, devices} from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
webServer: {
command: 'npx next dev -H 127.0.0.1',
url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://127.0.0.1:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: {...devices['Desktop Chrome']},
},
],
});
+61
View File
@@ -0,0 +1,61 @@
import type {AuthSession, CustomerProfile} from '@/types/account';
export const AUTH_STORAGE_KEY = 'rm-auth-session';
const DEMO_PHONE = '09123456789';
const DEMO_PASSWORD = '123456';
const DEMO_OTP = '12345';
const wait = (milliseconds = 450) => new Promise(resolve => setTimeout(resolve, milliseconds));
const emptyProfile = (phone: string): CustomerProfile => ({
type: 'individual',
phone,
firstName: '',
lastName: '',
email: '',
province: '',
city: '',
address: '',
postalCode: '',
preferredCallTime: '',
companyName: '',
nationalId: '',
economicCode: '',
});
export function isIranianMobile(phone: string) {
return /^09\d{9}$/.test(phone.replace(/\s/g, ''));
}
export async function requestOtp(phone: string): Promise<{expiresIn: number}> {
await wait();
if (!isIranianMobile(phone)) throw new Error('INVALID_PHONE');
/*
POST /auth/otp/request
Request: { "phone": "09123456789" }
Response: { "success": true, "expiresIn": 120 }
*/
return {expiresIn: 120};
}
export async function loginWithOtp(phone: string, code: string): Promise<AuthSession> {
await wait();
if (!isIranianMobile(phone) || code !== DEMO_OTP) throw new Error('INVALID_CREDENTIALS');
/*
POST /auth/otp/verify
Request: { "phone": "09123456789", "code": "12345" }
Response: { "accessToken": "...", "user": { "phone": "09123456789", "firstName": "", "lastName": "" } }
In production, prefer a Secure, HttpOnly session cookie instead of localStorage.
*/
return {accessToken: `mock-${phone}`, profile: emptyProfile(phone)};
}
export async function loginWithPassword(phone: string, password: string): Promise<AuthSession> {
await wait();
if (phone !== DEMO_PHONE || password !== DEMO_PASSWORD) throw new Error('INVALID_CREDENTIALS');
/*
POST /auth/password/login
Request: { "phone": "09123456789", "password": "123456" }
Response: { "accessToken": "...", "user": { "phone": "09123456789", "firstName": "حامد", "lastName": "عبدالله‌زاده" } }
*/
return {accessToken: `mock-${phone}`, profile: {...emptyProfile(phone), firstName: 'کاربر', lastName: 'آزمایشی'}};
}
+106
View File
@@ -0,0 +1,106 @@
import type {CustomerOrder, CustomerProfile} from '@/types/account';
const wait = () => new Promise(resolve => setTimeout(resolve, 300));
/**
* Builds demo orders with the latest profile fields until the real orders API is connected.
*/
function createOrders(customer: CustomerProfile): CustomerOrder[] {
const buyer = {
...customer,
firstName: customer.firstName || 'کاربر',
lastName: customer.lastName || 'روبات مارکت',
city: customer.city || 'مشهد',
address: customer.address || 'نشانی خریدار پس از تکمیل پروفایل ثبت می‌شود',
};
return [
{
id: 'order-128',
number: 'RM-1405-00128',
createdAt: '1405/06/12',
updatedAt: '1405/06/15',
status: 'proforma_issued',
paymentStatus: 'awaiting_payment',
customer: buyer,
items: [
{
id: 'rm-48',
name: 'دستگاه وندینگ روبات مارکت ۴۸',
model: 'RM-48',
image: '/images/machine-48-base.webp',
quantity: 1,
unitPrice: 4850000000,
modules: ['کارت‌خوان بانکی', 'سیستم سرمایش', 'برچسب اختصاصی برند'],
},
],
events: [
{status: 'registered', date: '1405/06/12', note: 'درخواست شما ثبت شد.', completed: true},
{
status: 'under_review',
date: '1405/06/13',
note: 'کارشناس فروش درخواست را بررسی کرد.',
completed: true,
},
{status: 'approved', date: '1405/06/14', note: 'جزئیات سفارش تلفنی تأیید شد.', completed: true},
{status: 'proforma_issued', date: '1405/06/15', note: 'پیش‌فاکتور قابل دریافت است.', completed: true},
],
invoice: {
number: 'PF-1405-0081',
issueDate: '1405/06/15',
validUntil: '1405/06/22',
approvedAt: '1405/06/15',
discount: 50000000,
shipping: 25000000,
tax: 480000000,
},
},
{
id: 'order-127',
number: 'RM-1405-00127',
createdAt: '1405/06/08',
updatedAt: '1405/06/10',
status: 'under_review',
paymentStatus: 'not_payable',
customer: buyer,
items: [
{
id: 'rm-35',
name: 'دستگاه وندینگ روبات مارکت ۳۵',
model: 'RM-35',
image: '/images/machine-35-base.webp',
quantity: 2,
unitPrice: 3950000000,
modules: ['کارت‌خوان بانکی'],
},
],
events: [
{status: 'registered', date: '1405/06/08', note: 'درخواست شما ثبت شد.', completed: true},
{
status: 'under_review',
date: '1405/06/10',
note: 'کارشناس برای هماهنگی با شما تماس می‌گیرد.',
completed: true,
},
],
},
];
}
export async function getOrders(customer: CustomerProfile): Promise<CustomerOrder[]> {
await wait();
/*
GET /orders
Response: { "orders": [{ "id": "order-128", "number": "RM-1405-00128", "status": "proforma_issued", "paymentStatus": "awaiting_payment" }] }
*/
return createOrders(customer);
}
export async function getOrder(id: string, customer: CustomerProfile): Promise<CustomerOrder | undefined> {
await wait();
/*
GET /orders/:id
Response: { "order": { "id": "order-128", "items": [], "events": [], "invoice": { "approvedAt": "1405/06/15" } } }
The backend must verify that the signed-in customer owns this order.
*/
return createOrders(customer).find(order => order.id === id);
}
+13
View File
@@ -0,0 +1,13 @@
import type {CustomerProfile} from '@/types/account';
const wait = () => new Promise(resolve => setTimeout(resolve, 350));
export async function saveProfile(profile: CustomerProfile): Promise<CustomerProfile> {
await wait();
/*
PATCH /me
Request: { "type": "corporate", "firstName": "...", "lastName": "...", "companyName": "...", "address": "..." }
Response: { "user": { "phone": "09123456789", "type": "corporate", "firstName": "...", "lastName": "..." } }
*/
return {...profile};
}
+10 -18
View File
@@ -16,7 +16,7 @@ const cartSlice = createSlice({
},
addToCart: (state, action: PayloadAction<CartProduct>) => {
const product = action.payload;
const existing = state.items.find((item) => item.id === product.id);
const existing = state.items.find(item => item.id === product.id);
if (existing) {
existing.qty += 1;
} else {
@@ -24,46 +24,38 @@ const cartSlice = createSlice({
}
},
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);
},
setQuantity: (state, action: PayloadAction<{id: string; qty: number}>) => {
const {id, qty} = action.payload;
if (!Number.isSafeInteger(qty) || qty < 1) return;
const item = state.items.find((item) => item.id === id);
const item = state.items.find(item => item.id === id);
if (item) item.qty = qty;
},
incrementQty: (state, action: PayloadAction<string>) => {
const item = state.items.find((item) => item.id === action.payload);
const item = state.items.find(item => item.id === action.payload);
if (item) item.qty += 1;
},
decrementQty: (state, action: PayloadAction<string>) => {
const item = state.items.find((item) => item.id === action.payload);
const item = state.items.find(item => item.id === action.payload);
if (item && item.qty > 1) {
item.qty -= 1;
} else if (item) {
state.items = state.items.filter((i) => i.id !== action.payload);
state.items = state.items.filter(i => i.id !== action.payload);
}
},
clearCart: (state) => {
clearCart: state => {
state.items = [];
},
},
});
export const {
restoreCart,
addToCart,
removeFromCart,
setQuantity,
incrementQty,
decrementQty,
clearCart,
} = cartSlice.actions;
export const {restoreCart, addToCart, removeFromCart, setQuantity, incrementQty, decrementQty, clearCart} =
cartSlice.actions;
// Selectors
export const selectCartItems = (state: RootState) => state.cart.items;
export const selectCartCount = (state: RootState) =>
state.cart.items.reduce((sum, item) => sum + item.qty, 0);
export const selectCartCount = (state: RootState) => state.cart.items.reduce((sum, item) => sum + item.qty, 0);
export const selectCartTotal = (state: RootState) =>
state.cart.items.reduce((sum, item) => sum + item.qty * item.priceNumeric, 0);
+2 -2
View File
@@ -12,5 +12,5 @@ export const makeStore = () =>
export const store = makeStore();
export type AppStore = ReturnType<typeof makeStore>;
export type RootState = ReturnType<AppStore["getState"]>;
export type AppDispatch = AppStore["dispatch"];
export type RootState = ReturnType<AppStore['getState']>;
export type AppDispatch = AppStore['dispatch'];
+1 -4
View File
@@ -1,10 +1,7 @@
import type {Config} from 'tailwindcss';
const config: Config = {
darkMode: 'class',
content: [
'./app/**/*.{js,jsx,ts,tsx}',
'./components/**/*.{js,jsx,ts,tsx}',
],
content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}', './features/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {
colors: {
+9
View File
@@ -0,0 +1,9 @@
import {expect, test} from '@playwright/test';
test('home page exposes the primary shopping journey', async ({page}) => {
await page.goto('/');
await expect(page).toHaveTitle(/Robot Market|روبات مارکت/);
await expect(page.locator('a[href="#showcase"]').first()).toBeVisible();
await expect(page.locator('a[href="https://panel.my-rm.com"]').first()).toBeVisible();
});
+18 -1
View File
@@ -17,6 +17,23 @@
"baseUrl": ".",
"paths": {"@/*": ["./*"]}
},
"include": ["next-env.d.ts", "app/**/*.ts", "app/**/*.tsx", "components/**/*.tsx", "data/**/*.ts", "lib/**/*.ts", "store/**/*.ts", "types/**/*.ts", "tailwind.config.ts", ".next/types/**/*.ts"],
"include": [
"next-env.d.ts",
"app/**/*.ts",
"app/**/*.tsx",
"components/**/*.tsx",
"data/**/*.ts",
"features/**/*.ts",
"features/**/*.tsx",
"lib/**/*.ts",
"tests/**/*.ts",
"store/**/*.ts",
"types/**/*.ts",
"tailwind.config.ts",
"vitest.config.ts",
"vitest.setup.ts",
"playwright.config.ts",
".next/types/**/*.ts"
],
"exclude": ["node_modules", "docs", "out"]
}
+75
View File
@@ -0,0 +1,75 @@
export type CustomerType = 'individual' | 'corporate';
export type OrderStatus =
| 'registered'
| 'under_review'
| 'needs_information'
| 'approved'
| 'proforma_issued'
| 'awaiting_payment'
| 'preparing'
| 'ready_to_ship'
| 'shipped'
| 'delivered'
| 'cancelled';
export type PaymentStatus = 'not_payable' | 'awaiting_payment' | 'paid' | 'failed';
export interface CustomerProfile {
type: CustomerType;
phone: string;
firstName: string;
lastName: string;
email: string;
province: string;
city: string;
address: string;
postalCode: string;
preferredCallTime: string;
companyName: string;
nationalId: string;
economicCode: string;
}
export interface AuthSession {
accessToken: string;
profile: CustomerProfile;
}
export interface OrderItem {
id: string;
name: string;
model: string;
image: string;
quantity: number;
unitPrice: number;
modules: string[];
}
export interface OrderEvent {
status: OrderStatus;
date: string;
note: string;
completed: boolean;
}
export interface ProformaInvoice {
number: string;
issueDate: string;
validUntil: string;
approvedAt: string;
discount: number;
shipping: number;
tax: number;
}
export interface CustomerOrder {
id: string;
number: string;
createdAt: string;
updatedAt: string;
status: OrderStatus;
paymentStatus: PaymentStatus;
items: OrderItem[];
customer: CustomerProfile;
events: OrderEvent[];
invoice?: ProformaInvoice;
}

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