Compare commits

...
15 Commits
Author SHA1 Message Date
Hamed bf6cb67fcf Merge branch 'develop' of ssh://git.my-rm.com:2222/Robot-Market/static-front into develop
Build & Deploy (develop) / build-and-deploy (push) Successful in 3m7s
2026-10-13 14:00:37 +03:30
Hamed 54c387bf73 chore: update device prices on website 2026-10-13 13:58:36 +03:30
Hamed 57b0cfeac8 refactor dashboard feature boundaries and add test setup 2026-09-30 12:27:57 +03:30
Hamed fb2c394433 fix(styles): include feature modules in Tailwind content scan 2026-09-30 12:02:08 +03:30
Ryan 0381c8b73c refac:updated ci/cd pipeline to create docker images with timestamp and commit sha
Build & Deploy (develop) / build-and-deploy (push) Successful in 2m11s
2026-09-29 11:05:36 +03:30
Ryan b93c780fea refac:updated ci/cd pipeline to create docker images with timestamp and commit sha
Build & Deploy (develop) / build-and-deploy (push) Successful in 2m17s
2026-09-29 10:59:09 +03:30
Hamed 92a3f014a6 fix(styles): include feature modules in Tailwind content scan
Build & Deploy (develop) / build-and-deploy (push) Successful in 2m13s
2026-09-29 10:00:19 +03:30
Hamed 4c8da3765d refactor(products): organize product showcase into feature module 2026-09-29 09:46:35 +03:30
Hamed ae6018a1f0 refactor(products): organize product showcase into feature module 2026-09-29 09:44:00 +03:30
Hamed fcded6f139 Replace desktop showcase video with scroll-driven color backgrounds 2026-09-29 09:22:23 +03:30
Hamed 5d9fe204e7 Replace desktop showcase video with scroll-driven color backgrounds
Build & Deploy (develop) / build-and-deploy (push) Successful in 2m22s
2026-09-27 10:35:12 +03:30
Hamed 1ad215e1bf Improve featured products layout and add support CTA 2026-09-22 06:19:01 +02:00
Hamed 217fe01de8 Improve featured products layout and add support CTA
Build & Deploy (develop) / build-and-deploy (push) Successful in 2m13s
2026-09-21 06:42:52 +02:00
Hamed 9c54fb1cea FAZ1
Build & Deploy (develop) / build-and-deploy (push) Successful in 2m17s
2026-09-20 12:42:11 +02:00
Hamed a4e3634176 FAZ1 2026-09-20 12:41:53 +02:00
60 changed files with 8801 additions and 4887 deletions
+9 -6
View File
@@ -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)
+11 -2
View File
@@ -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
File diff suppressed because one or more lines are too long
@@ -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>
+158 -84
View File
@@ -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" />
@@ -28,61 +28,61 @@
</component> </component>
<component name="NextEditCompletionFeaturesState"> <component name="NextEditCompletionFeaturesState">
<decayedCancelled> <decayedCancelled>
<entry key="MS100" value="1.028605430515295" /> <entry key="MS100" value="1.0" />
<entry key="MS500" value="2.124649469580447" /> <entry key="MS500" value="1.0" />
<entry key="S2" value="5.757603075779661" /> <entry key="S2" value="1.000087252972038" />
<entry key="S5" value="9.075791406095624" /> <entry key="S5" value="1.0245081193731238" />
<entry key="S10" value="11.490304279732543" /> <entry key="S10" value="1.2033303929041042" />
<entry key="S30" value="14.544855509715473" /> <entry key="S30" value="2.526416819401647" />
<entry key="S60" value="15.928253142043944" /> <entry key="S60" value="4.055177645414961" />
<entry key="M2" value="17.05794166660894" /> <entry key="M2" value="6.613842117661979" />
<entry key="M5" value="18.240030198039076" /> <entry key="M5" value="13.292786284149477" />
<entry key="M10" value="19.085397144523835" /> <entry key="M10" value="19.047637193847063" />
<entry key="M15" value="19.539401536900623" /> <entry key="M15" value="21.88400324113337" />
<entry key="M30" value="20.15328332293758" /> <entry key="M30" value="25.442136814414752" />
<entry key="H1" value="20.794543104303607" /> <entry key="H1" value="27.80466751680738" />
<entry key="H2" value="24.217320209573487" /> <entry key="H2" value="32.05099449690078" />
<entry key="H4" value="33.81335646745446" /> <entry key="H4" value="42.01119466369274" />
<entry key="D1" value="60.18887647810417" /> <entry key="D1" value="68.95561233852308" />
<entry key="W1" value="68.4601968975454" /> <entry key="W1" value="77.42240766540701" />
</decayedCancelled> </decayedCancelled>
<decayedSelected> <decayedSelected>
<entry key="MS100" value="0.0" /> <entry key="MS100" value="0.0" />
<entry key="MS500" value="8.699625509373026E-98" /> <entry key="MS500" value="6.173165109562985E-278" />
<entry key="S2" value="5.430941684423347E-25" /> <entry key="S2" value="4.98456166633934E-70" />
<entry key="S5" value="1.9676599943042008E-10" /> <entry key="S5" value="1.9013006178680956E-28" />
<entry key="S10" value="1.4027330445612952E-5" /> <entry key="S10" value="1.3788900274450676E-14" />
<entry key="S30" value="0.02411709586160527" /> <entry key="S30" value="2.4491942588613115E-5" />
<entry key="S60" value="0.1553011170701436" /> <entry key="S60" value="0.00561270246753653" />
<entry key="M2" value="0.3961550964122234" /> <entry key="M2" value="0.09687308384134666" />
<entry key="M5" value="0.7735594310297959" /> <entry key="M5" value="0.6088965925604509" />
<entry key="M10" value="1.120827746179152" /> <entry key="M10" value="1.2419655618123104" />
<entry key="M15" value="1.3221230513291828" /> <entry key="M15" value="1.625109194802163" />
<entry key="M30" value="1.6023719910246246" /> <entry key="M30" value="2.1768432348200197" />
<entry key="H1" value="1.7960429930071773" /> <entry key="H1" value="2.557178317988167" />
<entry key="H2" value="2.045967012949897" /> <entry key="H2" value="2.9129118025914487" />
<entry key="H4" value="2.5063189725869783" /> <entry key="H4" value="3.4288442997263706" />
<entry key="D1" value="3.6098476224764395" /> <entry key="D1" value="4.592699670680046" />
<entry key="W1" value="3.9392268953128196" /> <entry key="W1" value="4.936597530104908" />
</decayedSelected> </decayedSelected>
<decayedShown> <decayedShown>
<entry key="MS100" value="0.04871208675588815" /> <entry key="MS100" value="0.4444213405832851" />
<entry key="MS500" value="1.5761328815881446" /> <entry key="MS500" value="0.8502741600641959" />
<entry key="S2" value="5.568297668845106" /> <entry key="S2" value="0.9603446084215367" />
<entry key="S5" value="8.989731845795651" /> <entry key="S5" value="1.0078897996704945" />
<entry key="S10" value="11.439232203057065" /> <entry key="S10" value="1.1930999599005467" />
<entry key="S30" value="14.54354096178068" /> <entry key="S30" value="2.5186943108989044" />
<entry key="S60" value="16.064294199401182" /> <entry key="S60" value="4.054093611688289" />
<entry key="M2" value="17.43942775764285" /> <entry key="M2" value="6.702385865993853" />
<entry key="M5" value="19.00501566584518" /> <entry key="M5" value="13.890469482072719" />
<entry key="M10" value="20.200966633195225" /> <entry key="M10" value="20.28012126997275" />
<entry key="M15" value="20.857682272562908" /> <entry key="M15" value="23.50144975042241" />
<entry key="M30" value="21.753511294575368" /> <entry key="M30" value="27.614279851343763" />
<entry key="H1" value="22.589437021258547" /> <entry key="H1" value="30.359221991616337" />
<entry key="H2" value="26.262649646152585" /> <entry key="H2" value="34.96247820400055" />
<entry key="H4" value="36.319290758304845" /> <entry key="H4" value="45.43924535528764" />
<entry key="D1" value="63.798632595714466" /> <entry key="D1" value="73.5481501056019" />
<entry key="W1" value="72.39940953778996" /> <entry key="W1" value="82.35898081352131" />
</decayedShown> </decayedShown>
</component> </component>
<component name="ProjectColorInfo">{ <component name="ProjectColorInfo">{
@@ -90,43 +90,49 @@
&quot;associatedIndex&quot;: 2 &quot;associatedIndex&quot;: 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" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"ASKED_MARK_IGNORED_FILES_AS_EXCLUDED": "true", &quot;ASKED_MARK_IGNORED_FILES_AS_EXCLUDED&quot;: &quot;true&quot;,
"ModuleVcsDetector.initialDetectionPerformed": "true", &quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"RunOnceActivity.typescript.service.memoryLimit.init": "true", &quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
"codeWithMe.voiceChat.enabledByDefault": "false", &quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true", &quot;com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1&quot;: &quot;true&quot;,
"git-widget-placeholder": "develop", &quot;git-widget-placeholder&quot;: &quot;develop&quot;,
"javascript.nodejs.core.library.configured.version": "24.20.0", &quot;javascript.nodejs.core.library.configured.version&quot;: &quot;24.20.0&quot;,
"javascript.nodejs.core.library.typings.version": "24.13.3", &quot;javascript.nodejs.core.library.typings.version&quot;: &quot;24.13.3&quot;,
"javascript.preferred.runtime.type.id": "node", &quot;javascript.preferred.runtime.type.id&quot;: &quot;node&quot;,
"js.debugger.nextJs.config.created.client": "true", &quot;js.debugger.nextJs.config.created.client&quot;: &quot;true&quot;,
"js.debugger.nextJs.config.created.server": "true", &quot;js.debugger.nextJs.config.created.server&quot;: &quot;true&quot;,
"junie.onboarding.icon.badge.shown": "true", &quot;junie.onboarding.icon.badge.shown&quot;: &quot;true&quot;,
"kronk.content.tree.expanded.paths": "", &quot;kronk.content.tree.expanded.paths&quot;: &quot;&quot;,
"last_opened_file_path": "/home/hamed/Documents/Projects/GItLabRepo/device_web", &quot;last_opened_file_path&quot;: &quot;/home/hamed/Documents/Projects/GItLabRepo/device_web&quot;,
"node.js.detected.package.eslint": "true", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"node.js.detected.package.tslint": "true", &quot;node.js.detected.package.jshint&quot;: &quot;true&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"nodejs_package_manager_path": "npm", &quot;node.js.selected.package.jshint&quot;: &quot;&quot;,
"prettierjs.PrettierConfiguration.Package": "/home/hamed/Documents/Projects/GIthubRepo/robot-market-landing/node_modules/prettier", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"settings.editor.selected.configurable": "settings.javascript.prettier", &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
"to.speed.mode.migration.done": "true", &quot;prettierjs.PrettierConfiguration.Package&quot;: &quot;/home/hamed/Documents/Projects/GIthubRepo/robot-market-landing/node_modules/prettier&quot;,
"ts.external.directory.path": "/home/hamed/Documents/Projects/GIthubRepo/robot-market-landing/node_modules/typescript/lib", &quot;settings.editor.selected.configurable&quot;: &quot;preferences.lookFeel&quot;,
"vue.rearranger.settings.migration": "true" &quot;to.speed.mode.migration.done&quot;: &quot;true&quot;,
&quot;ts.external.directory.path&quot;: &quot;/home/hamed/Documents/Projects/GIthubRepo/robot-market-landing/node_modules/typescript/lib&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
} }
}]]></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,7 +556,63 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1789619546219</updated> <updated>1789619546219</updated>
</task> </task>
<option name="localTasksCounter" value="42" /> <task id="LOCAL-00042" summary="Improve featured products layout and add support CTA">
<option name="closed" value="true" />
<created>1789974962919</created>
<option name="number" value="00042" />
<option name="presentableId" value="LOCAL-00042" />
<option name="project" value="LOCAL" />
<updated>1789974962919</updated>
</task>
<task id="LOCAL-00043" summary="Replace desktop showcase video with scroll-driven color backgrounds">
<option name="closed" value="true" />
<created>1790492715355</created>
<option name="number" value="00043" />
<option name="presentableId" value="LOCAL-00043" />
<option name="project" value="LOCAL" />
<updated>1790492715355</updated>
</task>
<task id="LOCAL-00044" summary="Replace desktop showcase video with scroll-driven color backgrounds">
<option name="closed" value="true" />
<created>1790661145102</created>
<option name="number" value="00044" />
<option name="presentableId" value="LOCAL-00044" />
<option name="project" value="LOCAL" />
<updated>1790661145102</updated>
</task>
<task id="LOCAL-00045" summary="refactor(products): organize product showcase into feature module">
<option name="closed" value="true" />
<created>1790662441910</created>
<option name="number" value="00045" />
<option name="presentableId" value="LOCAL-00045" />
<option name="project" value="LOCAL" />
<updated>1790662441910</updated>
</task>
<task id="LOCAL-00046" summary="refactor(products): organize product showcase into feature module">
<option name="closed" value="true" />
<created>1790662596270</created>
<option name="number" value="00046" />
<option name="presentableId" value="LOCAL-00046" />
<option name="project" value="LOCAL" />
<updated>1790662596270</updated>
</task>
<task id="LOCAL-00047" summary="fix(styles): include feature modules in Tailwind content scan">
<option name="closed" value="true" />
<created>1790663421347</created>
<option name="number" value="00047" />
<option name="presentableId" value="LOCAL-00047" />
<option name="project" value="LOCAL" />
<updated>1790663421347</updated>
</task>
<task id="LOCAL-00048" summary="fix(styles): include feature modules in Tailwind content scan">
<option name="closed" value="true" />
<created>1790757130578</created>
<option name="number" value="00048" />
<option name="presentableId" value="LOCAL-00048" />
<option name="project" value="LOCAL" />
<updated>1790757130578</updated>
</task>
<option name="localTasksCounter" value="49" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -570,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.
+2 -34
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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: 'همه محصولات | روبات مارکت'};
-116
View File
@@ -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>
);
}
+2 -3
View File
@@ -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>
+7
View File
@@ -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
View File
@@ -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
View File
@@ -1 +0,0 @@
[{"eventName":"NEXT_CLI_SESSION_STOPPED","payload":{"nextVersion":"14.2.5","nodeVersion":"v26.3.0","cliCommand":"dev","durationMilliseconds":263824,"turboFlag":false,"pagesDir":false,"appDir":true}}]
-3
View File
@@ -1,3 +0,0 @@
{
"pages": {}
}
-16
View File
@@ -1,16 +0,0 @@
{
"polyfillFiles": [
"static/chunks/polyfills.js"
],
"devFiles": [],
"ampDevFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js"
],
"rootMainFiles": [],
"pages": {
"/_app": []
},
"ampFirstPages": []
}
Binary file not shown.
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
{"type": "commonjs"}
-1
View File
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
-1
View File
@@ -1 +0,0 @@
self.__BUILD_MANIFEST={"polyfillFiles":["static/chunks/polyfills.js"],"devFiles":[],"ampDevFiles":[],"lowPriorityFiles":["static/development/_buildManifest.js","static/development/_ssgManifest.js"],"rootMainFiles":[],"pages":{"/_app":[]},"ampFirstPages":[]}
-6
View File
@@ -1,6 +0,0 @@
{
"version": 3,
"middleware": {},
"functions": {},
"sortedMiddleware": []
}
@@ -1 +0,0 @@
self.__REACT_LOADABLE_MANIFEST="{}"
-1
View File
@@ -1 +0,0 @@
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"
-1
View File
@@ -1 +0,0 @@
{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}
-1
View File
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"OV7lo+xPvS/sDjLEddseUuhg7MfbZYxNg/IFGVNi66w=\"\n}"
@@ -1,5 +0,0 @@
{
"node": {},
"edge": {},
"encryptionKey": "OV7lo+xPvS/sDjLEddseUuhg7MfbZYxNg/IFGVNi66w="
}
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
self.__BUILD_MANIFEST = {__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},sortedPages:["\u002F_app"]};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
-1
View File
@@ -1 +0,0 @@
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
-2
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"type": "module"}
@@ -0,0 +1,56 @@
import {describe, expect, it} from 'vitest';
import type {CustomerOrder, CustomerProfile} from '@/types/account';
import {getActiveOrderCount, getOrderItemQuantity, getProfileCompletion} from '../lib/orderSummary';
const completeProfile: CustomerProfile = {
type: 'individual',
phone: '09123456789',
firstName: 'Sara',
lastName: 'Ahmadi',
email: 'sara@example.com',
province: 'Razavi Khorasan',
city: 'Mashhad',
address: 'Main street',
postalCode: '1234567890',
preferredCallTime: '',
companyName: '',
nationalId: '',
economicCode: '',
};
const makeOrder = (status: CustomerOrder['status'], quantities: number[] = [1]): CustomerOrder => ({
id: status,
number: `RM-${status}`,
createdAt: '1405/06/12',
updatedAt: '1405/06/15',
status,
paymentStatus: 'not_payable',
customer: completeProfile,
items: quantities.map((quantity, index) => ({
id: `item-${index}`,
name: 'Robot Market vending machine',
model: 'RM-48',
image: '/images/machine-48-base.webp',
quantity,
unitPrice: 100,
modules: [],
})),
events: [],
});
describe('dashboard order summary helpers', () => {
it('calculates required profile completion percentage', () => {
expect(getProfileCompletion(completeProfile)).toBe(100);
expect(getProfileCompletion({...completeProfile, city: '', postalCode: ''})).toBe(67);
});
it('counts active orders and excludes terminal statuses', () => {
const orders = [makeOrder('under_review'), makeOrder('delivered'), makeOrder('cancelled')];
expect(getActiveOrderCount(orders)).toBe(1);
});
it('totals item quantities for an order card', () => {
expect(getOrderItemQuantity(makeOrder('registered', [1, 2, 4]))).toBe(7);
});
});
@@ -0,0 +1,73 @@
'use client';
import Link from 'next/link';
import {CheckCircleOutlined, ClockCircleOutlined, FileTextOutlined} from '@ant-design/icons';
import {Progress, Skeleton} from 'antd';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import OrderCard from '@/components/dashboard/OrderCard';
import {accountCopy} from '@/data/account';
import {useCustomerOrders} from '../hooks/useCustomerOrders';
import {getActiveOrderCount, getProfileCompletion} from '../lib/orderSummary';
export default function DashboardOverview() {
const auth = useAuth();
const {locale} = useLanguage();
const copy = accountCopy[locale];
const numberLocale = locale === 'fa' ? 'fa-IR' : 'en-US';
const profile = auth.session?.profile;
const orders = useCustomerOrders(profile);
if (!profile || !orders) return <Skeleton active />;
const completion = getProfileCompletion(profile);
const activeOrders = getActiveOrderCount(orders);
const cards = [
{
label: copy.profileCompletion,
value: `${completion.toLocaleString(numberLocale)}٪`,
icon: <CheckCircleOutlined />,
extra: <Progress percent={completion} showInfo={false} size="small" />,
},
{
label: copy.activeOrders,
value: activeOrders.toLocaleString(numberLocale),
icon: <ClockCircleOutlined />,
},
{
label: copy.totalOrders,
value: orders.length.toLocaleString(numberLocale),
icon: <FileTextOutlined />,
},
];
return (
<>
<h1 className="text-3xl font-extrabold text-primary">
{copy.welcome} {profile.firstName}
</h1>
<p className="text-secondary mt-3">{copy.dashboardDescription}</p>
<div className="grid sm:grid-cols-3 gap-4 mt-8">
{cards.map(card => (
<div key={card.label} className="rounded-2xl border border-subtle bg-elevated p-5">
<div className="text-brand-400 text-2xl">{card.icon}</div>
<p className="text-secondary text-sm mt-4">{card.label}</p>
<strong className="text-2xl text-primary block mt-1">{card.value}</strong>
{card.extra && <div className="mt-3">{card.extra}</div>}
</div>
))}
</div>
<div className="flex items-center justify-between gap-4 mt-10 mb-5">
<h2 className="text-xl font-bold text-primary">{copy.recentOrders}</h2>
<Link href="/dashboard/orders" className="text-brand-400">
{copy.viewAll}
</Link>
</div>
<div className="space-y-4">
{orders.slice(0, 2).map(order => (
<OrderCard key={order.id} order={order} />
))}
</div>
</>
);
}
@@ -0,0 +1,34 @@
'use client';
import {Empty, Skeleton} from 'antd';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import OrderCard from '@/components/dashboard/OrderCard';
import {accountCopy} from '@/data/account';
import {useCustomerOrders} from '../hooks/useCustomerOrders';
export default function OrdersList() {
const auth = useAuth();
const {locale} = useLanguage();
const copy = accountCopy[locale];
const profile = auth.session?.profile;
const orders = useCustomerOrders(profile);
return (
<>
<h1 className="text-3xl font-extrabold text-primary">{copy.orders}</h1>
<p className="text-secondary mt-3 mb-7">{copy.dashboardDescription}</p>
{!orders ? (
<Skeleton active />
) : orders.length ? (
<div className="space-y-4">
{orders.map(order => (
<OrderCard key={order.id} order={order} />
))}
</div>
) : (
<Empty description={copy.emptyOrders} />
)}
</>
);
}
@@ -0,0 +1,28 @@
'use client';
import {useEffect, useState} from 'react';
import {getOrders} from '@/services/orders';
import type {CustomerOrder, CustomerProfile} from '@/types/account';
/**
* Loads the signed-in customer's orders after the browser auth profile is available.
*/
export function useCustomerOrders(profile: CustomerProfile | undefined) {
const [orders, setOrders] = useState<CustomerOrder[] | null>(null);
useEffect(() => {
if (!profile) return;
let active = true;
getOrders(profile).then(nextOrders => {
if (active) setOrders(nextOrders);
});
return () => {
active = false;
};
}, [profile]);
return orders;
}
+35
View File
@@ -0,0 +1,35 @@
import type {CustomerOrder, CustomerProfile} from '@/types/account';
const COMPLETE_PROFILE_FIELDS: Array<keyof CustomerProfile> = [
'firstName',
'lastName',
'province',
'city',
'address',
'postalCode',
];
const INACTIVE_ORDER_STATUSES = new Set<CustomerOrder['status']>(['delivered', 'cancelled']);
/**
* Calculates how much of the required buyer profile is ready for order processing.
*/
export function getProfileCompletion(profile: CustomerProfile): number {
const filledFields = COMPLETE_PROFILE_FIELDS.filter(field => Boolean(profile[field]));
return Math.round((filledFields.length / COMPLETE_PROFILE_FIELDS.length) * 100);
}
/**
* Counts orders that still need operational follow-up from the customer or sales team.
*/
export function getActiveOrderCount(orders: CustomerOrder[]): number {
return orders.filter(order => !INACTIVE_ORDER_STATUSES.has(order.status)).length;
}
/**
* Counts all units across an order, regardless of how many product lines it contains.
*/
export function getOrderItemQuantity(order: CustomerOrder): number {
return order.items.reduce((sum, item) => sum + item.quantity, 0);
}
@@ -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();
@@ -77,7 +77,8 @@ export default function ProductsPage() {
{productCategories.find(item => item.id === machine.category)?.label[locale]} {productCategories.find(item => item.id === machine.category)?.label[locale]}
</span> </span>
<h2 className="text-xl font-bold text-primary mt-2"> <h2 className="text-xl font-bold text-primary mt-2">
<Link href={`/products/${machine.id}`}>{machine.name}</Link> {/*<Link href={`/products/${machine.id}`}>{machine.name}</Link>*/}
<div>{machine.name}</div>
</h2> </h2>
<p className="text-secondary text-sm leading-7 mt-3 mb-5">{machine.description}</p> <p className="text-secondary text-sm leading-7 mt-3 mb-5">{machine.description}</p>
<p className="text-primary text-lg font-bold mt-auto mb-4">{machine.priceLabel}</p> <p className="text-primary text-lg font-bold mt-auto mb-4">{machine.priceLabel}</p>
@@ -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';
+3
View File
@@ -0,0 +1,3 @@
export {default as ProductDetails} from './components/ProductDetails';
export {default as ProductsPage} from './components/ProductsPage';
export {default as ShowcaseSection} from './components/ShowcaseSection';
+7163 -4228
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -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"
} }
} }
+20
View File
@@ -0,0 +1,20 @@
import {defineConfig, devices} from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
webServer: {
command: 'npx next dev -H 127.0.0.1',
url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://127.0.0.1:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: {...devices['Desktop Chrome']},
},
],
});
+3
View File
@@ -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
View File
@@ -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: {
+9
View File
@@ -0,0 +1,9 @@
import {expect, test} from '@playwright/test';
test('home page exposes the primary shopping journey', async ({page}) => {
await page.goto('/');
await expect(page).toHaveTitle(/Robot Market|روبات مارکت/);
await expect(page.locator('a[href="#showcase"]').first()).toBeVisible();
await expect(page.locator('a[href="https://panel.my-rm.com"]').first()).toBeVisible();
});
+6
View File
@@ -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"]
+17
View File
@@ -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,
},
},
});
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';