Compare commits

..
10 Commits
Author SHA1 Message Date
Hamed 4cd34106b0 refactor: migrate project to TypeScript and translate comments to English
Build & Deploy (develop) / build-and-deploy (push) Successful in 2m23s
2026-09-14 13:27:17 +02:00
Hamed 1ef621f832 refactor: remove disabled home sections and their unused assets 2026-09-14 14:39:05 +03:30
Hamed d5a7ed8092 feat: add landing page device images and fix machine branding
Build & Deploy (develop) / build-and-deploy (push) Successful in 1m41s
2026-09-14 10:39:58 +03:30
Hamed ba464e8345 feat: add landing page device images and fix machine branding
Build & Deploy (develop) / build-and-deploy (push) Successful in 1m40s
2026-09-14 10:19:47 +03:30
Hamed 7c73714d56 feat: add landing page device images and fix machine branding
Build & Deploy (develop) / build-and-deploy (push) Successful in 1m38s
2026-09-14 10:09:21 +03:30
Hamed fe86b58012 update
Build & Deploy (develop) / build-and-deploy (push) Successful in 1m44s
2026-09-14 08:36:22 +03:30
Hamed c4d9351e91 update
Build & Deploy (develop) / build-and-deploy (push) Successful in 1m39s
2026-09-12 07:43:21 +02:00
Hamed 7e7ebc88b6 initial new branch 2026-09-08 11:22:59 +03:30
Hamed 107413069c minor changes
Build & Deploy (develop) / build-and-deploy (push) Successful in 1m42s
2026-09-08 11:05:18 +03:30
Ryan f9915e4f8c fix:fixed vending price
Build & Deploy (develop) / build-and-deploy (push) Successful in 1m41s
2026-09-07 09:52:41 +03:30
59 changed files with 597 additions and 1266 deletions
+2 -8
View File
@@ -1,8 +1,2 @@
# Copy this file to .env.local and fill in your real keys. # Optional backend API base URL. Copy to .env.local to configure.
NEXT_PUBLIC_API_BASE_URL=https://your-real-backend.com
# Neshan (نشان) map API key — get one from https://platform.neshan.org
# Used by components/MapSection.jsx for the static map image.
NEXT_PUBLIC_NESHAN_API_KEY=
NEXT_PUBLIC_API_BASE_URL=https://your-real-backend.com
+7 -3
View File
@@ -33,9 +33,11 @@ jobs:
docker build -t robot-market:${{ steps.vars.outputs.sha }} . docker build -t robot-market:${{ steps.vars.outputs.sha }} .
- name: Save previous tag for rollback - name: Save previous tag for rollback
id: backup
run: | run: |
if [ -f /opt/robot-market-front/.env ]; then if [ -f /opt/robot-market-front/.env ]; then
cp /opt/robot-market-front/.env /opt/robot-market-front/.env.previous cp /opt/robot-market-front/.env /opt/robot-market-front/.env.previous
echo "available=true" >> "$GITHUB_OUTPUT"
fi fi
- name: Point compose at this build - name: Point compose at this build
@@ -43,12 +45,14 @@ jobs:
echo "IMAGE_TAG=${{ steps.vars.outputs.sha }}" > /opt/robot-market-front/.env echo "IMAGE_TAG=${{ steps.vars.outputs.sha }}" > /opt/robot-market-front/.env
- name: Deploy via compose - name: Deploy via compose
id: deploy
run: | run: |
docker compose --env-file /opt/robot-market-front/.env \ docker compose --env-file /opt/robot-market-front/.env \
-f /opt/robot-market-front/docker-compose.yml \ -f /opt/robot-market-front/docker-compose.yml \
up -d --force-recreate up -d --force-recreate
- name: Health check (retries for ~30s) - name: Health check (retries for ~30s)
id: health
run: | run: |
for i in $(seq 1 15); do for i in $(seq 1 15); do
if docker exec robot-market node -e " if docker exec robot-market node -e "
@@ -65,8 +69,8 @@ jobs:
echo "Health check failed after 30s" echo "Health check failed after 30s"
exit 1 exit 1
- name: Roll back to previous version on failed health check - name: Roll back after deployment or health check failure
if: failure() if: ${{ failure() && steps.backup.outputs.available == 'true' && (steps.deploy.outcome == 'failure' || steps.health.outcome == 'failure') }}
run: | run: |
if [ -f /opt/robot-market-front/.env.previous ]; then if [ -f /opt/robot-market-front/.env.previous ]; then
cp /opt/robot-market-front/.env.previous /opt/robot-market-front/.env cp /opt/robot-market-front/.env.previous /opt/robot-market-front/.env
@@ -96,4 +100,4 @@ jobs:
echo "Removing old image robot-market:$tag" echo "Removing old image robot-market:$tag"
docker rmi "robot-market:$tag" 2>/dev/null || true docker rmi "robot-market:$tag" 2>/dev/null || true
fi fi
done done
+1
View File
@@ -4,3 +4,4 @@ out/
.env*.local .env*.local
.DS_Store .DS_Store
npm-debug.log* npm-debug.log*
*.tsbuildinfo
+3
View File
@@ -14,6 +14,9 @@
<Item> <Item>
<option name="path" value=".gitea/workflows/test.yml" /> <option name="path" value=".gitea/workflows/test.yml" />
</Item> </Item>
<Item>
<option name="path" value=".gitea/workflows/develop.yml" />
</Item>
</list> </list>
</option> </option>
</SchemaInfo> </SchemaInfo>
+183 -81
View File
@@ -4,9 +4,38 @@
<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="initial new branch"> <list default="true" id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="refactor: remove disabled home sections and their unused assets">
<change beforePath="$PROJECT_DIR$/.gitignore" beforeDir="false" afterPath="$PROJECT_DIR$/.gitignore" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/data/content.js" beforeDir="false" afterPath="$PROJECT_DIR$/data/content.js" afterDir="false" /> <change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/context/LanguageContext.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/app/context/ThemeContext.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/app/layout.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/app/page.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/app/providers.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/AboutSection.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/CartDrawer.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/FeaturesSection.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/FloatingItems.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/Footer.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/Header.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/HeroSection.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/HorizontalShowcase.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/LanguageToggle.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/LocationsSection.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/MobileShowcase.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/ShowcaseSection.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/ThemeToggle.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/components/VendingCard.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/data/content.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/jsconfig.json" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/lib/axios.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/next.config.js" beforeDir="false" afterPath="$PROJECT_DIR$/next.config.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/store/cartSlice.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/store/store.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/tailwind.config.js" beforeDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -28,61 +57,61 @@
</component> </component>
<component name="NextEditCompletionFeaturesState"> <component name="NextEditCompletionFeaturesState">
<decayedCancelled> <decayedCancelled>
<entry key="MS100" value="1.7659734554897204" /> <entry key="MS100" value="1.0105021478132723" />
<entry key="MS500" value="2.6200917385797573" /> <entry key="MS500" value="1.6326904583013002" />
<entry key="S2" value="2.895672466750576" /> <entry key="S2" value="3.200495115489667" />
<entry key="S5" value="2.9711768722246337" /> <entry key="S5" value="4.148160592733714" />
<entry key="S10" value="3.095911797960576" /> <entry key="S10" value="4.983360398199462" />
<entry key="S30" value="3.6130929483281937" /> <entry key="S30" value="7.2170837140275825" />
<entry key="S60" value="4.862542006443887" /> <entry key="S60" value="9.732495501143132" />
<entry key="M2" value="8.222118193057797" /> <entry key="M2" value="14.025681297470134" />
<entry key="M5" value="14.352669475563026" /> <entry key="M5" value="22.17637857625762" />
<entry key="M10" value="18.025488407782152" /> <entry key="M10" value="27.702751951097298" />
<entry key="M15" value="19.665360290707866" /> <entry key="M15" value="30.22928327611057" />
<entry key="M30" value="22.62190726116564" /> <entry key="M30" value="34.21976380295617" />
<entry key="H1" value="26.416500535541072" /> <entry key="H1" value="38.60682034968035" />
<entry key="H2" value="30.051114228090462" /> <entry key="H2" value="42.59562362296086" />
<entry key="H4" value="32.65287419479587" /> <entry key="H4" value="45.40713861912438" />
<entry key="D1" value="35.38129255958758" /> <entry key="D1" value="48.33720180411775" />
<entry key="W1" value="35.9099877286286" /> <entry key="W1" value="48.90360237164015" />
</decayedCancelled> </decayedCancelled>
<decayedSelected> <decayedSelected>
<entry key="MS100" value="0.0" /> <entry key="MS100" value="0.0" />
<entry key="MS500" value="1.5499348360687339E-164" /> <entry key="MS500" value="8.86528819923573E-248" />
<entry key="S2" value="1.1157794535925266E-41" /> <entry key="S2" value="1.7255327824639203E-62" />
<entry key="S5" value="4.1594062872493325E-17" /> <entry key="S5" value="1.9713751865934254E-25" />
<entry key="S10" value="6.449345925944224E-9" /> <entry key="S10" value="4.440017101986686E-13" />
<entry key="S30" value="0.0018613950614188026" /> <entry key="S30" value="7.628893420815487E-5" />
<entry key="S60" value="0.043143887880194604" /> <entry key="S60" value="0.008734353680047248" />
<entry key="M2" value="0.20771106826597993" /> <entry key="M2" value="0.09345776415069663" />
<entry key="M5" value="0.533315069904893" /> <entry key="M5" value="0.387477201784943" />
<entry key="M10" value="0.7302842391185045" /> <entry key="M10" value="0.6224766676630884" />
<entry key="M15" value="0.8109510091490459" /> <entry key="M15" value="0.7290355699678522" />
<entry key="M30" value="0.9005281834285062" /> <entry key="M30" value="0.8538357980126227" />
<entry key="H1" value="0.9489616343290734" /> <entry key="H1" value="0.9240323576653702" />
<entry key="H2" value="0.9741466184969663" /> <entry key="H2" value="0.9612660181580177" />
<entry key="H4" value="0.986988661787442" /> <entry key="H4" value="0.9804417464378067" />
<entry key="D1" value="0.9978195926881375" /> <entry key="D1" value="0.9967134048485689" />
<entry key="W1" value="0.9996882217756649" /> <entry key="W1" value="0.9995298237292167" />
</decayedSelected> </decayedSelected>
<decayedShown> <decayedShown>
<entry key="MS100" value="1.6363299214720612" /> <entry key="MS100" value="0.01712637149186048" />
<entry key="MS500" value="2.580440430635064" /> <entry key="MS500" value="0.9540168467150943" />
<entry key="S2" value="2.884654206650678" /> <entry key="S2" value="2.9136793264313505" />
<entry key="S5" value="2.9666305108034594" /> <entry key="S5" value="4.013649896020289" />
<entry key="S10" value="3.0934710562664876" /> <entry key="S10" value="4.909460420460746" />
<entry key="S30" value="3.6138341741583195" /> <entry key="S30" value="7.187579804050971" />
<entry key="S60" value="4.903918162005831" /> <entry key="S60" value="9.723843431274007" />
<entry key="M2" value="8.426649355082025" /> <entry key="M2" value="14.107987700307802" />
<entry key="M5" value="14.882896956006677" /> <entry key="M5" value="22.55725525698723" />
<entry key="M10" value="18.75367770497998" /> <entry key="M10" value="28.321163439518607" />
<entry key="M15" value="20.474761353109788" /> <entry key="M15" value="30.955381308867647" />
<entry key="M30" value="23.521564444287325" /> <entry key="M30" value="35.07198888146704" />
<entry key="H1" value="27.364990222969524" /> <entry key="H1" value="39.52999740754272" />
<entry key="H2" value="31.025011433737856" /> <entry key="H2" value="43.55644480884343" />
<entry key="H4" value="33.639733831212105" /> <entry key="H4" value="46.38735262517173" />
<entry key="D1" value="36.37908993480534" /> <entry key="D1" value="49.33387639135037" />
<entry key="W1" value="36.90967275725512" /> <entry key="W1" value="49.903126627030524" />
</decayedShown> </decayedShown>
</component> </component>
<component name="ProjectColorInfo">{ <component name="ProjectColorInfo">{
@@ -94,33 +123,36 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent">{ <component name="PropertiesComponent"><![CDATA[{
&quot;keyToString&quot;: { "keyToString": {
&quot;ASKED_MARK_IGNORED_FILES_AS_EXCLUDED&quot;: &quot;true&quot;, "ASKED_MARK_IGNORED_FILES_AS_EXCLUDED": "true",
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;, "ModuleVcsDetector.initialDetectionPerformed": "true",
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;, "RunOnceActivity.ShowReadmeOnStart": "true",
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;, "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;, "RunOnceActivity.git.unshallow": "true",
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;, "RunOnceActivity.typescript.service.memoryLimit.init": "true",
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;, "codeWithMe.voiceChat.enabledByDefault": "false",
&quot;git-widget-placeholder&quot;: &quot;develop&quot;, "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
&quot;javascript.nodejs.core.library.configured.version&quot;: &quot;24.20.0&quot;, "git-widget-placeholder": "develop",
&quot;javascript.nodejs.core.library.typings.version&quot;: &quot;24.13.3&quot;, "javascript.nodejs.core.library.configured.version": "24.20.0",
&quot;javascript.preferred.runtime.type.id&quot;: &quot;node&quot;, "javascript.nodejs.core.library.typings.version": "24.13.3",
&quot;js.debugger.nextJs.config.created.client&quot;: &quot;true&quot;, "javascript.preferred.runtime.type.id": "node",
&quot;js.debugger.nextJs.config.created.server&quot;: &quot;true&quot;, "js.debugger.nextJs.config.created.client": "true",
&quot;kronk.content.tree.expanded.paths&quot;: &quot;&quot;, "js.debugger.nextJs.config.created.server": "true",
&quot;last_opened_file_path&quot;: &quot;/home/hamed/Documents/Projects/GItLabRepo/device_web&quot;, "junie.onboarding.icon.badge.shown": "true",
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;, "kronk.content.tree.expanded.paths": "",
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;, "last_opened_file_path": "/home/hamed/Documents/Projects/GItLabRepo/device_web",
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;, "node.js.detected.package.eslint": "true",
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;, "node.js.detected.package.tslint": "true",
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;, "node.js.selected.package.eslint": "(autodetect)",
&quot;settings.editor.selected.configurable&quot;: &quot;editor.preferences.fonts.default&quot;, "node.js.selected.package.tslint": "(autodetect)",
&quot;ts.external.directory.path&quot;: &quot;C:\\Program Files\\JetBrains\\WebStorm 2026.1.1\\plugins\\javascript-plugin\\jsLanguageServicesImpl\\external&quot;, "nodejs_package_manager_path": "npm",
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot; "settings.editor.selected.configurable": "editor.preferences.fonts.default",
"to.speed.mode.migration.done": "true",
"ts.external.directory.path": "/home/hamed/Documents/Projects/GIthubRepo/robot-market-landing/node_modules/typescript/lib",
"vue.rearranger.settings.migration": "true"
} }
}</component> }]]></component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="MoveFile.RECENT_KEYS"> <key name="MoveFile.RECENT_KEYS">
<recent name="D:\Project\GitHubRepo\robot-market-landing\public\images\icons" /> <recent name="D:\Project\GitHubRepo\robot-market-landing\public\images\icons" />
@@ -188,6 +220,21 @@
<workItem from="1788592387411" duration="8000" /> <workItem from="1788592387411" duration="8000" />
<workItem from="1788592424522" duration="13793000" /> <workItem from="1788592424522" duration="13793000" />
<workItem from="1788670304118" duration="1889000" /> <workItem from="1788670304118" duration="1889000" />
<workItem from="1788772211468" duration="3288000" />
<workItem from="1788845304938" duration="5986000" />
<workItem from="1789189667293" duration="1409000" />
<workItem from="1789191385769" duration="3038000" />
<workItem from="1789200888982" duration="408000" />
<workItem from="1789202103910" duration="646000" />
<workItem from="1789202776318" duration="599000" />
<workItem from="1789207496509" duration="153000" />
<workItem from="1789278993260" duration="734000" />
<workItem from="1789360783891" duration="48000" />
<workItem from="1789361888102" duration="68000" />
<workItem from="1789362025574" duration="23000" />
<workItem from="1789362137185" duration="377000" />
<workItem from="1789362644534" duration="6512000" />
<workItem from="1789383269194" duration="1732000" />
</task> </task>
<task id="LOCAL-00001" summary="update"> <task id="LOCAL-00001" summary="update">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -413,21 +460,76 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1788682678051</updated> <updated>1788682678051</updated>
</task> </task>
<option name="localTasksCounter" value="29" /> <task id="LOCAL-00029" summary="update">
<option name="closed" value="true" />
<created>1789191804591</created>
<option name="number" value="00029" />
<option name="presentableId" value="LOCAL-00029" />
<option name="project" value="LOCAL" />
<updated>1789191804591</updated>
</task>
<task id="LOCAL-00030" summary="update">
<option name="closed" value="true" />
<created>1789362384351</created>
<option name="number" value="00030" />
<option name="presentableId" value="LOCAL-00030" />
<option name="project" value="LOCAL" />
<updated>1789362384351</updated>
</task>
<task id="LOCAL-00031" summary="feat: add landing page device images and fix machine branding">
<option name="closed" value="true" />
<created>1789367962553</created>
<option name="number" value="00031" />
<option name="presentableId" value="LOCAL-00031" />
<option name="project" value="LOCAL" />
<updated>1789367962553</updated>
</task>
<task id="LOCAL-00032" summary="feat: add landing page device images and fix machine branding">
<option name="closed" value="true" />
<created>1789368598921</created>
<option name="number" value="00032" />
<option name="presentableId" value="LOCAL-00032" />
<option name="project" value="LOCAL" />
<updated>1789368598921</updated>
</task>
<task id="LOCAL-00033" summary="feat: add landing page device images and fix machine branding">
<option name="closed" value="true" />
<created>1789369799782</created>
<option name="number" value="00033" />
<option name="presentableId" value="LOCAL-00033" />
<option name="project" value="LOCAL" />
<updated>1789369799782</updated>
</task>
<task id="LOCAL-00034" summary="refactor: remove disabled home sections and their unused assets">
<option name="closed" value="true" />
<created>1789384147873</created>
<option name="number" value="00034" />
<option name="presentableId" value="LOCAL-00034" />
<option name="project" value="LOCAL" />
<updated>1789384147873</updated>
</task>
<option name="localTasksCounter" value="35" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" /> <option name="version" value="3" />
<option name="exactExcludedFiles">
<list>
<option value="$PROJECT_DIR$/components/AboutSection.jsx" />
</list>
</option>
</component> </component>
<component name="UnknownFeatures"> <component name="UnknownFeatures">
<option featureType="com.intellij.fileTypeFactory" implementationName="next.config.js" /> <option featureType="com.intellij.fileTypeFactory" implementationName="next.config.js" />
</component> </component>
<component name="VcsManagerConfiguration"> <component name="VcsManagerConfiguration">
<MESSAGE value="ui : change the position of header in Hero Section" /> <MESSAGE value="ui : change the position of header in Hero Section" />
<MESSAGE value="update" />
<MESSAGE value="feat:dockerized &amp; test actions add" /> <MESSAGE value="feat:dockerized &amp; test actions add" />
<MESSAGE value="feat:dockerized &amp; test actions added" /> <MESSAGE value="feat:dockerized &amp; test actions added" />
<MESSAGE value="initial new branch" /> <MESSAGE value="initial new branch" />
<option name="LAST_COMMIT_MESSAGE" value="initial new branch" /> <MESSAGE value="update" />
<MESSAGE value="feat: add landing page device images and fix machine branding" />
<MESSAGE value="refactor: remove disabled home sections and their unused assets" />
<option name="LAST_COMMIT_MESSAGE" value="refactor: remove disabled home sections and their unused assets" />
</component> </component>
</project> </project>
+39 -106
View File
@@ -1,132 +1,65 @@
# روبات مارکت — Vending Machine Showcase # روبات مارکت — Vending Machine Showcase
پروژه Next.js (App Router) صفحه معرفی محصولات «روبات مارکت»، با انیمیشن‌های GSAP/ScrollTrigger، صفحه معرفی دستگاه‌های وندینگ روبات مارکت، ساخته‌شده با Next.js، TypeScript، React و Ant Design.
مدیریت سبد خرید با Redux Toolkit، حالت تیره/روشن، دو زبانه (فارسی/انگلیسی)، نقشه، فرم تیکت
پشتیبانی، و ویجت چت.
## اجرای پروژه ## اجرا
```bash ```bash
npm install npm install
npm run dev npm run dev
``` ```
سپس `http://localhost:3000` را باز کنید. سایت در http://localhost:3000 در دسترس است.
## ساختار پروژه ## بررسی نوع‌ها
``` ```bash
app/ npm run typecheck
layout.jsx # پوسته HTML، فونت وزیرمتن
page.jsx # چیدمان کامل صفحه
providers.jsx # Redux + Theme + Language + AntD ConfigProvider
globals.css # توکن‌های رنگ دارک/لایت، جلوه اسپات‌لایت، تایپ‌رایتر
context/
ThemeContext.jsx # حالت دارک/لایت (ذخیره در localStorage)
LanguageContext.jsx # زبان فا/en (ذخیره در localStorage + جهت صفحه)
store/ # Redux (سبد خرید)
data/
content.js # منبع واحد محتوای دوزبانه (fa/en) — همه‌ی متن‌ها اینجاست
components/
Header.jsx # هدر + سوییچ زبان/تم + دکمه ورود به پنل کاربری
ThemeToggle.jsx / LanguageToggle.jsx
HeroSection.jsx # هدلاین تایپ‌شونده + پس‌زمینه رنگی در حال چرخش
HorizontalShowcase.jsx # اسکرول افقی پین‌شده + ویدیوی پس‌زمینه
VendingCard.jsx # کارت دستگاه + جلوه اسپات‌لایت
FloatingItems.jsx
FeaturesSection.jsx
AboutSection.jsx
LocationsSection.jsx
MapSection.jsx # نقشه Neshan (کلید API را خودتان اضافه می‌کنید)
CatalogSection.jsx # کاتالوگ محصولات قابل سفارش
IntroVideoSection.jsx # ویدیوی معرفی شرکت
TicketForm.jsx # فرم تیکت پشتیبانی
ChatWidget.jsx # آیکون شناور چت + مودال گفتگو
CartDrawer.jsx
Footer.jsx
public/
images/, gifs/, video/ # دارایی‌های placeholder — در پیام قبلی توضیح داده شده
``` ```
## ویژگی‌های اضافه‌شده در این نسخه کد برنامه با TypeScript و حالت strict بررسی می‌شود. نوع‌های مشترک در `types/domain.ts` و هوک‌های Redux در `store/hooks.ts` قرار دارند.
تنظیمات Next.js و PostCSS برای سازگاری با ابزارهای فعلی به JavaScript باقی مانده‌اند؛ تنظیمات Tailwind در `tailwind.config.ts` است.
### نقشه (Neshan) ## ساخت نسخه نهایی
`components/MapSection.jsx` از **Neshan Static Map API** استفاده می‌کند (یک تصویر ساده، بدون نیاز
به اسکریپت/SDK جاوااسکریپت). کلید API را اینجا وارد کنید:
1. یک فایل `.env.local` در ریشه پروژه بسازید (از روی `.env.local.example` کپی کنید) ```bash
2. مقدار `NEXT_PUBLIC_NESHAN_API_KEY` را با کلید واقعی خودتان از npm run build
[platform.neshan.org](https://platform.neshan.org) پر کنید npm start
```
تا وقتی کلید خالی باشد، به‌جای نقشه یک پیام راهنما («برای فعال شدن نقشه، کلید API نشان را در خروجی standalone برای اجرای Docker تنظیم شده است.
تنظیمات پروژه قرار دهید») نمایش داده می‌شود — سایت خراب نمی‌شود. نقشه در حالت تیره/روشن هم استایل
متفاوتی می‌گیرد (`standard-night` / `standard-day`). لینک «مشاهده در نشان» کنار هر آدرس به نقشه
تعاملی neshan.org می‌رود و نیازی به کلید ندارد. مختصات فعلی در `data/content.js`
(`mapLocations`) تقریبی هستند — با پین دقیق جایگزین کنید.
### کاتالوگ محصولات ## ساختار و بخش‌های فعال
`components/CatalogSection.jsx` — گرید محصولاتی (زعفران، تنقلات، نوشیدنی، قهوه، چای و ...) که
مشتریان دستگاه می‌توانند با قیمت کمتر از بازار سفارش دهند. هر آیتم قیمت بازار (خط‌خورده) و قیمت
ویژه را نشان می‌دهد و دکمه «افزودن به سفارش» آن را به همان سبد خرید Redux اضافه می‌کند (در کنار
دستگاه‌ها). لیست محصولات، قیمت‌ها و تصاویر در `data/content.js` (کلید `catalog`) قابل ویرایش است؛
تصاویر placeholder در `public/images/catalog/` قرار دارند.
### سکشن ویدیوی معرفی شرکت - `app/page.tsx`: صفحه اصلی شامل هدر، سبد خرید، هیرو، ویترین دستگاه‌ها، مزایا، درباره ما، محل‌های مناسب نصب و فوتر.
`components/IntroVideoSection.jsx` — یک پلیر ویدیوی مستقل (جدا از ویدیوی پس‌زمینه اسکرول افقی) با - `components/HeroSection.tsx`: اسلایدر تصاویر دستگاه‌ها.
پوستر و دکمه پخش استاندارد مرورگر. برای جایگزینی، فایل واقعی را در - `components/ShowcaseSection.tsx`: انتخاب نمایش موبایل یا ویترین افقی دسکتاپ.
`public/video/company-intro-placeholder.mp4` بگذارید (یا مسیر `introVideoSrc` را در - `components/HorizontalShowcase.tsx`: ویترین افقی با GSAP و ویدیوی پس‌زمینه وابسته به اسکرول.
`data/content.js` عوض کنید)؛ پوستر هم از `public/images/video-poster.svg` می‌آید. - `components/MobileShowcase.tsx`: فهرست دستگاه‌ها در موبایل.
- `components/VendingCard.tsx` و `components/FloatingItems.tsx`: نمایش دستگاه و جلوه‌های تصویری.
- `components/CartDrawer.tsx` و `store/`: سبد خرید و مدیریت تعداد دستگاه‌ها با Redux.
- `data/content.ts`: محتوای فارسی و انگلیسی، مشخصات و قیمت دستگاه‌ها و مسیر رسانه‌ها.
- `app/context/` و `app/providers.tsx`: زبان، جهت صفحه، تم و تنظیمات Ant Design.
- `app/globals.css`: رنگ‌ها و استایل‌های مشترک.
- `public/`: تصاویر دستگاه‌ها، اقلام شناور، گیف‌ها و ویدیوی ویترین.
### رنگ‌بندی متناوب سکشن‌ها ## زبان و تم
هر سکشن اصلی صفحه (به‌جز هیرو و بخش اسکرول افقی که پس‌زمینه مخصوص خودشان را دارند) به‌ترتیب بین سه
تن رنگی برند می‌چرخد: `bg-page` → `bg-surface` → `bg-elevated` → ... به‌همراه یک خط جداکننده ظریف
بالای هر بخش. این‌ها همان توکن‌های دارک/لایت هستند، پس با تغییر تم هم هماهنگ می‌مانند. برای تغییر
ترتیب یا اضافه کردن سکشن جدید، کافی‌ست یکی از این سه کلاس را روی `<section>` بگذارید.
### حالت دارک/لایت زبان فارسی/انگلیسی و حالت روشن/تیره از هدر تغییر می‌کنند و ترجیح کاربر در localStorage ذخیره می‌شود.
دکمه سوییچ در هدر (آیکون خورشید/ماه). ترجیح در `localStorage` ذخیره می‌شود و در اولین بار بر اساس ویترین افقی برای محاسبات اسکرول همیشه جهت LTR دارد، اما متن کارت‌ها جهت زبان فعال را رعایت می‌کند.
تنظیمات سیستم کاربر تعیین می‌شود. رنگ‌بندی از طریق متغیرهای CSS در `app/globals.css`
(`--color-bg-page`, `--color-text-primary`, ...) و کلاس‌های کمکی `bg-page` / `bg-surface` /
`text-primary` / `text-secondary` / `border-subtle` مدیریت می‌شود؛ برای هر رنگ جدید فقط همین‌جا را
ویرایش کنید. تم Ant Design هم به‌صورت خودکار (`darkAlgorithm` / `defaultAlgorithm`) هماهنگ می‌شود.
### دو زبانه (فارسی / انگلیسی) ## ورود و سبد خرید
دکمه «EN / فا» در هدر. تمام متن‌های سایت (منو، هیرو، محصولات، درباره ما، مکان‌های نصب، فرم تیکت،
چت، فوتر) از `data/content.js` خوانده می‌شوند — یک آبجکت با کلیدهای `fa` و `en`. تغییر زبان همچنین
جهت صفحه (`dir`) و `lang` تگ `html` را به‌روزرسانی می‌کند. برای اضافه یا اصلاح متن، فقط
`data/content.js` را ویرایش کنید؛ کامپوننت‌ها نیازی به تغییر ندارند.
> نکته فنی: بخش اسکرول افقی (`HorizontalShowcase`) به‌عمد همیشه `dir="ltr"` روی خود track دارد دکمه ورود به https://panel.my-rm.com متصل است.
> (چون محاسبات GSAP به آن نیاز دارد)، اما هر کارت داخلش جهت متن را بر اساس زبان فعال تنظیم می‌کند. سبد خرید در حافظه نگه‌داری می‌شود و با بارگذاری مجدد صفحه پاک می‌شود.
پرداخت هنوز متصل نیست؛ دکمه پرداخت در `CartDrawer.tsx` پیام نمایشی نشان می‌دهد.
### فرم تیکت پشتیبانی ## تنظیم API
`components/TicketForm.jsx` — فرم Ant Design با اعتبارسنجی (نام، تلفن، ایمیل اختیاری، موضوع،
اولویت، پیام). ارسال فعلاً فقط یک پیام موفقیت نمایش می‌دهد؛ بلوک کامنت‌شده
`// TODO: Connect to the real ticketing/support API` دقیقاً نشان می‌دهد کجا باید axios را وصل کنید.
### ویجت چت برای تعیین آدرس بک‌اند، از `.env.local.example` یک فایل `.env.local` بسازید و
دکمه شناور گوشه صفحه (`components/ChatWidget.jsx`) یک مودال گفتگوی ساده باز می‌کند. پیام‌ها فعلاً `NEXT_PUBLIC_API_BASE_URL` را تنظیم کنید. کلاینت مشترک API در `lib/axios.ts` قرار دارد.
در state محلی نگه‌داری می‌شوند و یک پاسخ خودکار نمایشی می‌دهند. بلوک
`// TODO: Connect to a real chat/support backend` محل اتصال به بک‌اند واقعی (یا وب‌سوکت) را مشخص
کرده.
### هیرو: تایپ‌رایتر + پس‌زمینه رنگی در حال تغییر ## جایگزینی رسانه‌ها
هدلاین با افکت تایپ‌رایتر بین چند جمله (`data/content.js` → `hero.typingPhrases`) می‌چرخد.
پس‌زمینه به‌صورت حلقه‌ای بین سبز برند (`#00a693`)، مشکی و سفید تغییر رنگ می‌دهد (با GSAP). یک لایه
scrim تیره روی آن قرار دارد تا متن در هر سه حالت خوانا بماند. وقتی ویدیوی هیرو را آماده کردید،
داخل `components/HeroSection.jsx` یک بلوک `TODO` کامنت‌شده دقیقاً نشان می‌دهد چطور به‌جای پس‌زمینه
رنگی، ویدیو را جایگزین کنید (فقط از حالت کامنت خارجش کنید).
### ورود به پنل کاربری فایل‌های واقعی را با مسیرهای موجود در `data/content.ts` جایگزین کنید یا مسیرها را در همان فایل تغییر دهید.
دکمه «ورود به پنل کاربری» / «Sign In» در هدر اضافه شده (در `components/Header.jsx`). چون گفتید ویدیوی پس‌زمینه ویترین از `public/video/showcase-placeholder.mp4` خوانده می‌شود.
لینک‌دهی را خودتان انجام می‌دهید، فعلاً فقط یک `onClick` خالی با کامنت TODO دارد — مسیر واقعی پنل
کاربری را همان‌جا وصل کنید.
## جایگزینی دارایی‌های واقعی (تصاویر/گیف/ویدیو)
همان‌طور که قبلاً توضیح داده شد، فایل‌های داخل `public/images`, `public/gifs`, `public/video`
placeholder هستند. برای جایگزینی، فایل جدید را با همان نام/مسیر جایگزین کنید یا مسیر را در
`data/content.js` تغییر دهید.
## اتصال درگاه پرداخت
در `components/CartDrawer.jsx` داخل `handleCheckout` بلوک `// TODO: Connect to payment gateway API`
با نمونه‌کد axios کامنت‌شده وجود دارد.
@@ -1,13 +1,23 @@
'use client'; 'use client';
import type {Dispatch, SetStateAction, ReactNode} from 'react';
import type {Locale, SiteContent, Direction} from '@/types/domain';
import {createContext, useContext, useEffect, useState} from 'react'; import {createContext, useContext, useEffect, useState} from 'react';
import {content} from '@/data/content'; import {content} from '@/data/content';
const LanguageContext = createContext(null); interface LanguageContextValue {
locale: Locale;
setLocale: Dispatch<SetStateAction<Locale>>;
toggleLocale: () => void;
t: SiteContent;
dir: Direction;
mounted: boolean;
}
export function LanguageProvider({children}) { const LanguageContext = createContext<LanguageContextValue | null>(null);
const [locale, setLocale] = useState('fa');
export function LanguageProvider({children}: {children: ReactNode}) {
const [locale, setLocale] = useState<Locale>('fa');
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
useEffect(() => { useEffect(() => {
@@ -1,12 +1,21 @@
'use client'; 'use client';
import type {Dispatch, SetStateAction, ReactNode} from 'react';
import type {Theme} from '@/types/domain';
import {createContext, useContext, useEffect, useState} from 'react'; import {createContext, useContext, useEffect, useState} from 'react';
const ThemeContext = createContext(null); interface ThemeContextValue {
theme: Theme;
setTheme: Dispatch<SetStateAction<Theme>>;
toggleTheme: () => void;
mounted: boolean;
}
export function ThemeProvider({children}) { const ThemeContext = createContext<ThemeContextValue | null>(null);
const [theme, setTheme] = useState('light'); export function ThemeProvider({children}: {children: ReactNode}) {
const [theme, setTheme] = useState<Theme>('light');
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -42,4 +51,3 @@ export function useTheme() {
if (!ctx) throw new Error('useTheme must be used within ThemeProvider'); if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx; return ctx;
} }
-32
View File
@@ -219,35 +219,3 @@ html[dir='rtl'] body {
opacity: 0; opacity: 0;
} }
} }
/* ------------------------------------------------------------------ */
/* نوار لوگوی بی‌پایان (trusted-by marquee) */
/* ------------------------------------------------------------------ */
@keyframes marqueeScroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
/* دقیقاً نصف عرض، چون لیست دو برابر شده */
}
.animate-marquee {
animation: marqueeScroll 28s linear infinite;
}
.animate-marquee:hover {
animation-play-state: paused;
}
/* ------------------------------------------------------------------ */
/* Chat widget */
/* ------------------------------------------------------------------ */
.chat-bubble {
max-width: 80%;
padding: 10px 14px;
border-radius: 14px;
font-size: 0.875rem;
line-height: 1.6;
}
+5 -3
View File
@@ -1,3 +1,5 @@
import type {Metadata, Viewport} from 'next';
import type {ReactNode} from 'react';
import {Vazirmatn} from 'next/font/google'; import {Vazirmatn} from 'next/font/google';
import './globals.css'; import './globals.css';
import Providers from './providers'; import Providers from './providers';
@@ -9,19 +11,19 @@ const vazirmatn = Vazirmatn({
}); });
export const metadata = { export const metadata: Metadata = {
title: 'روبات مارکت | دستگاه‌های وندینگ هوشمند', title: 'روبات مارکت | دستگاه‌های وندینگ هوشمند',
description: description:
'روبات مارکت؛ دستگاه‌های فروش خودکار هوشمند با مدیریت آنلاین، تحویل نرم و طراحی مدرن.', 'روبات مارکت؛ دستگاه‌های فروش خودکار هوشمند با مدیریت آنلاین، تحویل نرم و طراحی مدرن.',
}; };
export const viewport = { export const viewport: Viewport = {
themeColor: '#00a693', themeColor: '#00a693',
}; };
export default function RootLayout({children}) { export default function RootLayout({children}: {children: ReactNode}) {
return ( return (
<html lang="fa" dir="rtl" className={`${vazirmatn.variable} light`} suppressHydrationWarning> <html lang="fa" dir="rtl" className={`${vazirmatn.variable} light`} suppressHydrationWarning>
+6 -32
View File
@@ -1,49 +1,23 @@
import Header from '@/components/Header'; import LocationsSection from '@/components/LocationsSection';
import HeroSection from '@/components/HeroSection'; import ShowcaseSection from "@/components/ShowcaseSection";
import TrustedBySection from '@/components/TrustedBySection';
import FeaturesSection from '@/components/FeaturesSection'; import FeaturesSection from '@/components/FeaturesSection';
import AboutSection from '@/components/AboutSection'; import AboutSection from '@/components/AboutSection';
import CatalogSection from '@/components/CatalogSection'; import HeroSection from '@/components/HeroSection';
import LocationsSection from '@/components/LocationsSection';
import IntroVideoSection from '@/components/IntroVideoSection';
import MapSection from '@/components/MapSection';
import TicketForm from '@/components/TicketForm';
import Footer from '@/components/Footer';
import CartDrawer from '@/components/CartDrawer'; import CartDrawer from '@/components/CartDrawer';
import ChatWidget from '@/components/ChatWidget'; import Footer from '@/components/Footer';
import ShowcaseSection from "@/components/ShowcaseSection"; import Header from '@/components/Header';
export default function HomePage() { export default function HomePage() {
return ( return (
<main className="relative bg-page"> <main className="relative bg-page">
<Header/> <Header/>
<CartDrawer/> <CartDrawer/>
{/*faz 1*/}
{/*<ChatWidget/>*/}
<HeroSection/> <HeroSection/>
<ShowcaseSection/> <ShowcaseSection/>
{/*<TrustedBySection/>*/}
<FeaturesSection/> <FeaturesSection/>
<AboutSection/> <AboutSection/>
{/*<CatalogSection/>*/}
<LocationsSection/> <LocationsSection/>
{/*<IntroVideoSection/>*/}
{/*<MapSection/>*/}
{/*<TicketForm/>*/}
<Footer/> <Footer/>
</main> </main>
); );
+6 -4
View File
@@ -1,8 +1,10 @@
'use client'; 'use client';
// پل ارتباطی بین Redux، حالت تیره/روشن، و زبان فا/en با تنظیمات ConfigProvider آنت‌دیزاین. import type {ReactNode} from 'react';
// Connect Redux, language, and theme settings to Ant Design.
import {Provider as ReduxProvider} from 'react-redux'; import {Provider as ReduxProvider} from 'react-redux';
import type {ThemeConfig} from 'antd';
import {ConfigProvider, theme as antdThemeApi} from 'antd'; import {ConfigProvider, theme as antdThemeApi} from 'antd';
import faIR from 'antd/locale/fa_IR'; import faIR from 'antd/locale/fa_IR';
import enUS from 'antd/locale/en_US'; import enUS from 'antd/locale/en_US';
@@ -10,11 +12,11 @@ import {store} from '@/store/store';
import {ThemeProvider, useTheme} from './context/ThemeContext'; import {ThemeProvider, useTheme} from './context/ThemeContext';
import {LanguageProvider, useLanguage} from './context/LanguageContext'; import {LanguageProvider, useLanguage} from './context/LanguageContext';
function AntdBridge({children}) { function AntdBridge({children}: {children: ReactNode}) {
const {theme} = useTheme(); const {theme} = useTheme();
const {locale, dir} = useLanguage(); const {locale, dir} = useLanguage();
const antdTheme = { const antdTheme: ThemeConfig = {
algorithm: theme === 'dark' ? antdThemeApi.darkAlgorithm : antdThemeApi.defaultAlgorithm, algorithm: theme === 'dark' ? antdThemeApi.darkAlgorithm : antdThemeApi.defaultAlgorithm,
token: { token: {
colorPrimary: '#00a693', colorPrimary: '#00a693',
@@ -48,7 +50,7 @@ function AntdBridge({children}) {
); );
} }
export default function Providers({children}) { export default function Providers({children}: {children: ReactNode}) {
return ( return (
<ReduxProvider store={store}> <ReduxProvider store={store}>
<ThemeProvider> <ThemeProvider>
@@ -1,6 +1,6 @@
'use client'; 'use client';
// سکشن معرفی شرکت با متن بلند؛ به‌خاطر طولانی بودن متن، یک دکمه «ادامه مطلب» برای باز/بسته کردن دارد. // Company introduction with optional expandable paragraphs.
import {useState} from 'react'; import {useState} from 'react';
import {useLanguage} from '@/app/context/LanguageContext'; import {useLanguage} from '@/app/context/LanguageContext';
@@ -1,38 +1,38 @@
'use client'; 'use client';
// کشوی (Drawer) سبد خرید: نمایش آیتم‌های انتخابی، تغییر تعداد، جمع کل، و دکمه پرداخت (placeholder). // Cart drawer with quantity editing, totals, and placeholder checkout.
import {Drawer, Button, Empty, InputNumber, message} from 'antd'; import {Drawer, Button, Empty, InputNumber, message} from 'antd';
import {DeleteOutlined} from '@ant-design/icons'; import {DeleteOutlined} from '@ant-design/icons';
import {useSelector, useDispatch} from 'react-redux'; import {useAppSelector, useAppDispatch} from '@/store/hooks';
import { import {
selectCartItems, selectCartItems,
selectCartTotal, selectCartTotal,
selectIsDrawerOpen, selectIsDrawerOpen,
closeDrawer, closeDrawer,
removeFromCart, removeFromCart,
incrementQty, setQuantity,
decrementQty,
} from '@/store/cartSlice'; } from '@/store/cartSlice';
import {useLanguage} from '@/app/context/LanguageContext'; import {useLanguage} from '@/app/context/LanguageContext';
import type {Locale} from '@/types/domain';
import api from "@/lib/axios"; import api from "@/lib/axios";
function toLocaleNumber(n, locale) { function toLocaleNumber(n: number, locale: Locale) {
return n.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US'); return n.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US');
} }
export default function CartDrawer() { export default function CartDrawer() {
const dispatch = useDispatch(); const dispatch = useAppDispatch();
const isOpen = useSelector(selectIsDrawerOpen); const isOpen = useAppSelector(selectIsDrawerOpen);
const items = useSelector(selectCartItems); const items = useAppSelector(selectCartItems);
const total = useSelector(selectCartTotal); const total = useAppSelector(selectCartTotal);
const {t, locale, dir} = useLanguage(); const {t, locale, dir} = useLanguage();
const currencySuffix = locale === 'fa' ? ' ریال' : ' Rials'; const currencySuffix = locale === 'fa' ? ' ریال' : ' Rials';
const handleCheckout = async () => { const handleCheckout = async () => {
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// TODO: Connect to payment gateway API. // TODO: Connect to payment gateway API..
// try { // try {
// const { data } = await axios.post('/api/payment/create', { // const { data } = await axios.post('/api/payment/create', {
@@ -48,6 +48,7 @@ export default function CartDrawer() {
// ------------------------------------------------------------------- // -------------------------------------------------------------------
await message.info(t.cart.checkoutToast); await message.info(t.cart.checkoutToast);
}; };
return ( return (
@@ -76,10 +77,10 @@ export default function CartDrawer() {
<InputNumber <InputNumber
size="small" size="small"
min={1} min={1}
precision={0}
value={item.qty} value={item.qty}
onChange={(val) => { onChange={(val) => {
if (val > item.qty) dispatch(incrementQty(item.id)); if (val !== null) dispatch(setQuantity({id: item.id, qty: val}));
else if (val < item.qty) dispatch(decrementQty(item.id));
}} }}
className="w-16" className="w-16"
/> />
-86
View File
@@ -1,86 +0,0 @@
'use client';
// کاتالوگ محصولات جانبی (زعفران، تنقلات، نوشیدنی و ...) که مشتریان دستگاه با قیمت کمتر از بازار
// می‌توانند سفارش دهند. دکمه «افزودن به سفارش» از همان سبد خرید Redux دستگاه‌ها استفاده می‌کند.
import { Button, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { useDispatch } from 'react-redux';
import { addToCart } from '@/store/cartSlice';
import { useLanguage } from '@/app/context/LanguageContext';
function toLocaleNumber(n, locale) {
return n.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US');
}
export default function CatalogSection() {
const { t, locale } = useLanguage();
const dispatch = useDispatch();
const currencySuffix = locale === 'fa' ? ' ریال' : ' Rials';
const handleAdd = (item) => {
dispatch(
addToCart({
id: `catalog-${item.id}`,
name: item.name,
priceNumeric: item.priceNumeric,
priceLabel: `${toLocaleNumber(item.priceNumeric, locale)}${currencySuffix}`,
image: item.image,
})
);
message.success(t.catalog.addToast(item.name));
};
return (
<section id="catalog" className="relative px-6 md:px-16 py-20 md:py-28 bg-page border-t border-subtle">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-4">
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.catalog.eyebrow}</span>
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text mt-3">{t.catalog.title}</h2>
</div>
<p className="text-secondary text-sm md:text-base text-center max-w-2xl mx-auto mb-12">
{t.catalog.subtitle}
</p>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
{t.catalog.items.map((item) => (
<div
key={item.id}
className="bg-surface border border-subtle rounded-2xl p-5 flex flex-col hover:border-brand-500/60 transition-colors"
>
<div className="flex items-center gap-4 mb-4">
<img src={item.image} alt={item.name} className="w-16 h-16 object-contain shrink-0" />
<div className="min-w-0">
<span className="text-[11px] text-brand-400 font-semibold">{item.category}</span>
<h3 className="text-primary font-bold text-sm leading-6">{item.name}</h3>
</div>
</div>
<div className="mt-auto flex items-end justify-between gap-2">
<div>
<p className="text-xs text-secondary line-through opacity-70">
{toLocaleNumber(item.originalPriceNumeric, locale)}
{currencySuffix}
</p>
<p className="text-base font-bold text-primary">
{toLocaleNumber(item.priceNumeric, locale)}
{currencySuffix}
</p>
</div>
<Button
type="primary"
size="middle"
icon={<PlusOutlined />}
onClick={() => handleAdd(item)}
>
{t.catalog.addToOrder}
</Button>
</div>
</div>
))}
</div>
</div>
</section>
);
}
-103
View File
@@ -1,103 +0,0 @@
'use client';
// آیکون شناور چت گوشه صفحه؛ با کلیک یک مودال گفتگوی ساده باز می‌شود (فعلاً بدون اتصال به بک‌اند واقعی).
import { useEffect, useRef, useState } from 'react';
import { Modal, Input, Button, Tooltip } from 'antd';
import { MessageOutlined, SendOutlined, CloseOutlined } from '@ant-design/icons';
import { useLanguage } from '@/app/context/LanguageContext';
export default function ChatWidget() {
const { t, dir } = useLanguage();
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState('');
const [messages, setMessages] = useState([]);
const scrollRef = useRef(null);
// Seed the bot greeting once, in the current language.
useEffect(() => {
setMessages([{ from: 'bot', text: t.chat.initialBotMessage }]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t.chat.initialBotMessage]);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}, [messages, open]);
const handleSend = () => {
const text = draft.trim();
if (!text) return;
setMessages((prev) => [...prev, { from: 'user', text }]);
setDraft('');
// -----------------------------------------------------------------
// TODO: Connect to a real chat/support backend (e.g. via axios or a
// websocket) instead of this local placeholder reply.
//
// import axios from 'axios';
// const { data } = await axios.post('/api/chat', { message: text });
// setMessages((prev) => [...prev, { from: 'bot', text: data.reply }]);
// api() <===> todo : use this func to fetch ...
// -----------------------------------------------------------------
setTimeout(() => {
setMessages((prev) => [...prev, { from: 'bot', text: t.chat.fallbackReply }]);
}, 600);
};
return (
<>
<Tooltip title={t.chat.fabLabel} placement="left">
<button
onClick={() => setOpen(true)}
aria-label={t.chat.fabLabel}
className="fixed bottom-6 z-40 w-14 h-14 rounded-full bg-brand hover:bg-brand-400 text-ink-950 shadow-brandGlow flex items-center justify-center text-2xl transition-transform hover:scale-105"
style={dir === 'rtl' ? { left: '1.5rem' } : { right: '1.5rem' }}
>
<MessageOutlined />
</button>
</Tooltip>
<Modal
open={open}
onCancel={() => setOpen(false)}
footer={null}
closeIcon={<CloseOutlined />}
title={t.chat.title}
centered
>
<div
ref={scrollRef}
className="flex flex-col gap-3 h-80 overflow-y-auto py-2 px-1"
>
{messages.map((m, i) => (
<div
key={i}
className={`chat-bubble ${
m.from === 'user'
? 'self-end bg-brand text-ink-950'
: 'self-start bg-surface-strong text-primary'
}`}
>
{m.text}
</div>
))}
</div>
<div className="flex items-center gap-2 mt-3 border-t border-subtle pt-3">
<Input
size="large"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onPressEnter={handleSend}
placeholder={t.chat.placeholder}
/>
<Button type="primary" size="large" icon={<SendOutlined />} onClick={handleSend}>
{t.chat.send}
</Button>
</div>
</Modal>
</>
);
}
@@ -1,6 +1,6 @@
'use client'; 'use client';
// نوار مزایا: ضمانت کالا، مشاوره رایگان، خرید اقساطی، بسته‌بندی ایمن. // Service benefits: warranty, consultation, installments, and packaging.
import { import {
SafetyCertificateOutlined, SafetyCertificateOutlined,
@@ -1,17 +1,18 @@
'use client'; 'use client';
// رندر آیتم‌های شناور (چیپس/نوشابه) دور دستگاه با حرکت تصادفی و ملایم GSAP. // Animate decorative snack images with gentle randomized motion.
import type {FloatingSnack} from '@/types/domain';
import {useEffect, useRef} from 'react'; import {useEffect, useRef} from 'react';
import {gsap} from 'gsap'; import {gsap} from 'gsap';
/** /**
* Renders the centralized `floatingSnacks` config as absolutely-positioned * Renders the centralized `floatingSnacks` config as absolutely-positioned
* images and gives each one a gentle, randomized floating drift via GSAP. * images and gives each one a gentle, randomized floating drift via GSAP.
* Positions/sizes/images all come from data/content.js — edit there only. * Positions/sizes/images all come from data/content.ts — edit there only.
*/ */
export default function FloatingItems({items}) { export default function FloatingItems({items}: {items: FloatingSnack[]}) {
const containerRef = useRef(null); const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const nodes = containerRef.current?.querySelectorAll('[data-float-item]'); const nodes = containerRef.current?.querySelectorAll('[data-float-item]');
@@ -1,6 +1,6 @@
'use client'; 'use client';
// فوتر سایت: درباره ما کوتاه، لینک‌های شبکه اجتماعی، دسترسی سریع، و اطلاعات تماس/آدرس. // Company information, quick links, social links, and contact details.
import { PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons'; import { PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons';
import { useLanguage } from '@/app/context/LanguageContext'; import { useLanguage } from '@/app/context/LanguageContext';
@@ -36,11 +36,7 @@ export default function Footer() {
<li><a href="#hero" className="hover:text-brand-400">{t.nav.home}</a></li> <li><a href="#hero" className="hover:text-brand-400">{t.nav.home}</a></li>
<li><a href="#showcase" className="hover:text-brand-400">{t.nav.showcase}</a></li> <li><a href="#showcase" className="hover:text-brand-400">{t.nav.showcase}</a></li>
<li><a href="#about" className="hover:text-brand-400">{t.nav.about}</a></li> <li><a href="#about" className="hover:text-brand-400">{t.nav.about}</a></li>
{/*<li><a href="#catalog" className="hover:text-brand-400">{t.nav.catalog}</a></li>*/}
<li><a href="#locations" className="hover:text-brand-400">{t.nav.locations}</a></li> <li><a href="#locations" className="hover:text-brand-400">{t.nav.locations}</a></li>
{/*<li><a href="#video" className="hover:text-brand-400">{t.nav.video}</a></li>*/}
{/*<li><a href="#map" className="hover:text-brand-400">{t.nav.map}</a></li>*/}
{/*<li><a href="#support" className="hover:text-brand-400">{t.nav.support}</a></li>*/}
</ul> </ul>
</div> </div>
+20 -13
View File
@@ -1,20 +1,20 @@
'use client'; 'use client';
// هدر ثابت بالای صفحه: لوگو، منوی ناوبری، سوییچ زبان/تم، دکمه ورود به پنل کاربری و آیکون سبد خرید. // Fixed navigation with language, theme, account, and cart controls.
import {useState, useEffect} from 'react'; import {useState, useEffect} from 'react';
import Image from 'next/image'; import Image from 'next/image';
import {Badge, Button} from 'antd'; import {Badge, Button} from 'antd';
import {ShoppingCartOutlined, MenuOutlined, CloseOutlined, UserOutlined} from '@ant-design/icons'; import {ShoppingCartOutlined, MenuOutlined, CloseOutlined, UserOutlined} from '@ant-design/icons';
import {useSelector, useDispatch} from 'react-redux'; import {useAppSelector, useAppDispatch} from '@/store/hooks';
import {selectCartCount, openDrawer} from '@/store/cartSlice'; import {selectCartCount, openDrawer} from '@/store/cartSlice';
import {useLanguage} from '@/app/context/LanguageContext'; import {useLanguage} from '@/app/context/LanguageContext';
import ThemeToggle from './ThemeToggle'; import ThemeToggle from './ThemeToggle';
import LanguageToggle from './LanguageToggle'; import LanguageToggle from './LanguageToggle';
export default function Header() { export default function Header() {
const dispatch = useDispatch(); const dispatch = useAppDispatch();
const cartCount = useSelector(selectCartCount); const cartCount = useAppSelector(selectCartCount);
const {t} = useLanguage(); const {t} = useLanguage();
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
@@ -23,11 +23,7 @@ export default function Header() {
{href: '#hero', label: t.nav.home}, {href: '#hero', label: t.nav.home},
{href: '#showcase', label: t.nav.showcase}, {href: '#showcase', label: t.nav.showcase},
{href: '#about', label: t.nav.about}, {href: '#about', label: t.nav.about},
// {href: '#catalog', label: t.nav.catalog},
{href: '#locations', label: t.nav.locations}, {href: '#locations', label: t.nav.locations},
// {href: '#video', label: t.nav.video},
// {href: '#map', label: t.nav.map},
// {href: '#support', label: t.nav.support},
]; ];
useEffect(() => { useEffect(() => {
@@ -36,6 +32,11 @@ export default function Header() {
return () => window.removeEventListener('scroll', onScroll); return () => window.removeEventListener('scroll', onScroll);
}, []); }, []);
console.log(
"%c👋 Hamed Abdollahzade · Frontend Developer",
"font-size:14px;font-weight:600;"
);
return ( return (
<header <header
className={`fixed top-0 inset-x-0 z-50 transition-all duration-300 ${ className={`fixed top-0 inset-x-0 z-50 transition-all duration-300 ${
@@ -75,16 +76,15 @@ export default function Header() {
{t.nav.login} {t.nav.login}
</Button> </Button>
{/* faz 1 */} <Badge count={cartCount} size="small" offset={[-2, 2]}>
{/* <Badge count={cartCount} size="small" offset={[-2, 2]}>
<Button <Button
shape="circle" shape="circle"
size="large" size="large"
icon={<ShoppingCartOutlined/>} icon={<ShoppingCartOutlined/>}
onClick={() => dispatch(openDrawer())} onClick={() => dispatch(openDrawer())}
aria-label="cart" aria-label={t.cart.title}
/> />
</Badge>*/} </Badge>
<button <button
className="xl:hidden text-brand-400 text-2xl" className="xl:hidden text-brand-400 text-2xl"
@@ -112,7 +112,14 @@ export default function Header() {
<div className="flex items-center gap-3 pt-2 border-t border-subtle"> <div className="flex items-center gap-3 pt-2 border-t border-subtle">
<LanguageToggle/> <LanguageToggle/>
<ThemeToggle/> <ThemeToggle/>
<Button icon={<UserOutlined/>} className="flex-1"> <Button
icon={<UserOutlined/>}
className="flex-1"
href="https://panel.my-rm.com"
target="_blank"
rel="noopener noreferrer"
onClick={() => setMobileOpen(false)}
>
{t.nav.login} {t.nav.login}
</Button> </Button>
</div> </div>
@@ -1,25 +1,20 @@
'use client'; 'use client';
/** /** Full-width product carousel with autoplay and manual navigation. */
* سکشن اول سایت (Hero) — ایده کلی از صفحه وندینگ سایت Sielaff گرفته شده:
* یک اسلایدر تمام‌عرض که هر ۵ ثانیه به‌صورت خودکار به اسلاید بعدی می‌رود،
* همراه با یک شکل مورب رنگی (به‌جای قرمز آن‌ها، رنگ سبز برند خودمان #00a693)،
* عنوان بزرگ دو خطی، و دکمه‌های فلش قبلی/بعدی برای ناوبری دستی.
*/
import {useCallback, useEffect, useRef, useState} from 'react'; import {useCallback, useEffect, useRef, useState} from 'react';
import {LeftOutlined, RightOutlined} from '@ant-design/icons'; import {LeftOutlined, RightOutlined} from '@ant-design/icons';
import {useLanguage} from '@/app/context/LanguageContext'; import {useLanguage} from '@/app/context/LanguageContext';
const AUTOPLAY_DELAY = 5000; // هر ۵ ثانیه اسلاید عوض می‌شود const AUTOPLAY_DELAY = 5000; // Advance every five seconds.
export default function HeroSection() { export default function HeroSection() {
const {t, dir} = useLanguage(); const {t, dir} = useLanguage();
const slides = t.hero.slides; const slides = t.hero.slides;
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const timerRef = useRef(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// رفتن به اسلاید بعدی (با چرخش به اول لیست وقتی به آخر رسید) // Wrap around after the last slide.
const goToNext = useCallback(() => { const goToNext = useCallback(() => {
setActiveIndex((prev) => (prev + 1) % slides.length); setActiveIndex((prev) => (prev + 1) % slides.length);
}, [slides.length]); }, [slides.length]);
@@ -28,9 +23,7 @@ export default function HeroSection() {
setActiveIndex((prev) => (prev - 1 + slides.length) % slides.length); setActiveIndex((prev) => (prev - 1 + slides.length) % slides.length);
}, [slides.length]); }, [slides.length]);
// تایمر پخش خودکار: هر بار که اسلاید عوض می‌شود، تایمر قبلی پاک و یک تایمر // Restart the autoplay timer after manual navigation.
// جدید ۵ ثانیه‌ای ست می‌شود. با کلیک دستی روی فلش‌ها هم همین تابع دوباره
// صدا زده می‌شود تا شمارش از نو شروع شود (تجربه کاربری بهتر).
const restartAutoplay = useCallback(() => { const restartAutoplay = useCallback(() => {
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(goToNext, AUTOPLAY_DELAY); timerRef.current = setInterval(goToNext, AUTOPLAY_DELAY);
@@ -43,10 +36,10 @@ export default function HeroSection() {
}; };
}, [restartAutoplay]); }, [restartAutoplay]);
const handleManualNav = (direction) => { const handleManualNav = (direction: 'next' | 'prev') => {
if (direction === 'next') goToNext(); if (direction === 'next') goToNext();
else goToPrev(); else goToPrev();
restartAutoplay(); // شمارش ۵ ثانیه بعد از کلیک دستی از نو شروع شود restartAutoplay(); // Reset the delay after a manual click.
}; };
const slide = slides[activeIndex]; const slide = slides[activeIndex];
@@ -54,7 +47,7 @@ export default function HeroSection() {
return ( return (
<section id="hero" className="relative h-screen w-full overflow-hidden bg-page"> <section id="hero" className="relative h-screen w-full overflow-hidden bg-page">
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{/* شکل مورب تزئینی سمت راست/چپ — نسخه سبز برند به‌جای قرمز Sielaff */} {/* Decorative brand-colored shape. */}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
<div <div
className="absolute inset-y-0 w-1/3 hidden md:block" className="absolute inset-y-0 w-1/3 hidden md:block"
@@ -68,8 +61,8 @@ export default function HeroSection() {
/> />
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{/* اسلایدهای عکس — همه اسلایدها روی هم قرار دارند و فقط با opacity */} {/* Fade between overlapping product images. */}
{/* محو/نمایان می‌شوند (transition نرم به‌جای پرش ناگهانی) */} {/* Use opacity transitions for smooth slide changes. */}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
<div className="absolute inset-0"> <div className="absolute inset-0">
{slides.map((s, i) => ( {slides.map((s, i) => (
@@ -88,7 +81,7 @@ export default function HeroSection() {
))} ))}
</div> </div>
{/* لایه‌ی نیمه‌شفاف پشت متن، برای خوانایی روی هر عکسی */} {/* Overlay for text readability. */}
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
@@ -98,7 +91,7 @@ export default function HeroSection() {
/> />
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{/* متن روی اسلاید: کیکر کوچک + عنوان بزرگ دو خطی + توضیح + دکمه CTA */} {/* Active slide headline, description, and call to action. */}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
<div <div
className="relative z-10 h-full flex flex-col justify-end md:justify-end px-6 md:px-16 pb-28 md:pb-72"> className="relative z-10 h-full flex flex-col justify-end md:justify-end px-6 md:px-16 pb-28 md:pb-72">
@@ -124,7 +117,7 @@ export default function HeroSection() {
</div> </div>
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{/* فلش‌های ناوبری دستی (قبلی / بعدی) — وسط دو طرف */} {/* Previous and next slide controls. */}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
<button <button
onClick={() => handleManualNav('prev')} onClick={() => handleManualNav('prev')}
@@ -141,7 +134,7 @@ export default function HeroSection() {
<LeftOutlined/> <LeftOutlined/>
</button> </button>
{/* نقطه‌های شمارشگر اسلاید پایین صفحه — کلیک روی هرکدام مستقیم به همان اسلاید می‌رود */} {/* Jump directly to a slide using the indicators. */}
<div className="absolute bottom-8 inset-x-0 z-20 flex justify-center gap-2"> <div className="absolute bottom-8 inset-x-0 z-20 flex justify-center gap-2">
{slides.map((s, i) => ( {slides.map((s, i) => (
<button <button
@@ -1,11 +1,12 @@
'use client'; 'use client';
import type {Machine} from '@/types/domain';
import {useEffect, useRef, useState} from 'react'; import {useEffect, useRef, useState} from 'react';
import {gsap} from 'gsap'; import {gsap} from 'gsap';
import {ScrollTrigger} from 'gsap/ScrollTrigger'; import {ScrollTrigger} from 'gsap/ScrollTrigger';
import {Modal, Button, message} from 'antd'; import {Modal, Button, message} from 'antd';
import {ShoppingCartOutlined} from '@ant-design/icons'; import {ShoppingCartOutlined} from '@ant-design/icons';
import {useDispatch} from 'react-redux'; import {useAppDispatch} from '@/store/hooks';
import VendingCard from './VendingCard'; import VendingCard from './VendingCard';
import {showcaseVideoSrc} from '@/data/content'; import {showcaseVideoSrc} from '@/data/content';
import {useLanguage} from '@/app/context/LanguageContext'; import {useLanguage} from '@/app/context/LanguageContext';
@@ -14,12 +15,12 @@ import {addToCart} from '@/store/cartSlice';
gsap.registerPlugin(ScrollTrigger); gsap.registerPlugin(ScrollTrigger);
export default function HorizontalShowcase() { export default function HorizontalShowcase() {
const sectionRef = useRef(null); const sectionRef = useRef<HTMLElement>(null);
const trackRef = useRef(null); const trackRef = useRef<HTMLDivElement>(null);
const videoRef = useRef(null); const videoRef = useRef<HTMLVideoElement>(null);
const dispatch = useDispatch(); const dispatch = useAppDispatch();
const {t} = useLanguage(); const {t} = useLanguage();
const [quickViewMachine, setQuickViewMachine] = useState(null); const [quickViewMachine, setQuickViewMachine] = useState<Machine | null>(null);
const [videoAvailable, setVideoAvailable] = useState(true); const [videoAvailable, setVideoAvailable] = useState(true);
useEffect(() => { useEffect(() => {
@@ -33,10 +34,10 @@ export default function HorizontalShowcase() {
Math.max(track.scrollWidth - window.innerWidth, 0) Math.max(track.scrollWidth - window.innerWidth, 0)
); );
let animationFrameId = null; let animationFrameId: number | null = null;
let latestProgress = 0; let latestProgress = 0;
const syncVideo = (progress) => { const syncVideo = (progress: number) => {
latestProgress = progress; latestProgress = progress;
if (animationFrameId !== null) return; if (animationFrameId !== null) return;
@@ -148,4 +149,4 @@ export default function HorizontalShowcase() {
</Modal> </Modal>
</section> </section>
); );
} }
-35
View File
@@ -1,35 +0,0 @@
'use client';
// سکشن مستقل ویدیوی معرفی شرکت (جدا از ویدیوی پس‌زمینه ویترین محصولات).
import { useLanguage } from '@/app/context/LanguageContext';
import { introVideoSrc, introVideoPoster } from '@/data/content';
export default function IntroVideoSection() {
const { t } = useLanguage();
return (
<section id="video" className="relative px-6 md:px-16 py-20 md:py-28 bg-elevated border-t border-subtle">
<div className="max-w-5xl mx-auto text-center">
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.introVideo.eyebrow}</span>
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text mt-3 mb-3">
{t.introVideo.title}
</h2>
<p className="text-secondary text-sm md:text-base mb-10">{t.introVideo.subtitle}</p>
{/*
TODO: replace /public/video/company-intro-placeholder.mp4 with the real
company introduction video (keep the same filename, or update
`introVideoSrc` in data/content.js). The poster image shows before
playback starts — swap /public/images/video-poster.svg the same way.
*/}
<div className="rounded-2xl overflow-hidden border border-subtle shadow-brandGlow">
<video controls poster={introVideoPoster} className="w-full aspect-video bg-black">
<source src={introVideoSrc} type="video/mp4" />
{t.introVideo.videoNotSupported}
</video>
</div>
</div>
</section>
);
}
@@ -1,6 +1,6 @@
'use client'; 'use client';
// دکمه سوییچ زبان فارسی/انگلیسی. // Switch between Persian and English.
import { Button, Tooltip } from 'antd'; import { Button, Tooltip } from 'antd';
import { useLanguage } from '@/app/context/LanguageContext'; import { useLanguage } from '@/app/context/LanguageContext';
@@ -1,6 +1,6 @@
'use client'; 'use client';
// گرید «مکان مناسب برای نصب» — هتل‌ها، مدارس، داروخانه‌ها، بیمارستان‌ها، شیفت شب، محصولات تخصصی. // Suggested installation locations for vending machines.
import { import {
HomeOutlined, HomeOutlined,
-129
View File
@@ -1,129 +0,0 @@
'use client';
// نقشه نشان (Neshan) — از Static Map API استفاده می‌کند. تا وقتی کلید API خالی باشد،
// به‌جای نقشه یک پیام راهنما نشان داده می‌شود (نه تصویر خراب).
import {useState} from 'react';
import {EnvironmentOutlined} from '@ant-design/icons';
import {useLanguage} from '@/app/context/LanguageContext';
import {useTheme} from '@/app/context/ThemeContext';
import {mapLocations} from '@/data/content';
import Image from "next/image";
// ---------------------------------------------------------------------------
// Neshan (نشان) map integration.
//
// TODO: put your Neshan API key here, or (preferred) set it as an environment
// variable in `.env.local`:
// NEXT_PUBLIC_NESHAN_API_KEY=your-key-here
// Get a key from https://platform.neshan.org
// ---------------------------------------------------------------------------
const NESHAN_API_KEY = process.env.NEXT_PUBLIC_NESHAN_API_KEY || '';
// Neshan's Static Map API — a plain image, no JS SDK/script tag needed.
// Verify query param names/values against the current Neshan docs
// (https://platform.neshan.org/api/static-map/) if this ever needs updating —
// `type` accepts their day/night map styles, e.g. "standard-day" / "standard-night".
function staticMapUrl({lat, lng}, mapStyle) {
const params = new URLSearchParams({
key: NESHAN_API_KEY,
type: mapStyle,
center: `${lat},${lng}`,
zoom: '15',
size: '600x400',
marker: `${lat},${lng}`,
});
return `https://api.neshan.org/v5/static?${params.toString()}`;
}
function neshanViewUrl({lat, lng}) {
return `https://neshan.org/maps/@${lat},${lng},15z`;
}
function MapThumb({coords, mapStyle, alt}) {
const [errored, setErrored] = useState(false);
const {t} = useLanguage();
if (!NESHAN_API_KEY || errored) {
return (
<div
className="w-full h-full min-h-[280px] flex flex-col items-center justify-center gap-3 bg-surface text-secondary text-sm text-center p-6">
<EnvironmentOutlined className="text-3xl text-brand-400"/>
<p>{t.map.configNotice}</p>
</div>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={staticMapUrl(coords, mapStyle)}
alt={alt}
onError={() => setErrored(true)}
className="w-full h-full object-cover"
/>
);
}
export default function MapSection() {
const {t} = useLanguage();
const {theme} = useTheme();
const mapStyle = theme === 'dark' ? 'standard-night' : 'standard-day';
const pins = [
{
key: 'factory',
label: t.map.factoryLabel,
coords: mapLocations.factory,
address: t.contactInfo.factoryAddress
},
{
key: 'sales',
label: t.map.salesLabel,
coords: mapLocations.salesOffice,
address: t.contactInfo.salesOfficeAddress
},
];
return (
<section id="map" className="relative px-6 md:px-16 py-20 md:py-28 bg-page border-t border-subtle">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.map.eyebrow}</span>
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text mt-3">{t.map.title}</h2>
</div>
<div className="grid lg:grid-cols-[1.3fr,1fr] gap-6">
<div className="rounded-2xl overflow-hidden border border-subtle h-[360px] lg:h-auto min-h-[360px]">
<MapThumb coords={pins[0].coords} mapStyle={mapStyle} alt={pins[0].label}/>
</div>
<div className="flex flex-col gap-4">
{pins.map((pin) => (
<div key={pin.key} className="bg-surface border border-subtle rounded-2xl p-5">
<div className="flex items-start gap-3">
<div
className="w-10 h-10 shrink-0 rounded-lg bg-brand/15 border border-subtle flex items-center justify-center text-brand-400">
<EnvironmentOutlined/>
</div>
<div>
<h3 className="text-primary font-bold text-sm mb-1">{pin.label}</h3>
<p className="text-secondary text-sm leading-7 mb-2">{pin.address}</p>
<a
href={neshanViewUrl(pin.coords)}
target="_blank"
rel="noopener noreferrer"
className="text-brand-400 hover:text-brand-300 text-xs font-semibold transition-colors"
>
{t.map.openInMaps} ↗
</a>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</section>
);
}
@@ -1,18 +1,19 @@
'use client'; 'use client';
import type {Machine} from '@/types/domain';
import {useState} from 'react'; import {useState} from 'react';
import {Modal, Button, message} from 'antd'; import {Modal, Button, message} from 'antd';
import {ShoppingCartOutlined, EyeOutlined} from '@ant-design/icons'; import {ShoppingCartOutlined, EyeOutlined} from '@ant-design/icons';
import {useDispatch} from 'react-redux'; import {useAppDispatch} from '@/store/hooks';
import {useLanguage} from '@/app/context/LanguageContext'; import {useLanguage} from '@/app/context/LanguageContext';
import {addToCart} from '@/store/cartSlice'; import {addToCart} from '@/store/cartSlice';
export default function MobileShowcase() { export default function MobileShowcase() {
const dispatch = useDispatch(); const dispatch = useAppDispatch();
const {t, dir} = useLanguage(); const {t, dir} = useLanguage();
const [quickViewMachine, setQuickViewMachine] = useState(null); const [quickViewMachine, setQuickViewMachine] = useState<Machine | null>(null);
const handleAddToCart = (machine) => { const handleAddToCart = (machine: Machine) => {
dispatch( dispatch(
addToCart({ addToCart({
id: machine.id, id: machine.id,
@@ -104,4 +105,4 @@ export default function MobileShowcase() {
</Modal> </Modal>
</section> </section>
); );
} }
@@ -7,14 +7,14 @@ import MobileShowcase from './MobileShowcase';
const MOBILE_BREAKPOINT = 768; const MOBILE_BREAKPOINT = 768;
export default function ShowcaseSection() { export default function ShowcaseSection() {
const [isMobile, setIsMobile] = useState(null); const [isMobile, setIsMobile] = useState<boolean | null>(null);
useEffect(() => { useEffect(() => {
const mediaQuery = window.matchMedia( const mediaQuery = window.matchMedia(
`(max-width: ${MOBILE_BREAKPOINT - 1}px)` `(max-width: ${MOBILE_BREAKPOINT - 1}px)`
); );
const handleChange = (event) => setIsMobile(event.matches); const handleChange = (event: MediaQueryListEvent) => setIsMobile(event.matches);
setIsMobile(mediaQuery.matches); setIsMobile(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange); mediaQuery.addEventListener('change', handleChange);
@@ -25,4 +25,4 @@ export default function ShowcaseSection() {
if (isMobile === null) return null; if (isMobile === null) return null;
return isMobile ? <MobileShowcase/> : <HorizontalShowcase/>; return isMobile ? <MobileShowcase/> : <HorizontalShowcase/>;
} }
-119
View File
@@ -1,119 +0,0 @@
'use client';
// فرم ثبت تیکت پشتیبانی با اعتبارسنجی antd؛ ارسال واقعی به بک‌اند در بلوک TODO داخل کد مشخص شده.
import { Form, Input, Select, Button, message } from 'antd';
import { useLanguage } from '@/app/context/LanguageContext';
const { TextArea } = Input;
export default function TicketForm() {
const { t, locale } = useLanguage();
const [form] = Form.useForm();
const handleFinish = async (values) => {
// -------------------------------------------------------------------
// TODO: Connect to the real ticketing/support API.
//
// import axios from 'axios';
//
// try {
// await axios.post('/api/tickets', values);
// } catch (err) {
// message.error('...');
// return;
// }
// api() <===> todo : use this func to fetch ...
// -------------------------------------------------------------------
console.log('Ticket submitted (placeholder):', values);
message.success(t.ticket.successMsg);
form.resetFields();
};
return (
<section id="support" className="relative px-6 md:px-16 py-20 md:py-28 bg-surface border-t border-subtle">
<div className="max-w-2xl mx-auto">
<div className="text-center mb-10">
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.ticket.eyebrow}</span>
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text mt-3 mb-3">
{t.ticket.title}
</h2>
<p className="text-secondary text-sm md:text-base">{t.ticket.subtitle}</p>
</div>
<Form
form={form}
layout="vertical"
onFinish={handleFinish}
requiredMark={false}
initialValues={{ priority: 'medium' }}
>
<div className="grid md:grid-cols-2 gap-x-4">
<Form.Item
label={<span className="text-secondary">{t.ticket.name}</span>}
name="name"
rules={[{ required: true, message: t.ticket.requiredMsg }]}
>
<Input size="large" placeholder={t.ticket.namePlaceholder} />
</Form.Item>
<Form.Item
label={<span className="text-secondary">{t.ticket.phone}</span>}
name="phone"
rules={[
{ required: true, message: t.ticket.requiredMsg },
{ pattern: /^[0-9+\s-]{7,15}$/, message: t.ticket.invalidPhone },
]}
>
<Input size="large" placeholder={t.ticket.phonePlaceholder} dir="ltr" />
</Form.Item>
</div>
<div className="grid md:grid-cols-2 gap-x-4">
<Form.Item
label={<span className="text-secondary">{t.ticket.email}</span>}
name="email"
rules={[{ type: 'email', message: t.ticket.invalidEmail }]}
>
<Input size="large" placeholder={t.ticket.emailPlaceholder} dir="ltr" />
</Form.Item>
<Form.Item
label={<span className="text-secondary">{t.ticket.priority}</span>}
name="priority"
>
<Select
size="large"
options={t.ticket.priorities.map((p) => ({ value: p.value, label: p.label }))}
/>
</Form.Item>
</div>
<Form.Item
label={<span className="text-secondary">{t.ticket.subject}</span>}
name="subject"
rules={[{ required: true, message: t.ticket.requiredMsg }]}
>
<Input size="large" placeholder={t.ticket.subjectPlaceholder} />
</Form.Item>
<Form.Item
label={<span className="text-secondary">{t.ticket.message}</span>}
name="message"
rules={[{ required: true, message: t.ticket.requiredMsg }]}
>
<TextArea rows={5} placeholder={t.ticket.messagePlaceholder} />
</Form.Item>
<Form.Item className="mb-0">
<Button type="primary" size="large" htmlType="submit" block>
{t.ticket.submit}
</Button>
</Form.Item>
</Form>
</div>
</section>
);
}
-56
View File
@@ -1,56 +0,0 @@
'use client';
/**
* سکشن «مشتریانی که به ما اعتماد کرده‌اند» — یک نوار لوگوی بی‌پایان (marquee)
* که به‌آرامی و پیوسته حرکت می‌کند. برای ساخت افکت حلقه‌ی بی‌پایان، لیست
* لوگوها را دو بار پشت‌سرهم رندر می‌کنیم و با انیمیشن CSS آن را جابه‌جا می‌کنیم.
*
* نکته فنی: مسیر (track) همیشه جهت LTR دارد، چون منطق «حرکت به چپ» انیمیشن
* فرض بر همین جهت است — دقیقاً همان مشکلی که در اسکرول افقی محصولات داشتیم و
* حلش کردیم. این‌جا هم برای جلوگیری از همان باگ، جهت را ثابت نگه داشتیم.
*/
import { useLanguage } from '@/app/context/LanguageContext';
export default function TrustedBySection() {
const { t } = useLanguage();
const clients = t.trustedBy.clients;
// لیست لوگوها را دو برابر می‌کنیم تا وقتی نیمه اول با انیمیشن رد شد،
// نیمه دوم دقیقاً از همان‌جا ادامه بدهد و چشم متوجه پرش/شکاف نشود.
const doubledClients = [...clients, ...clients];
return (
<section className="relative px-6 md:px-16 py-16 md:py-20 bg-page border-t border-subtle overflow-hidden">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-10">
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.trustedBy.eyebrow}</span>
<h2 className="text-xl md:text-2xl font-extrabold text-primary mt-2">{t.trustedBy.title}</h2>
</div>
</div>
{/* یک محو تدریجی (fade mask) دو طرف نوار، تا لبه‌های لوگوها بریده به‌نظر نرسد */}
<div
className="relative"
style={{
maskImage: 'linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%)',
}}
>
<div className="flex w-max animate-marquee gap-10" dir="ltr">
{doubledClients.map((client, i) => (
<div
key={`${client.id}-${i}`}
className="w-40 md:w-48 shrink-0 flex items-center justify-center bg-surface border border-subtle rounded-xl p-4 opacity-80 hover:opacity-100 transition-opacity"
>
<img
src={`/images/clients/client-${((i % clients.length) + 1)}.svg`}
alt={client.name}
className="w-full h-auto"
/>
</div>
))}
</div>
</div>
</section>
);
}
@@ -1,23 +1,25 @@
'use client'; 'use client';
import type {Machine} from '@/types/domain';
import type {MouseEvent} from 'react';
import {useRef} from 'react'; import {useRef} from 'react';
import {Button, message} from 'antd'; import {Button, message} from 'antd';
import {ShoppingCartOutlined, EyeOutlined} from '@ant-design/icons'; import {ShoppingCartOutlined, EyeOutlined} from '@ant-design/icons';
import {useDispatch} from 'react-redux'; import {useAppDispatch} from '@/store/hooks';
import {addToCart} from '@/store/cartSlice'; import {addToCart} from '@/store/cartSlice';
import {useLanguage} from '@/app/context/LanguageContext'; import {useLanguage} from '@/app/context/LanguageContext';
import FloatingItems from './FloatingItems'; import FloatingItems from './FloatingItems';
import {floatingSnacks} from '@/data/content'; import {floatingSnacks} from '@/data/content';
export default function VendingCard({machine, onQuickView}) { export default function VendingCard({machine, onQuickView}: {machine: Machine; onQuickView?: (machine: Machine) => void}) {
const dispatch = useDispatch(); const dispatch = useAppDispatch();
const {t, dir} = useLanguage(); const {t, dir} = useLanguage();
const stageRef = useRef(null); const stageRef = useRef<HTMLDivElement>(null);
const lensRef = useRef(null); const lensRef = useRef<HTMLDivElement>(null);
const revealRef = useRef(null); const revealRef = useRef<HTMLImageElement>(null);
const handleMouseMove = (e) => { const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
const stage = stageRef.current; const stage = stageRef.current;
if (!stage) return; if (!stage) return;
const rect = stage.getBoundingClientRect(); const rect = stage.getBoundingClientRect();
+8 -267
View File
@@ -1,16 +1,18 @@
import type {FloatingSnack, Locale, Machine, SiteContent} from '@/types/domain';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Single bilingual content source (fa/en). Every UI string and every piece of // Single bilingual content source (fa/en). Every UI string and every piece of
// business copy lives here, per locale. Components read via useLanguage(). // business copy lives here, per locale. Components read via useLanguage().
// Asset paths (images/gifs/video) are locale-independent and stay the same. // Asset paths (images/gifs/video) are locale-independent and stay the same.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const machinesFa = [ const machinesFa: Machine[] = [
{ {
id: 'rm-35', id: 'rm-35',
name: 'روبات مارکت ۳۵ کانال', name: 'روبات مارکت ۳۵ کانال',
shortLabel: '۳۵ کانال', shortLabel: '۳۵ کانال',
priceLabel: '4,200,000,000 ریال', priceLabel: '4,180,000,000 ریال',
priceNumeric: 4200000000, priceNumeric: 4180000000,
description: 'یخچالدار، ۳۵ کانال اختصاصی، سیستم خنک‌کننده قدرتمند، تحویل نرم، مدیریت آنلاین ۲۴ ساعته.', description: 'یخچالدار، ۳۵ کانال اختصاصی، سیستم خنک‌کننده قدرتمند، تحویل نرم، مدیریت آنلاین ۲۴ ساعته.',
highlights: ['یخچالدار', '۳۵ کانال اختصاصی', 'تحویل نرم', 'مدیریت آنلاین ۲۴ ساعته'], highlights: ['یخچالدار', '۳۵ کانال اختصاصی', 'تحویل نرم', 'مدیریت آنلاین ۲۴ ساعته'],
image: '/images/machine-35-base.webp', image: '/images/machine-35-base.webp',
@@ -43,7 +45,7 @@ const machinesFa = [
}, },
]; ];
const machinesEn = [ const machinesEn: Machine[] = [
{ {
id: 'rm-35', id: 'rm-35',
name: 'Robot Market RM-35', name: 'Robot Market RM-35',
@@ -82,7 +84,7 @@ const machinesEn = [
}, },
]; ];
export const content = { export const content: Record<Locale, SiteContent> = {
fa: { fa: {
dir: 'rtl', dir: 'rtl',
lang: 'fa', lang: 'fa',
@@ -96,10 +98,6 @@ export const content = {
showcase: 'دستگاه‌ها', showcase: 'دستگاه‌ها',
about: 'درباره ما', about: 'درباره ما',
locations: 'مکان‌های نصب', locations: 'مکان‌های نصب',
catalog: 'کاتالوگ محصولات',
video: 'ویدیوی معرفی',
map: 'نقشه',
support: 'پشتیبانی',
contact: 'تماس با ما', contact: 'تماس با ما',
login: 'ورود به پنل کاربری', login: 'ورود به پنل کاربری',
}, },
@@ -148,18 +146,6 @@ export const content = {
gifAlt: (name) => `نمایش عملکرد ${name}`, gifAlt: (name) => `نمایش عملکرد ${name}`,
}, },
machines: machinesFa, machines: machinesFa,
trustedBy: {
eyebrow: 'اعتماد شما، افتخار ماست',
title: 'کسب‌وکارهایی که به روبات مارکت اعتماد کرده‌اند',
clients: [
{id: 'c1', name: 'هتل پارسیان'},
{id: 'c2', name: 'دانشگاه فردوسی'},
{id: 'c3', name: 'بیمارستان رضوی'},
{id: 'c4', name: 'فروشگاه‌های زنجیره‌ای رفاه'},
{id: 'c5', name: 'مجتمع اداری آسمان'},
{id: 'c6', name: 'باشگاه ورزشی تلاش'},
],
},
features: [ features: [
{ {
id: 'warranty', id: 'warranty',
@@ -243,114 +229,6 @@ export const content = {
}, },
], ],
}, },
catalog: {
eyebrow: 'کاتالوگ محصولات',
title: 'محصولات قابل سفارش برای دستگاه شما',
subtitle:
'با خرید دستگاه روبات مارکت، می‌توانید این محصولات را با قیمت عمده و کمتر از بازار سفارش دهید؛ تیم ما هم چیدمان اولیه و شارژ دستگاه شما را انجام می‌دهد.',
addToOrder: 'افزودن به سفارش',
originalPriceLabel: 'قیمت بازار',
machinePriceLabel: 'قیمت ویژه مشتریان روبات مارکت',
addToast: (name) => `${name} به لیست سفارش اضافه شد`,
items: [
{
id: 'saffron',
name: 'زعفران ممتاز درجه‌یک',
category: 'زعفران',
image: '/images/catalog/saffron.svg',
originalPriceNumeric: 12000000,
priceNumeric: 8900000
},
{
id: 'chips',
name: 'چیپس و پفک (بسته عمده)',
category: 'تنقلات',
image: '/images/snacks/chips-1.svg',
originalPriceNumeric: 450000,
priceNumeric: 320000
},
{
id: 'soda',
name: 'نوشابه و نوشیدنی گازدار',
category: 'نوشیدنی',
image: '/images/snacks/soda-1.svg',
originalPriceNumeric: 250000,
priceNumeric: 180000
},
{
id: 'water',
name: 'آب معدنی',
category: 'نوشیدنی',
image: '/images/catalog/water.svg',
originalPriceNumeric: 120000,
priceNumeric: 80000
},
{
id: 'coffee',
name: 'قهوه فوری',
category: 'گرم',
image: '/images/catalog/coffee.svg',
originalPriceNumeric: 650000,
priceNumeric: 470000
},
{
id: 'tea',
name: 'چای کیسه‌ای',
category: 'گرم',
image: '/images/catalog/tea.svg',
originalPriceNumeric: 380000,
priceNumeric: 270000
},
],
},
introVideo: {
eyebrow: 'ویدیوی معرفی',
title: 'با روبات مارکت آشنا شوید',
subtitle: 'نگاهی کوتاه به کارخانه، فرآیند تولید و عملکرد دستگاه‌های روبات مارکت.',
videoNotSupported: 'مرورگر شما از پخش ویدیو پشتیبانی نمی‌کند.',
},
map: {
eyebrow: 'موقعیت ما روی نقشه',
title: 'کارخانه و دفاتر روبات مارکت',
factoryLabel: 'کارخانه',
salesLabel: 'دفتر فروش مرکزی',
openInMaps: 'مشاهده در نشان',
configNotice: 'برای فعال شدن نقشه، کلید API نشان را در تنظیمات پروژه قرار دهید.',
},
ticket: {
eyebrow: 'پشتیبانی',
title: 'ثبت تیکت پشتیبانی',
subtitle: 'سوال یا درخواستی دارید؟ فرم زیر را پر کنید تا تیم پشتیبانی در سریع‌ترین زمان با شما تماس بگیرد.',
name: 'نام و نام خانوادگی',
namePlaceholder: 'مثال: علی رضایی',
phone: 'شماره تماس',
phonePlaceholder: '09xxxxxxxxx',
email: 'ایمیل (اختیاری)',
emailPlaceholder: 'example@email.com',
subject: 'موضوع',
subjectPlaceholder: 'موضوع درخواست خود را بنویسید',
priority: 'اولویت',
priorities: [
{value: 'low', label: 'کم'},
{value: 'medium', label: 'متوسط'},
{value: 'high', label: 'فوری'},
],
message: 'پیام شما',
messagePlaceholder: 'توضیحات کامل درخواست یا مشکل خود را بنویسید…',
submit: 'ارسال تیکت',
requiredMsg: 'این فیلد الزامی است',
invalidPhone: 'شماره تماس معتبر نیست',
invalidEmail: 'ایمیل معتبر نیست',
successMsg: 'تیکت شما با موفقیت ثبت شد. به‌زودی با شما تماس می‌گیریم.',
},
chat: {
fabLabel: 'گفتگوی آنلاین',
title: 'پشتیبانی آنلاین روبات مارکت',
placeholder: 'پیام خود را بنویسید…',
send: 'ارسال',
initialBotMessage: 'سلام! 👋 چطور می‌تونم امروز کمکتون کنم؟',
fallbackReply: 'ممنون از پیامتون؛ همکاران ما به‌زودی پاسخ می‌دهند.',
},
footer: { footer: {
aboutLabel: 'روبات مارکت', aboutLabel: 'روبات مارکت',
aboutText: 'ارائه‌دهنده دستگاه‌های وندینگ هوشمند برای کسب‌وکارهای مدرن — محصولی از شرکت نگین سازان پیشگام رادمهر آتوسا.', aboutText: 'ارائه‌دهنده دستگاه‌های وندینگ هوشمند برای کسب‌وکارهای مدرن — محصولی از شرکت نگین سازان پیشگام رادمهر آتوسا.',
@@ -386,10 +264,6 @@ export const content = {
showcase: 'Machines', showcase: 'Machines',
about: 'About Us', about: 'About Us',
locations: 'Where to Install', locations: 'Where to Install',
catalog: 'Product Catalog',
video: 'Intro Video',
map: 'Map',
support: 'Support',
contact: 'Contact', contact: 'Contact',
login: 'Sign In', login: 'Sign In',
}, },
@@ -438,19 +312,6 @@ export const content = {
gifAlt: (name) => `${name} in action`, gifAlt: (name) => `${name} in action`,
}, },
machines: machinesEn, machines: machinesEn,
trustedBy: {
eyebrow: 'Trusted by businesses like yours',
title: 'Businesses That Trust Robot Market',
// Sample/placeholder names — replace with real client names.
clients: [
{id: 'c1', name: 'Parsian Hotel'},
{id: 'c2', name: 'Ferdowsi University'},
{id: 'c3', name: 'Razavi Hospital'},
{id: 'c4', name: 'Refah Retail Chain'},
{id: 'c5', name: 'Asemaan Office Complex'},
{id: 'c6', name: 'Talash Sports Club'},
],
},
features: [ features: [
{ {
id: 'warranty', id: 'warranty',
@@ -534,114 +395,6 @@ export const content = {
}, },
], ],
}, },
catalog: {
eyebrow: 'Product Catalog',
title: 'Products You Can Order for Your Machine',
subtitle:
'When you buy a Robot Market machine, you can order these products at a wholesale price below market rate — our team also handles the initial stocking and layout for you.',
addToOrder: 'Add to Order',
originalPriceLabel: 'Market price',
machinePriceLabel: 'Robot Market customer price',
addToast: (name) => `${name} added to your order`,
items: [
{
id: 'saffron',
name: 'Premium Grade-1 Saffron',
category: 'Saffron',
image: '/images/catalog/saffron.svg',
originalPriceNumeric: 12000000,
priceNumeric: 8900000
},
{
id: 'chips',
name: 'Chips & Crisps (bulk pack)',
category: 'Snacks',
image: '/images/snacks/chips-1.svg',
originalPriceNumeric: 450000,
priceNumeric: 320000
},
{
id: 'soda',
name: 'Soda & Carbonated Drinks',
category: 'Drinks',
image: '/images/snacks/soda-1.svg',
originalPriceNumeric: 250000,
priceNumeric: 180000
},
{
id: 'water',
name: 'Mineral Water',
category: 'Drinks',
image: '/images/catalog/water.svg',
originalPriceNumeric: 120000,
priceNumeric: 80000
},
{
id: 'coffee',
name: 'Instant Coffee',
category: 'Hot Drinks',
image: '/images/catalog/coffee.svg',
originalPriceNumeric: 650000,
priceNumeric: 470000
},
{
id: 'tea',
name: 'Tea Bags',
category: 'Hot Drinks',
image: '/images/catalog/tea.svg',
originalPriceNumeric: 380000,
priceNumeric: 270000
},
],
},
introVideo: {
eyebrow: 'Intro Video',
title: 'Get to Know Robot Market',
subtitle: 'A short look at the factory, our production process, and the machines in action.',
videoNotSupported: 'Your browser does not support video playback.',
},
map: {
eyebrow: 'Find Us on the Map',
title: 'Robot Market Factory & Offices',
factoryLabel: 'Factory',
salesLabel: 'Central Sales Office',
openInMaps: 'Open in Neshan',
configNotice: 'Add your Neshan API key in the project settings to activate the map.',
},
ticket: {
eyebrow: 'Support',
title: 'Submit a Support Ticket',
subtitle: 'Have a question or request? Fill out the form below and our support team will get back to you as soon as possible.',
name: 'Full Name',
namePlaceholder: 'e.g. John Smith',
phone: 'Phone Number',
phonePlaceholder: '09xxxxxxxxx',
email: 'Email (optional)',
emailPlaceholder: 'example@email.com',
subject: 'Subject',
subjectPlaceholder: 'Write the subject of your request',
priority: 'Priority',
priorities: [
{value: 'low', label: 'Low'},
{value: 'medium', label: 'Medium'},
{value: 'high', label: 'Urgent'},
],
message: 'Your Message',
messagePlaceholder: 'Describe your request or issue in full…',
submit: 'Submit Ticket',
requiredMsg: 'This field is required',
invalidPhone: 'Invalid phone number',
invalidEmail: 'Invalid email address',
successMsg: 'Your ticket was submitted successfully. We will contact you soon.',
},
chat: {
fabLabel: 'Live Chat',
title: 'Robot Market Live Support',
placeholder: 'Type your message…',
send: 'Send',
initialBotMessage: "Hi! 👋 How can we help you today?",
fallbackReply: "Thanks for your message — our team will reply shortly.",
},
footer: { footer: {
aboutLabel: 'Robot Market', aboutLabel: 'Robot Market',
aboutText: 'Smart vending machines for modern businesses — a product of Negin Sazan Pishgam Radmehr Atousa Co.', aboutText: 'Smart vending machines for modern businesses — a product of Negin Sazan Pishgam Radmehr Atousa Co.',
@@ -666,7 +419,7 @@ export const content = {
}; };
// Locale-independent asset config (positions/paths only — same in both languages). // Locale-independent asset config (positions/paths only — same in both languages).
export const floatingSnacks = [ export const floatingSnacks: FloatingSnack[] = [
{id: 'chips-1', image: '/images/snacks/item-1.webp', size: 60, top: '5%', left: '-5%', duration: 5.5, delay: 0}, {id: 'chips-1', image: '/images/snacks/item-1.webp', size: 60, top: '5%', left: '-5%', duration: 5.5, delay: 0},
{id: 'soda-1', image: '/images/snacks/item-2.webp', size: 55, top: '68%', left: '2%', duration: 6.2, delay: 0.6}, {id: 'soda-1', image: '/images/snacks/item-2.webp', size: 55, top: '68%', left: '2%', duration: 6.2, delay: 0.6},
{id: 'chips-2', image: '/images/snacks/item-3.webp', size: 65, top: '10%', left: '85%', duration: 4.8, delay: 0.3}, {id: 'chips-2', image: '/images/snacks/item-3.webp', size: 65, top: '10%', left: '85%', duration: 4.8, delay: 0.3},
@@ -676,15 +429,3 @@ export const floatingSnacks = [
]; ];
export const showcaseVideoSrc = '/video/showcase-placeholder.mp4'; export const showcaseVideoSrc = '/video/showcase-placeholder.mp4';
// Dedicated company introduction video (different from the horizontal-showcase
// scroll-scrubbed background video above). Drop the real file at this path.
export const introVideoSrc = '/video/company-intro-placeholder.mp4';
export const introVideoPoster = '/images/video-poster.svg';
// Placeholder coordinates for Toos Industrial Town, Mashhad (approximate).
// Replace with the exact factory pin — see README.
export const mapLocations = {
factory: {lat: 36.42, lng: 59.445},
salesOffice: {lat: 36.3110, lng: 59.5683}, // approximate central Mashhad placeholder
};
-9
View File
@@ -1,9 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
"jsx": "react"
}
}
+4 -4
View File
@@ -43,11 +43,11 @@ api.interceptors.response.use(
} }
if (status >= 500) { if (status >= 500) {
// message.error('خطایی در سرور رخ داد. لطفاً بعداً تلاش کنید.'); // TODO: Display a server error message.
} }
} else if (error.request) { } else if (error.request) {
// درخواست ارسال شد ولی هیچ جوابی نیومد (قطعی اینترنت، سرور خاموش و ...) // The request was sent, but no response arrived.
// TODO: message.error('اتصال با سرور برقرار نشد.'); // TODO: Display a network connection error.
} }
@@ -56,4 +56,4 @@ api.interceptors.response.use(
} }
); );
export default api; export default api;
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
+3 -1
View File
@@ -1,5 +1,7 @@
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''; const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
// Next.js 14 loads its configuration as JavaScript.
/** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
@@ -19,4 +21,4 @@ const nextConfig = {
assetPrefix: basePath || undefined, assetPrefix: basePath || undefined,
}; };
module.exports = nextConfig; module.exports = nextConfig;
+64 -1
View File
@@ -19,9 +19,13 @@
"react-redux": "9.1.2" "react-redux": "9.1.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "20.19.43",
"@types/react": "18.3.31",
"@types/react-dom": "18.3.7",
"autoprefixer": "10.4.19", "autoprefixer": "10.4.19",
"postcss": "8.4.39", "postcss": "8.4.39",
"tailwindcss": "3.4.6" "tailwindcss": "3.4.6",
"typescript": "5.9.3"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@@ -576,6 +580,44 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/@types/use-sync-external-store": { "node_modules/@types/use-sync-external-store": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
@@ -2917,6 +2959,27 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+13 -8
View File
@@ -6,22 +6,27 @@
"dev": "next dev -H 0.0.0.0", "dev": "next dev -H 0.0.0.0",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "5.3.7",
"@reduxjs/toolkit": "2.2.7",
"antd": "5.19.1",
"axios": "1.7.2",
"gsap": "3.12.5",
"next": "14.2.5", "next": "14.2.5",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"antd": "5.19.1", "react-redux": "9.1.2"
"@ant-design/icons": "5.3.7",
"@reduxjs/toolkit": "2.2.7",
"react-redux": "9.1.2",
"axios": "1.7.2",
"gsap": "3.12.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "20.19.43",
"@types/react": "18.3.31",
"@types/react-dom": "18.3.7",
"autoprefixer": "10.4.19", "autoprefixer": "10.4.19",
"postcss": "8.4.39", "postcss": "8.4.39",
"tailwindcss": "3.4.6" "tailwindcss": "3.4.6",
"typescript": "5.9.3"
} }
} }
-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 120" width="140" height="120">
<rect x="20" y="30" width="80" height="70" rx="8" fill="#6b3f2a" stroke="#3a1f13" stroke-width="3"/>
<path d="M100 45 q22 0 22 20 q0 20 -22 20" fill="none" stroke="#3a1f13" stroke-width="6"/>
<ellipse cx="60" cy="30" rx="40" ry="10" fill="#c8a27a"/>
<text x="60" y="72" font-family="Tahoma, sans-serif" font-size="12" fill="#ffe9d6" text-anchor="middle">قهوه</text>
</svg>

Before

Width:  |  Height:  |  Size: 473 B

-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 140" width="120" height="140">
<rect x="15" y="20" width="90" height="105" rx="10" fill="#7a1f2b" stroke="#3d0f15" stroke-width="3"/>
<rect x="15" y="20" width="90" height="34" rx="10" fill="#00a693"/>
<path d="M40 70 q10 -14 20 0 q10 -14 20 0" stroke="#ffcf4d" stroke-width="3" fill="none"/>
<text x="60" y="100" font-family="Tahoma, sans-serif" font-size="13" fill="#ffe9b0" text-anchor="middle">زعفران</text>
</svg>

Before

Width:  |  Height:  |  Size: 490 B

-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 140" width="120" height="140">
<rect x="20" y="25" width="80" height="95" rx="6" fill="#00a693" stroke="#017065" stroke-width="3"/>
<rect x="30" y="15" width="60" height="16" rx="4" fill="#e6f7f4"/>
<line x1="60" y1="0" x2="60" y2="15" stroke="#8fdccc" stroke-width="2"/>
<text x="60" y="80" font-family="Tahoma, sans-serif" font-size="13" fill="#e6f7f4" text-anchor="middle">چای</text>
</svg>

Before

Width:  |  Height:  |  Size: 462 B

-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 90 160" width="90" height="160">
<path d="M30 10 h30 v18 l8 10 v100 a6 6 0 0 1 -6 6 h-34 a6 6 0 0 1 -6 -6 v-100 l8 -10 z" fill="#bfe9ff" stroke="#00a693" stroke-width="3"/>
<rect x="26" y="70" width="38" height="30" fill="#ffffff" opacity="0.85"/>
<text x="45" y="90" font-family="Tahoma, sans-serif" font-size="11" fill="#017065" text-anchor="middle">آب</text>
</svg>

Before

Width:  |  Height:  |  Size: 430 B

-7
View File
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" width="200" height="80">
<rect x="2" y="2" width="196" height="76" rx="12" fill="none" stroke="#00a693" stroke-opacity="0.3" stroke-width="2"/>
<circle cx="40" cy="40" r="20" fill="#00a693"/>
<text x="40" y="47" font-family="Tahoma, sans-serif" font-size="20" font-weight="bold" fill="#ffffff" text-anchor="middle">پ</text>
<rect x="72" y="32" width="100" height="8" rx="4" fill="#00a693" fill-opacity="0.35"/>
<rect x="72" y="46" width="70" height="6" rx="3" fill="#00a693" fill-opacity="0.2"/>
</svg>

Before

Width:  |  Height:  |  Size: 575 B

-7
View File
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" width="200" height="80">
<rect x="2" y="2" width="196" height="76" rx="12" fill="none" stroke="#00a693" stroke-opacity="0.3" stroke-width="2"/>
<circle cx="40" cy="40" r="20" fill="#017065"/>
<text x="40" y="47" font-family="Tahoma, sans-serif" font-size="20" font-weight="bold" fill="#ffffff" text-anchor="middle">ف</text>
<rect x="72" y="32" width="100" height="8" rx="4" fill="#00a693" fill-opacity="0.35"/>
<rect x="72" y="46" width="70" height="6" rx="3" fill="#00a693" fill-opacity="0.2"/>
</svg>

Before

Width:  |  Height:  |  Size: 575 B

-7
View File
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" width="200" height="80">
<rect x="2" y="2" width="196" height="76" rx="12" fill="none" stroke="#00a693" stroke-opacity="0.3" stroke-width="2"/>
<circle cx="40" cy="40" r="20" fill="#00a693"/>
<text x="40" y="47" font-family="Tahoma, sans-serif" font-size="20" font-weight="bold" fill="#ffffff" text-anchor="middle">ر</text>
<rect x="72" y="32" width="100" height="8" rx="4" fill="#00a693" fill-opacity="0.35"/>
<rect x="72" y="46" width="70" height="6" rx="3" fill="#00a693" fill-opacity="0.2"/>
</svg>

Before

Width:  |  Height:  |  Size: 575 B

-7
View File
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" width="200" height="80">
<rect x="2" y="2" width="196" height="76" rx="12" fill="none" stroke="#00a693" stroke-opacity="0.3" stroke-width="2"/>
<circle cx="40" cy="40" r="20" fill="#0b3d38"/>
<text x="40" y="47" font-family="Tahoma, sans-serif" font-size="20" font-weight="bold" fill="#ffffff" text-anchor="middle">ر</text>
<rect x="72" y="32" width="100" height="8" rx="4" fill="#00a693" fill-opacity="0.35"/>
<rect x="72" y="46" width="70" height="6" rx="3" fill="#00a693" fill-opacity="0.2"/>
</svg>

Before

Width:  |  Height:  |  Size: 575 B

-7
View File
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" width="200" height="80">
<rect x="2" y="2" width="196" height="76" rx="12" fill="none" stroke="#00a693" stroke-opacity="0.3" stroke-width="2"/>
<circle cx="40" cy="40" r="20" fill="#00a693"/>
<text x="40" y="47" font-family="Tahoma, sans-serif" font-size="20" font-weight="bold" fill="#ffffff" text-anchor="middle">آ</text>
<rect x="72" y="32" width="100" height="8" rx="4" fill="#00a693" fill-opacity="0.35"/>
<rect x="72" y="46" width="70" height="6" rx="3" fill="#00a693" fill-opacity="0.2"/>
</svg>

Before

Width:  |  Height:  |  Size: 575 B

-7
View File
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" width="200" height="80">
<rect x="2" y="2" width="196" height="76" rx="12" fill="none" stroke="#00a693" stroke-opacity="0.3" stroke-width="2"/>
<circle cx="40" cy="40" r="20" fill="#017065"/>
<text x="40" y="47" font-family="Tahoma, sans-serif" font-size="20" font-weight="bold" fill="#ffffff" text-anchor="middle">ت</text>
<rect x="72" y="32" width="100" height="8" rx="4" fill="#00a693" fill-opacity="0.35"/>
<rect x="72" y="46" width="70" height="6" rx="3" fill="#00a693" fill-opacity="0.2"/>
</svg>

Before

Width:  |  Height:  |  Size: 575 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" width="1280" height="720">
<defs>
<linearGradient id="posterBg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0b3d38"/>
<stop offset="1" stop-color="#04120f"/>
</linearGradient>
</defs>
<rect width="1280" height="720" fill="url(#posterBg)"/>
<circle cx="640" cy="360" r="70" fill="#00a693" opacity="0.9"/>
<path d="M615 325 l70 35 l-70 35 z" fill="#04120f"/>
<text x="640" y="480" font-family="Tahoma, sans-serif" font-size="30" fill="#e6f7f4" text-anchor="middle">ویدیوی معرفی روبات مارکت — placeholder</text>
</svg>

Before

Width:  |  Height:  |  Size: 649 B

Binary file not shown.
+19 -9
View File
@@ -1,6 +1,9 @@
import type {PayloadAction} from '@reduxjs/toolkit';
import type {CartProduct, CartState} from '@/types/domain';
import type {RootState} from './store';
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
const initialState = { const initialState: CartState = {
items: [], // { id, name, priceNumeric, priceLabel, qty, image } items: [], // { id, name, priceNumeric, priceLabel, qty, image }
isDrawerOpen: false, isDrawerOpen: false,
}; };
@@ -9,7 +12,7 @@ const cartSlice = createSlice({
name: 'cart', name: 'cart',
initialState, initialState,
reducers: { reducers: {
addToCart: (state, action) => { addToCart: (state, action: PayloadAction<CartProduct>) => {
const product = action.payload; const product = action.payload;
const existing = state.items.find((item) => item.id === product.id); const existing = state.items.find((item) => item.id === product.id);
if (existing) { if (existing) {
@@ -19,14 +22,20 @@ const cartSlice = createSlice({
} }
state.isDrawerOpen = true; state.isDrawerOpen = true;
}, },
removeFromCart: (state, action) => { removeFromCart: (state, action: PayloadAction<string>) => {
state.items = state.items.filter((item) => item.id !== action.payload); state.items = state.items.filter((item) => item.id !== action.payload);
}, },
incrementQty: (state, action) => { setQuantity: (state, action: PayloadAction<{id: string; qty: number}>) => {
const { id, qty } = action.payload;
if (!Number.isSafeInteger(qty) || qty < 1) return;
const item = state.items.find((item) => item.id === id);
if (item) item.qty = qty;
},
incrementQty: (state, action: PayloadAction<string>) => {
const item = state.items.find((item) => item.id === action.payload); const item = state.items.find((item) => item.id === action.payload);
if (item) item.qty += 1; if (item) item.qty += 1;
}, },
decrementQty: (state, action) => { decrementQty: (state, action: PayloadAction<string>) => {
const item = state.items.find((item) => item.id === action.payload); const item = state.items.find((item) => item.id === action.payload);
if (item && item.qty > 1) { if (item && item.qty > 1) {
item.qty -= 1; item.qty -= 1;
@@ -52,6 +61,7 @@ const cartSlice = createSlice({
export const { export const {
addToCart, addToCart,
removeFromCart, removeFromCart,
setQuantity,
incrementQty, incrementQty,
decrementQty, decrementQty,
clearCart, clearCart,
@@ -61,11 +71,11 @@ export const {
} = cartSlice.actions; } = cartSlice.actions;
// Selectors // Selectors
export const selectCartItems = (state) => state.cart.items; export const selectCartItems = (state: RootState) => state.cart.items;
export const selectCartCount = (state) => export const selectCartCount = (state: RootState) =>
state.cart.items.reduce((sum, item) => sum + item.qty, 0); state.cart.items.reduce((sum, item) => sum + item.qty, 0);
export const selectCartTotal = (state) => export const selectCartTotal = (state: RootState) =>
state.cart.items.reduce((sum, item) => sum + item.qty * item.priceNumeric, 0); state.cart.items.reduce((sum, item) => sum + item.qty * item.priceNumeric, 0);
export const selectIsDrawerOpen = (state) => state.cart.isDrawerOpen; export const selectIsDrawerOpen = (state: RootState) => state.cart.isDrawerOpen;
export default cartSlice.reducer; export default cartSlice.reducer;
+7
View File
@@ -0,0 +1,7 @@
'use client';
import { useDispatch, useSelector } from 'react-redux';
import type { AppDispatch, RootState } from './store';
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
+4
View File
@@ -10,3 +10,7 @@ export const makeStore = () =>
// Singleton store for the client (App Router renders providers on the client tree). // Singleton store for the client (App Router renders providers on the client tree).
export const store = makeStore(); export const store = makeStore();
export type AppStore = ReturnType<typeof makeStore>;
export type RootState = ReturnType<AppStore["getState"]>;
export type AppDispatch = AppStore["dispatch"];
+4 -2
View File
@@ -1,5 +1,5 @@
/** @type {import('tailwindcss').Config} */ import type {Config} from 'tailwindcss';
module.exports = { const config: Config = {
darkMode: 'class', darkMode: 'class',
content: [ content: [
'./app/**/*.{js,jsx,ts,tsx}', './app/**/*.{js,jsx,ts,tsx}',
@@ -55,3 +55,5 @@ module.exports = {
preflight: true, preflight: true,
}, },
}; };
export default config;
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "app/**/*.ts", "app/**/*.tsx", "components/**/*.tsx", "data/**/*.ts", "lib/**/*.ts", "store/**/*.ts", "types/**/*.ts", "tailwind.config.ts", ".next/types/**/*.ts"],
"exclude": ["node_modules", "docs", "out"]
}
+81
View File
@@ -0,0 +1,81 @@
export type Locale = 'fa' | 'en';
export type Direction = 'rtl' | 'ltr';
export type Theme = 'light' | 'dark';
export interface Machine {
id: string;
name: string;
shortLabel: string;
priceLabel: string;
priceNumeric: number;
description: string;
highlights: string[];
image: string;
imageAlt: string;
gif: string;
}
export type CartProduct = Pick<Machine, 'id' | 'name' | 'priceNumeric' | 'priceLabel' | 'image'>;
export interface CartItem extends CartProduct { qty: number }
export interface CartState { items: CartItem[]; isDrawerOpen: boolean }
export interface FloatingSnack {
id: string;
image: string;
size: number;
top: string;
left: string;
duration: number;
delay: number;
}
export type FeatureIcon = 'safety' | 'consulting' | 'installment' | 'packaging';
export type LocationIcon = 'hotel' | 'school' | 'pharmacy' | 'hospital' | 'night' | 'specialty';
interface InfoCard<Icon extends string> {
id: string;
icon: Icon;
title: string;
description: string;
}
export interface SiteContent {
dir: Direction;
lang: Locale;
meta: { title: string; description: string };
nav: Record<'home' | 'showcase' | 'about' | 'locations' | 'contact' | 'login', string>;
hero: {
loading: string;
cta: string;
slides: { id: string; kicker: string; titleLines: string[]; sub: string; image: string }[];
};
cart: {
title: string;
empty: string;
total: string;
checkout: string;
checkoutToast: string;
addToast: (name: string) => string;
};
machineActions: {
addToCart: string;
quickView: string;
wishlistAdd: string;
wishlistRemove: string;
hoverHint: string;
altVariant: string;
gifAlt: (name: string) => string;
};
machines: Machine[];
features: InfoCard<FeatureIcon>[];
about: { eyebrow: string; legalName: string; more: string; less: string; paragraphs: string[] };
locations: { title: string; items: InfoCard<LocationIcon>[] };
footer: Record<'aboutLabel' | 'aboutText' | 'quickLinks' | 'contactTitle' | 'factoryLabel' | 'salesLabel' | 'rights', string>;
contactInfo: {
orderPhone: string;
orderPhoneHref: string;
factoryAddress: string;
salesOfficeAddress: string;
socialLinks: { id: string; label: string; href: string }[];
};
}