Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf6cb67fcf | ||
|
|
54c387bf73 | ||
|
|
57b0cfeac8 | ||
|
|
fb2c394433 | ||
|
|
0381c8b73c | ||
|
|
b93c780fea | ||
|
|
92a3f014a6 | ||
|
|
4c8da3765d | ||
|
|
ae6018a1f0 | ||
|
|
fcded6f139 | ||
|
|
5d9fe204e7 | ||
|
|
1ad215e1bf | ||
|
|
217fe01de8 | ||
|
|
9c54fb1cea | ||
|
|
a4e3634176 | ||
|
|
a7aa278608 | ||
|
|
6cfeff85b6 | ||
|
|
e6103ef6fd | ||
|
|
dc64b41c80 | ||
|
|
812029fe84 | ||
|
|
7c55b3916c | ||
|
|
f49ef11c9c | ||
|
|
421760dc62 | ||
|
|
0390a35d01 | ||
|
|
4cd34106b0 | ||
|
|
1ef621f832 |
+1
-7
@@ -1,8 +1,2 @@
|
|||||||
# Copy this file to .env.local and fill in your real keys.
|
# Optional backend API base URL. Copy to .env.local to configure.
|
||||||
|
|
||||||
# Neshan (نشان) map API key — get one from https://platform.neshan.org
|
|
||||||
# Used by components/MapSection.jsx for the static map image.
|
|
||||||
NEXT_PUBLIC_NESHAN_API_KEY=
|
|
||||||
|
|
||||||
|
|
||||||
NEXT_PUBLIC_API_BASE_URL=https://your-real-backend.com
|
NEXT_PUBLIC_API_BASE_URL=https://your-real-backend.com
|
||||||
@@ -20,17 +20,20 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Resolve commit SHA
|
- name: Resolve image tag
|
||||||
id: vars
|
id: vars
|
||||||
run: |
|
run: |
|
||||||
# For PRs, gitea.sha is a temporary merge commit — use the real
|
# For PRs, gitea.sha is a temporary merge commit — use the real
|
||||||
# PR head commit instead so tags map to actual git history.
|
# PR head commit instead so tags map to actual git history.
|
||||||
FULL_SHA="${{ gitea.event.pull_request.head.sha || gitea.sha }}"
|
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: |
|
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
|
- name: Save previous tag for rollback
|
||||||
id: backup
|
id: backup
|
||||||
@@ -42,7 +45,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Point compose at this build
|
- name: Point compose at this build
|
||||||
run: |
|
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
|
- name: Deploy via compose
|
||||||
id: deploy
|
id: deploy
|
||||||
@@ -85,7 +88,7 @@ jobs:
|
|||||||
- name: Clean up dangling (untagged) layers
|
- name: Clean up dangling (untagged) layers
|
||||||
run: docker image prune -f
|
run: docker image prune -f
|
||||||
|
|
||||||
- name: Prune old SHA-tagged images (keep last 5)
|
- name: Prune old build-tagged images (keep last 5)
|
||||||
run: |
|
run: |
|
||||||
KEEP=5
|
KEEP=5
|
||||||
CURRENT_TAG=$(grep IMAGE_TAG /opt/robot-market-front/.env 2>/dev/null | cut -d= -f2)
|
CURRENT_TAG=$(grep IMAGE_TAG /opt/robot-market-front/.env 2>/dev/null | cut -d= -f2)
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ name: Rollback (develop)
|
|||||||
|
|
||||||
# Trigger manually from the Gitea Actions UI.
|
# Trigger manually from the Gitea Actions UI.
|
||||||
# Leave "sha" empty to roll back to the previous deploy.
|
# 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:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
sha:
|
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
|
required: false
|
||||||
default: ""
|
default: ''
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
rollback:
|
rollback:
|
||||||
@@ -18,7 +19,15 @@ jobs:
|
|||||||
- name: Determine target tag
|
- name: Determine target tag
|
||||||
run: |
|
run: |
|
||||||
if [ -n "${{ gitea.event.inputs.sha }}" ]; then
|
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
|
else
|
||||||
TAG=$(grep IMAGE_TAG /opt/robot-market-front/.env.previous | cut -d= -f2)
|
TAG=$(grep IMAGE_TAG /opt/robot-market-front/.env.previous | cut -d= -f2)
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ out/
|
|||||||
.env*.local
|
.env*.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
Generated
+8
@@ -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>
|
||||||
Generated
+224
-58
@@ -4,9 +4,9 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="feat: add landing page device images and fix machine branding">
|
<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$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/public/images/machine-slide-2.webp" beforeDir="false" afterPath="$PROJECT_DIR$/public/images/machine-slide-2.webp" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/data/content.ts" beforeDir="false" afterPath="$PROJECT_DIR$/data/content.ts" afterDir="false" />
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<component name="Git.Settings">
|
<component name="Git.Settings">
|
||||||
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
||||||
<map>
|
<map>
|
||||||
<entry key="$PROJECT_DIR$" value="develop" />
|
<entry key="$PROJECT_DIR$" value="Implement" />
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
@@ -28,61 +28,61 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="NextEditCompletionFeaturesState">
|
<component name="NextEditCompletionFeaturesState">
|
||||||
<decayedCancelled>
|
<decayedCancelled>
|
||||||
<entry key="MS100" value="1.0105021478132723" />
|
<entry key="MS100" value="1.0" />
|
||||||
<entry key="MS500" value="1.6326904583013002" />
|
<entry key="MS500" value="1.0" />
|
||||||
<entry key="S2" value="3.200495115489667" />
|
<entry key="S2" value="1.000087252972038" />
|
||||||
<entry key="S5" value="4.148160592733714" />
|
<entry key="S5" value="1.0245081193731238" />
|
||||||
<entry key="S10" value="4.983360398199462" />
|
<entry key="S10" value="1.2033303929041042" />
|
||||||
<entry key="S30" value="7.2170837140275825" />
|
<entry key="S30" value="2.526416819401647" />
|
||||||
<entry key="S60" value="9.732495501143132" />
|
<entry key="S60" value="4.055177645414961" />
|
||||||
<entry key="M2" value="14.025681297470134" />
|
<entry key="M2" value="6.613842117661979" />
|
||||||
<entry key="M5" value="22.17637857625762" />
|
<entry key="M5" value="13.292786284149477" />
|
||||||
<entry key="M10" value="27.702751951097298" />
|
<entry key="M10" value="19.047637193847063" />
|
||||||
<entry key="M15" value="30.22928327611057" />
|
<entry key="M15" value="21.88400324113337" />
|
||||||
<entry key="M30" value="34.21976380295617" />
|
<entry key="M30" value="25.442136814414752" />
|
||||||
<entry key="H1" value="38.60682034968035" />
|
<entry key="H1" value="27.80466751680738" />
|
||||||
<entry key="H2" value="42.59562362296086" />
|
<entry key="H2" value="32.05099449690078" />
|
||||||
<entry key="H4" value="45.40713861912438" />
|
<entry key="H4" value="42.01119466369274" />
|
||||||
<entry key="D1" value="48.33720180411775" />
|
<entry key="D1" value="68.95561233852308" />
|
||||||
<entry key="W1" value="48.90360237164015" />
|
<entry key="W1" value="77.42240766540701" />
|
||||||
</decayedCancelled>
|
</decayedCancelled>
|
||||||
<decayedSelected>
|
<decayedSelected>
|
||||||
<entry key="MS100" value="0.0" />
|
<entry key="MS100" value="0.0" />
|
||||||
<entry key="MS500" value="8.86528819923573E-248" />
|
<entry key="MS500" value="6.173165109562985E-278" />
|
||||||
<entry key="S2" value="1.7255327824639203E-62" />
|
<entry key="S2" value="4.98456166633934E-70" />
|
||||||
<entry key="S5" value="1.9713751865934254E-25" />
|
<entry key="S5" value="1.9013006178680956E-28" />
|
||||||
<entry key="S10" value="4.440017101986686E-13" />
|
<entry key="S10" value="1.3788900274450676E-14" />
|
||||||
<entry key="S30" value="7.628893420815487E-5" />
|
<entry key="S30" value="2.4491942588613115E-5" />
|
||||||
<entry key="S60" value="0.008734353680047248" />
|
<entry key="S60" value="0.00561270246753653" />
|
||||||
<entry key="M2" value="0.09345776415069663" />
|
<entry key="M2" value="0.09687308384134666" />
|
||||||
<entry key="M5" value="0.387477201784943" />
|
<entry key="M5" value="0.6088965925604509" />
|
||||||
<entry key="M10" value="0.6224766676630884" />
|
<entry key="M10" value="1.2419655618123104" />
|
||||||
<entry key="M15" value="0.7290355699678522" />
|
<entry key="M15" value="1.625109194802163" />
|
||||||
<entry key="M30" value="0.8538357980126227" />
|
<entry key="M30" value="2.1768432348200197" />
|
||||||
<entry key="H1" value="0.9240323576653702" />
|
<entry key="H1" value="2.557178317988167" />
|
||||||
<entry key="H2" value="0.9612660181580177" />
|
<entry key="H2" value="2.9129118025914487" />
|
||||||
<entry key="H4" value="0.9804417464378067" />
|
<entry key="H4" value="3.4288442997263706" />
|
||||||
<entry key="D1" value="0.9967134048485689" />
|
<entry key="D1" value="4.592699670680046" />
|
||||||
<entry key="W1" value="0.9995298237292167" />
|
<entry key="W1" value="4.936597530104908" />
|
||||||
</decayedSelected>
|
</decayedSelected>
|
||||||
<decayedShown>
|
<decayedShown>
|
||||||
<entry key="MS100" value="0.01712637149186048" />
|
<entry key="MS100" value="0.4444213405832851" />
|
||||||
<entry key="MS500" value="0.9540168467150943" />
|
<entry key="MS500" value="0.8502741600641959" />
|
||||||
<entry key="S2" value="2.9136793264313505" />
|
<entry key="S2" value="0.9603446084215367" />
|
||||||
<entry key="S5" value="4.013649896020289" />
|
<entry key="S5" value="1.0078897996704945" />
|
||||||
<entry key="S10" value="4.909460420460746" />
|
<entry key="S10" value="1.1930999599005467" />
|
||||||
<entry key="S30" value="7.187579804050971" />
|
<entry key="S30" value="2.5186943108989044" />
|
||||||
<entry key="S60" value="9.723843431274007" />
|
<entry key="S60" value="4.054093611688289" />
|
||||||
<entry key="M2" value="14.107987700307802" />
|
<entry key="M2" value="6.702385865993853" />
|
||||||
<entry key="M5" value="22.55725525698723" />
|
<entry key="M5" value="13.890469482072719" />
|
||||||
<entry key="M10" value="28.321163439518607" />
|
<entry key="M10" value="20.28012126997275" />
|
||||||
<entry key="M15" value="30.955381308867647" />
|
<entry key="M15" value="23.50144975042241" />
|
||||||
<entry key="M30" value="35.07198888146704" />
|
<entry key="M30" value="27.614279851343763" />
|
||||||
<entry key="H1" value="39.52999740754272" />
|
<entry key="H1" value="30.359221991616337" />
|
||||||
<entry key="H2" value="43.55644480884343" />
|
<entry key="H2" value="34.96247820400055" />
|
||||||
<entry key="H4" value="46.38735262517173" />
|
<entry key="H4" value="45.43924535528764" />
|
||||||
<entry key="D1" value="49.33387639135037" />
|
<entry key="D1" value="73.5481501056019" />
|
||||||
<entry key="W1" value="49.903126627030524" />
|
<entry key="W1" value="82.35898081352131" />
|
||||||
</decayedShown>
|
</decayedShown>
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectColorInfo">{
|
<component name="ProjectColorInfo">{
|
||||||
@@ -90,6 +90,9 @@
|
|||||||
"associatedIndex": 2
|
"associatedIndex": 2
|
||||||
}</component>
|
}</component>
|
||||||
<component name="ProjectId" id="3HDEfy1HyzjDcELlwvnRRXxPltq" />
|
<component name="ProjectId" id="3HDEfy1HyzjDcELlwvnRRXxPltq" />
|
||||||
|
<component name="ProjectLevelVcsManager">
|
||||||
|
<ConfirmationsSetting value="2" id="Add" />
|
||||||
|
</component>
|
||||||
<component name="ProjectViewState">
|
<component name="ProjectViewState">
|
||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
<option name="showLibraryContents" value="true" />
|
<option name="showLibraryContents" value="true" />
|
||||||
@@ -114,18 +117,22 @@
|
|||||||
"kronk.content.tree.expanded.paths": "",
|
"kronk.content.tree.expanded.paths": "",
|
||||||
"last_opened_file_path": "/home/hamed/Documents/Projects/GItLabRepo/device_web",
|
"last_opened_file_path": "/home/hamed/Documents/Projects/GItLabRepo/device_web",
|
||||||
"node.js.detected.package.eslint": "true",
|
"node.js.detected.package.eslint": "true",
|
||||||
|
"node.js.detected.package.jshint": "true",
|
||||||
"node.js.detected.package.tslint": "true",
|
"node.js.detected.package.tslint": "true",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
|
"node.js.selected.package.jshint": "",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
"nodejs_package_manager_path": "npm",
|
"nodejs_package_manager_path": "npm",
|
||||||
"settings.editor.selected.configurable": "editor.preferences.fonts.default",
|
"prettierjs.PrettierConfiguration.Package": "/home/hamed/Documents/Projects/GIthubRepo/robot-market-landing/node_modules/prettier",
|
||||||
|
"settings.editor.selected.configurable": "preferences.lookFeel",
|
||||||
"to.speed.mode.migration.done": "true",
|
"to.speed.mode.migration.done": "true",
|
||||||
"ts.external.directory.path": "C:\\Program Files\\JetBrains\\WebStorm 2026.1.1\\plugins\\javascript-plugin\\jsLanguageServicesImpl\\external",
|
"ts.external.directory.path": "/home/hamed/Documents/Projects/GIthubRepo/robot-market-landing/node_modules/typescript/lib",
|
||||||
"vue.rearranger.settings.migration": "true"
|
"vue.rearranger.settings.migration": "true"
|
||||||
}
|
}
|
||||||
}</component>
|
}</component>
|
||||||
<component name="RecentsManager">
|
<component name="RecentsManager">
|
||||||
<key name="MoveFile.RECENT_KEYS">
|
<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\icons" />
|
||||||
<recent name="D:\Project\GitHubRepo\robot-market-landing\public\images" />
|
<recent name="D:\Project\GitHubRepo\robot-market-landing\public\images" />
|
||||||
<recent name="D:\Project\GitHubRepo\robot-market-landing\app" />
|
<recent name="D:\Project\GitHubRepo\robot-market-landing\app" />
|
||||||
@@ -204,7 +211,22 @@
|
|||||||
<workItem from="1789361888102" duration="68000" />
|
<workItem from="1789361888102" duration="68000" />
|
||||||
<workItem from="1789362025574" duration="23000" />
|
<workItem from="1789362025574" duration="23000" />
|
||||||
<workItem from="1789362137185" duration="377000" />
|
<workItem from="1789362137185" duration="377000" />
|
||||||
<workItem from="1789362644534" duration="4681000" />
|
<workItem from="1789362644534" duration="6512000" />
|
||||||
|
<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>
|
||||||
<task id="LOCAL-00001" summary="update">
|
<task id="LOCAL-00001" summary="update">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
@@ -462,11 +484,144 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1789368598921</updated>
|
<updated>1789368598921</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="33" />
|
<task id="LOCAL-00033" summary="feat: add landing page device images and fix machine branding">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1789369799782</created>
|
||||||
|
<option name="number" value="00033" />
|
||||||
|
<option name="presentableId" value="LOCAL-00033" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1789369799782</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00034" summary="refactor: remove disabled home sections and their unused assets">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1789384147873</created>
|
||||||
|
<option name="number" value="00034" />
|
||||||
|
<option name="presentableId" value="LOCAL-00034" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1789384147873</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00035" summary="refactor: migrate project to TypeScript and translate comments to English">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1789385239281</created>
|
||||||
|
<option name="number" value="00035" />
|
||||||
|
<option name="presentableId" value="LOCAL-00035" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1789385239281</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00036" summary="feat: add cart and product pages, quantity controls, and first-visit development notice">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1789449280006</created>
|
||||||
|
<option name="number" value="00036" />
|
||||||
|
<option name="presentableId" value="LOCAL-00036" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1789449280006</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00037" summary="feat: add cart and product pages, quantity controls, and first-visit development notice">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1789451442320</created>
|
||||||
|
<option name="number" value="00037" />
|
||||||
|
<option name="presentableId" value="LOCAL-00037" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1789451442320</updated>
|
||||||
|
</task>
|
||||||
|
<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 />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
<option name="version" value="3" />
|
<option name="version" value="3" />
|
||||||
|
<option name="exactExcludedFiles">
|
||||||
|
<list>
|
||||||
|
<option value="$PROJECT_DIR$/components/AboutSection.jsx" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
</component>
|
</component>
|
||||||
<component name="UnknownFeatures">
|
<component name="UnknownFeatures">
|
||||||
<option featureType="com.intellij.fileTypeFactory" implementationName="next.config.js" />
|
<option featureType="com.intellij.fileTypeFactory" implementationName="next.config.js" />
|
||||||
@@ -478,6 +633,17 @@
|
|||||||
<MESSAGE value="initial new branch" />
|
<MESSAGE value="initial new branch" />
|
||||||
<MESSAGE value="update" />
|
<MESSAGE value="update" />
|
||||||
<MESSAGE value="feat: add landing page device images and fix machine branding" />
|
<MESSAGE value="feat: add landing page device images and fix machine branding" />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="feat: add landing page device images and fix machine branding" />
|
<MESSAGE value="refactor: remove disabled home sections and their unused assets" />
|
||||||
|
<MESSAGE value="refactor: migrate project to TypeScript and translate comments to English" />
|
||||||
|
<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>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
docs
|
||||||
|
public
|
||||||
|
.idea
|
||||||
|
.agents
|
||||||
|
.codex
|
||||||
|
*.tsbuildinfo
|
||||||
|
package-lock.json
|
||||||
@@ -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.
|
||||||
@@ -1,132 +1,74 @@
|
|||||||
# روبات مارکت — Vending Machine Showcase
|
# روبات مارکت — Vending Machine Showcase
|
||||||
|
|
||||||
پروژه Next.js (App Router) صفحه معرفی محصولات «روبات مارکت»، با انیمیشنهای GSAP/ScrollTrigger،
|
صفحه معرفی دستگاههای وندینگ روبات مارکت، ساختهشده با Next.js، TypeScript، React و Ant Design.
|
||||||
مدیریت سبد خرید با Redux Toolkit، حالت تیره/روشن، دو زبانه (فارسی/انگلیسی)، نقشه، فرم تیکت
|
|
||||||
پشتیبانی، و ویجت چت.
|
|
||||||
|
|
||||||
## اجرای پروژه
|
## اجرا
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
سپس `http://localhost:3000` را باز کنید.
|
سایت در http://localhost:3000 در دسترس است.
|
||||||
|
|
||||||
## ساختار پروژه
|
## بررسی نوعها
|
||||||
|
|
||||||
```
|
```bash
|
||||||
app/
|
npm run typecheck
|
||||||
layout.jsx # پوسته HTML، فونت وزیرمتن
|
|
||||||
page.jsx # چیدمان کامل صفحه
|
|
||||||
providers.jsx # Redux + Theme + Language + AntD ConfigProvider
|
|
||||||
globals.css # توکنهای رنگ دارک/لایت، جلوه اسپاتلایت، تایپرایتر
|
|
||||||
context/
|
|
||||||
ThemeContext.jsx # حالت دارک/لایت (ذخیره در localStorage)
|
|
||||||
LanguageContext.jsx # زبان فا/en (ذخیره در localStorage + جهت صفحه)
|
|
||||||
store/ # Redux (سبد خرید)
|
|
||||||
data/
|
|
||||||
content.js # منبع واحد محتوای دوزبانه (fa/en) — همهی متنها اینجاست
|
|
||||||
components/
|
|
||||||
Header.jsx # هدر + سوییچ زبان/تم + دکمه ورود به پنل کاربری
|
|
||||||
ThemeToggle.jsx / LanguageToggle.jsx
|
|
||||||
HeroSection.jsx # هدلاین تایپشونده + پسزمینه رنگی در حال چرخش
|
|
||||||
HorizontalShowcase.jsx # اسکرول افقی پینشده + ویدیوی پسزمینه
|
|
||||||
VendingCard.jsx # کارت دستگاه + جلوه اسپاتلایت
|
|
||||||
FloatingItems.jsx
|
|
||||||
FeaturesSection.jsx
|
|
||||||
AboutSection.jsx
|
|
||||||
LocationsSection.jsx
|
|
||||||
MapSection.jsx # نقشه Neshan (کلید API را خودتان اضافه میکنید)
|
|
||||||
CatalogSection.jsx # کاتالوگ محصولات قابل سفارش
|
|
||||||
IntroVideoSection.jsx # ویدیوی معرفی شرکت
|
|
||||||
TicketForm.jsx # فرم تیکت پشتیبانی
|
|
||||||
ChatWidget.jsx # آیکون شناور چت + مودال گفتگو
|
|
||||||
CartDrawer.jsx
|
|
||||||
Footer.jsx
|
|
||||||
public/
|
|
||||||
images/, gifs/, video/ # داراییهای placeholder — در پیام قبلی توضیح داده شده
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## ویژگیهای اضافهشده در این نسخه
|
کد برنامه با TypeScript و حالت strict بررسی میشود. نوعهای مشترک در `types/domain.ts` و هوکهای Redux در `store/hooks.ts` قرار دارند.
|
||||||
|
تنظیمات Next.js و PostCSS برای سازگاری با ابزارهای فعلی به JavaScript باقی ماندهاند؛ تنظیمات Tailwind در `tailwind.config.ts` است.
|
||||||
|
|
||||||
### نقشه (Neshan)
|
## ساخت نسخه نهایی
|
||||||
`components/MapSection.jsx` از **Neshan Static Map API** استفاده میکند (یک تصویر ساده، بدون نیاز
|
|
||||||
به اسکریپت/SDK جاوااسکریپت). کلید API را اینجا وارد کنید:
|
|
||||||
|
|
||||||
1. یک فایل `.env.local` در ریشه پروژه بسازید (از روی `.env.local.example` کپی کنید)
|
```bash
|
||||||
2. مقدار `NEXT_PUBLIC_NESHAN_API_KEY` را با کلید واقعی خودتان از
|
npm run build
|
||||||
[platform.neshan.org](https://platform.neshan.org) پر کنید
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
تا وقتی کلید خالی باشد، بهجای نقشه یک پیام راهنما («برای فعال شدن نقشه، کلید API نشان را در
|
خروجی standalone برای اجرای Docker تنظیم شده است.
|
||||||
تنظیمات پروژه قرار دهید») نمایش داده میشود — سایت خراب نمیشود. نقشه در حالت تیره/روشن هم استایل
|
|
||||||
متفاوتی میگیرد (`standard-night` / `standard-day`). لینک «مشاهده در نشان» کنار هر آدرس به نقشه
|
|
||||||
تعاملی neshan.org میرود و نیازی به کلید ندارد. مختصات فعلی در `data/content.js`
|
|
||||||
(`mapLocations`) تقریبی هستند — با پین دقیق جایگزین کنید.
|
|
||||||
|
|
||||||
### کاتالوگ محصولات
|
## ساختار و بخشهای فعال
|
||||||
`components/CatalogSection.jsx` — گرید محصولاتی (زعفران، تنقلات، نوشیدنی، قهوه، چای و ...) که
|
|
||||||
مشتریان دستگاه میتوانند با قیمت کمتر از بازار سفارش دهند. هر آیتم قیمت بازار (خطخورده) و قیمت
|
|
||||||
ویژه را نشان میدهد و دکمه «افزودن به سفارش» آن را به همان سبد خرید Redux اضافه میکند (در کنار
|
|
||||||
دستگاهها). لیست محصولات، قیمتها و تصاویر در `data/content.js` (کلید `catalog`) قابل ویرایش است؛
|
|
||||||
تصاویر placeholder در `public/images/catalog/` قرار دارند.
|
|
||||||
|
|
||||||
### سکشن ویدیوی معرفی شرکت
|
- `app/page.tsx`: صفحه اصلی شامل هیرو، ویترین دستگاهها، مزایا، درباره ما و محلهای مناسب نصب.
|
||||||
`components/IntroVideoSection.jsx` — یک پلیر ویدیوی مستقل (جدا از ویدیوی پسزمینه اسکرول افقی) با
|
- `app/layout.tsx`: هدر، فوتر و پیام توسعه مشترک بین صفحات.
|
||||||
پوستر و دکمه پخش استاندارد مرورگر. برای جایگزینی، فایل واقعی را در
|
- `app/cart/page.tsx`: صفحه مستقل سبد خرید.
|
||||||
`public/video/company-intro-placeholder.mp4` بگذارید (یا مسیر `introVideoSrc` را در
|
- `app/products/page.tsx`: همه محصولات با فیلتر دستهبندی ذخیرهشده در URL؛ برای نمونه `/products?category=warm-vending`.
|
||||||
`data/content.js` عوض کنید)؛ پوستر هم از `public/images/video-poster.svg` میآید.
|
- `data/products.ts`: دستهبندیها و شناسه سه محصول منتخب ویترین اسکرولی Home؛ سایر محصولات فقط در فهرست کامل نمایش داده میشوند.
|
||||||
|
- `app/products/[id]/page.tsx`: صفحه جزئیات هر دستگاه، تصاویر، ویژگیها و کنترل تعداد.
|
||||||
|
- `components/HeroSection.tsx`: اسلایدر تصاویر دستگاهها.
|
||||||
|
- `components/ShowcaseSection.tsx`: انتخاب نمایش موبایل یا ویترین افقی دسکتاپ.
|
||||||
|
- `components/HorizontalShowcase.tsx`: ویترین افقی با GSAP و ویدیوی پسزمینه وابسته به اسکرول.
|
||||||
|
- `components/MobileShowcase.tsx`: فهرست دستگاهها در موبایل.
|
||||||
|
- `components/VendingCard.tsx` و `components/FloatingItems.tsx`: نمایش دستگاه و جلوههای تصویری.
|
||||||
|
- `components/CartPage.tsx`، `components/QuantityControl.tsx` و `store/`: سبد خرید و کنترل افزایش و کاهش تعداد با Redux.
|
||||||
|
- `components/CartPersistence.tsx`: حفظ شناسه و تعداد دستگاهها در مرورگر.
|
||||||
|
- `components/DevelopmentNotice.tsx`: پیام اولین بازدید؛ پس از بستهشدن دوباره در همان مرورگر نمایش داده نمیشود.
|
||||||
|
- `data/content.ts`: محتوای فارسی و انگلیسی، مشخصات و قیمت دستگاهها و مسیر رسانهها.
|
||||||
|
- `data/commerce.ts`: متنهای دوزبانه سبد خرید، جزئیات محصول و پیام توسعه.
|
||||||
|
- `app/context/` و `app/providers.tsx`: زبان، جهت صفحه، تم و تنظیمات Ant Design.
|
||||||
|
- `app/globals.css`: رنگها و استایلهای مشترک.
|
||||||
|
- `public/`: تصاویر دستگاهها، اقلام شناور، گیفها و ویدیوی ویترین.
|
||||||
|
|
||||||
### رنگبندی متناوب سکشنها
|
## زبان و تم
|
||||||
هر سکشن اصلی صفحه (بهجز هیرو و بخش اسکرول افقی که پسزمینه مخصوص خودشان را دارند) بهترتیب بین سه
|
|
||||||
تن رنگی برند میچرخد: `bg-page` → `bg-surface` → `bg-elevated` → ... بههمراه یک خط جداکننده ظریف
|
|
||||||
بالای هر بخش. اینها همان توکنهای دارک/لایت هستند، پس با تغییر تم هم هماهنگ میمانند. برای تغییر
|
|
||||||
ترتیب یا اضافه کردن سکشن جدید، کافیست یکی از این سه کلاس را روی `<section>` بگذارید.
|
|
||||||
|
|
||||||
### حالت دارک/لایت
|
زبان فارسی/انگلیسی و حالت روشن/تیره از هدر تغییر میکنند و ترجیح کاربر در localStorage ذخیره میشود.
|
||||||
دکمه سوییچ در هدر (آیکون خورشید/ماه). ترجیح در `localStorage` ذخیره میشود و در اولین بار بر اساس
|
ویترین افقی برای محاسبات اسکرول همیشه جهت LTR دارد، اما متن کارتها جهت زبان فعال را رعایت میکند.
|
||||||
تنظیمات سیستم کاربر تعیین میشود. رنگبندی از طریق متغیرهای CSS در `app/globals.css`
|
|
||||||
(`--color-bg-page`, `--color-text-primary`, ...) و کلاسهای کمکی `bg-page` / `bg-surface` /
|
|
||||||
`text-primary` / `text-secondary` / `border-subtle` مدیریت میشود؛ برای هر رنگ جدید فقط همینجا را
|
|
||||||
ویرایش کنید. تم Ant Design هم بهصورت خودکار (`darkAlgorithm` / `defaultAlgorithm`) هماهنگ میشود.
|
|
||||||
|
|
||||||
### دو زبانه (فارسی / انگلیسی)
|
## ورود و سبد خرید
|
||||||
دکمه «EN / فا» در هدر. تمام متنهای سایت (منو، هیرو، محصولات، درباره ما، مکانهای نصب، فرم تیکت،
|
|
||||||
چت، فوتر) از `data/content.js` خوانده میشوند — یک آبجکت با کلیدهای `fa` و `en`. تغییر زبان همچنین
|
|
||||||
جهت صفحه (`dir`) و `lang` تگ `html` را بهروزرسانی میکند. برای اضافه یا اصلاح متن، فقط
|
|
||||||
`data/content.js` را ویرایش کنید؛ کامپوننتها نیازی به تغییر ندارند.
|
|
||||||
|
|
||||||
> نکته فنی: بخش اسکرول افقی (`HorizontalShowcase`) بهعمد همیشه `dir="ltr"` روی خود track دارد
|
دکمه ورود به https://panel.my-rm.com متصل است.
|
||||||
> (چون محاسبات GSAP به آن نیاز دارد)، اما هر کارت داخلش جهت متن را بر اساس زبان فعال تنظیم میکند.
|
سبد خرید در مسیر `/cart` نمایش داده میشود و انتخابها پس از بارگذاری مجدد حفظ میشوند.
|
||||||
|
کنترلهای مثبت و منفی در کارتها، صفحه محصول و سبد هماهنگاند؛ کاهش تعداد از یک به صفر دستگاه را از سبد حذف میکند.
|
||||||
|
پرداخت هنوز متصل نیست؛ دکمه پرداخت غیرفعال است و توضیح آن نمایش داده میشود.
|
||||||
|
|
||||||
### فرم تیکت پشتیبانی
|
## تنظیم API
|
||||||
`components/TicketForm.jsx` — فرم Ant Design با اعتبارسنجی (نام، تلفن، ایمیل اختیاری، موضوع،
|
|
||||||
اولویت، پیام). ارسال فعلاً فقط یک پیام موفقیت نمایش میدهد؛ بلوک کامنتشده
|
|
||||||
`// TODO: Connect to the real ticketing/support API` دقیقاً نشان میدهد کجا باید axios را وصل کنید.
|
|
||||||
|
|
||||||
### ویجت چت
|
برای تعیین آدرس بکاند، از `.env.local.example` یک فایل `.env.local` بسازید و
|
||||||
دکمه شناور گوشه صفحه (`components/ChatWidget.jsx`) یک مودال گفتگوی ساده باز میکند. پیامها فعلاً
|
`NEXT_PUBLIC_API_BASE_URL` را تنظیم کنید. کلاینت مشترک API در `lib/axios.ts` قرار دارد.
|
||||||
در state محلی نگهداری میشوند و یک پاسخ خودکار نمایشی میدهند. بلوک
|
|
||||||
`// TODO: Connect to a real chat/support backend` محل اتصال به بکاند واقعی (یا وبسوکت) را مشخص
|
|
||||||
کرده.
|
|
||||||
|
|
||||||
### هیرو: تایپرایتر + پسزمینه رنگی در حال تغییر
|
## جایگزینی رسانهها
|
||||||
هدلاین با افکت تایپرایتر بین چند جمله (`data/content.js` → `hero.typingPhrases`) میچرخد.
|
|
||||||
پسزمینه بهصورت حلقهای بین سبز برند (`#00a693`)، مشکی و سفید تغییر رنگ میدهد (با GSAP). یک لایه
|
|
||||||
scrim تیره روی آن قرار دارد تا متن در هر سه حالت خوانا بماند. وقتی ویدیوی هیرو را آماده کردید،
|
|
||||||
داخل `components/HeroSection.jsx` یک بلوک `TODO` کامنتشده دقیقاً نشان میدهد چطور بهجای پسزمینه
|
|
||||||
رنگی، ویدیو را جایگزین کنید (فقط از حالت کامنت خارجش کنید).
|
|
||||||
|
|
||||||
### ورود به پنل کاربری
|
فایلهای واقعی را با مسیرهای موجود در `data/content.ts` جایگزین کنید یا مسیرها را در همان فایل تغییر دهید.
|
||||||
دکمه «ورود به پنل کاربری» / «Sign In» در هدر اضافه شده (در `components/Header.jsx`). چون گفتید
|
ویدیوی پسزمینه ویترین از `public/video/showcase-placeholder.mp4` خوانده میشود.
|
||||||
لینکدهی را خودتان انجام میدهید، فعلاً فقط یک `onClick` خالی با کامنت TODO دارد — مسیر واقعی پنل
|
|
||||||
کاربری را همانجا وصل کنید.
|
|
||||||
|
|
||||||
## جایگزینی داراییهای واقعی (تصاویر/گیف/ویدیو)
|
|
||||||
همانطور که قبلاً توضیح داده شد، فایلهای داخل `public/images`, `public/gifs`, `public/video`
|
|
||||||
placeholder هستند. برای جایگزینی، فایل جدید را با همان نام/مسیر جایگزین کنید یا مسیر را در
|
|
||||||
`data/content.js` تغییر دهید.
|
|
||||||
|
|
||||||
## اتصال درگاه پرداخت
|
|
||||||
در `components/CartDrawer.jsx` داخل `handleCheckout` بلوک `// TODO: Connect to payment gateway API`
|
|
||||||
با نمونهکد axios کامنتشده وجود دارد.
|
|
||||||
|
|||||||
@@ -0,0 +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 (
|
||||||
|
<ProtectedPage>
|
||||||
|
<CartPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,13 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import type {Dispatch, SetStateAction, ReactNode} from 'react';
|
||||||
|
import type {Locale, SiteContent, Direction} from '@/types/domain';
|
||||||
import {createContext, useContext, useEffect, useState} from 'react';
|
import {createContext, useContext, useEffect, useState} from 'react';
|
||||||
import {content} from '@/data/content';
|
import {content} from '@/data/content';
|
||||||
|
|
||||||
const LanguageContext = createContext(null);
|
interface LanguageContextValue {
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: Dispatch<SetStateAction<Locale>>;
|
||||||
|
toggleLocale: () => void;
|
||||||
|
t: SiteContent;
|
||||||
|
dir: Direction;
|
||||||
|
mounted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function LanguageProvider({children}) {
|
const LanguageContext = createContext<LanguageContextValue | null>(null);
|
||||||
const [locale, setLocale] = useState('fa');
|
|
||||||
|
export function LanguageProvider({children}: {children: ReactNode}) {
|
||||||
|
const [locale, setLocale] = useState<Locale>('fa');
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -23,7 +33,7 @@ export function LanguageProvider({children}) {
|
|||||||
window.localStorage.setItem('rm-locale', locale);
|
window.localStorage.setItem('rm-locale', locale);
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
const toggleLocale = () => setLocale((l) => (l === 'fa' ? 'en' : 'fa'));
|
const toggleLocale = () => setLocale(l => (l === 'fa' ? 'en' : 'fa'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LanguageContext.Provider
|
<LanguageContext.Provider
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import type {Dispatch, SetStateAction, ReactNode} from 'react';
|
||||||
|
import type {Theme} from '@/types/domain';
|
||||||
import {createContext, useContext, useEffect, useState} from 'react';
|
import {createContext, useContext, useEffect, useState} from 'react';
|
||||||
|
|
||||||
const ThemeContext = createContext(null);
|
interface ThemeContextValue {
|
||||||
|
theme: Theme;
|
||||||
|
setTheme: Dispatch<SetStateAction<Theme>>;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
mounted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function ThemeProvider({children}) {
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
const [theme, setTheme] = useState('light');
|
export function ThemeProvider({children}: {children: ReactNode}) {
|
||||||
|
const [theme, setTheme] = useState<Theme>('light');
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = window.localStorage.getItem('rm-theme');
|
const saved = window.localStorage.getItem('rm-theme');
|
||||||
if (saved === 'light' || saved === 'dark') {
|
if (saved === 'light' || saved === 'dark') {
|
||||||
@@ -20,7 +27,6 @@ export function ThemeProvider({children}) {
|
|||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.remove('light', 'dark');
|
root.classList.remove('light', 'dark');
|
||||||
@@ -28,13 +34,9 @@ export function ThemeProvider({children}) {
|
|||||||
window.localStorage.setItem('rm-theme', theme);
|
window.localStorage.setItem('rm-theme', theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
|
const toggleTheme = () => setTheme(t => (t === 'dark' ? 'light' : 'dark'));
|
||||||
|
|
||||||
return (
|
return <ThemeContext.Provider value={{theme, setTheme, toggleTheme, mounted}}>{children}</ThemeContext.Provider>;
|
||||||
<ThemeContext.Provider value={{theme, setTheme, toggleTheme, mounted}}>
|
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
@@ -42,4 +44,3 @@ export function useTheme() {
|
|||||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import OrdersList from '@/features/dashboard/components/OrdersList';
|
||||||
|
|
||||||
|
export default function OrdersPage() {
|
||||||
|
return <OrdersList />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import DashboardOverview from '@/features/dashboard/components/DashboardOverview';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return <DashboardOverview />;
|
||||||
|
}
|
||||||
@@ -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
-35
@@ -169,7 +169,8 @@ html[dir='rtl'] body {
|
|||||||
height: 190px;
|
height: 190px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 2px solid rgba(0, 166, 147, 0.9);
|
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);
|
0 0 40px 6px rgba(0, 166, 147, 0.45);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
@@ -211,43 +212,135 @@ html[dir='rtl'] body {
|
|||||||
animation: caretBlink 0.9s steps(1) infinite;
|
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 {
|
@keyframes caretBlink {
|
||||||
0%, 49% {
|
0%,
|
||||||
|
49% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
50%, 100% {
|
50%,
|
||||||
|
100% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* نوار لوگوی بیپایان (trusted-by marquee) */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
@keyframes marqueeScroll {
|
|
||||||
from {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
/* دقیقاً نصف عرض، چون لیست دو برابر شده */
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-marquee {
|
|
||||||
animation: marqueeScroll 28s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-marquee:hover {
|
|
||||||
animation-play-state: paused;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Chat widget */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
.chat-bubble {
|
|
||||||
max-width: 80%;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 14px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import {Vazirmatn} from 'next/font/google';
|
|
||||||
import './globals.css';
|
|
||||||
import Providers from './providers';
|
|
||||||
|
|
||||||
const vazirmatn = Vazirmatn({
|
|
||||||
subsets: ['arabic'],
|
|
||||||
variable: '--font-vazirmatn',
|
|
||||||
display: 'swap',
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: 'روبات مارکت | دستگاههای وندینگ هوشمند',
|
|
||||||
description:
|
|
||||||
'روبات مارکت؛ دستگاههای فروش خودکار هوشمند با مدیریت آنلاین، تحویل نرم و طراحی مدرن.',
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export const viewport = {
|
|
||||||
themeColor: '#00a693',
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export default function RootLayout({children}) {
|
|
||||||
|
|
||||||
return (
|
|
||||||
<html lang="fa" dir="rtl" className={`${vazirmatn.variable} light`} suppressHydrationWarning>
|
|
||||||
<body className="bg-page text-primary antialiased transition-colors duration-300">
|
|
||||||
<Providers>{children}</Providers>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type {Metadata, Viewport} from 'next';
|
||||||
|
import type {ReactNode} from 'react';
|
||||||
|
import {Vazirmatn} from 'next/font/google';
|
||||||
|
import './globals.css';
|
||||||
|
import Providers from './providers';
|
||||||
|
import Header from '@/components/Header';
|
||||||
|
import Footer from '@/components/Footer';
|
||||||
|
import DevelopmentNotice from '@/components/DevelopmentNotice';
|
||||||
|
|
||||||
|
const vazirmatn = Vazirmatn({
|
||||||
|
subsets: ['arabic'],
|
||||||
|
variable: '--font-vazirmatn',
|
||||||
|
display: 'swap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'روبات مارکت | دستگاههای وندینگ هوشمند',
|
||||||
|
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">
|
||||||
|
<Providers>
|
||||||
|
<Header />
|
||||||
|
{children}
|
||||||
|
<Footer />
|
||||||
|
<DevelopmentNotice />
|
||||||
|
</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,38 +0,0 @@
|
|||||||
import LocationsSection from '@/components/LocationsSection';
|
|
||||||
import ShowcaseSection from "@/components/ShowcaseSection";
|
|
||||||
import FeaturesSection from '@/components/FeaturesSection';
|
|
||||||
import AboutSection from '@/components/AboutSection';
|
|
||||||
import HeroSection from '@/components/HeroSection';
|
|
||||||
import CartDrawer from '@/components/CartDrawer';
|
|
||||||
import Footer from '@/components/Footer';
|
|
||||||
import Header from '@/components/Header';
|
|
||||||
import TrustedBySection from '@/components/TrustedBySection';
|
|
||||||
import CatalogSection from '@/components/CatalogSection';
|
|
||||||
import IntroVideoSection from '@/components/IntroVideoSection';
|
|
||||||
import MapSection from '@/components/MapSection';
|
|
||||||
import TicketForm from '@/components/TicketForm';
|
|
||||||
import ChatWidget from '@/components/ChatWidget';
|
|
||||||
|
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="relative bg-page">
|
|
||||||
<Header/>
|
|
||||||
<CartDrawer/>
|
|
||||||
{/*<ChatWidget/>*/}
|
|
||||||
<HeroSection/>
|
|
||||||
<ShowcaseSection/>
|
|
||||||
{/*<TrustedBySection/>*/}
|
|
||||||
<FeaturesSection/>
|
|
||||||
<AboutSection/>
|
|
||||||
{/*<CatalogSection/>*/}
|
|
||||||
<LocationsSection/>
|
|
||||||
{/*<IntroVideoSection/>*/}
|
|
||||||
{/*<MapSection/>*/}
|
|
||||||
{/*<TicketForm/>*/}
|
|
||||||
<Footer/>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import LocationsSection from '@/components/LocationsSection';
|
||||||
|
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 />
|
||||||
|
<ShowcaseSection />
|
||||||
|
<FeaturesSection />
|
||||||
|
<AboutSection />
|
||||||
|
<LocationsSection />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type {Metadata} from 'next';
|
||||||
|
import {notFound} from 'next/navigation';
|
||||||
|
import {content} from '@/data/content';
|
||||||
|
import {ProductDetails} from '@/features/products';
|
||||||
|
import ProtectedPage from '@/components/auth/ProtectedPage';
|
||||||
|
|
||||||
|
type Props = {params: {id: string}};
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return content.fa.machines.map(machine => ({id: machine.id}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateMetadata({params}: Props): Metadata {
|
||||||
|
const machine = content.fa.machines.find(item => item.id === params.id);
|
||||||
|
if (!machine) return {};
|
||||||
|
return {title: `${machine.name} | روبات مارکت`, description: machine.description};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductPage({params}: Props) {
|
||||||
|
if (!content.fa.machines.some(machine => machine.id === params.id)) notFound();
|
||||||
|
return (
|
||||||
|
<ProtectedPage>
|
||||||
|
<ProductDetails id={params.id} />
|
||||||
|
</ProtectedPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {Suspense} from 'react';
|
||||||
|
import type {Metadata} from 'next';
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProductsPage />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
// پل ارتباطی بین Redux، حالت تیره/روشن، و زبان فا/en با تنظیمات ConfigProvider آنتدیزاین.
|
import type {ReactNode} from 'react';
|
||||||
|
// Connect Redux, language, and theme settings to Ant Design.
|
||||||
|
|
||||||
import {Provider as ReduxProvider} from 'react-redux';
|
import {Provider as ReduxProvider} from 'react-redux';
|
||||||
import {ConfigProvider, theme as antdThemeApi} from 'antd';
|
import type {ThemeConfig} from 'antd';
|
||||||
|
import {App as AntdApp, ConfigProvider, theme as antdThemeApi} from 'antd';
|
||||||
import faIR from 'antd/locale/fa_IR';
|
import faIR from 'antd/locale/fa_IR';
|
||||||
import enUS from 'antd/locale/en_US';
|
import enUS from 'antd/locale/en_US';
|
||||||
import {store} from '@/store/store';
|
import {store} from '@/store/store';
|
||||||
import {ThemeProvider, useTheme} from './context/ThemeContext';
|
import {ThemeProvider, useTheme} from './context/ThemeContext';
|
||||||
import {LanguageProvider, useLanguage} from './context/LanguageContext';
|
import {LanguageProvider, useLanguage} from './context/LanguageContext';
|
||||||
|
import CartPersistence from '@/components/CartPersistence';
|
||||||
|
import {AuthProvider} from './context/AuthContext';
|
||||||
|
|
||||||
function AntdBridge({children}) {
|
function AntdBridge({children}: {children: ReactNode}) {
|
||||||
const {theme} = useTheme();
|
const {theme} = useTheme();
|
||||||
const {locale, dir} = useLanguage();
|
const {locale, dir} = useLanguage();
|
||||||
|
|
||||||
const antdTheme = {
|
const antdTheme: ThemeConfig = {
|
||||||
algorithm: theme === 'dark' ? antdThemeApi.darkAlgorithm : antdThemeApi.defaultAlgorithm,
|
algorithm: theme === 'dark' ? antdThemeApi.darkAlgorithm : antdThemeApi.defaultAlgorithm,
|
||||||
token: {
|
token: {
|
||||||
colorPrimary: '#00a693',
|
colorPrimary: '#00a693',
|
||||||
@@ -35,25 +39,25 @@ function AntdBridge({children}) {
|
|||||||
headerBg: theme === 'dark' ? '#0c2b24' : '#ffffff',
|
headerBg: theme === 'dark' ? '#0c2b24' : '#ffffff',
|
||||||
titleColor: theme === 'dark' ? '#e6f7f4' : '#04231f',
|
titleColor: theme === 'dark' ? '#e6f7f4' : '#04231f',
|
||||||
},
|
},
|
||||||
Drawer: {
|
|
||||||
colorBgElevated: theme === 'dark' ? '#0c2b24' : '#ffffff',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider direction={dir} locale={locale === 'fa' ? faIR : enUS} theme={antdTheme}>
|
<ConfigProvider direction={dir} locale={locale === 'fa' ? faIR : enUS} theme={antdTheme}>
|
||||||
{children}
|
<AntdApp>{children}</AntdApp>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Providers({children}) {
|
export default function Providers({children}: {children: ReactNode}) {
|
||||||
return (
|
return (
|
||||||
<ReduxProvider store={store}>
|
<ReduxProvider store={store}>
|
||||||
|
<CartPersistence />
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<AntdBridge>{children}</AntdBridge>
|
<AntdBridge>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</AntdBridge>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ReduxProvider>
|
</ReduxProvider>
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
// سکشن معرفی شرکت با متن بلند؛ بهخاطر طولانی بودن متن، یک دکمه «ادامه مطلب» برای باز/بسته کردن دارد.
|
// Company introduction with optional expandable paragraphs.
|
||||||
|
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
|
||||||
export default function AboutSection() {
|
export default function AboutSection() {
|
||||||
const {t} = useLanguage();
|
const {t} = useLanguage();
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const visibleParagraphs = expanded ? t.about.paragraphs : t.about.paragraphs.slice(0, 2);
|
const visibleParagraphs = expanded ? t.about.paragraphs : t.about.paragraphs.slice(0, 2);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -26,14 +26,13 @@ export default function AboutSection() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* faz 1 */}
|
<button
|
||||||
{/* <button
|
onClick={() => setExpanded(v => !v)}
|
||||||
onClick={() => setExpanded((v) => !v)}
|
|
||||||
className="mt-6 inline-flex items-center gap-2 text-brand-400 hover:text-brand-300 text-sm font-semibold transition-colors"
|
className="mt-6 inline-flex items-center gap-2 text-brand-400 hover:text-brand-300 text-sm font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
{expanded ? t.about.less : t.about.more}
|
{expanded ? t.about.less : t.about.more}
|
||||||
<span className={`transition-transform ${expanded ? 'rotate-180' : ''}`}>˅</span>
|
<span className={`transition-transform ${expanded ? 'rotate-180' : ''}`}>˅</span>
|
||||||
</button>*/}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
// کشوی (Drawer) سبد خرید: نمایش آیتمهای انتخابی، تغییر تعداد، جمع کل، و دکمه پرداخت (placeholder).
|
|
||||||
|
|
||||||
import {Drawer, Button, Empty, InputNumber, message} from 'antd';
|
|
||||||
import {DeleteOutlined} from '@ant-design/icons';
|
|
||||||
import {useSelector, useDispatch} from 'react-redux';
|
|
||||||
import {
|
|
||||||
selectCartItems,
|
|
||||||
selectCartTotal,
|
|
||||||
selectIsDrawerOpen,
|
|
||||||
closeDrawer,
|
|
||||||
removeFromCart,
|
|
||||||
setQuantity,
|
|
||||||
} from '@/store/cartSlice';
|
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
|
||||||
import api from "@/lib/axios";
|
|
||||||
|
|
||||||
function toLocaleNumber(n, locale) {
|
|
||||||
return n.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CartDrawer() {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const isOpen = useSelector(selectIsDrawerOpen);
|
|
||||||
const items = useSelector(selectCartItems);
|
|
||||||
const total = useSelector(selectCartTotal);
|
|
||||||
const {t, locale, dir} = useLanguage();
|
|
||||||
|
|
||||||
const currencySuffix = locale === 'fa' ? ' ریال' : ' Rials';
|
|
||||||
|
|
||||||
const handleCheckout = async () => {
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
// TODO: Connect to payment gateway API..
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// const { data } = await axios.post('/api/payment/create', {
|
|
||||||
// items: items.map((i) => ({ id: i.id, qty: i.qty })),
|
|
||||||
// totalAmount: total,
|
|
||||||
// });
|
|
||||||
// window.location.href = data.paymentRedirectUrl;
|
|
||||||
// } catch (err) {
|
|
||||||
// message.error('...');
|
|
||||||
// }
|
|
||||||
|
|
||||||
// api() <===> todo : use this func to fetch ...
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
await message.info(t.cart.checkoutToast);
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
title={t.cart.title}
|
|
||||||
placement={dir === 'rtl' ? 'right' : 'left'}
|
|
||||||
onClose={() => dispatch(closeDrawer())}
|
|
||||||
open={isOpen}
|
|
||||||
width={400}
|
|
||||||
>
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<Empty description={<span className="text-secondary">{t.cart.empty}</span>} className="mt-16"/>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{items.map((item) => (
|
|
||||||
<div key={item.id}
|
|
||||||
className="flex items-center gap-3 bg-surface border border-subtle rounded-xl p-3">
|
|
||||||
<img src={item.image} alt={item.name} className="w-14 h-20 object-contain shrink-0"/>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-semibold text-primary truncate">{item.name}</p>
|
|
||||||
<p className="text-xs text-brand-400 mt-1">
|
|
||||||
{toLocaleNumber(item.priceNumeric, locale)}
|
|
||||||
{currencySuffix}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<InputNumber
|
|
||||||
size="small"
|
|
||||||
min={1}
|
|
||||||
precision={0}
|
|
||||||
value={item.qty}
|
|
||||||
onChange={(val) => {
|
|
||||||
if (val !== null) dispatch(setQuantity({id: item.id, qty: val}));
|
|
||||||
}}
|
|
||||||
className="w-16"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
danger
|
|
||||||
size="small"
|
|
||||||
icon={<DeleteOutlined/>}
|
|
||||||
onClick={() => dispatch(removeFromCart(item.id))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="border-t border-subtle pt-4 mt-2">
|
|
||||||
<div className="flex items-center justify-between text-sm text-secondary mb-4">
|
|
||||||
<span>{t.cart.total}</span>
|
|
||||||
<span className="text-lg font-bold text-primary">
|
|
||||||
{toLocaleNumber(total, locale)}
|
|
||||||
{currencySuffix}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button type="primary" block size="large" onClick={handleCheckout}>
|
|
||||||
{t.cart.checkout}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {Button, Empty} from 'antd';
|
||||||
|
import {DeleteOutlined} from '@ant-design/icons';
|
||||||
|
import {useAppDispatch, useAppSelector} from '@/store/hooks';
|
||||||
|
import {removeFromCart, selectCartItems, selectCartTotal} from '@/store/cartSlice';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {commerceCopy} from '@/data/commerce';
|
||||||
|
import QuantityControl from './QuantityControl';
|
||||||
|
|
||||||
|
export default function CartPage() {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const items = useAppSelector(selectCartItems);
|
||||||
|
const total = useAppSelector(selectCartTotal);
|
||||||
|
const {t, locale} = useLanguage();
|
||||||
|
const copy = commerceCopy[locale];
|
||||||
|
const money = (value: number) => `${value.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')} ${copy.currency}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-7xl min-h-[65vh] px-5 md:px-8 pt-28 pb-20">
|
||||||
|
<Link href="/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>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid lg:grid-cols-[minmax(0,1fr),340px] gap-8 items-start">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{items.map(item => {
|
||||||
|
const product = t.machines.find(machine => machine.id === item.id) ?? item;
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={item.id}
|
||||||
|
className="rounded-2xl border border-subtle bg-surface p-5 flex flex-wrap sm:flex-nowrap gap-5 items-center"
|
||||||
|
>
|
||||||
|
<Link href={`/products/${item.id}`} className="shrink-0">
|
||||||
|
<img
|
||||||
|
src={product.image}
|
||||||
|
alt={product.name}
|
||||||
|
className="w-24 h-32 object-contain"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Link
|
||||||
|
href={`/products/${item.id}`}
|
||||||
|
className="font-bold text-lg text-primary hover:text-brand-400"
|
||||||
|
>
|
||||||
|
{product.name}
|
||||||
|
</Link>
|
||||||
|
<p className="text-secondary text-sm mt-2">
|
||||||
|
{copy.unitPrice}: {money(item.priceNumeric)}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3 items-center mt-4">
|
||||||
|
<QuantityControl product={product} />
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
aria-label={`${copy.remove}: ${product.name}`}
|
||||||
|
onClick={() => dispatch(removeFromCart(item.id))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-primary font-semibold">
|
||||||
|
{copy.lineTotal}: {money(item.qty * item.priceNumeric)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<aside className="lg:sticky lg:top-28 border border-subtle bg-elevated rounded-2xl p-6">
|
||||||
|
<h2 className="font-bold text-xl text-primary">{copy.summary}</h2>
|
||||||
|
<div className="border-t border-subtle mt-5 pt-5 flex flex-wrap justify-between gap-3">
|
||||||
|
<span className="text-secondary">{t.cart.total}</span>
|
||||||
|
<strong className="text-primary">{money(total)}</strong>
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary text-sm leading-7 my-5">{copy.checkoutUnavailable}</p>
|
||||||
|
<Button type="primary" size="large" disabled block>
|
||||||
|
{t.cart.checkout}
|
||||||
|
</Button>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useEffect} from 'react';
|
||||||
|
import {useStore} from 'react-redux';
|
||||||
|
import type {AppStore} from '@/store/store';
|
||||||
|
import type {CartItem} from '@/types/domain';
|
||||||
|
import {restoreCart} from '@/store/cartSlice';
|
||||||
|
import {content} from '@/data/content';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'rm-cart';
|
||||||
|
const useAppStore = useStore.withTypes<AppStore>();
|
||||||
|
|
||||||
|
export default function CartPersistence() {
|
||||||
|
const store = useAppStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]');
|
||||||
|
if (Array.isArray(saved)) {
|
||||||
|
const items: CartItem[] = [];
|
||||||
|
for (const entry of saved) {
|
||||||
|
if (
|
||||||
|
!entry ||
|
||||||
|
typeof entry !== 'object' ||
|
||||||
|
typeof entry.id !== 'string' ||
|
||||||
|
!Number.isSafeInteger(entry.qty) ||
|
||||||
|
entry.qty < 1
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const machine = content.fa.machines.find(item => item.id === entry.id);
|
||||||
|
if (!machine || items.some(item => item.id === machine.id)) continue;
|
||||||
|
// Restore identifiers and quantities; use current catalog prices and images.
|
||||||
|
items.push({
|
||||||
|
id: machine.id,
|
||||||
|
name: machine.name,
|
||||||
|
image: machine.image,
|
||||||
|
priceNumeric: machine.priceNumeric,
|
||||||
|
priceLabel: machine.priceLabel,
|
||||||
|
qty: entry.qty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
store.dispatch(restoreCart(items));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid or unavailable storage must not prevent shopping.
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.subscribe(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify(store.getState().cart.items.map(({id, qty}) => ({id, qty}))),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Keep the in-memory cart usable when storage is full or disabled.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
// کاتالوگ محصولات جانبی (زعفران، تنقلات، نوشیدنی و ...) که مشتریان دستگاه با قیمت کمتر از بازار
|
|
||||||
// میتوانند سفارش دهند. دکمه «افزودن به سفارش» از همان سبد خرید Redux دستگاهها استفاده میکند.
|
|
||||||
|
|
||||||
import { Button, message } from 'antd';
|
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
import { addToCart } from '@/store/cartSlice';
|
|
||||||
import { useLanguage } from '@/app/context/LanguageContext';
|
|
||||||
|
|
||||||
function toLocaleNumber(n, locale) {
|
|
||||||
return n.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CatalogSection() {
|
|
||||||
const { t, locale } = useLanguage();
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const currencySuffix = locale === 'fa' ? ' ریال' : ' Rials';
|
|
||||||
|
|
||||||
const handleAdd = (item) => {
|
|
||||||
dispatch(
|
|
||||||
addToCart({
|
|
||||||
id: `catalog-${item.id}`,
|
|
||||||
name: item.name,
|
|
||||||
priceNumeric: item.priceNumeric,
|
|
||||||
priceLabel: `${toLocaleNumber(item.priceNumeric, locale)}${currencySuffix}`,
|
|
||||||
image: item.image,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
message.success(t.catalog.addToast(item.name));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="catalog" className="relative px-6 md:px-16 py-20 md:py-28 bg-page border-t border-subtle">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<div className="text-center mb-4">
|
|
||||||
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.catalog.eyebrow}</span>
|
|
||||||
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text mt-3">{t.catalog.title}</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-secondary text-sm md:text-base text-center max-w-2xl mx-auto mb-12">
|
|
||||||
{t.catalog.subtitle}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
||||||
{t.catalog.items.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="bg-surface border border-subtle rounded-2xl p-5 flex flex-col hover:border-brand-500/60 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4 mb-4">
|
|
||||||
<img src={item.image} alt={item.name} className="w-16 h-16 object-contain shrink-0" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="text-[11px] text-brand-400 font-semibold">{item.category}</span>
|
|
||||||
<h3 className="text-primary font-bold text-sm leading-6">{item.name}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto flex items-end justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-secondary line-through opacity-70">
|
|
||||||
{toLocaleNumber(item.originalPriceNumeric, locale)}
|
|
||||||
{currencySuffix}
|
|
||||||
</p>
|
|
||||||
<p className="text-base font-bold text-primary">
|
|
||||||
{toLocaleNumber(item.priceNumeric, locale)}
|
|
||||||
{currencySuffix}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="middle"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() => handleAdd(item)}
|
|
||||||
>
|
|
||||||
{t.catalog.addToOrder}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
// آیکون شناور چت گوشه صفحه؛ با کلیک یک مودال گفتگوی ساده باز میشود (فعلاً بدون اتصال به بکاند واقعی).
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { Modal, Input, Button, Tooltip } from 'antd';
|
|
||||||
import { MessageOutlined, SendOutlined, CloseOutlined } from '@ant-design/icons';
|
|
||||||
import { useLanguage } from '@/app/context/LanguageContext';
|
|
||||||
|
|
||||||
export default function ChatWidget() {
|
|
||||||
const { t, dir } = useLanguage();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [draft, setDraft] = useState('');
|
|
||||||
const [messages, setMessages] = useState([]);
|
|
||||||
const scrollRef = useRef(null);
|
|
||||||
|
|
||||||
// Seed the bot greeting once, in the current language.
|
|
||||||
useEffect(() => {
|
|
||||||
setMessages([{ from: 'bot', text: t.chat.initialBotMessage }]);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [t.chat.initialBotMessage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
|
||||||
}, [messages, open]);
|
|
||||||
|
|
||||||
const handleSend = () => {
|
|
||||||
const text = draft.trim();
|
|
||||||
if (!text) return;
|
|
||||||
|
|
||||||
setMessages((prev) => [...prev, { from: 'user', text }]);
|
|
||||||
setDraft('');
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
// TODO: Connect to a real chat/support backend (e.g. via axios or a
|
|
||||||
// websocket) instead of this local placeholder reply.
|
|
||||||
//
|
|
||||||
// import axios from 'axios';
|
|
||||||
// const { data } = await axios.post('/api/chat', { message: text });
|
|
||||||
// setMessages((prev) => [...prev, { from: 'bot', text: data.reply }]);
|
|
||||||
// api() <===> todo : use this func to fetch ...
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
setTimeout(() => {
|
|
||||||
setMessages((prev) => [...prev, { from: 'bot', text: t.chat.fallbackReply }]);
|
|
||||||
}, 600);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Tooltip title={t.chat.fabLabel} placement="left">
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
aria-label={t.chat.fabLabel}
|
|
||||||
className="fixed bottom-6 z-40 w-14 h-14 rounded-full bg-brand hover:bg-brand-400 text-ink-950 shadow-brandGlow flex items-center justify-center text-2xl transition-transform hover:scale-105"
|
|
||||||
style={dir === 'rtl' ? { left: '1.5rem' } : { right: '1.5rem' }}
|
|
||||||
>
|
|
||||||
<MessageOutlined />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
onCancel={() => setOpen(false)}
|
|
||||||
footer={null}
|
|
||||||
closeIcon={<CloseOutlined />}
|
|
||||||
title={t.chat.title}
|
|
||||||
centered
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
className="flex flex-col gap-3 h-80 overflow-y-auto py-2 px-1"
|
|
||||||
>
|
|
||||||
{messages.map((m, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={`chat-bubble ${
|
|
||||||
m.from === 'user'
|
|
||||||
? 'self-end bg-brand text-ink-950'
|
|
||||||
: 'self-start bg-surface-strong text-primary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{m.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-3 border-t border-subtle pt-3">
|
|
||||||
<Input
|
|
||||||
size="large"
|
|
||||||
value={draft}
|
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
|
||||||
onPressEnter={handleSend}
|
|
||||||
placeholder={t.chat.placeholder}
|
|
||||||
/>
|
|
||||||
<Button type="primary" size="large" icon={<SendOutlined />} onClick={handleSend}>
|
|
||||||
{t.chat.send}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
import {Button, Modal} from 'antd';
|
||||||
|
import {ToolOutlined} from '@ant-design/icons';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {commerceCopy} from '@/data/commerce';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'rm-development-notice-seen';
|
||||||
|
|
||||||
|
export default function DevelopmentNotice() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const {locale} = useLanguage();
|
||||||
|
const copy = commerceCopy[locale];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
setOpen(localStorage.getItem(STORAGE_KEY) !== 'true');
|
||||||
|
} catch {
|
||||||
|
// Show the notice even when browser storage is unavailable.
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
setOpen(false);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, 'true');
|
||||||
|
} catch {
|
||||||
|
// Dismissing the notice must not depend on storage access.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
centered
|
||||||
|
title={copy.developmentTitle}
|
||||||
|
onCancel={dismiss}
|
||||||
|
footer={
|
||||||
|
<Button type="primary" onClick={dismiss}>
|
||||||
|
{copy.acknowledge}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ToolOutlined className="text-brand-400 text-3xl my-4" />
|
||||||
|
<p className="text-secondary leading-8">{copy.developmentMessage}</p>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
// نوار مزایا: ضمانت کالا، مشاوره رایگان، خرید اقساطی، بستهبندی ایمن.
|
|
||||||
|
|
||||||
import {
|
|
||||||
SafetyCertificateOutlined,
|
|
||||||
CustomerServiceOutlined,
|
|
||||||
CreditCardOutlined,
|
|
||||||
InboxOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { useLanguage } from '@/app/context/LanguageContext';
|
|
||||||
|
|
||||||
const ICONS = {
|
|
||||||
safety: SafetyCertificateOutlined,
|
|
||||||
consulting: CustomerServiceOutlined,
|
|
||||||
installment: CreditCardOutlined,
|
|
||||||
packaging: InboxOutlined,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function FeaturesSection() {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
const Icon = ICONS[feature.icon];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={feature.id}
|
|
||||||
className="group bg-surface border border-subtle rounded-2xl p-6 text-center hover:border-brand-500/60 hover:bg-surface-strong transition-colors"
|
|
||||||
>
|
|
||||||
<div className="mx-auto mb-4 w-14 h-14 rounded-xl bg-brand/15 border border-subtle flex items-center justify-center text-brand-400 text-2xl group-hover:bg-brand/25 transition-colors">
|
|
||||||
{Icon && <Icon />}
|
|
||||||
</div>
|
|
||||||
<h3 className="text-primary font-bold text-base mb-2">{feature.title}</h3>
|
|
||||||
<p className="text-secondary text-sm leading-6">{feature.description}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Service benefits: warranty, consultation, installments, and packaging.
|
||||||
|
|
||||||
|
import {SafetyCertificateOutlined, CustomerServiceOutlined, CreditCardOutlined, InboxOutlined} from '@ant-design/icons';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
safety: SafetyCertificateOutlined,
|
||||||
|
consulting: CustomerServiceOutlined,
|
||||||
|
installment: CreditCardOutlined,
|
||||||
|
packaging: InboxOutlined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FeaturesSection() {
|
||||||
|
const {t} = useLanguage();
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
const Icon = ICONS[feature.icon];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={feature.id}
|
||||||
|
className="group bg-surface border border-subtle rounded-2xl p-6 text-center hover:border-brand-500/60 hover:bg-surface-strong transition-colors"
|
||||||
|
>
|
||||||
|
<div className="mx-auto mb-4 w-14 h-14 rounded-xl bg-brand/15 border border-subtle flex items-center justify-center text-brand-400 text-2xl group-hover:bg-brand/25 transition-colors">
|
||||||
|
{Icon && <Icon />}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-primary font-bold text-base mb-2">{feature.title}</h3>
|
||||||
|
<p className="text-secondary text-sm leading-6">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
// فوتر سایت: درباره ما کوتاه، لینکهای شبکه اجتماعی، دسترسی سریع، و اطلاعات تماس/آدرس.
|
|
||||||
|
|
||||||
import { PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
|
||||||
import { useLanguage } from '@/app/context/LanguageContext';
|
|
||||||
|
|
||||||
export default function Footer() {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
const c = t.contactInfo;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer id="contact" className="relative border-t border-subtle bg-elevated">
|
|
||||||
<div className="max-w-6xl mx-auto px-6 md:px-16 py-14 grid md:grid-cols-3 gap-10 text-center md:text-start">
|
|
||||||
<div>
|
|
||||||
<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) => (
|
|
||||||
<a
|
|
||||||
key={link.id}
|
|
||||||
href={link.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-brand-400 hover:text-brand-300 text-sm transition-colors"
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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><a href="#hero" className="hover:text-brand-400">{t.nav.home}</a></li>
|
|
||||||
<li><a href="#showcase" className="hover:text-brand-400">{t.nav.showcase}</a></li>
|
|
||||||
<li><a href="#about" className="hover:text-brand-400">{t.nav.about}</a></li>
|
|
||||||
{/*<li><a href="#catalog" className="hover:text-brand-400">{t.nav.catalog}</a></li>*/}
|
|
||||||
<li><a href="#locations" className="hover:text-brand-400">{t.nav.locations}</a></li>
|
|
||||||
{/*<li><a href="#video" className="hover:text-brand-400">{t.nav.video}</a></li>*/}
|
|
||||||
{/*<li><a href="#map" className="hover:text-brand-400">{t.nav.map}</a></li>*/}
|
|
||||||
{/*<li><a href="#support" className="hover:text-brand-400">{t.nav.support}</a></li>*/}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-primary font-bold text-lg mb-3">{t.footer.contactTitle}</h3>
|
|
||||||
<div className="flex flex-col gap-4 text-sm text-secondary">
|
|
||||||
<a
|
|
||||||
href={`tel:${c.orderPhoneHref}`}
|
|
||||||
className="flex items-center justify-center md:justify-start gap-2 hover:text-brand-400 transition-colors"
|
|
||||||
dir="ltr"
|
|
||||||
>
|
|
||||||
<PhoneOutlined />
|
|
||||||
<span>{c.orderPhone}</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-center md:justify-start gap-2">
|
|
||||||
<EnvironmentOutlined className="mt-0.5 shrink-0" />
|
|
||||||
<div className="text-start">
|
|
||||||
<p className="text-primary-90 font-semibold mb-1">{t.footer.salesLabel}</p>
|
|
||||||
<p className="leading-7">{c.salesOfficeAddress}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-center md:justify-start gap-2">
|
|
||||||
<EnvironmentOutlined className="mt-0.5 shrink-0" />
|
|
||||||
<div className="text-start">
|
|
||||||
<p className="text-primary-90 font-semibold mb-1">{t.footer.factoryLabel}</p>
|
|
||||||
<p className="leading-7">{c.factoryAddress}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-subtle py-5 text-center text-xs text-secondary">
|
|
||||||
© {new Date().getFullYear()} {t.footer.aboutLabel}. {t.footer.rights}
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Company information, quick links, social links, and contact details.
|
||||||
|
|
||||||
|
import {commerceCopy} from '@/data/commerce';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {PhoneOutlined, EnvironmentOutlined} from '@ant-design/icons';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
const {t, locale} = useLanguage();
|
||||||
|
const c = t.contactInfo;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer id="contact" className="relative border-t border-subtle bg-elevated">
|
||||||
|
<div className="max-w-6xl mx-auto px-6 md:px-16 py-14 grid md:grid-cols-3 gap-10 text-center md:text-start">
|
||||||
|
<div>
|
||||||
|
<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 => (
|
||||||
|
<a
|
||||||
|
key={link.id}
|
||||||
|
href={link.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-brand-400 hover:text-brand-300 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-primary font-bold text-lg mb-3">{t.footer.contactTitle}</h3>
|
||||||
|
<div className="flex flex-col gap-4 text-sm text-secondary">
|
||||||
|
<a
|
||||||
|
href={`tel:${c.orderPhoneHref}`}
|
||||||
|
className="flex items-center justify-center md:justify-start gap-2 hover:text-brand-400 transition-colors"
|
||||||
|
dir="ltr"
|
||||||
|
>
|
||||||
|
<PhoneOutlined />
|
||||||
|
<span>{c.orderPhone}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-center md:justify-start gap-2">
|
||||||
|
<EnvironmentOutlined className="mt-0.5 shrink-0" />
|
||||||
|
<div className="text-start">
|
||||||
|
<p className="text-primary-90 font-semibold mb-1">{t.footer.salesLabel}</p>
|
||||||
|
<p className="leading-7">{c.salesOfficeAddress}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-center md:justify-start gap-2">
|
||||||
|
<EnvironmentOutlined className="mt-0.5 shrink-0" />
|
||||||
|
<div className="text-start">
|
||||||
|
<p className="text-primary-90 font-semibold mb-1">{t.footer.factoryLabel}</p>
|
||||||
|
<p className="leading-7">{c.factoryAddress}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-subtle py-5 text-center text-xs text-secondary">
|
||||||
|
© {new Date().getFullYear()} {t.footer.aboutLabel}. {t.footer.rights}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
// هدر ثابت بالای صفحه: لوگو، منوی ناوبری، سوییچ زبان/تم، دکمه ورود به پنل کاربری و آیکون سبد خرید.
|
|
||||||
|
|
||||||
import {useState, useEffect} from 'react';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import {Badge, Button} from 'antd';
|
|
||||||
import {ShoppingCartOutlined, MenuOutlined, CloseOutlined, UserOutlined} from '@ant-design/icons';
|
|
||||||
import {useSelector, useDispatch} from 'react-redux';
|
|
||||||
import {selectCartCount, openDrawer} from '@/store/cartSlice';
|
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
|
||||||
import ThemeToggle from './ThemeToggle';
|
|
||||||
import LanguageToggle from './LanguageToggle';
|
|
||||||
|
|
||||||
export default function Header() {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const cartCount = useSelector(selectCartCount);
|
|
||||||
const {t} = useLanguage();
|
|
||||||
const [scrolled, setScrolled] = useState(false);
|
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
|
||||||
|
|
||||||
const navLinks = [
|
|
||||||
{href: '#hero', label: t.nav.home},
|
|
||||||
{href: '#showcase', label: t.nav.showcase},
|
|
||||||
{href: '#about', label: t.nav.about},
|
|
||||||
// {href: '#catalog', label: t.nav.catalog},
|
|
||||||
{href: '#locations', label: t.nav.locations},
|
|
||||||
// {href: '#video', label: t.nav.video},
|
|
||||||
// {href: '#map', label: t.nav.map},
|
|
||||||
// {href: '#support', label: t.nav.support},
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onScroll = () => setScrolled(window.scrollY > 24);
|
|
||||||
window.addEventListener('scroll', onScroll);
|
|
||||||
return () => window.removeEventListener('scroll', onScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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 ? '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">
|
|
||||||
<a href="#hero" className="flex items-center gap-2 shrink-0">
|
|
||||||
<Image src="/images/icons/logo.png" alt="روبات مارکت" width={150} height={42} priority/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<nav className="hidden xl:flex items-center gap-5 2xl:gap-7">
|
|
||||||
{navLinks.map((link) => (
|
|
||||||
<a
|
|
||||||
key={link.href}
|
|
||||||
href={link.href}
|
|
||||||
className="text-sm text-secondary hover:text-brand-300 transition-colors whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 md:gap-3">
|
|
||||||
<div className="hidden md:flex items-center gap-2">
|
|
||||||
<LanguageToggle/>
|
|
||||||
<ThemeToggle/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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]}>
|
|
||||||
<Button
|
|
||||||
shape="circle"
|
|
||||||
size="large"
|
|
||||||
icon={<ShoppingCartOutlined/>}
|
|
||||||
onClick={() => dispatch(openDrawer())}
|
|
||||||
aria-label={t.cart.title}
|
|
||||||
/>
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="xl:hidden text-brand-400 text-2xl"
|
|
||||||
onClick={() => setMobileOpen((v) => !v)}
|
|
||||||
aria-label="menu"
|
|
||||||
>
|
|
||||||
{mobileOpen ? <CloseOutlined/> : <MenuOutlined/>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</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) => (
|
|
||||||
<a
|
|
||||||
key={link.href}
|
|
||||||
href={link.href}
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
className="text-secondary text-base"
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Fixed navigation with language, theme, account, and cart controls.
|
||||||
|
|
||||||
|
import {useState, useEffect} from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {usePathname} from 'next/navigation';
|
||||||
|
import {Badge, Button} from 'antd';
|
||||||
|
import {ShoppingCartOutlined, MenuOutlined, CloseOutlined, UserOutlined} from '@ant-design/icons';
|
||||||
|
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';
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{href: '/#hero', label: t.nav.home},
|
||||||
|
{href: '/products', label: commerceCopy[locale].allProducts},
|
||||||
|
{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);
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener('scroll', onScroll);
|
||||||
|
return () => window.removeEventListener('scroll', onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mx-auto max-w-7xl px-5 md:px-8 h-20 flex items-center justify-between gap-4">
|
||||||
|
<Link href="/#hero" onClick={() => setMobileOpen(false)} className="flex items-center gap-2 shrink-0">
|
||||||
|
<Image src="/images/icons/logo.png" alt="روبات مارکت" width={150} height={42} priority />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="hidden xl:flex items-center gap-5 2xl:gap-7">
|
||||||
|
{navLinks.map(link => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="text-sm text-secondary hover:text-brand-300 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 md:gap-3">
|
||||||
|
<div className="hidden md:flex items-center gap-2">
|
||||||
|
<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>*/}
|
||||||
|
{/*)}*/}
|
||||||
|
|
||||||
|
{/*<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)}
|
||||||
|
aria-label="menu"
|
||||||
|
>
|
||||||
|
{mobileOpen ? <CloseOutlined /> : <MenuOutlined />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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 => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="text-secondary text-base"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-3 pt-2 border-t border-subtle">
|
||||||
|
<LanguageToggle />
|
||||||
|
<ThemeToggle />
|
||||||
|
{/*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>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,36 +1,30 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/**
|
/** Full-width product carousel with autoplay and manual navigation. */
|
||||||
* سکشن اول سایت (Hero) — ایده کلی از صفحه وندینگ سایت Sielaff گرفته شده:
|
|
||||||
* یک اسلایدر تمامعرض که هر ۵ ثانیه بهصورت خودکار به اسلاید بعدی میرود،
|
|
||||||
* همراه با یک شکل مورب رنگی (بهجای قرمز آنها، رنگ سبز برند خودمان #00a693)،
|
|
||||||
* عنوان بزرگ دو خطی، و دکمههای فلش قبلی/بعدی برای ناوبری دستی.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
import {LeftOutlined, RightOutlined} from '@ant-design/icons';
|
import {LeftOutlined, RightOutlined} from '@ant-design/icons';
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {accountCopy} from '@/data/account';
|
||||||
|
|
||||||
const AUTOPLAY_DELAY = 5000; // هر ۵ ثانیه اسلاید عوض میشود
|
const AUTOPLAY_DELAY = 5000; // Advance every five seconds.
|
||||||
|
|
||||||
export default function HeroSection() {
|
export default function HeroSection() {
|
||||||
const {t, dir} = useLanguage();
|
const {t, dir, locale} = useLanguage();
|
||||||
const slides = t.hero.slides;
|
const slides = t.hero.slides;
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const timerRef = useRef(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
// رفتن به اسلاید بعدی (با چرخش به اول لیست وقتی به آخر رسید)
|
// Wrap around after the last slide.
|
||||||
const goToNext = useCallback(() => {
|
const goToNext = useCallback(() => {
|
||||||
setActiveIndex((prev) => (prev + 1) % slides.length);
|
setActiveIndex(prev => (prev + 1) % slides.length);
|
||||||
}, [slides.length]);
|
}, [slides.length]);
|
||||||
|
|
||||||
const goToPrev = useCallback(() => {
|
const goToPrev = useCallback(() => {
|
||||||
setActiveIndex((prev) => (prev - 1 + slides.length) % slides.length);
|
setActiveIndex(prev => (prev - 1 + slides.length) % slides.length);
|
||||||
}, [slides.length]);
|
}, [slides.length]);
|
||||||
|
|
||||||
// تایمر پخش خودکار: هر بار که اسلاید عوض میشود، تایمر قبلی پاک و یک تایمر
|
// Restart the autoplay timer after manual navigation.
|
||||||
// جدید ۵ ثانیهای ست میشود. با کلیک دستی روی فلشها هم همین تابع دوباره
|
|
||||||
// صدا زده میشود تا شمارش از نو شروع شود (تجربه کاربری بهتر).
|
|
||||||
const restartAutoplay = useCallback(() => {
|
const restartAutoplay = useCallback(() => {
|
||||||
if (timerRef.current) clearInterval(timerRef.current);
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
timerRef.current = setInterval(goToNext, AUTOPLAY_DELAY);
|
timerRef.current = setInterval(goToNext, AUTOPLAY_DELAY);
|
||||||
@@ -43,10 +37,10 @@ export default function HeroSection() {
|
|||||||
};
|
};
|
||||||
}, [restartAutoplay]);
|
}, [restartAutoplay]);
|
||||||
|
|
||||||
const handleManualNav = (direction) => {
|
const handleManualNav = (direction: 'next' | 'prev') => {
|
||||||
if (direction === 'next') goToNext();
|
if (direction === 'next') goToNext();
|
||||||
else goToPrev();
|
else goToPrev();
|
||||||
restartAutoplay(); // شمارش ۵ ثانیه بعد از کلیک دستی از نو شروع شود
|
restartAutoplay(); // Reset the delay after a manual click.
|
||||||
};
|
};
|
||||||
|
|
||||||
const slide = slides[activeIndex];
|
const slide = slides[activeIndex];
|
||||||
@@ -54,7 +48,7 @@ export default function HeroSection() {
|
|||||||
return (
|
return (
|
||||||
<section id="hero" className="relative h-screen w-full overflow-hidden bg-page">
|
<section id="hero" className="relative h-screen w-full overflow-hidden bg-page">
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
{/* شکل مورب تزئینی سمت راست/چپ — نسخه سبز برند بهجای قرمز Sielaff */}
|
{/* Decorative brand-colored shape. */}
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-y-0 w-1/3 hidden md:block"
|
className="absolute inset-y-0 w-1/3 hidden md:block"
|
||||||
@@ -68,8 +62,8 @@ export default function HeroSection() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
{/* اسلایدهای عکس — همه اسلایدها روی هم قرار دارند و فقط با opacity */}
|
{/* Fade between overlapping product images. */}
|
||||||
{/* محو/نمایان میشوند (transition نرم بهجای پرش ناگهانی) */}
|
{/* Use opacity transitions for smooth slide changes. */}
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
{slides.map((s, i) => (
|
{slides.map((s, i) => (
|
||||||
@@ -88,7 +82,7 @@ export default function HeroSection() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* لایهی نیمهشفاف پشت متن، برای خوانایی روی هر عکسی */}
|
{/* Overlay for text readability. */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{
|
style={{
|
||||||
@@ -98,10 +92,9 @@ export default function HeroSection() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
{/* متن روی اسلاید: کیکر کوچک + عنوان بزرگ دو خطی + توضیح + دکمه CTA */}
|
{/* Active slide headline, description, and call to action. */}
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
<div
|
<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">
|
||||||
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">
|
<div className="max-w-2xl">
|
||||||
<span className="text-brand-400 font-semibold tracking-widest text-sm uppercase">
|
<span className="text-brand-400 font-semibold tracking-widest text-sm uppercase">
|
||||||
{slide.kicker}
|
{slide.kicker}
|
||||||
@@ -114,17 +107,27 @@ export default function HeroSection() {
|
|||||||
))}
|
))}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-5 text-white/90 text-base md:text-lg max-w-xl drop-shadow-lg">{slide.sub}</p>
|
<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
|
<a
|
||||||
href="#showcase"
|
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}
|
{t.hero.cta}
|
||||||
</a>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
{/* فلشهای ناوبری دستی (قبلی / بعدی) — وسط دو طرف */}
|
{/* Previous and next slide controls. */}
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleManualNav('prev')}
|
onClick={() => handleManualNav('prev')}
|
||||||
@@ -141,7 +144,7 @@ export default function HeroSection() {
|
|||||||
<LeftOutlined />
|
<LeftOutlined />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* نقطههای شمارشگر اسلاید پایین صفحه — کلیک روی هرکدام مستقیم به همان اسلاید میرود */}
|
{/* Jump directly to a slide using the indicators. */}
|
||||||
<div className="absolute bottom-8 inset-x-0 z-20 flex justify-center gap-2">
|
<div className="absolute bottom-8 inset-x-0 z-20 flex justify-center gap-2">
|
||||||
{slides.map((s, i) => (
|
{slides.map((s, i) => (
|
||||||
<button
|
<button
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {useEffect, useRef, useState} from 'react';
|
|
||||||
import {gsap} from 'gsap';
|
|
||||||
import {ScrollTrigger} from 'gsap/ScrollTrigger';
|
|
||||||
import {Modal, Button, message} from 'antd';
|
|
||||||
import {ShoppingCartOutlined} from '@ant-design/icons';
|
|
||||||
import {useDispatch} from 'react-redux';
|
|
||||||
import VendingCard from './VendingCard';
|
|
||||||
import {showcaseVideoSrc} from '@/data/content';
|
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
|
||||||
import {addToCart} from '@/store/cartSlice';
|
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
|
||||||
|
|
||||||
export default function HorizontalShowcase() {
|
|
||||||
const sectionRef = useRef(null);
|
|
||||||
const trackRef = useRef(null);
|
|
||||||
const videoRef = useRef(null);
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const {t} = useLanguage();
|
|
||||||
const [quickViewMachine, setQuickViewMachine] = useState(null);
|
|
||||||
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 = null;
|
|
||||||
let latestProgress = 0;
|
|
||||||
|
|
||||||
const syncVideo = (progress) => {
|
|
||||||
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();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleModalAddToCart = () => {
|
|
||||||
if (!quickViewMachine) return;
|
|
||||||
|
|
||||||
dispatch(
|
|
||||||
addToCart({
|
|
||||||
id: quickViewMachine.id,
|
|
||||||
name: quickViewMachine.name,
|
|
||||||
priceNumeric: quickViewMachine.priceNumeric,
|
|
||||||
priceLabel: quickViewMachine.priceLabel,
|
|
||||||
image: quickViewMachine.image,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
message.success(t.cart.addToast(quickViewMachine.name));
|
|
||||||
setQuickViewMachine(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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 ref={trackRef} className="horizontal-track relative z-10" dir="ltr">
|
|
||||||
{t.machines.map((machine) => (
|
|
||||||
<VendingCard key={machine.id} machine={machine} onQuickView={setQuickViewMachine}/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={!!quickViewMachine}
|
|
||||||
onCancel={() => setQuickViewMachine(null)}
|
|
||||||
footer={null}
|
|
||||||
centered
|
|
||||||
title={quickViewMachine?.name}
|
|
||||||
>
|
|
||||||
{quickViewMachine && (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<img
|
|
||||||
src={quickViewMachine.image}
|
|
||||||
alt={quickViewMachine.name}
|
|
||||||
className="w-full h-64 object-contain"
|
|
||||||
/>
|
|
||||||
<p className="text-secondary leading-7">{quickViewMachine.description}</p>
|
|
||||||
<p className="text-xl font-bold text-primary">{quickViewMachine.priceLabel}</p>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
icon={<ShoppingCartOutlined/>}
|
|
||||||
onClick={handleModalAddToCart}
|
|
||||||
>
|
|
||||||
{t.machineActions.addToCart}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
// سکشن مستقل ویدیوی معرفی شرکت (جدا از ویدیوی پسزمینه ویترین محصولات).
|
|
||||||
|
|
||||||
import { useLanguage } from '@/app/context/LanguageContext';
|
|
||||||
import { introVideoSrc, introVideoPoster } from '@/data/content';
|
|
||||||
|
|
||||||
export default function IntroVideoSection() {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="video" className="relative px-6 md:px-16 py-20 md:py-28 bg-elevated border-t border-subtle">
|
|
||||||
<div className="max-w-5xl mx-auto text-center">
|
|
||||||
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.introVideo.eyebrow}</span>
|
|
||||||
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text mt-3 mb-3">
|
|
||||||
{t.introVideo.title}
|
|
||||||
</h2>
|
|
||||||
<p className="text-secondary text-sm md:text-base mb-10">{t.introVideo.subtitle}</p>
|
|
||||||
|
|
||||||
{/*
|
|
||||||
TODO: replace /public/video/company-intro-placeholder.mp4 with the real
|
|
||||||
company introduction video (keep the same filename, or update
|
|
||||||
`introVideoSrc` in data/content.js). The poster image shows before
|
|
||||||
playback starts — swap /public/images/video-poster.svg the same way.
|
|
||||||
*/}
|
|
||||||
<div className="rounded-2xl overflow-hidden border border-subtle shadow-brandGlow">
|
|
||||||
<video controls poster={introVideoPoster} className="w-full aspect-video bg-black">
|
|
||||||
<source src={introVideoSrc} type="video/mp4" />
|
|
||||||
{t.introVideo.videoNotSupported}
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
// دکمه سوییچ زبان فارسی/انگلیسی.
|
|
||||||
|
|
||||||
import { Button, Tooltip } from 'antd';
|
|
||||||
import { useLanguage } from '@/app/context/LanguageContext';
|
|
||||||
|
|
||||||
export default function LanguageToggle() {
|
|
||||||
const { locale, toggleLocale } = useLanguage();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip title={locale === 'fa' ? 'Switch to English' : 'تغییر به فارسی'}>
|
|
||||||
<Button shape="circle" size="large" onClick={toggleLocale} aria-label="toggle language">
|
|
||||||
{locale === 'fa' ? 'EN' : 'فا'}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Switch between Persian and English.
|
||||||
|
|
||||||
|
import {Button, Tooltip} from 'antd';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
|
||||||
|
export default function LanguageToggle() {
|
||||||
|
const {locale, toggleLocale} = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={locale === 'fa' ? 'Switch to English' : 'تغییر به فارسی'}>
|
||||||
|
<Button shape="circle" size="large" onClick={toggleLocale} aria-label="toggle language">
|
||||||
|
{locale === 'fa' ? 'EN' : 'فا'}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
// گرید «مکان مناسب برای نصب» — هتلها، مدارس، داروخانهها، بیمارستانها، شیفت شب، محصولات تخصصی.
|
|
||||||
|
|
||||||
import {
|
|
||||||
HomeOutlined,
|
|
||||||
ReadOutlined,
|
|
||||||
ShopOutlined,
|
|
||||||
MedicineBoxOutlined,
|
|
||||||
ClockCircleOutlined,
|
|
||||||
AppstoreOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { useLanguage } from '@/app/context/LanguageContext';
|
|
||||||
|
|
||||||
const ICONS = {
|
|
||||||
hotel: HomeOutlined,
|
|
||||||
school: ReadOutlined,
|
|
||||||
pharmacy: ShopOutlined,
|
|
||||||
hospital: MedicineBoxOutlined,
|
|
||||||
night: ClockCircleOutlined,
|
|
||||||
specialty: AppstoreOutlined,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function LocationsSection() {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="locations" className="relative px-6 md:px-16 py-20 md:py-28 bg-surface border-t border-subtle">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<div className="text-center mb-12">
|
|
||||||
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text">{t.locations.title}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
||||||
{t.locations.items.map((loc) => {
|
|
||||||
const Icon = ICONS[loc.icon];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={loc.id}
|
|
||||||
className="bg-page-60 border border-subtle rounded-2xl p-6 hover:border-brand-500/60 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-brand/15 border border-subtle flex items-center justify-center text-brand-400 text-xl mb-4">
|
|
||||||
{Icon && <Icon />}
|
|
||||||
</div>
|
|
||||||
<h3 className="text-primary font-bold text-base mb-2">{loc.title}</h3>
|
|
||||||
<p className="text-secondary text-sm leading-7">{loc.description}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Suggested installation locations for vending machines.
|
||||||
|
|
||||||
|
import {
|
||||||
|
HomeOutlined,
|
||||||
|
ReadOutlined,
|
||||||
|
ShopOutlined,
|
||||||
|
MedicineBoxOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
AppstoreOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
hotel: HomeOutlined,
|
||||||
|
school: ReadOutlined,
|
||||||
|
pharmacy: ShopOutlined,
|
||||||
|
hospital: MedicineBoxOutlined,
|
||||||
|
night: ClockCircleOutlined,
|
||||||
|
specialty: AppstoreOutlined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LocationsSection() {
|
||||||
|
const {t} = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="locations" className="relative px-6 md:px-16 py-20 md:py-28 bg-surface border-t border-subtle">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text">{t.locations.title}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
{t.locations.items.map(loc => {
|
||||||
|
const Icon = ICONS[loc.icon];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={loc.id}
|
||||||
|
className="bg-page-60 border border-subtle rounded-2xl p-6 hover:border-brand-500/60 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-brand/15 border border-subtle flex items-center justify-center text-brand-400 text-xl mb-4">
|
||||||
|
{Icon && <Icon />}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-primary font-bold text-base mb-2">{loc.title}</h3>
|
||||||
|
<p className="text-secondary text-sm leading-7">{loc.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
// نقشه نشان (Neshan) — از Static Map API استفاده میکند. تا وقتی کلید API خالی باشد،
|
|
||||||
// بهجای نقشه یک پیام راهنما نشان داده میشود (نه تصویر خراب).
|
|
||||||
|
|
||||||
import {useState} from 'react';
|
|
||||||
import {EnvironmentOutlined} from '@ant-design/icons';
|
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
|
||||||
import {useTheme} from '@/app/context/ThemeContext';
|
|
||||||
import {mapLocations} from '@/data/content';
|
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Neshan (نشان) map integration.
|
|
||||||
//
|
|
||||||
// TODO: put your Neshan API key here, or (preferred) set it as an environment
|
|
||||||
// variable in `.env.local`:
|
|
||||||
// NEXT_PUBLIC_NESHAN_API_KEY=your-key-here
|
|
||||||
// Get a key from https://platform.neshan.org
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const NESHAN_API_KEY = process.env.NEXT_PUBLIC_NESHAN_API_KEY || '';
|
|
||||||
|
|
||||||
// Neshan's Static Map API — a plain image, no JS SDK/script tag needed.
|
|
||||||
// Verify query param names/values against the current Neshan docs
|
|
||||||
// (https://platform.neshan.org/api/static-map/) if this ever needs updating —
|
|
||||||
// `type` accepts their day/night map styles, e.g. "standard-day" / "standard-night".
|
|
||||||
function staticMapUrl({lat, lng}, mapStyle) {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
key: NESHAN_API_KEY,
|
|
||||||
type: mapStyle,
|
|
||||||
center: `${lat},${lng}`,
|
|
||||||
zoom: '15',
|
|
||||||
size: '600x400',
|
|
||||||
marker: `${lat},${lng}`,
|
|
||||||
});
|
|
||||||
return `https://api.neshan.org/v5/static?${params.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function neshanViewUrl({lat, lng}) {
|
|
||||||
return `https://neshan.org/maps/@${lat},${lng},15z`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MapThumb({coords, mapStyle, alt}) {
|
|
||||||
const [errored, setErrored] = useState(false);
|
|
||||||
const {t} = useLanguage();
|
|
||||||
|
|
||||||
if (!NESHAN_API_KEY || errored) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="w-full h-full min-h-[280px] flex flex-col items-center justify-center gap-3 bg-surface text-secondary text-sm text-center p-6">
|
|
||||||
<EnvironmentOutlined className="text-3xl text-brand-400"/>
|
|
||||||
<p>{t.map.configNotice}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={staticMapUrl(coords, mapStyle)}
|
|
||||||
alt={alt}
|
|
||||||
onError={() => setErrored(true)}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MapSection() {
|
|
||||||
const {t} = useLanguage();
|
|
||||||
const {theme} = useTheme();
|
|
||||||
const mapStyle = theme === 'dark' ? 'standard-night' : 'standard-day';
|
|
||||||
|
|
||||||
const pins = [
|
|
||||||
{
|
|
||||||
key: 'factory',
|
|
||||||
label: t.map.factoryLabel,
|
|
||||||
coords: mapLocations.factory,
|
|
||||||
address: t.contactInfo.factoryAddress
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'sales',
|
|
||||||
label: t.map.salesLabel,
|
|
||||||
coords: mapLocations.salesOffice,
|
|
||||||
address: t.contactInfo.salesOfficeAddress
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="map" className="relative px-6 md:px-16 py-20 md:py-28 bg-page border-t border-subtle">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<div className="text-center mb-12">
|
|
||||||
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.map.eyebrow}</span>
|
|
||||||
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text mt-3">{t.map.title}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid lg:grid-cols-[1.3fr,1fr] gap-6">
|
|
||||||
<div className="rounded-2xl overflow-hidden border border-subtle h-[360px] lg:h-auto min-h-[360px]">
|
|
||||||
<MapThumb coords={pins[0].coords} mapStyle={mapStyle} alt={pins[0].label}/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{pins.map((pin) => (
|
|
||||||
<div key={pin.key} className="bg-surface border border-subtle rounded-2xl p-5">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div
|
|
||||||
className="w-10 h-10 shrink-0 rounded-lg bg-brand/15 border border-subtle flex items-center justify-center text-brand-400">
|
|
||||||
<EnvironmentOutlined/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-primary font-bold text-sm mb-1">{pin.label}</h3>
|
|
||||||
<p className="text-secondary text-sm leading-7 mb-2">{pin.address}</p>
|
|
||||||
<a
|
|
||||||
href={neshanViewUrl(pin.coords)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-brand-400 hover:text-brand-300 text-xs font-semibold transition-colors"
|
|
||||||
>
|
|
||||||
{t.map.openInMaps} ↗
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {useState} from 'react';
|
|
||||||
import {Modal, Button, message} from 'antd';
|
|
||||||
import {ShoppingCartOutlined, EyeOutlined} from '@ant-design/icons';
|
|
||||||
import {useDispatch} from 'react-redux';
|
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
|
||||||
import {addToCart} from '@/store/cartSlice';
|
|
||||||
|
|
||||||
export default function MobileShowcase() {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const {t, dir} = useLanguage();
|
|
||||||
const [quickViewMachine, setQuickViewMachine] = useState(null);
|
|
||||||
|
|
||||||
const handleAddToCart = (machine) => {
|
|
||||||
dispatch(
|
|
||||||
addToCart({
|
|
||||||
id: machine.id,
|
|
||||||
name: machine.name,
|
|
||||||
priceNumeric: machine.priceNumeric,
|
|
||||||
priceLabel: machine.priceLabel,
|
|
||||||
image: machine.image,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
message.success(t.cart.addToast(machine.name));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleModalAddToCart = () => {
|
|
||||||
if (!quickViewMachine) return;
|
|
||||||
handleAddToCart(quickViewMachine);
|
|
||||||
setQuickViewMachine(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="showcase" className="relative w-full py-8 px-4" dir={dir}>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{t.machines.map((machine) => (
|
|
||||||
<div
|
|
||||||
key={machine.id}
|
|
||||||
className="flex gap-4 items-center bg-surface border border-subtle rounded-2xl p-4"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={machine.image}
|
|
||||||
alt={machine.name}
|
|
||||||
className="w-24 h-24 object-contain shrink-0"
|
|
||||||
onClick={() => setQuickViewMachine(machine)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5 min-w-0 flex-1">
|
|
||||||
<span className="text-brand-400 text-xs font-semibold tracking-wide">
|
|
||||||
{machine.shortLabel}
|
|
||||||
</span>
|
|
||||||
<h3 className="text-lg font-bold text-primary truncate">
|
|
||||||
{machine.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-secondary line-clamp-2">
|
|
||||||
{machine.description}
|
|
||||||
</p>
|
|
||||||
<p className="text-base font-bold text-primary mt-1">
|
|
||||||
{machine.priceLabel}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex justify-start items-center mt-3">
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="middle"
|
|
||||||
icon={<ShoppingCartOutlined/>}
|
|
||||||
onClick={() => handleAddToCart(machine)}
|
|
||||||
>
|
|
||||||
{t.machineActions.addToCart}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={!!quickViewMachine}
|
|
||||||
onCancel={() => setQuickViewMachine(null)}
|
|
||||||
footer={null}
|
|
||||||
centered
|
|
||||||
title={quickViewMachine?.name}
|
|
||||||
>
|
|
||||||
{quickViewMachine && (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<img
|
|
||||||
src={quickViewMachine.image}
|
|
||||||
alt={quickViewMachine.name}
|
|
||||||
className="w-full h-64 object-contain"
|
|
||||||
/>
|
|
||||||
<p className="text-secondary leading-7">{quickViewMachine.description}</p>
|
|
||||||
<p className="text-xl font-bold text-primary">{quickViewMachine.priceLabel}</p>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
icon={<ShoppingCartOutlined/>}
|
|
||||||
onClick={handleModalAddToCart}
|
|
||||||
>
|
|
||||||
{t.machineActions.addToCart}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {Button} from 'antd';
|
||||||
|
import {MinusOutlined, PlusOutlined} from '@ant-design/icons';
|
||||||
|
import type {CartProduct} from '@/types/domain';
|
||||||
|
import {useAppDispatch, useAppSelector} from '@/store/hooks';
|
||||||
|
import {addToCart, decrementQty} from '@/store/cartSlice';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {commerceCopy} from '@/data/commerce';
|
||||||
|
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}
|
||||||
|
aria-label={`${copy.decrease}: ${product.name}`}
|
||||||
|
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 />}
|
||||||
|
aria-label={`${copy.increase}: ${product.name}`}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {useEffect, useState} from 'react';
|
|
||||||
import HorizontalShowcase from './HorizontalShowcase';
|
|
||||||
import MobileShowcase from './MobileShowcase';
|
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768;
|
|
||||||
|
|
||||||
export default function ShowcaseSection() {
|
|
||||||
const [isMobile, setIsMobile] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mediaQuery = window.matchMedia(
|
|
||||||
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChange = (event) => setIsMobile(event.matches);
|
|
||||||
|
|
||||||
setIsMobile(mediaQuery.matches);
|
|
||||||
mediaQuery.addEventListener('change', handleChange);
|
|
||||||
|
|
||||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isMobile === null) return null;
|
|
||||||
|
|
||||||
return isMobile ? <MobileShowcase/> : <HorizontalShowcase/>;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
|
|
||||||
import { Button, Tooltip } from 'antd';
|
|
||||||
import { SunOutlined, MoonOutlined } from '@ant-design/icons';
|
|
||||||
import { useTheme } from '@/app/context/ThemeContext';
|
|
||||||
|
|
||||||
export default function ThemeToggle() {
|
|
||||||
const { theme, toggleTheme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip title={theme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
|
||||||
<Button
|
|
||||||
shape="circle"
|
|
||||||
size="large"
|
|
||||||
onClick={toggleTheme}
|
|
||||||
aria-label="toggle theme"
|
|
||||||
icon={theme === 'dark' ? <SunOutlined /> : <MoonOutlined />}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {Button, Tooltip} from 'antd';
|
||||||
|
import {SunOutlined, MoonOutlined} from '@ant-design/icons';
|
||||||
|
import {useTheme} from '@/app/context/ThemeContext';
|
||||||
|
|
||||||
|
export default function ThemeToggle() {
|
||||||
|
const {theme, toggleTheme} = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={theme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
||||||
|
<Button
|
||||||
|
shape="circle"
|
||||||
|
size="large"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label="toggle theme"
|
||||||
|
icon={theme === 'dark' ? <SunOutlined /> : <MoonOutlined />}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
// فرم ثبت تیکت پشتیبانی با اعتبارسنجی antd؛ ارسال واقعی به بکاند در بلوک TODO داخل کد مشخص شده.
|
|
||||||
|
|
||||||
import { Form, Input, Select, Button, message } from 'antd';
|
|
||||||
import { useLanguage } from '@/app/context/LanguageContext';
|
|
||||||
|
|
||||||
const { TextArea } = Input;
|
|
||||||
|
|
||||||
export default function TicketForm() {
|
|
||||||
const { t, locale } = useLanguage();
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
|
|
||||||
const handleFinish = async (values) => {
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
// TODO: Connect to the real ticketing/support API.
|
|
||||||
//
|
|
||||||
// import axios from 'axios';
|
|
||||||
//
|
|
||||||
// try {
|
|
||||||
// await axios.post('/api/tickets', values);
|
|
||||||
// } catch (err) {
|
|
||||||
// message.error('...');
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// api() <===> todo : use this func to fetch ...
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
console.log('Ticket submitted (placeholder):', values);
|
|
||||||
message.success(t.ticket.successMsg);
|
|
||||||
form.resetFields();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="support" className="relative px-6 md:px-16 py-20 md:py-28 bg-surface border-t border-subtle">
|
|
||||||
<div className="max-w-2xl mx-auto">
|
|
||||||
<div className="text-center mb-10">
|
|
||||||
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.ticket.eyebrow}</span>
|
|
||||||
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text mt-3 mb-3">
|
|
||||||
{t.ticket.title}
|
|
||||||
</h2>
|
|
||||||
<p className="text-secondary text-sm md:text-base">{t.ticket.subtitle}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
form={form}
|
|
||||||
layout="vertical"
|
|
||||||
onFinish={handleFinish}
|
|
||||||
requiredMark={false}
|
|
||||||
initialValues={{ priority: 'medium' }}
|
|
||||||
>
|
|
||||||
<div className="grid md:grid-cols-2 gap-x-4">
|
|
||||||
<Form.Item
|
|
||||||
label={<span className="text-secondary">{t.ticket.name}</span>}
|
|
||||||
name="name"
|
|
||||||
rules={[{ required: true, message: t.ticket.requiredMsg }]}
|
|
||||||
>
|
|
||||||
<Input size="large" placeholder={t.ticket.namePlaceholder} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
label={<span className="text-secondary">{t.ticket.phone}</span>}
|
|
||||||
name="phone"
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: t.ticket.requiredMsg },
|
|
||||||
{ pattern: /^[0-9+\s-]{7,15}$/, message: t.ticket.invalidPhone },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input size="large" placeholder={t.ticket.phonePlaceholder} dir="ltr" />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-x-4">
|
|
||||||
<Form.Item
|
|
||||||
label={<span className="text-secondary">{t.ticket.email}</span>}
|
|
||||||
name="email"
|
|
||||||
rules={[{ type: 'email', message: t.ticket.invalidEmail }]}
|
|
||||||
>
|
|
||||||
<Input size="large" placeholder={t.ticket.emailPlaceholder} dir="ltr" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
label={<span className="text-secondary">{t.ticket.priority}</span>}
|
|
||||||
name="priority"
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
size="large"
|
|
||||||
options={t.ticket.priorities.map((p) => ({ value: p.value, label: p.label }))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
label={<span className="text-secondary">{t.ticket.subject}</span>}
|
|
||||||
name="subject"
|
|
||||||
rules={[{ required: true, message: t.ticket.requiredMsg }]}
|
|
||||||
>
|
|
||||||
<Input size="large" placeholder={t.ticket.subjectPlaceholder} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
label={<span className="text-secondary">{t.ticket.message}</span>}
|
|
||||||
name="message"
|
|
||||||
rules={[{ required: true, message: t.ticket.requiredMsg }]}
|
|
||||||
>
|
|
||||||
<TextArea rows={5} placeholder={t.ticket.messagePlaceholder} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item className="mb-0">
|
|
||||||
<Button type="primary" size="large" htmlType="submit" block>
|
|
||||||
{t.ticket.submit}
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* سکشن «مشتریانی که به ما اعتماد کردهاند» — یک نوار لوگوی بیپایان (marquee)
|
|
||||||
* که بهآرامی و پیوسته حرکت میکند. برای ساخت افکت حلقهی بیپایان، لیست
|
|
||||||
* لوگوها را دو بار پشتسرهم رندر میکنیم و با انیمیشن CSS آن را جابهجا میکنیم.
|
|
||||||
*
|
|
||||||
* نکته فنی: مسیر (track) همیشه جهت LTR دارد، چون منطق «حرکت به چپ» انیمیشن
|
|
||||||
* فرض بر همین جهت است — دقیقاً همان مشکلی که در اسکرول افقی محصولات داشتیم و
|
|
||||||
* حلش کردیم. اینجا هم برای جلوگیری از همان باگ، جهت را ثابت نگه داشتیم.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useLanguage } from '@/app/context/LanguageContext';
|
|
||||||
|
|
||||||
export default function TrustedBySection() {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
const clients = t.trustedBy.clients;
|
|
||||||
// لیست لوگوها را دو برابر میکنیم تا وقتی نیمه اول با انیمیشن رد شد،
|
|
||||||
// نیمه دوم دقیقاً از همانجا ادامه بدهد و چشم متوجه پرش/شکاف نشود.
|
|
||||||
const doubledClients = [...clients, ...clients];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="relative px-6 md:px-16 py-16 md:py-20 bg-page border-t border-subtle overflow-hidden">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<div className="text-center mb-10">
|
|
||||||
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.trustedBy.eyebrow}</span>
|
|
||||||
<h2 className="text-xl md:text-2xl font-extrabold text-primary mt-2">{t.trustedBy.title}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* یک محو تدریجی (fade mask) دو طرف نوار، تا لبههای لوگوها بریده بهنظر نرسد */}
|
|
||||||
<div
|
|
||||||
className="relative"
|
|
||||||
style={{
|
|
||||||
maskImage: 'linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%)',
|
|
||||||
WebkitMaskImage: 'linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex w-max animate-marquee gap-10" dir="ltr">
|
|
||||||
{doubledClients.map((client, i) => (
|
|
||||||
<div
|
|
||||||
key={`${client.id}-${i}`}
|
|
||||||
className="w-40 md:w-48 shrink-0 flex items-center justify-center bg-surface border border-subtle rounded-xl p-4 opacity-80 hover:opacity-100 transition-opacity"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={`/images/clients/client-${((i % clients.length) + 1)}.svg`}
|
|
||||||
alt={client.name}
|
|
||||||
className="w-full h-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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.',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type {Locale} from '@/types/domain';
|
||||||
|
|
||||||
|
const fa = {
|
||||||
|
allProducts: 'همه محصولات',
|
||||||
|
featuredProducts: 'محصولات منتخب',
|
||||||
|
productsDescription: 'دستگاه مناسب خود را انتخاب کنید و مشخصات آن را بررسی کنید.',
|
||||||
|
category: 'دستهبندی محصولات',
|
||||||
|
allCategories: 'همه دستهبندیها',
|
||||||
|
clearFilters: 'پاک کردن فیلتر',
|
||||||
|
noProducts: 'محصولی با این دستهبندی پیدا نشد.',
|
||||||
|
unknownCategory: 'دستهبندی نامعتبر',
|
||||||
|
results: 'محصول',
|
||||||
|
details: 'جزئیات محصول',
|
||||||
|
increase: 'افزایش تعداد',
|
||||||
|
decrease: 'کاهش تعداد',
|
||||||
|
quantity: 'تعداد',
|
||||||
|
remove: 'حذف از سبد',
|
||||||
|
continueShopping: 'مشاهده دستگاهها',
|
||||||
|
cartDescription: 'دستگاههای انتخابشده و تعداد آنها را اینجا بررسی کنید.',
|
||||||
|
summary: 'خلاصه سبد خرید',
|
||||||
|
unitPrice: 'قیمت هر دستگاه',
|
||||||
|
lineTotal: 'جمع این دستگاه',
|
||||||
|
currency: 'ریال',
|
||||||
|
cartLink: 'مشاهده سبد خرید',
|
||||||
|
specifications: 'ویژگیهای دستگاه',
|
||||||
|
model: 'مدل دستگاه',
|
||||||
|
gallery: 'تصاویر دستگاه',
|
||||||
|
supportOrderTitle: 'تماس با پشتیبانی و ثبت سفارش',
|
||||||
|
supportOrderDescription: 'برای مشاوره خرید، بررسی ظرفیت دستگاه و ثبت سفارش مستقیم با تیم فروش تماس بگیرید.',
|
||||||
|
callSupport: 'تماس برای خرید',
|
||||||
|
baseImage: 'نمای اصلی',
|
||||||
|
alternateImage: 'نمای جایگزین',
|
||||||
|
developmentTitle: 'روبات مارکت در حال توسعه است',
|
||||||
|
developmentMessage: 'به روبات مارکت خوش آمدید. سایت در حال توسعه است و هنوز تمام بخشهای آن تکمیل نشدهاند.',
|
||||||
|
acknowledge: 'متوجه شدم، ادامه میدهم',
|
||||||
|
checkoutUnavailable: 'ثبت نهایی سفارش و پرداخت آنلاین هنوز فعال نیست.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commerceCopy: Record<Locale, Record<keyof typeof fa, string>> = {
|
||||||
|
fa,
|
||||||
|
en: {
|
||||||
|
allProducts: 'All products',
|
||||||
|
featuredProducts: 'Featured products',
|
||||||
|
productsDescription: 'Choose a machine and explore its specifications.',
|
||||||
|
category: 'Product category',
|
||||||
|
allCategories: 'All categories',
|
||||||
|
clearFilters: 'Clear filter',
|
||||||
|
noProducts: 'No products found in this category.',
|
||||||
|
unknownCategory: 'Unknown category',
|
||||||
|
results: 'products',
|
||||||
|
details: 'Product details',
|
||||||
|
increase: 'Increase quantity',
|
||||||
|
decrease: 'Decrease quantity',
|
||||||
|
quantity: 'Quantity',
|
||||||
|
remove: 'Remove from cart',
|
||||||
|
continueShopping: 'Browse machines',
|
||||||
|
cartDescription: 'Review your selected machines and adjust their quantities.',
|
||||||
|
summary: 'Cart summary',
|
||||||
|
unitPrice: 'Unit price',
|
||||||
|
lineTotal: 'Item total',
|
||||||
|
currency: 'Rials',
|
||||||
|
cartLink: 'View cart',
|
||||||
|
specifications: 'Machine features',
|
||||||
|
model: 'Machine model',
|
||||||
|
gallery: 'Machine images',
|
||||||
|
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.',
|
||||||
|
acknowledge: 'Got it, continue',
|
||||||
|
checkoutUnavailable: 'Order submission and online payment are not available yet.',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
|
import type {FloatingSnack, Locale, Machine, SiteContent} from '@/types/domain';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Single bilingual content source (fa/en). Every UI string and every piece of
|
// Single bilingual content source (fa/en). Every UI string and every piece of
|
||||||
// business copy lives here, per locale. Components read via useLanguage().
|
// business copy lives here, per locale. Components read via useLanguage().
|
||||||
// Asset paths (images/gifs/video) are locale-independent and stay the same.
|
// Asset paths (images/gifs/video) are locale-independent and stay the same.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const machinesFa = [
|
const machinesFa: Machine[] = [
|
||||||
{
|
{
|
||||||
id: 'rm-35',
|
id: 'rm-35',
|
||||||
|
category: 'warm-vending',
|
||||||
name: 'روبات مارکت ۳۵ کانال',
|
name: 'روبات مارکت ۳۵ کانال',
|
||||||
shortLabel: '۳۵ کانال',
|
shortLabel: '۳۵ کانال',
|
||||||
priceLabel: '4,180,000,000 ریال',
|
priceLabel: '4,680,000,000 ریال',
|
||||||
priceNumeric: 4180000000,
|
priceNumeric: 4680000000,
|
||||||
description: 'یخچالدار، ۳۵ کانال اختصاصی، سیستم خنککننده قدرتمند، تحویل نرم، مدیریت آنلاین ۲۴ ساعته.',
|
description: 'یخچالدار، ۳۵ کانال اختصاصی، سیستم خنککننده قدرتمند، تحویل نرم، مدیریت آنلاین ۲۴ ساعته.',
|
||||||
highlights: ['یخچالدار', '۳۵ کانال اختصاصی', 'تحویل نرم', 'مدیریت آنلاین ۲۴ ساعته'],
|
highlights: ['یخچالدار', '۳۵ کانال اختصاصی', 'تحویل نرم', 'مدیریت آنلاین ۲۴ ساعته'],
|
||||||
image: '/images/machine-35-base.webp',
|
image: '/images/machine-35-base.webp',
|
||||||
@@ -19,10 +22,11 @@ const machinesFa = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rm-48',
|
id: 'rm-48',
|
||||||
|
category: 'warm-vending',
|
||||||
name: 'روبات مارکت ۴۸ کانال',
|
name: 'روبات مارکت ۴۸ کانال',
|
||||||
shortLabel: '۴۸ کانال',
|
shortLabel: '۴۸ کانال',
|
||||||
priceLabel: '4,980,000,000 ریال',
|
priceLabel: '5,480,000,000 ریال',
|
||||||
priceNumeric: 4980000000,
|
priceNumeric: 5480000000,
|
||||||
description: 'یخچالدار، ۴۸ کانال اختصاصی، قابلیتهای پیشرفته مدیریت آنلاین و تحویل کالا.',
|
description: 'یخچالدار، ۴۸ کانال اختصاصی، قابلیتهای پیشرفته مدیریت آنلاین و تحویل کالا.',
|
||||||
highlights: ['یخچالدار', '۴۸ کانال اختصاصی', 'مدیریت آنلاین پیشرفته', 'تحویل هوشمند کالا'],
|
highlights: ['یخچالدار', '۴۸ کانال اختصاصی', 'مدیریت آنلاین پیشرفته', 'تحویل هوشمند کالا'],
|
||||||
image: '/images/machine-48-base.webp',
|
image: '/images/machine-48-base.webp',
|
||||||
@@ -31,10 +35,11 @@ const machinesFa = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rm-60',
|
id: 'rm-60',
|
||||||
|
category: 'warm-vending',
|
||||||
name: 'روبات مارکت ۶۰ کانال',
|
name: 'روبات مارکت ۶۰ کانال',
|
||||||
shortLabel: '۶۰ کانال',
|
shortLabel: '۶۰ کانال',
|
||||||
priceLabel: '5,480,000,000 ریال',
|
priceLabel: '5,980,000,000 ریال',
|
||||||
priceNumeric: 5480000000,
|
priceNumeric: 5980000000,
|
||||||
description: 'یخچالدار، ۶۰ کانال اختصاصی برای کسبوکارهای پرتردد.',
|
description: 'یخچالدار، ۶۰ کانال اختصاصی برای کسبوکارهای پرتردد.',
|
||||||
highlights: ['یخچالدار', '۶۰ کانال اختصاصی', 'مناسب اماکن پرتردد', 'ظرفیت بالا'],
|
highlights: ['یخچالدار', '۶۰ کانال اختصاصی', 'مناسب اماکن پرتردد', 'ظرفیت بالا'],
|
||||||
image: '/images/machine-60-base.webp',
|
image: '/images/machine-60-base.webp',
|
||||||
@@ -43,14 +48,16 @@ const machinesFa = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const machinesEn = [
|
const machinesEn: Machine[] = [
|
||||||
{
|
{
|
||||||
id: 'rm-35',
|
id: 'rm-35',
|
||||||
|
category: 'warm-vending',
|
||||||
name: 'Robot Market RM-35',
|
name: 'Robot Market RM-35',
|
||||||
shortLabel: '35 Channels',
|
shortLabel: '35 Channels',
|
||||||
priceLabel: '4,180,000,000 Rials',
|
priceLabel: '4,680,000,000 Rials',
|
||||||
priceNumeric: 4180000000,
|
priceNumeric: 4680000000,
|
||||||
description: 'Refrigerated, 35 dedicated channels, powerful cooling system, soft delivery, 24/7 online management.',
|
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'],
|
highlights: ['Refrigerated', '35 dedicated channels', 'Soft delivery', '24/7 online management'],
|
||||||
image: '/images/machine-35-base.webp',
|
image: '/images/machine-35-base.webp',
|
||||||
imageAlt: '/images/machine-35-white.webp',
|
imageAlt: '/images/machine-35-white.webp',
|
||||||
@@ -58,10 +65,11 @@ const machinesEn = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rm-48',
|
id: 'rm-48',
|
||||||
|
category: 'warm-vending',
|
||||||
name: 'Robot Market RM-48',
|
name: 'Robot Market RM-48',
|
||||||
shortLabel: '48 Channels',
|
shortLabel: '48 Channels',
|
||||||
priceLabel: '4,980,000,000 Rials',
|
priceLabel: '5,480,000,000 Rials',
|
||||||
priceNumeric: 4980000000,
|
priceNumeric: 5480000000,
|
||||||
description: 'Refrigerated, 48 dedicated channels, advanced online management and delivery capabilities.',
|
description: 'Refrigerated, 48 dedicated channels, advanced online management and delivery capabilities.',
|
||||||
highlights: ['Refrigerated', '48 dedicated channels', 'Advanced online management', 'Smart delivery'],
|
highlights: ['Refrigerated', '48 dedicated channels', 'Advanced online management', 'Smart delivery'],
|
||||||
image: '/images/machine-48-base.webp',
|
image: '/images/machine-48-base.webp',
|
||||||
@@ -70,10 +78,11 @@ const machinesEn = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rm-60',
|
id: 'rm-60',
|
||||||
|
category: 'warm-vending',
|
||||||
name: 'Robot Market RM-60',
|
name: 'Robot Market RM-60',
|
||||||
shortLabel: '60 Channels',
|
shortLabel: '60 Channels',
|
||||||
priceLabel: '5,480,000,000 Rials',
|
priceLabel: '5,980,000,000 Rials',
|
||||||
priceNumeric: 5480000000,
|
priceNumeric: 5980000000,
|
||||||
description: 'Refrigerated, 60 dedicated channels for high-traffic businesses.',
|
description: 'Refrigerated, 60 dedicated channels for high-traffic businesses.',
|
||||||
highlights: ['Refrigerated', '60 dedicated channels', 'Ideal for high-traffic venues', 'High capacity'],
|
highlights: ['Refrigerated', '60 dedicated channels', 'Ideal for high-traffic venues', 'High capacity'],
|
||||||
image: '/images/machine-60-base.webp',
|
image: '/images/machine-60-base.webp',
|
||||||
@@ -82,24 +91,19 @@ const machinesEn = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const content = {
|
export const content: Record<Locale, SiteContent> = {
|
||||||
fa: {
|
fa: {
|
||||||
dir: 'rtl',
|
dir: 'rtl',
|
||||||
lang: 'fa',
|
lang: 'fa',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'روبات مارکت | دستگاههای وندینگ هوشمند',
|
title: 'روبات مارکت | دستگاههای وندینگ هوشمند',
|
||||||
description:
|
description: 'روبات مارکت؛ دستگاههای فروش خودکار هوشمند با مدیریت آنلاین، تحویل نرم و طراحی مدرن.',
|
||||||
'روبات مارکت؛ دستگاههای فروش خودکار هوشمند با مدیریت آنلاین، تحویل نرم و طراحی مدرن.',
|
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
home: 'خانه',
|
home: 'خانه',
|
||||||
showcase: 'دستگاهها',
|
showcase: 'دستگاهها',
|
||||||
about: 'درباره ما',
|
about: 'درباره ما',
|
||||||
locations: 'مکانهای نصب',
|
locations: 'مکانهای نصب',
|
||||||
catalog: 'کاتالوگ محصولات',
|
|
||||||
video: 'ویدیوی معرفی',
|
|
||||||
map: 'نقشه',
|
|
||||||
support: 'پشتیبانی',
|
|
||||||
contact: 'تماس با ما',
|
contact: 'تماس با ما',
|
||||||
login: 'ورود به پنل کاربری',
|
login: 'ورود به پنل کاربری',
|
||||||
},
|
},
|
||||||
@@ -136,54 +140,41 @@ export const content = {
|
|||||||
total: 'جمع کل',
|
total: 'جمع کل',
|
||||||
checkout: 'پرداخت / تسویه حساب',
|
checkout: 'پرداخت / تسویه حساب',
|
||||||
checkoutToast: 'درگاه پرداخت بهزودی متصل میشود.',
|
checkoutToast: 'درگاه پرداخت بهزودی متصل میشود.',
|
||||||
addToast: (name) => `${name} به سبد خرید اضافه شد`,
|
addToast: name => `${name} به سبد خرید اضافه شد`,
|
||||||
},
|
},
|
||||||
machineActions: {
|
machineActions: {
|
||||||
addToCart: 'افزودن به سبد خرید',
|
addToCart: 'افزودن به سبد خرید',
|
||||||
quickView: 'مشاهده سریع',
|
|
||||||
wishlistAdd: 'افزودن به علاقهمندیها',
|
wishlistAdd: 'افزودن به علاقهمندیها',
|
||||||
wishlistRemove: 'حذف از علاقهمندیها',
|
wishlistRemove: 'حذف از علاقهمندیها',
|
||||||
hoverHint: 'نشانگر را روی دستگاه حرکت دهید',
|
hoverHint: 'نشانگر را روی دستگاه حرکت دهید',
|
||||||
altVariant: 'رنگ جایگزین',
|
altVariant: 'رنگ جایگزین',
|
||||||
gifAlt: (name) => `نمایش عملکرد ${name}`,
|
gifAlt: name => `نمایش عملکرد ${name}`,
|
||||||
},
|
},
|
||||||
machines: machinesFa,
|
machines: machinesFa,
|
||||||
trustedBy: {
|
|
||||||
eyebrow: 'اعتماد شما، افتخار ماست',
|
|
||||||
title: 'کسبوکارهایی که به روبات مارکت اعتماد کردهاند',
|
|
||||||
clients: [
|
|
||||||
{id: 'c1', name: 'هتل پارسیان'},
|
|
||||||
{id: 'c2', name: 'دانشگاه فردوسی'},
|
|
||||||
{id: 'c3', name: 'بیمارستان رضوی'},
|
|
||||||
{id: 'c4', name: 'فروشگاههای زنجیرهای رفاه'},
|
|
||||||
{id: 'c5', name: 'مجتمع اداری آسمان'},
|
|
||||||
{id: 'c6', name: 'باشگاه ورزشی تلاش'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
features: [
|
features: [
|
||||||
{
|
{
|
||||||
id: 'warranty',
|
id: 'warranty',
|
||||||
icon: 'safety',
|
icon: 'safety',
|
||||||
title: 'ضمانت کالا',
|
title: 'ضمانت کالا',
|
||||||
description: 'کلیه محصولات شرکت دارای یک سال خدمات پس از فروش میباشند.'
|
description: 'کلیه محصولات شرکت دارای یک سال خدمات پس از فروش میباشند.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'consulting',
|
id: 'consulting',
|
||||||
icon: 'consulting',
|
icon: 'consulting',
|
||||||
title: 'مشاوره تخصصی و رایگان',
|
title: 'مشاوره تخصصی و رایگان',
|
||||||
description: 'خدمات مشاوره رایگان در مورد نوع دستگاه و چیدمان محصولات.'
|
description: 'خدمات مشاوره رایگان در مورد نوع دستگاه و چیدمان محصولات.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'installment',
|
id: 'installment',
|
||||||
icon: 'installment',
|
icon: 'installment',
|
||||||
title: 'امکان خرید اقساطی',
|
title: 'امکان خرید اقساطی',
|
||||||
description: 'امکان خرید اقساط با شرایط آسان و مناسب جهت کلیه مشاغل.'
|
description: 'امکان خرید اقساط با شرایط آسان و مناسب جهت کلیه مشاغل.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'packaging',
|
id: 'packaging',
|
||||||
icon: 'packaging',
|
icon: 'packaging',
|
||||||
title: 'بستهبندی ایمن',
|
title: 'بستهبندی ایمن',
|
||||||
description: 'با درخواست شما، محصولات با بستهبندی باکس پالت با هزینهای اندک ارسال میشود.'
|
description: 'با درخواست شما، محصولات با بستهبندی باکس پالت با هزینهای اندک ارسال میشود.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
about: {
|
about: {
|
||||||
@@ -209,151 +200,48 @@ export const content = {
|
|||||||
id: 'hotels',
|
id: 'hotels',
|
||||||
icon: 'hotel',
|
icon: 'hotel',
|
||||||
title: 'هتلها و مراکز اقامتی',
|
title: 'هتلها و مراکز اقامتی',
|
||||||
description: 'هر جایی که جمعیت زیادی در صف انتظار قرار دارند، بهجز درآمد، روبات مارکت جزو امکانات رفاهی آن مکان محسوب شده و میزان رضایت مراجعان را نیز افزایش میدهد.'
|
description:
|
||||||
|
'هر جایی که جمعیت زیادی در صف انتظار قرار دارند، بهجز درآمد، روبات مارکت جزو امکانات رفاهی آن مکان محسوب شده و میزان رضایت مراجعان را نیز افزایش میدهد.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'schools',
|
id: 'schools',
|
||||||
icon: 'school',
|
icon: 'school',
|
||||||
title: 'مراکز آموزشی و مدارس',
|
title: 'مراکز آموزشی و مدارس',
|
||||||
description: 'در صورتی که هر یک از دانشآموزان و دانشجویان برای خرید مایحتاج خود به خارج از مجموعه مراجعه کنند و خدایناکرده اتفاقی بیفتد، مسئولیت آن مستقیماً به عهده مدیر آموزشگاه است. در نتیجه وجود یک روبات مارکت در هر آموزشگاه ضروری است.'
|
description:
|
||||||
|
'در صورتی که هر یک از دانشآموزان و دانشجویان برای خرید مایحتاج خود به خارج از مجموعه مراجعه کنند و خدایناکرده اتفاقی بیفتد، مسئولیت آن مستقیماً به عهده مدیر آموزشگاه است. در نتیجه وجود یک روبات مارکت در هر آموزشگاه ضروری است.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pharmacies',
|
id: 'pharmacies',
|
||||||
icon: 'pharmacy',
|
icon: 'pharmacy',
|
||||||
title: 'داروخانهها و سوپرمارکتها',
|
title: 'داروخانهها و سوپرمارکتها',
|
||||||
description: 'برای فروش محصولات جنسی و پیشگیری، ویژه مشتریانی که برای خرید آن معذوریت دارند.'
|
description: 'برای فروش محصولات جنسی و پیشگیری، ویژه مشتریانی که برای خرید آن معذوریت دارند.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hospitals',
|
id: 'hospitals',
|
||||||
icon: 'hospital',
|
icon: 'hospital',
|
||||||
title: 'بیمارستانها و مراکز درمانی',
|
title: 'بیمارستانها و مراکز درمانی',
|
||||||
description: 'برای فروش محصولات جنسی و پیشگیری، ویژه مشتریانی که برای خرید آن معذوریت دارند.'
|
description: 'برای فروش محصولات جنسی و پیشگیری، ویژه مشتریانی که برای خرید آن معذوریت دارند.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'night-shift',
|
id: 'night-shift',
|
||||||
icon: 'night',
|
icon: 'night',
|
||||||
title: 'کشیک شیفت شب',
|
title: 'کشیک شیفت شب',
|
||||||
description: 'امکان تعبیه روبات مارکت پشت شیشه سکوریت مغازهها نیز وجود دارد تا در زمان تعطیلی فروشگاه و حتی نیمهشبها، روبات زحمتکش در حال خدمتدهی به مشتریان عزیز شما باشد.'
|
description:
|
||||||
|
'امکان تعبیه روبات مارکت پشت شیشه سکوریت مغازهها نیز وجود دارد تا در زمان تعطیلی فروشگاه و حتی نیمهشبها، روبات زحمتکش در حال خدمتدهی به مشتریان عزیز شما باشد.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'specialty',
|
id: 'specialty',
|
||||||
icon: 'specialty',
|
icon: 'specialty',
|
||||||
title: 'محصولات تخصصی',
|
title: 'محصولات تخصصی',
|
||||||
description: 'اگر تولیدکننده هستید یا نماینده تولیدکننده محصولی خاص هستید، یک روبات مارکت میتواند مسئولیت معرفی و عرضه محصولات تخصصی شما را نیز بر عهده بگیرد.'
|
description:
|
||||||
|
'اگر تولیدکننده هستید یا نماینده تولیدکننده محصولی خاص هستید، یک روبات مارکت میتواند مسئولیت معرفی و عرضه محصولات تخصصی شما را نیز بر عهده بگیرد.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
catalog: {
|
|
||||||
eyebrow: 'کاتالوگ محصولات',
|
|
||||||
title: 'محصولات قابل سفارش برای دستگاه شما',
|
|
||||||
subtitle:
|
|
||||||
'با خرید دستگاه روبات مارکت، میتوانید این محصولات را با قیمت عمده و کمتر از بازار سفارش دهید؛ تیم ما هم چیدمان اولیه و شارژ دستگاه شما را انجام میدهد.',
|
|
||||||
addToOrder: 'افزودن به سفارش',
|
|
||||||
originalPriceLabel: 'قیمت بازار',
|
|
||||||
machinePriceLabel: 'قیمت ویژه مشتریان روبات مارکت',
|
|
||||||
addToast: (name) => `${name} به لیست سفارش اضافه شد`,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'saffron',
|
|
||||||
name: 'زعفران ممتاز درجهیک',
|
|
||||||
category: 'زعفران',
|
|
||||||
image: '/images/catalog/saffron.svg',
|
|
||||||
originalPriceNumeric: 12000000,
|
|
||||||
priceNumeric: 8900000
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'chips',
|
|
||||||
name: 'چیپس و پفک (بسته عمده)',
|
|
||||||
category: 'تنقلات',
|
|
||||||
image: '/images/snacks/chips-1.svg',
|
|
||||||
originalPriceNumeric: 450000,
|
|
||||||
priceNumeric: 320000
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'soda',
|
|
||||||
name: 'نوشابه و نوشیدنی گازدار',
|
|
||||||
category: 'نوشیدنی',
|
|
||||||
image: '/images/snacks/soda-1.svg',
|
|
||||||
originalPriceNumeric: 250000,
|
|
||||||
priceNumeric: 180000
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'water',
|
|
||||||
name: 'آب معدنی',
|
|
||||||
category: 'نوشیدنی',
|
|
||||||
image: '/images/catalog/water.svg',
|
|
||||||
originalPriceNumeric: 120000,
|
|
||||||
priceNumeric: 80000
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'coffee',
|
|
||||||
name: 'قهوه فوری',
|
|
||||||
category: 'گرم',
|
|
||||||
image: '/images/catalog/coffee.svg',
|
|
||||||
originalPriceNumeric: 650000,
|
|
||||||
priceNumeric: 470000
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tea',
|
|
||||||
name: 'چای کیسهای',
|
|
||||||
category: 'گرم',
|
|
||||||
image: '/images/catalog/tea.svg',
|
|
||||||
originalPriceNumeric: 380000,
|
|
||||||
priceNumeric: 270000
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
introVideo: {
|
|
||||||
eyebrow: 'ویدیوی معرفی',
|
|
||||||
title: 'با روبات مارکت آشنا شوید',
|
|
||||||
subtitle: 'نگاهی کوتاه به کارخانه، فرآیند تولید و عملکرد دستگاههای روبات مارکت.',
|
|
||||||
videoNotSupported: 'مرورگر شما از پخش ویدیو پشتیبانی نمیکند.',
|
|
||||||
},
|
|
||||||
map: {
|
|
||||||
eyebrow: 'موقعیت ما روی نقشه',
|
|
||||||
title: 'کارخانه و دفاتر روبات مارکت',
|
|
||||||
factoryLabel: 'کارخانه',
|
|
||||||
salesLabel: 'دفتر فروش مرکزی',
|
|
||||||
openInMaps: 'مشاهده در نشان',
|
|
||||||
configNotice: 'برای فعال شدن نقشه، کلید API نشان را در تنظیمات پروژه قرار دهید.',
|
|
||||||
},
|
|
||||||
ticket: {
|
|
||||||
eyebrow: 'پشتیبانی',
|
|
||||||
title: 'ثبت تیکت پشتیبانی',
|
|
||||||
subtitle: 'سوال یا درخواستی دارید؟ فرم زیر را پر کنید تا تیم پشتیبانی در سریعترین زمان با شما تماس بگیرد.',
|
|
||||||
name: 'نام و نام خانوادگی',
|
|
||||||
namePlaceholder: 'مثال: علی رضایی',
|
|
||||||
phone: 'شماره تماس',
|
|
||||||
phonePlaceholder: '09xxxxxxxxx',
|
|
||||||
email: 'ایمیل (اختیاری)',
|
|
||||||
emailPlaceholder: 'example@email.com',
|
|
||||||
subject: 'موضوع',
|
|
||||||
subjectPlaceholder: 'موضوع درخواست خود را بنویسید',
|
|
||||||
priority: 'اولویت',
|
|
||||||
priorities: [
|
|
||||||
{value: 'low', label: 'کم'},
|
|
||||||
{value: 'medium', label: 'متوسط'},
|
|
||||||
{value: 'high', label: 'فوری'},
|
|
||||||
],
|
|
||||||
message: 'پیام شما',
|
|
||||||
messagePlaceholder: 'توضیحات کامل درخواست یا مشکل خود را بنویسید…',
|
|
||||||
submit: 'ارسال تیکت',
|
|
||||||
requiredMsg: 'این فیلد الزامی است',
|
|
||||||
invalidPhone: 'شماره تماس معتبر نیست',
|
|
||||||
invalidEmail: 'ایمیل معتبر نیست',
|
|
||||||
successMsg: 'تیکت شما با موفقیت ثبت شد. بهزودی با شما تماس میگیریم.',
|
|
||||||
},
|
|
||||||
chat: {
|
|
||||||
fabLabel: 'گفتگوی آنلاین',
|
|
||||||
title: 'پشتیبانی آنلاین روبات مارکت',
|
|
||||||
placeholder: 'پیام خود را بنویسید…',
|
|
||||||
send: 'ارسال',
|
|
||||||
initialBotMessage: 'سلام! 👋 چطور میتونم امروز کمکتون کنم؟',
|
|
||||||
fallbackReply: 'ممنون از پیامتون؛ همکاران ما بهزودی پاسخ میدهند.',
|
|
||||||
},
|
|
||||||
footer: {
|
footer: {
|
||||||
aboutLabel: 'روبات مارکت',
|
aboutLabel: 'روبات مارکت',
|
||||||
aboutText: 'ارائهدهنده دستگاههای وندینگ هوشمند برای کسبوکارهای مدرن — محصولی از شرکت نگین سازان پیشگام رادمهر آتوسا.',
|
aboutText:
|
||||||
|
'ارائهدهنده دستگاههای وندینگ هوشمند برای کسبوکارهای مدرن — محصولی از شرکت نگین سازان پیشگام رادمهر آتوسا.',
|
||||||
quickLinks: 'دسترسی سریع',
|
quickLinks: 'دسترسی سریع',
|
||||||
contactTitle: 'تماس با ما',
|
contactTitle: 'تماس با ما',
|
||||||
factoryLabel: 'کارخانه',
|
factoryLabel: 'کارخانه',
|
||||||
@@ -363,7 +251,8 @@ export const content = {
|
|||||||
contactInfo: {
|
contactInfo: {
|
||||||
orderPhone: '۰۹۱۵۱۶۰۱۸۴۷',
|
orderPhone: '۰۹۱۵۱۶۰۱۸۴۷',
|
||||||
orderPhoneHref: '09151601847',
|
orderPhoneHref: '09151601847',
|
||||||
factoryAddress: 'مشهد، کیلومتر ۸ جاده مشهد - چناران، دور برگردان اصلی شهرک صنعتی توس به سمت مشهد، بعد از نیروگاه، اولین فرعی سمت راست (خیابان کاله)، اولین کوچه سمت راست، پلاک ۱۱۰، شرکت نگین سازان پیشگام رادمهر آتوسا',
|
factoryAddress:
|
||||||
|
'مشهد، کیلومتر ۸ جاده مشهد - چناران، دور برگردان اصلی شهرک صنعتی توس به سمت مشهد، بعد از نیروگاه، اولین فرعی سمت راست (خیابان کاله)، اولین کوچه سمت راست، پلاک ۱۱۰، شرکت نگین سازان پیشگام رادمهر آتوسا',
|
||||||
salesOfficeAddress: 'مشهد، نبش پیامبر اعظم ۷، جنب بیمارستان رضوی، برج نخل، واحد ۴۰۱',
|
salesOfficeAddress: 'مشهد، نبش پیامبر اعظم ۷، جنب بیمارستان رضوی، برج نخل، واحد ۴۰۱',
|
||||||
socialLinks: [
|
socialLinks: [
|
||||||
{id: 'instagram', label: 'صفحه اینستاگرام', href: 'http://instagram.com/vending_machin_life'},
|
{id: 'instagram', label: 'صفحه اینستاگرام', href: 'http://instagram.com/vending_machin_life'},
|
||||||
@@ -386,10 +275,6 @@ export const content = {
|
|||||||
showcase: 'Machines',
|
showcase: 'Machines',
|
||||||
about: 'About Us',
|
about: 'About Us',
|
||||||
locations: 'Where to Install',
|
locations: 'Where to Install',
|
||||||
catalog: 'Product Catalog',
|
|
||||||
video: 'Intro Video',
|
|
||||||
map: 'Map',
|
|
||||||
support: 'Support',
|
|
||||||
contact: 'Contact',
|
contact: 'Contact',
|
||||||
login: 'Sign In',
|
login: 'Sign In',
|
||||||
},
|
},
|
||||||
@@ -426,55 +311,41 @@ export const content = {
|
|||||||
total: 'Total',
|
total: 'Total',
|
||||||
checkout: 'Checkout',
|
checkout: 'Checkout',
|
||||||
checkoutToast: 'Payment gateway will be connected soon.',
|
checkoutToast: 'Payment gateway will be connected soon.',
|
||||||
addToast: (name) => `${name} added to cart`,
|
addToast: name => `${name} added to cart`,
|
||||||
},
|
},
|
||||||
machineActions: {
|
machineActions: {
|
||||||
addToCart: 'Add to Cart',
|
addToCart: 'Add to Cart',
|
||||||
quickView: 'Quick View',
|
|
||||||
wishlistAdd: 'Add to Wishlist',
|
wishlistAdd: 'Add to Wishlist',
|
||||||
wishlistRemove: 'Remove from Wishlist',
|
wishlistRemove: 'Remove from Wishlist',
|
||||||
hoverHint: 'Move your cursor over the machine',
|
hoverHint: 'Move your cursor over the machine',
|
||||||
altVariant: 'Alternate color',
|
altVariant: 'Alternate color',
|
||||||
gifAlt: (name) => `${name} in action`,
|
gifAlt: name => `${name} in action`,
|
||||||
},
|
},
|
||||||
machines: machinesEn,
|
machines: machinesEn,
|
||||||
trustedBy: {
|
|
||||||
eyebrow: 'Trusted by businesses like yours',
|
|
||||||
title: 'Businesses That Trust Robot Market',
|
|
||||||
// Sample/placeholder names — replace with real client names.
|
|
||||||
clients: [
|
|
||||||
{id: 'c1', name: 'Parsian Hotel'},
|
|
||||||
{id: 'c2', name: 'Ferdowsi University'},
|
|
||||||
{id: 'c3', name: 'Razavi Hospital'},
|
|
||||||
{id: 'c4', name: 'Refah Retail Chain'},
|
|
||||||
{id: 'c5', name: 'Asemaan Office Complex'},
|
|
||||||
{id: 'c6', name: 'Talash Sports Club'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
features: [
|
features: [
|
||||||
{
|
{
|
||||||
id: 'warranty',
|
id: 'warranty',
|
||||||
icon: 'safety',
|
icon: 'safety',
|
||||||
title: 'Product Warranty',
|
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',
|
id: 'consulting',
|
||||||
icon: 'consulting',
|
icon: 'consulting',
|
||||||
title: 'Free Expert 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',
|
id: 'installment',
|
||||||
icon: 'installment',
|
icon: 'installment',
|
||||||
title: 'Installment Purchase',
|
title: 'Installment Purchase',
|
||||||
description: 'Flexible, easy installment plans suitable for any business.'
|
description: 'Flexible, easy installment plans suitable for any business.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'packaging',
|
id: 'packaging',
|
||||||
icon: 'packaging',
|
icon: 'packaging',
|
||||||
title: 'Secure 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: {
|
about: {
|
||||||
@@ -485,12 +356,12 @@ export const content = {
|
|||||||
paragraphs: [
|
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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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: {
|
locations: {
|
||||||
@@ -500,151 +371,50 @@ export const content = {
|
|||||||
id: 'hotels',
|
id: 'hotels',
|
||||||
icon: 'hotel',
|
icon: 'hotel',
|
||||||
title: 'Hotels & Accommodations',
|
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',
|
id: 'schools',
|
||||||
icon: 'school',
|
icon: 'school',
|
||||||
title: 'Schools & Educational Centers',
|
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',
|
id: 'pharmacies',
|
||||||
icon: 'pharmacy',
|
icon: 'pharmacy',
|
||||||
title: 'Pharmacies & Supermarkets',
|
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',
|
id: 'hospitals',
|
||||||
icon: 'hospital',
|
icon: 'hospital',
|
||||||
title: 'Hospitals & Medical Centers',
|
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',
|
id: 'night-shift',
|
||||||
icon: 'night',
|
icon: 'night',
|
||||||
title: 'Night-Shift Coverage',
|
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',
|
id: 'specialty',
|
||||||
icon: 'specialty',
|
icon: 'specialty',
|
||||||
title: 'Specialty Products',
|
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.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
catalog: {
|
|
||||||
eyebrow: 'Product Catalog',
|
|
||||||
title: 'Products You Can Order for Your Machine',
|
|
||||||
subtitle:
|
|
||||||
'When you buy a Robot Market machine, you can order these products at a wholesale price below market rate — our team also handles the initial stocking and layout for you.',
|
|
||||||
addToOrder: 'Add to Order',
|
|
||||||
originalPriceLabel: 'Market price',
|
|
||||||
machinePriceLabel: 'Robot Market customer price',
|
|
||||||
addToast: (name) => `${name} added to your order`,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'saffron',
|
|
||||||
name: 'Premium Grade-1 Saffron',
|
|
||||||
category: 'Saffron',
|
|
||||||
image: '/images/catalog/saffron.svg',
|
|
||||||
originalPriceNumeric: 12000000,
|
|
||||||
priceNumeric: 8900000
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'chips',
|
|
||||||
name: 'Chips & Crisps (bulk pack)',
|
|
||||||
category: 'Snacks',
|
|
||||||
image: '/images/snacks/chips-1.svg',
|
|
||||||
originalPriceNumeric: 450000,
|
|
||||||
priceNumeric: 320000
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'soda',
|
|
||||||
name: 'Soda & Carbonated Drinks',
|
|
||||||
category: 'Drinks',
|
|
||||||
image: '/images/snacks/soda-1.svg',
|
|
||||||
originalPriceNumeric: 250000,
|
|
||||||
priceNumeric: 180000
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'water',
|
|
||||||
name: 'Mineral Water',
|
|
||||||
category: 'Drinks',
|
|
||||||
image: '/images/catalog/water.svg',
|
|
||||||
originalPriceNumeric: 120000,
|
|
||||||
priceNumeric: 80000
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'coffee',
|
|
||||||
name: 'Instant Coffee',
|
|
||||||
category: 'Hot Drinks',
|
|
||||||
image: '/images/catalog/coffee.svg',
|
|
||||||
originalPriceNumeric: 650000,
|
|
||||||
priceNumeric: 470000
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tea',
|
|
||||||
name: 'Tea Bags',
|
|
||||||
category: 'Hot Drinks',
|
|
||||||
image: '/images/catalog/tea.svg',
|
|
||||||
originalPriceNumeric: 380000,
|
|
||||||
priceNumeric: 270000
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
introVideo: {
|
|
||||||
eyebrow: 'Intro Video',
|
|
||||||
title: 'Get to Know Robot Market',
|
|
||||||
subtitle: 'A short look at the factory, our production process, and the machines in action.',
|
|
||||||
videoNotSupported: 'Your browser does not support video playback.',
|
|
||||||
},
|
|
||||||
map: {
|
|
||||||
eyebrow: 'Find Us on the Map',
|
|
||||||
title: 'Robot Market Factory & Offices',
|
|
||||||
factoryLabel: 'Factory',
|
|
||||||
salesLabel: 'Central Sales Office',
|
|
||||||
openInMaps: 'Open in Neshan',
|
|
||||||
configNotice: 'Add your Neshan API key in the project settings to activate the map.',
|
|
||||||
},
|
|
||||||
ticket: {
|
|
||||||
eyebrow: 'Support',
|
|
||||||
title: 'Submit a Support Ticket',
|
|
||||||
subtitle: 'Have a question or request? Fill out the form below and our support team will get back to you as soon as possible.',
|
|
||||||
name: 'Full Name',
|
|
||||||
namePlaceholder: 'e.g. John Smith',
|
|
||||||
phone: 'Phone Number',
|
|
||||||
phonePlaceholder: '09xxxxxxxxx',
|
|
||||||
email: 'Email (optional)',
|
|
||||||
emailPlaceholder: 'example@email.com',
|
|
||||||
subject: 'Subject',
|
|
||||||
subjectPlaceholder: 'Write the subject of your request',
|
|
||||||
priority: 'Priority',
|
|
||||||
priorities: [
|
|
||||||
{value: 'low', label: 'Low'},
|
|
||||||
{value: 'medium', label: 'Medium'},
|
|
||||||
{value: 'high', label: 'Urgent'},
|
|
||||||
],
|
|
||||||
message: 'Your Message',
|
|
||||||
messagePlaceholder: 'Describe your request or issue in full…',
|
|
||||||
submit: 'Submit Ticket',
|
|
||||||
requiredMsg: 'This field is required',
|
|
||||||
invalidPhone: 'Invalid phone number',
|
|
||||||
invalidEmail: 'Invalid email address',
|
|
||||||
successMsg: 'Your ticket was submitted successfully. We will contact you soon.',
|
|
||||||
},
|
|
||||||
chat: {
|
|
||||||
fabLabel: 'Live Chat',
|
|
||||||
title: 'Robot Market Live Support',
|
|
||||||
placeholder: 'Type your message…',
|
|
||||||
send: 'Send',
|
|
||||||
initialBotMessage: "Hi! 👋 How can we help you today?",
|
|
||||||
fallbackReply: "Thanks for your message — our team will reply shortly.",
|
|
||||||
},
|
|
||||||
footer: {
|
footer: {
|
||||||
aboutLabel: 'Robot Market',
|
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',
|
quickLinks: 'Quick Links',
|
||||||
contactTitle: 'Contact Us',
|
contactTitle: 'Contact Us',
|
||||||
factoryLabel: 'Factory',
|
factoryLabel: 'Factory',
|
||||||
@@ -654,7 +424,8 @@ export const content = {
|
|||||||
contactInfo: {
|
contactInfo: {
|
||||||
orderPhone: '09151601847',
|
orderPhone: '09151601847',
|
||||||
orderPhoneHref: '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',
|
salesOfficeAddress: 'Mashhad, corner of Payambar-e Azam 7, next to Razavi Hospital, Nakhl Tower, Unit 401',
|
||||||
socialLinks: [
|
socialLinks: [
|
||||||
{id: 'instagram', label: 'Instagram', href: 'http://instagram.com/vending_machin_life'},
|
{id: 'instagram', label: 'Instagram', href: 'http://instagram.com/vending_machin_life'},
|
||||||
@@ -666,7 +437,7 @@ export const content = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Locale-independent asset config (positions/paths only — same in both languages).
|
// Locale-independent asset config (positions/paths only — same in both languages).
|
||||||
export const floatingSnacks = [
|
export const floatingSnacks: FloatingSnack[] = [
|
||||||
{id: 'chips-1', image: '/images/snacks/item-1.webp', size: 60, top: '5%', left: '-5%', duration: 5.5, delay: 0},
|
{id: 'chips-1', image: '/images/snacks/item-1.webp', size: 60, top: '5%', left: '-5%', duration: 5.5, delay: 0},
|
||||||
{id: 'soda-1', image: '/images/snacks/item-2.webp', size: 55, top: '68%', left: '2%', duration: 6.2, delay: 0.6},
|
{id: 'soda-1', image: '/images/snacks/item-2.webp', size: 55, top: '68%', left: '2%', duration: 6.2, delay: 0.6},
|
||||||
{id: 'chips-2', image: '/images/snacks/item-3.webp', size: 65, top: '10%', left: '85%', duration: 4.8, delay: 0.3},
|
{id: 'chips-2', image: '/images/snacks/item-3.webp', size: 65, top: '10%', left: '85%', duration: 4.8, delay: 0.3},
|
||||||
@@ -676,15 +447,3 @@ export const floatingSnacks = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const showcaseVideoSrc = '/video/showcase-placeholder.mp4';
|
export const showcaseVideoSrc = '/video/showcase-placeholder.mp4';
|
||||||
|
|
||||||
// Dedicated company introduction video (different from the horizontal-showcase
|
|
||||||
// scroll-scrubbed background video above). Drop the real file at this path.
|
|
||||||
export const introVideoSrc = '/video/company-intro-placeholder.mp4';
|
|
||||||
export const introVideoPoster = '/images/video-poster.svg';
|
|
||||||
|
|
||||||
// Placeholder coordinates for Toos Industrial Town, Mashhad (approximate).
|
|
||||||
// Replace with the exact factory pin — see README.
|
|
||||||
export const mapLocations = {
|
|
||||||
factory: {lat: 36.42, lng: 59.445},
|
|
||||||
salesOffice: {lat: 36.3110, lng: 59.5683}, // approximate central Mashhad placeholder
|
|
||||||
};
|
|
||||||
+1
-1
@@ -4,7 +4,7 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
container_name: robot-market
|
container_name: robot-market
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- '3000:3000'
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
|
|||||||
@@ -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}}]
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"pages": {}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"polyfillFiles": [
|
|
||||||
"static/chunks/polyfills.js"
|
|
||||||
],
|
|
||||||
"devFiles": [],
|
|
||||||
"ampDevFiles": [],
|
|
||||||
"lowPriorityFiles": [
|
|
||||||
"static/development/_buildManifest.js",
|
|
||||||
"static/development/_ssgManifest.js"
|
|
||||||
],
|
|
||||||
"rootMainFiles": [],
|
|
||||||
"pages": {
|
|
||||||
"/_app": []
|
|
||||||
},
|
|
||||||
"ampFirstPages": []
|
|
||||||
}
|
|
||||||
BIN
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
{"type": "commonjs"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
|
|
||||||
@@ -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":[]}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 3,
|
|
||||||
"middleware": {},
|
|
||||||
"functions": {},
|
|
||||||
"sortedMiddleware": []
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
self.__REACT_LOADABLE_MANIFEST="{}"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -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="
|
|
||||||
}
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
-1
@@ -1 +0,0 @@
|
|||||||
self.__BUILD_MANIFEST = {__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},sortedPages:["\u002F_app"]};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
// رندر آیتمهای شناور (چیپس/نوشابه) دور دستگاه با حرکت تصادفی و ملایم GSAP.
|
// Animate decorative snack images with gentle randomized motion.
|
||||||
|
|
||||||
|
import type {FloatingSnack} from '@/types/domain';
|
||||||
import {useEffect, useRef} from 'react';
|
import {useEffect, useRef} from 'react';
|
||||||
import {gsap} from 'gsap';
|
import {gsap} from 'gsap';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the centralized `floatingSnacks` config as absolutely-positioned
|
* Renders the centralized `floatingSnacks` config as absolutely-positioned
|
||||||
* images and gives each one a gentle, randomized floating drift via GSAP.
|
* images and gives each one a gentle, randomized floating drift via GSAP.
|
||||||
* Positions/sizes/images all come from data/content.js — edit there only.
|
* Positions/sizes/images all come from data/content.ts — edit there only.
|
||||||
*/
|
*/
|
||||||
export default function FloatingItems({items}) {
|
export default function FloatingItems({items}: {items: FloatingSnack[]}) {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nodes = containerRef.current?.querySelectorAll('[data-float-item]');
|
const nodes = containerRef.current?.querySelectorAll('[data-float-item]');
|
||||||
@@ -31,12 +32,12 @@ export default function FloatingItems({items}) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => tweens.forEach((t) => t.kill());
|
return () => tweens.forEach(t => t.kill());
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="absolute inset-0 pointer-events-none hidden md:block">
|
<div ref={containerRef} className="absolute inset-0 pointer-events-none hidden md:block">
|
||||||
{items.map((item) => (
|
{items.map(item => (
|
||||||
<img
|
<img
|
||||||
key={item.id}
|
key={item.id}
|
||||||
data-float-item
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useState} from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {CheckCircleOutlined} from '@ant-design/icons';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {commerceCopy} from '@/data/commerce';
|
||||||
|
import QuantityControl from '@/components/QuantityControl';
|
||||||
|
|
||||||
|
export default function ProductDetails({id}: {id: string}) {
|
||||||
|
const {t, locale} = useLanguage();
|
||||||
|
const copy = commerceCopy[locale];
|
||||||
|
const machine = t.machines.find(item => item.id === id);
|
||||||
|
const [alternate, setAlternate] = useState(false);
|
||||||
|
if (!machine) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-7xl mx-auto px-5 md:px-8 pt-28 pb-20">
|
||||||
|
<nav aria-label={copy.details} className="text-sm text-secondary flex flex-wrap gap-3 mb-8">
|
||||||
|
<Link href="/" className="hover:text-brand-400">
|
||||||
|
{t.nav.home}
|
||||||
|
</Link>
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
<Link href="/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"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-center gap-3 mt-6">
|
||||||
|
{[false, true].map(value => (
|
||||||
|
<button
|
||||||
|
key={String(value)}
|
||||||
|
onClick={() => setAlternate(value)}
|
||||||
|
aria-pressed={alternate === value}
|
||||||
|
aria-label={value ? copy.alternateImage : copy.baseImage}
|
||||||
|
className={`rounded-xl border p-2 ${alternate === value ? 'border-brand bg-brand/10' : 'border-subtle'}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={value ? machine.imageAlt : machine.image}
|
||||||
|
alt=""
|
||||||
|
className="h-20 w-20 object-contain"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<p className="text-brand-400 font-semibold">{machine.shortLabel}</p>
|
||||||
|
<h1 className="text-3xl lg:text-4xl font-extrabold text-primary mt-3 leading-relaxed">
|
||||||
|
{machine.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-secondary mt-5 leading-8">{machine.description}</p>
|
||||||
|
<p className="text-sm text-secondary mt-4">
|
||||||
|
{copy.model}: <span dir="ltr">{machine.id.toUpperCase()}</span>
|
||||||
|
</p>
|
||||||
|
<h2 className="font-bold text-primary text-xl mt-8 mb-4">{copy.specifications}</h2>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{machine.highlights.map(feature => (
|
||||||
|
<li key={feature} className="flex gap-3 text-secondary">
|
||||||
|
<CheckCircleOutlined className="text-brand-400" />
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="border-t border-subtle mt-8 pt-6">
|
||||||
|
<p className="text-sm text-secondary">{copy.unitPrice}</p>
|
||||||
|
<p className="text-2xl font-bold text-primary mt-2">{machine.priceLabel}</p>
|
||||||
|
<div className="mt-5 flex flex-wrap gap-4 items-center">
|
||||||
|
<QuantityControl product={machine} />
|
||||||
|
<Link href="/cart" className="text-brand-400 font-semibold hover:underline">
|
||||||
|
{copy.cartLink}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary text-sm leading-7 mt-5">{copy.checkoutUnavailable}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user