Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf6cb67fcf | ||
|
|
54c387bf73 | ||
|
|
57b0cfeac8 | ||
|
|
fb2c394433 | ||
|
|
0381c8b73c | ||
|
|
b93c780fea | ||
|
|
92a3f014a6 | ||
|
|
4c8da3765d | ||
|
|
ae6018a1f0 | ||
|
|
fcded6f139 | ||
|
|
5d9fe204e7 | ||
|
|
1ad215e1bf | ||
|
|
217fe01de8 |
@@ -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,7 +2,8 @@ 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:
|
||||||
@@ -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
|
||||||
|
|||||||
Generated
-246
File diff suppressed because one or more lines are too long
-4
@@ -1,4 +0,0 @@
|
|||||||
<changelist name="Uncommitted_changes_before_Checkout_at_9_20_26,_11_55 AM_[Changes]" date="1789898166789" recycled="true" deleted="true">
|
|
||||||
<option name="PATH" value="$PROJECT_DIR$/.idea/shelf/Uncommitted_changes_before_Checkout_at_9_20_26,_11_55 AM_[Changes]/shelved.patch" />
|
|
||||||
<option name="DESCRIPTION" value="Uncommitted changes before Checkout at 9/20/26, 11:55 AM [Changes]" />
|
|
||||||
</changelist>
|
|
||||||
Generated
+122
-56
@@ -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="FAZ1">
|
<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$/components/ProductsPage.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/ProductsPage.tsx" 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" />
|
||||||
@@ -30,59 +30,59 @@
|
|||||||
<decayedCancelled>
|
<decayedCancelled>
|
||||||
<entry key="MS100" value="1.0" />
|
<entry key="MS100" value="1.0" />
|
||||||
<entry key="MS500" value="1.0" />
|
<entry key="MS500" value="1.0" />
|
||||||
<entry key="S2" value="1.0" />
|
<entry key="S2" value="1.000087252972038" />
|
||||||
<entry key="S5" value="1.0" />
|
<entry key="S5" value="1.0245081193731238" />
|
||||||
<entry key="S10" value="1.0" />
|
<entry key="S10" value="1.2033303929041042" />
|
||||||
<entry key="S30" value="1.0000004701758343" />
|
<entry key="S30" value="2.526416819401647" />
|
||||||
<entry key="S60" value="1.0028638060052324" />
|
<entry key="S60" value="4.055177645414961" />
|
||||||
<entry key="M2" value="1.228725374867468" />
|
<entry key="M2" value="6.613842117661979" />
|
||||||
<entry key="M5" value="4.250728076454436" />
|
<entry key="M5" value="13.292786284149477" />
|
||||||
<entry key="M10" value="9.057099856689712" />
|
<entry key="M10" value="19.047637193847063" />
|
||||||
<entry key="M15" value="11.99587061191686" />
|
<entry key="M15" value="21.88400324113337" />
|
||||||
<entry key="M30" value="16.11837076953653" />
|
<entry key="M30" value="25.442136814414752" />
|
||||||
<entry key="H1" value="19.010632856516875" />
|
<entry key="H1" value="27.80466751680738" />
|
||||||
<entry key="H2" value="23.538026208971672" />
|
<entry key="H2" value="32.05099449690078" />
|
||||||
<entry key="H4" value="33.619941709812636" />
|
<entry key="H4" value="42.01119466369274" />
|
||||||
<entry key="D1" value="60.82950176199853" />
|
<entry key="D1" value="68.95561233852308" />
|
||||||
<entry key="W1" value="69.40165249199089" />
|
<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="0.0" />
|
<entry key="MS500" value="6.173165109562985E-278" />
|
||||||
<entry key="S2" value="2.3885730499652336E-137" />
|
<entry key="S2" value="4.98456166633934E-70" />
|
||||||
<entry key="S5" value="2.2452019521541863E-55" />
|
<entry key="S5" value="1.9013006178680956E-28" />
|
||||||
<entry key="S10" value="4.73835620458634E-28" />
|
<entry key="S10" value="1.3788900274450676E-14" />
|
||||||
<entry key="S30" value="7.79607309213201E-10" />
|
<entry key="S30" value="2.4491942588613115E-5" />
|
||||||
<entry key="S60" value="2.7922225225741823E-5" />
|
<entry key="S60" value="0.00561270246753653" />
|
||||||
<entry key="M2" value="0.005311937671232334" />
|
<entry key="M2" value="0.09687308384134666" />
|
||||||
<entry key="M5" value="0.13786333322655445" />
|
<entry key="M5" value="0.6088965925604509" />
|
||||||
<entry key="M10" value="0.4731691462708206" />
|
<entry key="M10" value="1.2419655618123104" />
|
||||||
<entry key="M15" value="0.744029645841161" />
|
<entry key="M15" value="1.625109194802163" />
|
||||||
<entry key="M30" value="1.2020499827667592" />
|
<entry key="M30" value="2.1768432348200197" />
|
||||||
<entry key="H1" value="1.5555942142762111" />
|
<entry key="H1" value="2.557178317988167" />
|
||||||
<entry key="H2" value="1.9040941673772573" />
|
<entry key="H2" value="2.9129118025914487" />
|
||||||
<entry key="H4" value="2.417860494585188" />
|
<entry key="H4" value="3.4288442997263706" />
|
||||||
<entry key="D1" value="3.588294005920995" />
|
<entry key="D1" value="4.592699670680046" />
|
||||||
<entry key="W1" value="3.9358582269860896" />
|
<entry key="W1" value="4.936597530104908" />
|
||||||
</decayedSelected>
|
</decayedSelected>
|
||||||
<decayedShown>
|
<decayedShown>
|
||||||
<entry key="MS100" value="0.002107867649422708" />
|
<entry key="MS100" value="0.4444213405832851" />
|
||||||
<entry key="MS500" value="0.29158734244711687" />
|
<entry key="MS500" value="0.8502741600641959" />
|
||||||
<entry key="S2" value="0.7348389482848886" />
|
<entry key="S2" value="0.9603446084215367" />
|
||||||
<entry key="S5" value="0.8840500779329719" />
|
<entry key="S5" value="1.0078897996704945" />
|
||||||
<entry key="S10" value="0.9402393726774964" />
|
<entry key="S10" value="1.1930999599005467" />
|
||||||
<entry key="S30" value="0.9796697229086149" />
|
<entry key="S30" value="2.5186943108989044" />
|
||||||
<entry key="S60" value="0.99267069237379" />
|
<entry key="S60" value="4.054093611688289" />
|
||||||
<entry key="M2" value="1.2287187163179756" />
|
<entry key="M2" value="6.702385865993853" />
|
||||||
<entry key="M5" value="4.385011444628511" />
|
<entry key="M5" value="13.890469482072719" />
|
||||||
<entry key="M10" value="9.52702268872706" />
|
<entry key="M10" value="20.28012126997275" />
|
||||||
<entry key="M15" value="12.737053539335015" />
|
<entry key="M15" value="23.50144975042241" />
|
||||||
<entry key="M30" value="17.318470096071508" />
|
<entry key="M30" value="27.614279851343763" />
|
||||||
<entry key="H1" value="20.565060675354452" />
|
<entry key="H1" value="30.359221991616337" />
|
||||||
<entry key="H2" value="25.441441430474942" />
|
<entry key="H2" value="34.96247820400055" />
|
||||||
<entry key="H4" value="36.03738830839267" />
|
<entry key="H4" value="45.43924535528764" />
|
||||||
<entry key="D1" value="64.41769767739929" />
|
<entry key="D1" value="73.5481501056019" />
|
||||||
<entry key="W1" value="73.33749545723742" />
|
<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,12 +117,14 @@
|
|||||||
"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",
|
||||||
"prettierjs.PrettierConfiguration.Package": "/home/hamed/Documents/Projects/GIthubRepo/robot-market-landing/node_modules/prettier",
|
"prettierjs.PrettierConfiguration.Package": "/home/hamed/Documents/Projects/GIthubRepo/robot-market-landing/node_modules/prettier",
|
||||||
"settings.editor.selected.configurable": "settings.javascript.prettier",
|
"settings.editor.selected.configurable": "preferences.lookFeel",
|
||||||
"to.speed.mode.migration.done": "true",
|
"to.speed.mode.migration.done": "true",
|
||||||
"ts.external.directory.path": "/home/hamed/Documents/Projects/GIthubRepo/robot-market-landing/node_modules/typescript/lib",
|
"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"
|
||||||
@@ -127,6 +132,7 @@
|
|||||||
}</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" />
|
||||||
@@ -213,6 +219,14 @@
|
|||||||
<workItem from="1789618878095" duration="14000" />
|
<workItem from="1789618878095" duration="14000" />
|
||||||
<workItem from="1789618912128" duration="2222000" />
|
<workItem from="1789618912128" duration="2222000" />
|
||||||
<workItem from="1789622601965" duration="2006000" />
|
<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" />
|
||||||
@@ -542,15 +556,63 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1789619546219</updated>
|
<updated>1789619546219</updated>
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00042" summary="FAZ1">
|
<task id="LOCAL-00042" summary="Improve featured products layout and add support CTA">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
<created>1789900914642</created>
|
<created>1789974962919</created>
|
||||||
<option name="number" value="00042" />
|
<option name="number" value="00042" />
|
||||||
<option name="presentableId" value="LOCAL-00042" />
|
<option name="presentableId" value="LOCAL-00042" />
|
||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1789900914642</updated>
|
<updated>1789974962919</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="43" />
|
<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">
|
||||||
@@ -578,6 +640,10 @@
|
|||||||
<MESSAGE value="chore: format project automatically before starting dev server" />
|
<MESSAGE value="chore: format project automatically before starting dev server" />
|
||||||
<MESSAGE value="chore: configure prettier for consistent WebStorm formatting" />
|
<MESSAGE value="chore: configure prettier for consistent WebStorm formatting" />
|
||||||
<MESSAGE value="FAZ1" />
|
<MESSAGE value="FAZ1" />
|
||||||
<option name="LAST_COMMIT_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,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,37 +1,5 @@
|
|||||||
'use client';
|
import OrdersList from '@/features/dashboard/components/OrdersList';
|
||||||
|
|
||||||
import {useEffect, useState} from 'react';
|
|
||||||
import {Empty, Skeleton} from 'antd';
|
|
||||||
import type {CustomerOrder} from '@/types/account';
|
|
||||||
import {useAuth} from '@/app/context/AuthContext';
|
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
|
||||||
import {accountCopy} from '@/data/account';
|
|
||||||
import {getOrders} from '@/services/orders';
|
|
||||||
import OrderCard from '@/components/dashboard/OrderCard';
|
|
||||||
|
|
||||||
export default function OrdersPage() {
|
export default function OrdersPage() {
|
||||||
const auth = useAuth();
|
return <OrdersList />;
|
||||||
const {locale} = useLanguage();
|
|
||||||
const copy = accountCopy[locale];
|
|
||||||
const [orders, setOrders] = useState<CustomerOrder[] | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (auth.session) getOrders(auth.session.profile).then(setOrders);
|
|
||||||
}, [auth.session]);
|
|
||||||
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} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-81
@@ -1,84 +1,5 @@
|
|||||||
'use client';
|
import DashboardOverview from '@/features/dashboard/components/DashboardOverview';
|
||||||
|
|
||||||
import {useEffect, useMemo, useState} from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import {Progress, Skeleton} from 'antd';
|
|
||||||
import {CheckCircleOutlined, ClockCircleOutlined, FileTextOutlined} from '@ant-design/icons';
|
|
||||||
import type {CustomerOrder} from '@/types/account';
|
|
||||||
import {useAuth} from '@/app/context/AuthContext';
|
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
|
||||||
import {accountCopy} from '@/data/account';
|
|
||||||
import {getOrders} from '@/services/orders';
|
|
||||||
import OrderCard from '@/components/dashboard/OrderCard';
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const auth = useAuth();
|
return <DashboardOverview />;
|
||||||
const {locale} = useLanguage();
|
|
||||||
const copy = accountCopy[locale];
|
|
||||||
const [orders, setOrders] = useState<CustomerOrder[] | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (auth.session) getOrders(auth.session.profile).then(setOrders);
|
|
||||||
}, [auth.session]);
|
|
||||||
const completion = useMemo(() => {
|
|
||||||
if (!auth.session) return 0;
|
|
||||||
const profile = auth.session.profile;
|
|
||||||
const required = [
|
|
||||||
profile.firstName,
|
|
||||||
profile.lastName,
|
|
||||||
profile.province,
|
|
||||||
profile.city,
|
|
||||||
profile.address,
|
|
||||||
profile.postalCode,
|
|
||||||
];
|
|
||||||
return Math.round((required.filter(Boolean).length / required.length) * 100);
|
|
||||||
}, [auth.session]);
|
|
||||||
if (!orders) return <Skeleton active />;
|
|
||||||
const activeOrders = orders.filter(order => !['delivered', 'cancelled'].includes(order.status)).length;
|
|
||||||
const cards = [
|
|
||||||
{
|
|
||||||
label: copy.profileCompletion,
|
|
||||||
value: `${completion.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')}٪`,
|
|
||||||
icon: <CheckCircleOutlined />,
|
|
||||||
extra: <Progress percent={completion} showInfo={false} size="small" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: copy.activeOrders,
|
|
||||||
value: activeOrders.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US'),
|
|
||||||
icon: <ClockCircleOutlined />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: copy.totalOrders,
|
|
||||||
value: orders.length.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US'),
|
|
||||||
icon: <FileTextOutlined />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h1 className="text-3xl font-extrabold text-primary">
|
|
||||||
{copy.welcome} {auth.session?.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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import LocationsSection from '@/components/LocationsSection';
|
import LocationsSection from '@/components/LocationsSection';
|
||||||
import ShowcaseSection from '@/components/ShowcaseSection';
|
import {ShowcaseSection} from '@/features/products';
|
||||||
import FeaturesSection from '@/components/FeaturesSection';
|
import FeaturesSection from '@/components/FeaturesSection';
|
||||||
import AboutSection from '@/components/AboutSection';
|
import AboutSection from '@/components/AboutSection';
|
||||||
import HeroSection from '@/components/HeroSection';
|
import HeroSection from '@/components/HeroSection';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type {Metadata} from 'next';
|
import type {Metadata} from 'next';
|
||||||
import {notFound} from 'next/navigation';
|
import {notFound} from 'next/navigation';
|
||||||
import {content} from '@/data/content';
|
import {content} from '@/data/content';
|
||||||
import ProductDetails from '@/components/ProductDetails';
|
import {ProductDetails} from '@/features/products';
|
||||||
import ProtectedPage from '@/components/auth/ProtectedPage';
|
import ProtectedPage from '@/components/auth/ProtectedPage';
|
||||||
|
|
||||||
type Props = {params: {id: string}};
|
type Props = {params: {id: string}};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Suspense} from 'react';
|
import {Suspense} from 'react';
|
||||||
import type {Metadata} from 'next';
|
import type {Metadata} from 'next';
|
||||||
import ProductsPage from '@/components/ProductsPage';
|
import {ProductsPage} from '@/features/products';
|
||||||
|
|
||||||
export const metadata: Metadata = {title: 'همه محصولات | روبات مارکت'};
|
export const metadata: Metadata = {title: 'همه محصولات | روبات مارکت'};
|
||||||
|
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {useEffect, useRef, useState} from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import {gsap} from 'gsap';
|
|
||||||
import {ScrollTrigger} from 'gsap/ScrollTrigger';
|
|
||||||
import VendingCard from './VendingCard';
|
|
||||||
import {showcaseVideoSrc} from '@/data/content';
|
|
||||||
import {featuredMachines} from '@/data/products';
|
|
||||||
import {commerceCopy} from '@/data/commerce';
|
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
|
||||||
|
|
||||||
export default function HorizontalShowcase() {
|
|
||||||
const sectionRef = useRef<HTMLElement>(null);
|
|
||||||
const trackRef = useRef<HTMLDivElement>(null);
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
const {t, locale} = useLanguage();
|
|
||||||
const copy = commerceCopy[locale];
|
|
||||||
const [videoAvailable, setVideoAvailable] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const ctx = gsap.context(() => {
|
|
||||||
const track = trackRef.current;
|
|
||||||
const section = sectionRef.current;
|
|
||||||
|
|
||||||
if (!track || !section) return;
|
|
||||||
|
|
||||||
const scrollDistance = () => Math.max(track.scrollWidth - window.innerWidth, 0);
|
|
||||||
|
|
||||||
let animationFrameId: number | null = null;
|
|
||||||
let latestProgress = 0;
|
|
||||||
|
|
||||||
const syncVideo = (progress: number) => {
|
|
||||||
latestProgress = progress;
|
|
||||||
if (animationFrameId !== null) return;
|
|
||||||
|
|
||||||
animationFrameId = window.requestAnimationFrame(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (video && Number.isFinite(video.duration) && video.duration > 0) {
|
|
||||||
video.currentTime = latestProgress * video.duration;
|
|
||||||
}
|
|
||||||
animationFrameId = null;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const horizontalTween = gsap.to(track, {
|
|
||||||
x: () => -scrollDistance(),
|
|
||||||
ease: 'none',
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: section,
|
|
||||||
start: 'top top',
|
|
||||||
end: () => `+=${scrollDistance()}`,
|
|
||||||
scrub: 1,
|
|
||||||
pin: true,
|
|
||||||
invalidateOnRefresh: true,
|
|
||||||
onUpdate: self => syncVideo(self.progress),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (animationFrameId !== null) window.cancelAnimationFrame(animationFrameId);
|
|
||||||
horizontalTween.scrollTrigger?.kill();
|
|
||||||
horizontalTween.kill();
|
|
||||||
};
|
|
||||||
}, sectionRef);
|
|
||||||
|
|
||||||
return () => ctx.revert();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="showcase" ref={sectionRef} className="relative w-full overflow-hidden">
|
|
||||||
<div className="absolute inset-0 z-0">
|
|
||||||
{videoAvailable && (
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
src={showcaseVideoSrc}
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
preload="auto"
|
|
||||||
className="w-full h-full object-cover opacity-60"
|
|
||||||
onError={() => setVideoAvailable(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
'linear-gradient(180deg, rgba(4,18,15,0.55) 0%, rgba(7,30,25,0.3) 50%, rgba(4,18,15,0.6) 100%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-20 h-40 pt-20 bg-page-85 backdrop-blur-md">
|
|
||||||
<div className="max-w-7xl h-full mx-auto px-8 flex justify-between items-center gap-4">
|
|
||||||
<h2 className="text-2xl font-bold text-primary">{copy.featuredProducts}</h2>
|
|
||||||
<Link href="/products" className="text-brand-400 hover:underline">
|
|
||||||
{copy.allProducts}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={trackRef}
|
|
||||||
className="horizontal-track relative z-10 [&_.horizontal-panel]:h-full [&_.spotlight-stage]:max-h-[calc(100vh-12rem)]"
|
|
||||||
style={{height: 'calc(100vh - 10rem)'}}
|
|
||||||
dir="ltr"
|
|
||||||
>
|
|
||||||
{featuredMachines(t.machines).map(machine => (
|
|
||||||
<VendingCard key={machine.id} machine={machine} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import Link from 'next/link';
|
|||||||
import type {CustomerOrder} from '@/types/account';
|
import type {CustomerOrder} from '@/types/account';
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
import {accountCopy, paymentStatusLabels} from '@/data/account';
|
import {accountCopy, paymentStatusLabels} from '@/data/account';
|
||||||
|
import {getOrderItemQuantity} from '@/features/dashboard/lib/orderSummary';
|
||||||
import OrderStatusTag from './OrderStatusTag';
|
import OrderStatusTag from './OrderStatusTag';
|
||||||
|
|
||||||
export default function OrderCard({order}: {order: CustomerOrder}) {
|
export default function OrderCard({order}: {order: CustomerOrder}) {
|
||||||
@@ -32,9 +33,7 @@ export default function OrderCard({order}: {order: CustomerOrder}) {
|
|||||||
<div>
|
<div>
|
||||||
<span className="text-secondary block">{copy.orderItems}</span>
|
<span className="text-secondary block">{copy.orderItems}</span>
|
||||||
<span className="text-primary">
|
<span className="text-primary">
|
||||||
{order.items
|
{getOrderItemQuantity(order).toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')}
|
||||||
.reduce((sum, item) => sum + item.quantity, 0)
|
|
||||||
.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ const fa = {
|
|||||||
specifications: 'ویژگیهای دستگاه',
|
specifications: 'ویژگیهای دستگاه',
|
||||||
model: 'مدل دستگاه',
|
model: 'مدل دستگاه',
|
||||||
gallery: 'تصاویر دستگاه',
|
gallery: 'تصاویر دستگاه',
|
||||||
|
supportOrderTitle: 'تماس با پشتیبانی و ثبت سفارش',
|
||||||
|
supportOrderDescription: 'برای مشاوره خرید، بررسی ظرفیت دستگاه و ثبت سفارش مستقیم با تیم فروش تماس بگیرید.',
|
||||||
|
callSupport: 'تماس برای خرید',
|
||||||
baseImage: 'نمای اصلی',
|
baseImage: 'نمای اصلی',
|
||||||
alternateImage: 'نمای جایگزین',
|
alternateImage: 'نمای جایگزین',
|
||||||
developmentTitle: 'روبات مارکت در حال توسعه است',
|
developmentTitle: 'روبات مارکت در حال توسعه است',
|
||||||
@@ -60,6 +63,10 @@ export const commerceCopy: Record<Locale, Record<keyof typeof fa, string>> = {
|
|||||||
specifications: 'Machine features',
|
specifications: 'Machine features',
|
||||||
model: 'Machine model',
|
model: 'Machine model',
|
||||||
gallery: 'Machine images',
|
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',
|
baseImage: 'Main view',
|
||||||
alternateImage: 'Alternate view',
|
alternateImage: 'Alternate view',
|
||||||
developmentTitle: 'Robot Market is under development',
|
developmentTitle: 'Robot Market is under development',
|
||||||
|
|||||||
+12
-12
@@ -12,8 +12,8 @@ const machinesFa: Machine[] = [
|
|||||||
category: 'warm-vending',
|
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',
|
||||||
@@ -25,8 +25,8 @@ const machinesFa: Machine[] = [
|
|||||||
category: 'warm-vending',
|
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',
|
||||||
@@ -38,8 +38,8 @@ const machinesFa: Machine[] = [
|
|||||||
category: 'warm-vending',
|
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',
|
||||||
@@ -54,8 +54,8 @@ const machinesEn: Machine[] = [
|
|||||||
category: 'warm-vending',
|
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:
|
description:
|
||||||
'Refrigerated, 35 dedicated channels, powerful cooling system, soft delivery, 24/7 online management.',
|
'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'],
|
||||||
@@ -68,8 +68,8 @@ const machinesEn: Machine[] = [
|
|||||||
category: 'warm-vending',
|
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',
|
||||||
@@ -81,8 +81,8 @@ const machinesEn: Machine[] = [
|
|||||||
category: 'warm-vending',
|
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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import {PhoneOutlined} from '@ant-design/icons';
|
||||||
import {featuredMachines} from '@/data/products';
|
import {featuredMachines} from '@/features/products/data/catalog';
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
import {commerceCopy} from '@/data/commerce';
|
import {commerceCopy} from '@/data/commerce';
|
||||||
import QuantityControl from './QuantityControl';
|
|
||||||
|
|
||||||
export default function MobileShowcase() {
|
export default function MobileShowcase() {
|
||||||
const {t, dir, locale} = useLanguage();
|
const {t, dir, locale} = useLanguage();
|
||||||
|
const copy = commerceCopy[locale];
|
||||||
|
const contact = t.contactInfo;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="showcase" className="relative w-full py-8 px-4" dir={dir}>
|
<section id="showcase" className="relative w-full py-8 px-4" dir={dir}>
|
||||||
<div className="flex gap-4 overflow-x-auto snap-x snap-mandatory pb-4">
|
<div className="flex flex-col gap-4 pb-4">
|
||||||
{featuredMachines(t.machines).map(machine => (
|
{featuredMachines(t.machines).map(machine => (
|
||||||
<article
|
<article key={machine.id} className="w-full bg-surface border border-subtle rounded-2xl p-4">
|
||||||
key={machine.id}
|
|
||||||
className="w-[90%] shrink-0 snap-start bg-surface border border-subtle rounded-2xl p-4"
|
|
||||||
>
|
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
{/*<Link href={`/products/${machine.id}`} 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" />
|
<img src={machine.image} alt={machine.name} className="w-24 h-28 object-contain shrink-0" />
|
||||||
@@ -31,12 +29,26 @@ export default function MobileShowcase() {
|
|||||||
{/* <div className="flex flex-wrap justify-between items-center gap-3">
|
{/* <div className="flex flex-wrap justify-between items-center gap-3">
|
||||||
<QuantityControl product={machine} />
|
<QuantityControl product={machine} />
|
||||||
<Link href={`/products/${machine.id}`} className="text-sm text-brand-400 hover:underline">
|
<Link href={`/products/${machine.id}`} className="text-sm text-brand-400 hover:underline">
|
||||||
{commerceCopy[locale].details}
|
{copy.details}
|
||||||
</Link>
|
</Link>
|
||||||
</div>*/}
|
</div>*/}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
|||||||
import {CheckCircleOutlined} from '@ant-design/icons';
|
import {CheckCircleOutlined} from '@ant-design/icons';
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
import {commerceCopy} from '@/data/commerce';
|
import {commerceCopy} from '@/data/commerce';
|
||||||
import QuantityControl from './QuantityControl';
|
import QuantityControl from '@/components/QuantityControl';
|
||||||
|
|
||||||
export default function ProductDetails({id}: {id: string}) {
|
export default function ProductDetails({id}: {id: string}) {
|
||||||
const {t, locale} = useLanguage();
|
const {t, locale} = useLanguage();
|
||||||
@@ -4,8 +4,8 @@ import Link from 'next/link';
|
|||||||
import {usePathname, useRouter, useSearchParams} from 'next/navigation';
|
import {usePathname, useRouter, useSearchParams} from 'next/navigation';
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
import {commerceCopy} from '@/data/commerce';
|
import {commerceCopy} from '@/data/commerce';
|
||||||
import {filterMachines, productCategories} from '@/data/products';
|
import {filterMachines, productCategories} from '@/features/products/data/catalog';
|
||||||
import QuantityControl from './QuantityControl';
|
import QuantityControl from '@/components/QuantityControl';
|
||||||
|
|
||||||
export default function ProductsPage() {
|
export default function ProductsPage() {
|
||||||
const {t, locale} = useLanguage();
|
const {t, locale} = useLanguage();
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import {PhoneOutlined} from '@ant-design/icons';
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
import {commerceCopy} from '@/data/commerce';
|
import {commerceCopy} from '@/data/commerce';
|
||||||
import HorizontalShowcase from './HorizontalShowcase';
|
import HorizontalShowcase from './HorizontalShowcase';
|
||||||
@@ -10,7 +11,7 @@ import MobileShowcase from './MobileShowcase';
|
|||||||
const MOBILE_BREAKPOINT = 768;
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
export default function ShowcaseSection() {
|
export default function ShowcaseSection() {
|
||||||
const {locale} = useLanguage();
|
const {t, locale} = useLanguage();
|
||||||
const copy = commerceCopy[locale];
|
const copy = commerceCopy[locale];
|
||||||
const [isMobile, setIsMobile] = useState<boolean | null>(null);
|
const [isMobile, setIsMobile] = useState<boolean | null>(null);
|
||||||
|
|
||||||
@@ -26,7 +27,29 @@ export default function ShowcaseSection() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (isMobile === null) return null;
|
if (isMobile === null) return null;
|
||||||
if (!isMobile) return <HorizontalShowcase />;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -3,9 +3,6 @@
|
|||||||
import type {Machine} from '@/types/domain';
|
import type {Machine} from '@/types/domain';
|
||||||
import type {MouseEvent} from 'react';
|
import type {MouseEvent} from 'react';
|
||||||
import {useRef} from 'react';
|
import {useRef} from 'react';
|
||||||
import QuantityControl from './QuantityControl';
|
|
||||||
import ProtectedLink from './auth/ProtectedLink';
|
|
||||||
import {commerceCopy} from '@/data/commerce';
|
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
import FloatingItems from './FloatingItems';
|
import FloatingItems from './FloatingItems';
|
||||||
import {floatingSnacks} from '@/data/content';
|
import {floatingSnacks} from '@/data/content';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export {default as ProductDetails} from './components/ProductDetails';
|
||||||
|
export {default as ProductsPage} from './components/ProductsPage';
|
||||||
|
export {default as ShowcaseSection} from './components/ShowcaseSection';
|
||||||
Generated
+2939
-4
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -10,7 +10,10 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "5.3.7",
|
"@ant-design/icons": "5.3.7",
|
||||||
@@ -27,13 +30,20 @@
|
|||||||
"react-redux": "9.1.2"
|
"react-redux": "9.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.1",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@types/node": "20.19.43",
|
"@types/node": "20.19.43",
|
||||||
"@types/react": "18.3.31",
|
"@types/react": "18.3.31",
|
||||||
"@types/react-dom": "18.3.7",
|
"@types/react-dom": "18.3.7",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "10.4.19",
|
"autoprefixer": "10.4.19",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"postcss": "8.4.39",
|
"postcss": "8.4.39",
|
||||||
"prettier": "3.9.6",
|
"prettier": "3.9.6",
|
||||||
"tailwindcss": "3.4.6",
|
"tailwindcss": "3.4.6",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import {defineConfig, devices} from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
webServer: {
|
||||||
|
command: 'npx next dev -H 127.0.0.1',
|
||||||
|
url: 'http://127.0.0.1:3000',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://127.0.0.1:3000',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {...devices['Desktop Chrome']},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -2,6 +2,9 @@ import type {CustomerOrder, CustomerProfile} from '@/types/account';
|
|||||||
|
|
||||||
const wait = () => new Promise(resolve => setTimeout(resolve, 300));
|
const wait = () => new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds demo orders with the latest profile fields until the real orders API is connected.
|
||||||
|
*/
|
||||||
function createOrders(customer: CustomerProfile): CustomerOrder[] {
|
function createOrders(customer: CustomerProfile): CustomerOrder[] {
|
||||||
const buyer = {
|
const buyer = {
|
||||||
...customer,
|
...customer,
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import type {Config} from 'tailwindcss';
|
import type {Config} from 'tailwindcss';
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}'],
|
content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}', './features/**/*.{js,jsx,ts,tsx}'],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import {expect, test} from '@playwright/test';
|
||||||
|
|
||||||
|
test('home page exposes the primary shopping journey', async ({page}) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await expect(page).toHaveTitle(/Robot Market|روبات مارکت/);
|
||||||
|
await expect(page.locator('a[href="#showcase"]').first()).toBeVisible();
|
||||||
|
await expect(page.locator('a[href="https://panel.my-rm.com"]').first()).toBeVisible();
|
||||||
|
});
|
||||||
@@ -23,10 +23,16 @@
|
|||||||
"app/**/*.tsx",
|
"app/**/*.tsx",
|
||||||
"components/**/*.tsx",
|
"components/**/*.tsx",
|
||||||
"data/**/*.ts",
|
"data/**/*.ts",
|
||||||
|
"features/**/*.ts",
|
||||||
|
"features/**/*.tsx",
|
||||||
"lib/**/*.ts",
|
"lib/**/*.ts",
|
||||||
|
"tests/**/*.ts",
|
||||||
"store/**/*.ts",
|
"store/**/*.ts",
|
||||||
"types/**/*.ts",
|
"types/**/*.ts",
|
||||||
"tailwind.config.ts",
|
"tailwind.config.ts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"vitest.setup.ts",
|
||||||
|
"playwright.config.ts",
|
||||||
".next/types/**/*.ts"
|
".next/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules", "docs", "out"]
|
"exclude": ["node_modules", "docs", "out"]
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import {defineConfig} from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
include: ['features/**/*.test.ts', 'features/**/*.test.tsx', 'components/**/*.test.tsx'],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': new URL('.', import.meta.url).pathname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
Reference in New Issue
Block a user