Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cd34106b0 | ||
|
|
1ef621f832 | ||
|
|
d5a7ed8092 | ||
|
|
ba464e8345 | ||
|
|
7c73714d56 | ||
|
|
fe86b58012 | ||
|
|
c4d9351e91 | ||
|
|
7e7ebc88b6 | ||
|
|
107413069c | ||
|
|
f9915e4f8c |
@@ -1,8 +1,2 @@
|
||||
# Copy this file to .env.local and fill in your real keys.
|
||||
|
||||
# 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=
|
||||
|
||||
|
||||
# Optional backend API base URL. Copy to .env.local to configure.
|
||||
NEXT_PUBLIC_API_BASE_URL=https://your-real-backend.com
|
||||
@@ -33,9 +33,11 @@ jobs:
|
||||
docker build -t robot-market:${{ steps.vars.outputs.sha }} .
|
||||
|
||||
- name: Save previous tag for rollback
|
||||
id: backup
|
||||
run: |
|
||||
if [ -f /opt/robot-market-front/.env ]; then
|
||||
cp /opt/robot-market-front/.env /opt/robot-market-front/.env.previous
|
||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Point compose at this build
|
||||
@@ -43,12 +45,14 @@ jobs:
|
||||
echo "IMAGE_TAG=${{ steps.vars.outputs.sha }}" > /opt/robot-market-front/.env
|
||||
|
||||
- name: Deploy via compose
|
||||
id: deploy
|
||||
run: |
|
||||
docker compose --env-file /opt/robot-market-front/.env \
|
||||
-f /opt/robot-market-front/docker-compose.yml \
|
||||
up -d --force-recreate
|
||||
|
||||
- name: Health check (retries for ~30s)
|
||||
id: health
|
||||
run: |
|
||||
for i in $(seq 1 15); do
|
||||
if docker exec robot-market node -e "
|
||||
@@ -65,8 +69,8 @@ jobs:
|
||||
echo "Health check failed after 30s"
|
||||
exit 1
|
||||
|
||||
- name: Roll back to previous version on failed health check
|
||||
if: failure()
|
||||
- name: Roll back after deployment or health check failure
|
||||
if: ${{ failure() && steps.backup.outputs.available == 'true' && (steps.deploy.outcome == 'failure' || steps.health.outcome == 'failure') }}
|
||||
run: |
|
||||
if [ -f /opt/robot-market-front/.env.previous ]; then
|
||||
cp /opt/robot-market-front/.env.previous /opt/robot-market-front/.env
|
||||
|
||||
@@ -4,3 +4,4 @@ out/
|
||||
.env*.local
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
<Item>
|
||||
<option name="path" value=".gitea/workflows/test.yml" />
|
||||
</Item>
|
||||
<Item>
|
||||
<option name="path" value=".gitea/workflows/develop.yml" />
|
||||
</Item>
|
||||
</list>
|
||||
</option>
|
||||
</SchemaInfo>
|
||||
|
||||
@@ -4,9 +4,38 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<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$/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>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -28,61 +57,61 @@
|
||||
</component>
|
||||
<component name="NextEditCompletionFeaturesState">
|
||||
<decayedCancelled>
|
||||
<entry key="MS100" value="1.7659734554897204" />
|
||||
<entry key="MS500" value="2.6200917385797573" />
|
||||
<entry key="S2" value="2.895672466750576" />
|
||||
<entry key="S5" value="2.9711768722246337" />
|
||||
<entry key="S10" value="3.095911797960576" />
|
||||
<entry key="S30" value="3.6130929483281937" />
|
||||
<entry key="S60" value="4.862542006443887" />
|
||||
<entry key="M2" value="8.222118193057797" />
|
||||
<entry key="M5" value="14.352669475563026" />
|
||||
<entry key="M10" value="18.025488407782152" />
|
||||
<entry key="M15" value="19.665360290707866" />
|
||||
<entry key="M30" value="22.62190726116564" />
|
||||
<entry key="H1" value="26.416500535541072" />
|
||||
<entry key="H2" value="30.051114228090462" />
|
||||
<entry key="H4" value="32.65287419479587" />
|
||||
<entry key="D1" value="35.38129255958758" />
|
||||
<entry key="W1" value="35.9099877286286" />
|
||||
<entry key="MS100" value="1.0105021478132723" />
|
||||
<entry key="MS500" value="1.6326904583013002" />
|
||||
<entry key="S2" value="3.200495115489667" />
|
||||
<entry key="S5" value="4.148160592733714" />
|
||||
<entry key="S10" value="4.983360398199462" />
|
||||
<entry key="S30" value="7.2170837140275825" />
|
||||
<entry key="S60" value="9.732495501143132" />
|
||||
<entry key="M2" value="14.025681297470134" />
|
||||
<entry key="M5" value="22.17637857625762" />
|
||||
<entry key="M10" value="27.702751951097298" />
|
||||
<entry key="M15" value="30.22928327611057" />
|
||||
<entry key="M30" value="34.21976380295617" />
|
||||
<entry key="H1" value="38.60682034968035" />
|
||||
<entry key="H2" value="42.59562362296086" />
|
||||
<entry key="H4" value="45.40713861912438" />
|
||||
<entry key="D1" value="48.33720180411775" />
|
||||
<entry key="W1" value="48.90360237164015" />
|
||||
</decayedCancelled>
|
||||
<decayedSelected>
|
||||
<entry key="MS100" value="0.0" />
|
||||
<entry key="MS500" value="1.5499348360687339E-164" />
|
||||
<entry key="S2" value="1.1157794535925266E-41" />
|
||||
<entry key="S5" value="4.1594062872493325E-17" />
|
||||
<entry key="S10" value="6.449345925944224E-9" />
|
||||
<entry key="S30" value="0.0018613950614188026" />
|
||||
<entry key="S60" value="0.043143887880194604" />
|
||||
<entry key="M2" value="0.20771106826597993" />
|
||||
<entry key="M5" value="0.533315069904893" />
|
||||
<entry key="M10" value="0.7302842391185045" />
|
||||
<entry key="M15" value="0.8109510091490459" />
|
||||
<entry key="M30" value="0.9005281834285062" />
|
||||
<entry key="H1" value="0.9489616343290734" />
|
||||
<entry key="H2" value="0.9741466184969663" />
|
||||
<entry key="H4" value="0.986988661787442" />
|
||||
<entry key="D1" value="0.9978195926881375" />
|
||||
<entry key="W1" value="0.9996882217756649" />
|
||||
<entry key="MS500" value="8.86528819923573E-248" />
|
||||
<entry key="S2" value="1.7255327824639203E-62" />
|
||||
<entry key="S5" value="1.9713751865934254E-25" />
|
||||
<entry key="S10" value="4.440017101986686E-13" />
|
||||
<entry key="S30" value="7.628893420815487E-5" />
|
||||
<entry key="S60" value="0.008734353680047248" />
|
||||
<entry key="M2" value="0.09345776415069663" />
|
||||
<entry key="M5" value="0.387477201784943" />
|
||||
<entry key="M10" value="0.6224766676630884" />
|
||||
<entry key="M15" value="0.7290355699678522" />
|
||||
<entry key="M30" value="0.8538357980126227" />
|
||||
<entry key="H1" value="0.9240323576653702" />
|
||||
<entry key="H2" value="0.9612660181580177" />
|
||||
<entry key="H4" value="0.9804417464378067" />
|
||||
<entry key="D1" value="0.9967134048485689" />
|
||||
<entry key="W1" value="0.9995298237292167" />
|
||||
</decayedSelected>
|
||||
<decayedShown>
|
||||
<entry key="MS100" value="1.6363299214720612" />
|
||||
<entry key="MS500" value="2.580440430635064" />
|
||||
<entry key="S2" value="2.884654206650678" />
|
||||
<entry key="S5" value="2.9666305108034594" />
|
||||
<entry key="S10" value="3.0934710562664876" />
|
||||
<entry key="S30" value="3.6138341741583195" />
|
||||
<entry key="S60" value="4.903918162005831" />
|
||||
<entry key="M2" value="8.426649355082025" />
|
||||
<entry key="M5" value="14.882896956006677" />
|
||||
<entry key="M10" value="18.75367770497998" />
|
||||
<entry key="M15" value="20.474761353109788" />
|
||||
<entry key="M30" value="23.521564444287325" />
|
||||
<entry key="H1" value="27.364990222969524" />
|
||||
<entry key="H2" value="31.025011433737856" />
|
||||
<entry key="H4" value="33.639733831212105" />
|
||||
<entry key="D1" value="36.37908993480534" />
|
||||
<entry key="W1" value="36.90967275725512" />
|
||||
<entry key="MS100" value="0.01712637149186048" />
|
||||
<entry key="MS500" value="0.9540168467150943" />
|
||||
<entry key="S2" value="2.9136793264313505" />
|
||||
<entry key="S5" value="4.013649896020289" />
|
||||
<entry key="S10" value="4.909460420460746" />
|
||||
<entry key="S30" value="7.187579804050971" />
|
||||
<entry key="S60" value="9.723843431274007" />
|
||||
<entry key="M2" value="14.107987700307802" />
|
||||
<entry key="M5" value="22.55725525698723" />
|
||||
<entry key="M10" value="28.321163439518607" />
|
||||
<entry key="M15" value="30.955381308867647" />
|
||||
<entry key="M30" value="35.07198888146704" />
|
||||
<entry key="H1" value="39.52999740754272" />
|
||||
<entry key="H2" value="43.55644480884343" />
|
||||
<entry key="H4" value="46.38735262517173" />
|
||||
<entry key="D1" value="49.33387639135037" />
|
||||
<entry key="W1" value="49.903126627030524" />
|
||||
</decayedShown>
|
||||
</component>
|
||||
<component name="ProjectColorInfo">{
|
||||
@@ -94,33 +123,36 @@
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent">{
|
||||
"keyToString": {
|
||||
"ASKED_MARK_IGNORED_FILES_AS_EXCLUDED": "true",
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||
"git-widget-placeholder": "develop",
|
||||
"javascript.nodejs.core.library.configured.version": "24.20.0",
|
||||
"javascript.nodejs.core.library.typings.version": "24.13.3",
|
||||
"javascript.preferred.runtime.type.id": "node",
|
||||
"js.debugger.nextJs.config.created.client": "true",
|
||||
"js.debugger.nextJs.config.created.server": "true",
|
||||
"kronk.content.tree.expanded.paths": "",
|
||||
"last_opened_file_path": "/home/hamed/Documents/Projects/GItLabRepo/device_web",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"settings.editor.selected.configurable": "editor.preferences.fonts.default",
|
||||
"ts.external.directory.path": "C:\\Program Files\\JetBrains\\WebStorm 2026.1.1\\plugins\\javascript-plugin\\jsLanguageServicesImpl\\external",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
<component name="PropertiesComponent"><![CDATA[{
|
||||
"keyToString": {
|
||||
"ASKED_MARK_IGNORED_FILES_AS_EXCLUDED": "true",
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
|
||||
"git-widget-placeholder": "develop",
|
||||
"javascript.nodejs.core.library.configured.version": "24.20.0",
|
||||
"javascript.nodejs.core.library.typings.version": "24.13.3",
|
||||
"javascript.preferred.runtime.type.id": "node",
|
||||
"js.debugger.nextJs.config.created.client": "true",
|
||||
"js.debugger.nextJs.config.created.server": "true",
|
||||
"junie.onboarding.icon.badge.shown": "true",
|
||||
"kronk.content.tree.expanded.paths": "",
|
||||
"last_opened_file_path": "/home/hamed/Documents/Projects/GItLabRepo/device_web",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"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">
|
||||
<key name="MoveFile.RECENT_KEYS">
|
||||
<recent name="D:\Project\GitHubRepo\robot-market-landing\public\images\icons" />
|
||||
@@ -188,6 +220,21 @@
|
||||
<workItem from="1788592387411" duration="8000" />
|
||||
<workItem from="1788592424522" duration="13793000" />
|
||||
<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 id="LOCAL-00001" summary="update">
|
||||
<option name="closed" value="true" />
|
||||
@@ -413,21 +460,76 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1788682678051</updated>
|
||||
</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 />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
<option name="version" value="3" />
|
||||
<option name="exactExcludedFiles">
|
||||
<list>
|
||||
<option value="$PROJECT_DIR$/components/AboutSection.jsx" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="UnknownFeatures">
|
||||
<option featureType="com.intellij.fileTypeFactory" implementationName="next.config.js" />
|
||||
</component>
|
||||
<component name="VcsManagerConfiguration">
|
||||
<MESSAGE value="ui : change the position of header in Hero Section" />
|
||||
<MESSAGE value="update" />
|
||||
<MESSAGE value="feat:dockerized & test actions add" />
|
||||
<MESSAGE value="feat:dockerized & test actions added" />
|
||||
<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>
|
||||
</project>
|
||||
@@ -1,132 +1,65 @@
|
||||
# روبات مارکت — Vending Machine Showcase
|
||||
|
||||
پروژه Next.js (App Router) صفحه معرفی محصولات «روبات مارکت»، با انیمیشنهای GSAP/ScrollTrigger،
|
||||
مدیریت سبد خرید با Redux Toolkit، حالت تیره/روشن، دو زبانه (فارسی/انگلیسی)، نقشه، فرم تیکت
|
||||
پشتیبانی، و ویجت چت.
|
||||
صفحه معرفی دستگاههای وندینگ روبات مارکت، ساختهشده با Next.js، TypeScript، React و Ant Design.
|
||||
|
||||
## اجرای پروژه
|
||||
## اجرا
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
سپس `http://localhost:3000` را باز کنید.
|
||||
سایت در http://localhost:3000 در دسترس است.
|
||||
|
||||
## ساختار پروژه
|
||||
## بررسی نوعها
|
||||
|
||||
```
|
||||
app/
|
||||
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 — در پیام قبلی توضیح داده شده
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## ویژگیهای اضافهشده در این نسخه
|
||||
کد برنامه با 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` کپی کنید)
|
||||
2. مقدار `NEXT_PUBLIC_NESHAN_API_KEY` را با کلید واقعی خودتان از
|
||||
[platform.neshan.org](https://platform.neshan.org) پر کنید
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
تا وقتی کلید خالی باشد، بهجای نقشه یک پیام راهنما («برای فعال شدن نقشه، کلید API نشان را در
|
||||
تنظیمات پروژه قرار دهید») نمایش داده میشود — سایت خراب نمیشود. نقشه در حالت تیره/روشن هم استایل
|
||||
متفاوتی میگیرد (`standard-night` / `standard-day`). لینک «مشاهده در نشان» کنار هر آدرس به نقشه
|
||||
تعاملی neshan.org میرود و نیازی به کلید ندارد. مختصات فعلی در `data/content.js`
|
||||
(`mapLocations`) تقریبی هستند — با پین دقیق جایگزین کنید.
|
||||
خروجی standalone برای اجرای Docker تنظیم شده است.
|
||||
|
||||
### کاتالوگ محصولات
|
||||
`components/CatalogSection.jsx` — گرید محصولاتی (زعفران، تنقلات، نوشیدنی، قهوه، چای و ...) که
|
||||
مشتریان دستگاه میتوانند با قیمت کمتر از بازار سفارش دهند. هر آیتم قیمت بازار (خطخورده) و قیمت
|
||||
ویژه را نشان میدهد و دکمه «افزودن به سفارش» آن را به همان سبد خرید Redux اضافه میکند (در کنار
|
||||
دستگاهها). لیست محصولات، قیمتها و تصاویر در `data/content.js` (کلید `catalog`) قابل ویرایش است؛
|
||||
تصاویر placeholder در `public/images/catalog/` قرار دارند.
|
||||
## ساختار و بخشهای فعال
|
||||
|
||||
### سکشن ویدیوی معرفی شرکت
|
||||
`components/IntroVideoSection.jsx` — یک پلیر ویدیوی مستقل (جدا از ویدیوی پسزمینه اسکرول افقی) با
|
||||
پوستر و دکمه پخش استاندارد مرورگر. برای جایگزینی، فایل واقعی را در
|
||||
`public/video/company-intro-placeholder.mp4` بگذارید (یا مسیر `introVideoSrc` را در
|
||||
`data/content.js` عوض کنید)؛ پوستر هم از `public/images/video-poster.svg` میآید.
|
||||
- `app/page.tsx`: صفحه اصلی شامل هدر، سبد خرید، هیرو، ویترین دستگاهها، مزایا، درباره ما، محلهای مناسب نصب و فوتر.
|
||||
- `components/HeroSection.tsx`: اسلایدر تصاویر دستگاهها.
|
||||
- `components/ShowcaseSection.tsx`: انتخاب نمایش موبایل یا ویترین افقی دسکتاپ.
|
||||
- `components/HorizontalShowcase.tsx`: ویترین افقی با GSAP و ویدیوی پسزمینه وابسته به اسکرول.
|
||||
- `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` ذخیره میشود و در اولین بار بر اساس
|
||||
تنظیمات سیستم کاربر تعیین میشود. رنگبندی از طریق متغیرهای CSS در `app/globals.css`
|
||||
(`--color-bg-page`, `--color-text-primary`, ...) و کلاسهای کمکی `bg-page` / `bg-surface` /
|
||||
`text-primary` / `text-secondary` / `border-subtle` مدیریت میشود؛ برای هر رنگ جدید فقط همینجا را
|
||||
ویرایش کنید. تم Ant Design هم بهصورت خودکار (`darkAlgorithm` / `defaultAlgorithm`) هماهنگ میشود.
|
||||
زبان فارسی/انگلیسی و حالت روشن/تیره از هدر تغییر میکنند و ترجیح کاربر در localStorage ذخیره میشود.
|
||||
ویترین افقی برای محاسبات اسکرول همیشه جهت LTR دارد، اما متن کارتها جهت زبان فعال را رعایت میکند.
|
||||
|
||||
### دو زبانه (فارسی / انگلیسی)
|
||||
دکمه «EN / فا» در هدر. تمام متنهای سایت (منو، هیرو، محصولات، درباره ما، مکانهای نصب، فرم تیکت،
|
||||
چت، فوتر) از `data/content.js` خوانده میشوند — یک آبجکت با کلیدهای `fa` و `en`. تغییر زبان همچنین
|
||||
جهت صفحه (`dir`) و `lang` تگ `html` را بهروزرسانی میکند. برای اضافه یا اصلاح متن، فقط
|
||||
`data/content.js` را ویرایش کنید؛ کامپوننتها نیازی به تغییر ندارند.
|
||||
## ورود و سبد خرید
|
||||
|
||||
> نکته فنی: بخش اسکرول افقی (`HorizontalShowcase`) بهعمد همیشه `dir="ltr"` روی خود track دارد
|
||||
> (چون محاسبات GSAP به آن نیاز دارد)، اما هر کارت داخلش جهت متن را بر اساس زبان فعال تنظیم میکند.
|
||||
دکمه ورود به https://panel.my-rm.com متصل است.
|
||||
سبد خرید در حافظه نگهداری میشود و با بارگذاری مجدد صفحه پاک میشود.
|
||||
پرداخت هنوز متصل نیست؛ دکمه پرداخت در `CartDrawer.tsx` پیام نمایشی نشان میدهد.
|
||||
|
||||
### فرم تیکت پشتیبانی
|
||||
`components/TicketForm.jsx` — فرم Ant Design با اعتبارسنجی (نام، تلفن، ایمیل اختیاری، موضوع،
|
||||
اولویت، پیام). ارسال فعلاً فقط یک پیام موفقیت نمایش میدهد؛ بلوک کامنتشده
|
||||
`// TODO: Connect to the real ticketing/support API` دقیقاً نشان میدهد کجا باید axios را وصل کنید.
|
||||
## تنظیم API
|
||||
|
||||
### ویجت چت
|
||||
دکمه شناور گوشه صفحه (`components/ChatWidget.jsx`) یک مودال گفتگوی ساده باز میکند. پیامها فعلاً
|
||||
در state محلی نگهداری میشوند و یک پاسخ خودکار نمایشی میدهند. بلوک
|
||||
`// TODO: Connect to a real chat/support backend` محل اتصال به بکاند واقعی (یا وبسوکت) را مشخص
|
||||
کرده.
|
||||
برای تعیین آدرس بکاند، از `.env.local.example` یک فایل `.env.local` بسازید و
|
||||
`NEXT_PUBLIC_API_BASE_URL` را تنظیم کنید. کلاینت مشترک API در `lib/axios.ts` قرار دارد.
|
||||
|
||||
### هیرو: تایپرایتر + پسزمینه رنگی در حال تغییر
|
||||
هدلاین با افکت تایپرایتر بین چند جمله (`data/content.js` → `hero.typingPhrases`) میچرخد.
|
||||
پسزمینه بهصورت حلقهای بین سبز برند (`#00a693`)، مشکی و سفید تغییر رنگ میدهد (با GSAP). یک لایه
|
||||
scrim تیره روی آن قرار دارد تا متن در هر سه حالت خوانا بماند. وقتی ویدیوی هیرو را آماده کردید،
|
||||
داخل `components/HeroSection.jsx` یک بلوک `TODO` کامنتشده دقیقاً نشان میدهد چطور بهجای پسزمینه
|
||||
رنگی، ویدیو را جایگزین کنید (فقط از حالت کامنت خارجش کنید).
|
||||
## جایگزینی رسانهها
|
||||
|
||||
### ورود به پنل کاربری
|
||||
دکمه «ورود به پنل کاربری» / «Sign In» در هدر اضافه شده (در `components/Header.jsx`). چون گفتید
|
||||
لینکدهی را خودتان انجام میدهید، فعلاً فقط یک `onClick` خالی با کامنت TODO دارد — مسیر واقعی پنل
|
||||
کاربری را همانجا وصل کنید.
|
||||
|
||||
## جایگزینی داراییهای واقعی (تصاویر/گیف/ویدیو)
|
||||
همانطور که قبلاً توضیح داده شد، فایلهای داخل `public/images`, `public/gifs`, `public/video`
|
||||
placeholder هستند. برای جایگزینی، فایل جدید را با همان نام/مسیر جایگزین کنید یا مسیر را در
|
||||
`data/content.js` تغییر دهید.
|
||||
|
||||
## اتصال درگاه پرداخت
|
||||
در `components/CartDrawer.jsx` داخل `handleCheckout` بلوک `// TODO: Connect to payment gateway API`
|
||||
با نمونهکد axios کامنتشده وجود دارد.
|
||||
فایلهای واقعی را با مسیرهای موجود در `data/content.ts` جایگزین کنید یا مسیرها را در همان فایل تغییر دهید.
|
||||
ویدیوی پسزمینه ویترین از `public/video/showcase-placeholder.mp4` خوانده میشود.
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
'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 {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 [locale, setLocale] = useState('fa');
|
||||
const LanguageContext = createContext<LanguageContextValue | null>(null);
|
||||
|
||||
export function LanguageProvider({children}: {children: ReactNode}) {
|
||||
const [locale, setLocale] = useState<Locale>('fa');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1,12 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import type {Dispatch, SetStateAction, ReactNode} from 'react';
|
||||
import type {Theme} from '@/types/domain';
|
||||
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);
|
||||
|
||||
|
||||
@@ -42,4 +51,3 @@ export function useTheme() {
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -219,35 +219,3 @@ html[dir='rtl'] body {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* نوار لوگوی بیپایان (trusted-by marquee) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@keyframes marqueeScroll {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
/* دقیقاً نصف عرض، چون لیست دو برابر شده */
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
animation: marqueeScroll 28s linear infinite;
|
||||
}
|
||||
|
||||
.animate-marquee:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Chat widget */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.chat-bubble {
|
||||
max-width: 80%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type {Metadata, Viewport} from 'next';
|
||||
import type {ReactNode} from 'react';
|
||||
import {Vazirmatn} from 'next/font/google';
|
||||
import './globals.css';
|
||||
import Providers from './providers';
|
||||
@@ -9,19 +11,19 @@ const vazirmatn = Vazirmatn({
|
||||
});
|
||||
|
||||
|
||||
export const metadata = {
|
||||
export const metadata: Metadata = {
|
||||
title: 'روبات مارکت | دستگاههای وندینگ هوشمند',
|
||||
description:
|
||||
'روبات مارکت؛ دستگاههای فروش خودکار هوشمند با مدیریت آنلاین، تحویل نرم و طراحی مدرن.',
|
||||
};
|
||||
|
||||
|
||||
export const viewport = {
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#00a693',
|
||||
};
|
||||
|
||||
|
||||
export default function RootLayout({children}) {
|
||||
export default function RootLayout({children}: {children: ReactNode}) {
|
||||
|
||||
return (
|
||||
<html lang="fa" dir="rtl" className={`${vazirmatn.variable} light`} suppressHydrationWarning>
|
||||
@@ -1,49 +1,23 @@
|
||||
import Header from '@/components/Header';
|
||||
import HeroSection from '@/components/HeroSection';
|
||||
import TrustedBySection from '@/components/TrustedBySection';
|
||||
import LocationsSection from '@/components/LocationsSection';
|
||||
import ShowcaseSection from "@/components/ShowcaseSection";
|
||||
import FeaturesSection from '@/components/FeaturesSection';
|
||||
import AboutSection from '@/components/AboutSection';
|
||||
import CatalogSection from '@/components/CatalogSection';
|
||||
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 HeroSection from '@/components/HeroSection';
|
||||
import CartDrawer from '@/components/CartDrawer';
|
||||
import ChatWidget from '@/components/ChatWidget';
|
||||
import ShowcaseSection from "@/components/ShowcaseSection";
|
||||
|
||||
import Footer from '@/components/Footer';
|
||||
import Header from '@/components/Header';
|
||||
|
||||
export default function HomePage() {
|
||||
|
||||
return (
|
||||
<main className="relative bg-page">
|
||||
<Header/>
|
||||
|
||||
<CartDrawer/>
|
||||
|
||||
{/*faz 1*/}
|
||||
{/*<ChatWidget/>*/}
|
||||
|
||||
<HeroSection/>
|
||||
|
||||
<ShowcaseSection/>
|
||||
|
||||
{/*<TrustedBySection/>*/}
|
||||
|
||||
<FeaturesSection/>
|
||||
|
||||
<AboutSection/>
|
||||
|
||||
{/*<CatalogSection/>*/}
|
||||
|
||||
<LocationsSection/>
|
||||
|
||||
{/*<IntroVideoSection/>*/}
|
||||
|
||||
{/*<MapSection/>*/}
|
||||
|
||||
{/*<TicketForm/>*/}
|
||||
|
||||
<Footer/>
|
||||
</main>
|
||||
);
|
||||
@@ -1,8 +1,10 @@
|
||||
'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 type {ThemeConfig} from 'antd';
|
||||
import {ConfigProvider, theme as antdThemeApi} from 'antd';
|
||||
import faIR from 'antd/locale/fa_IR';
|
||||
import enUS from 'antd/locale/en_US';
|
||||
@@ -10,11 +12,11 @@ import {store} from '@/store/store';
|
||||
import {ThemeProvider, useTheme} from './context/ThemeContext';
|
||||
import {LanguageProvider, useLanguage} from './context/LanguageContext';
|
||||
|
||||
function AntdBridge({children}) {
|
||||
function AntdBridge({children}: {children: ReactNode}) {
|
||||
const {theme} = useTheme();
|
||||
const {locale, dir} = useLanguage();
|
||||
|
||||
const antdTheme = {
|
||||
const antdTheme: ThemeConfig = {
|
||||
algorithm: theme === 'dark' ? antdThemeApi.darkAlgorithm : antdThemeApi.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: '#00a693',
|
||||
@@ -48,7 +50,7 @@ function AntdBridge({children}) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Providers({children}) {
|
||||
export default function Providers({children}: {children: ReactNode}) {
|
||||
return (
|
||||
<ReduxProvider store={store}>
|
||||
<ThemeProvider>
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
// سکشن معرفی شرکت با متن بلند؛ بهخاطر طولانی بودن متن، یک دکمه «ادامه مطلب» برای باز/بسته کردن دارد.
|
||||
// Company introduction with optional expandable paragraphs.
|
||||
|
||||
import {useState} from 'react';
|
||||
import {useLanguage} from '@/app/context/LanguageContext';
|
||||
@@ -1,38 +1,38 @@
|
||||
'use client';
|
||||
|
||||
// کشوی (Drawer) سبد خرید: نمایش آیتمهای انتخابی، تغییر تعداد، جمع کل، و دکمه پرداخت (placeholder).
|
||||
// Cart drawer with quantity editing, totals, and placeholder checkout.
|
||||
|
||||
import {Drawer, Button, Empty, InputNumber, message} from 'antd';
|
||||
import {DeleteOutlined} from '@ant-design/icons';
|
||||
import {useSelector, useDispatch} from 'react-redux';
|
||||
import {useAppSelector, useAppDispatch} from '@/store/hooks';
|
||||
import {
|
||||
selectCartItems,
|
||||
selectCartTotal,
|
||||
selectIsDrawerOpen,
|
||||
closeDrawer,
|
||||
removeFromCart,
|
||||
incrementQty,
|
||||
decrementQty,
|
||||
setQuantity,
|
||||
} from '@/store/cartSlice';
|
||||
import {useLanguage} from '@/app/context/LanguageContext';
|
||||
import type {Locale} from '@/types/domain';
|
||||
import api from "@/lib/axios";
|
||||
|
||||
function toLocaleNumber(n, locale) {
|
||||
function toLocaleNumber(n: number, locale: Locale) {
|
||||
return n.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US');
|
||||
}
|
||||
|
||||
export default function CartDrawer() {
|
||||
const dispatch = useDispatch();
|
||||
const isOpen = useSelector(selectIsDrawerOpen);
|
||||
const items = useSelector(selectCartItems);
|
||||
const total = useSelector(selectCartTotal);
|
||||
const dispatch = useAppDispatch();
|
||||
const isOpen = useAppSelector(selectIsDrawerOpen);
|
||||
const items = useAppSelector(selectCartItems);
|
||||
const total = useAppSelector(selectCartTotal);
|
||||
const {t, locale, dir} = useLanguage();
|
||||
|
||||
const currencySuffix = locale === 'fa' ? ' ریال' : ' Rials';
|
||||
|
||||
const handleCheckout = async () => {
|
||||
// -------------------------------------------------------------------
|
||||
// TODO: Connect to payment gateway API.
|
||||
// TODO: Connect to payment gateway API..
|
||||
|
||||
// try {
|
||||
// const { data } = await axios.post('/api/payment/create', {
|
||||
@@ -48,6 +48,7 @@ export default function CartDrawer() {
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
await message.info(t.cart.checkoutToast);
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -76,10 +77,10 @@ export default function CartDrawer() {
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
precision={0}
|
||||
value={item.qty}
|
||||
onChange={(val) => {
|
||||
if (val > item.qty) dispatch(incrementQty(item.id));
|
||||
else if (val < item.qty) dispatch(decrementQty(item.id));
|
||||
if (val !== null) dispatch(setQuantity({id: item.id, qty: val}));
|
||||
}}
|
||||
className="w-16"
|
||||
/>
|
||||
@@ -1,86 +0,0 @@
|
||||
'use client';
|
||||
|
||||
// کاتالوگ محصولات جانبی (زعفران، تنقلات، نوشیدنی و ...) که مشتریان دستگاه با قیمت کمتر از بازار
|
||||
// میتوانند سفارش دهند. دکمه «افزودن به سفارش» از همان سبد خرید Redux دستگاهها استفاده میکند.
|
||||
|
||||
import { Button, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { addToCart } from '@/store/cartSlice';
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
|
||||
function toLocaleNumber(n, locale) {
|
||||
return n.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US');
|
||||
}
|
||||
|
||||
export default function CatalogSection() {
|
||||
const { t, locale } = useLanguage();
|
||||
const dispatch = useDispatch();
|
||||
const currencySuffix = locale === 'fa' ? ' ریال' : ' Rials';
|
||||
|
||||
const handleAdd = (item) => {
|
||||
dispatch(
|
||||
addToCart({
|
||||
id: `catalog-${item.id}`,
|
||||
name: item.name,
|
||||
priceNumeric: item.priceNumeric,
|
||||
priceLabel: `${toLocaleNumber(item.priceNumeric, locale)}${currencySuffix}`,
|
||||
image: item.image,
|
||||
})
|
||||
);
|
||||
|
||||
message.success(t.catalog.addToast(item.name));
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="catalog" className="relative px-6 md:px-16 py-20 md:py-28 bg-page border-t border-subtle">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-4">
|
||||
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.catalog.eyebrow}</span>
|
||||
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text mt-3">{t.catalog.title}</h2>
|
||||
</div>
|
||||
<p className="text-secondary text-sm md:text-base text-center max-w-2xl mx-auto mb-12">
|
||||
{t.catalog.subtitle}
|
||||
</p>
|
||||
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{t.catalog.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-surface border border-subtle rounded-2xl p-5 flex flex-col hover:border-brand-500/60 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<img src={item.image} alt={item.name} className="w-16 h-16 object-contain shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-[11px] text-brand-400 font-semibold">{item.category}</span>
|
||||
<h3 className="text-primary font-bold text-sm leading-6">{item.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-end justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-secondary line-through opacity-70">
|
||||
{toLocaleNumber(item.originalPriceNumeric, locale)}
|
||||
{currencySuffix}
|
||||
</p>
|
||||
<p className="text-base font-bold text-primary">
|
||||
{toLocaleNumber(item.priceNumeric, locale)}
|
||||
{currencySuffix}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
size="middle"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => handleAdd(item)}
|
||||
>
|
||||
{t.catalog.addToOrder}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
'use client';
|
||||
|
||||
// آیکون شناور چت گوشه صفحه؛ با کلیک یک مودال گفتگوی ساده باز میشود (فعلاً بدون اتصال به بکاند واقعی).
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Modal, Input, Button, Tooltip } from 'antd';
|
||||
import { MessageOutlined, SendOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
|
||||
export default function ChatWidget() {
|
||||
const { t, dir } = useLanguage();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [messages, setMessages] = useState([]);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
// Seed the bot greeting once, in the current language.
|
||||
useEffect(() => {
|
||||
setMessages([{ from: 'bot', text: t.chat.initialBotMessage }]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [t.chat.initialBotMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}, [messages, open]);
|
||||
|
||||
const handleSend = () => {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
|
||||
setMessages((prev) => [...prev, { from: 'user', text }]);
|
||||
setDraft('');
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// TODO: Connect to a real chat/support backend (e.g. via axios or a
|
||||
// websocket) instead of this local placeholder reply.
|
||||
//
|
||||
// import axios from 'axios';
|
||||
// const { data } = await axios.post('/api/chat', { message: text });
|
||||
// setMessages((prev) => [...prev, { from: 'bot', text: data.reply }]);
|
||||
// api() <===> todo : use this func to fetch ...
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
setTimeout(() => {
|
||||
setMessages((prev) => [...prev, { from: 'bot', text: t.chat.fallbackReply }]);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={t.chat.fabLabel} placement="left">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={t.chat.fabLabel}
|
||||
className="fixed bottom-6 z-40 w-14 h-14 rounded-full bg-brand hover:bg-brand-400 text-ink-950 shadow-brandGlow flex items-center justify-center text-2xl transition-transform hover:scale-105"
|
||||
style={dir === 'rtl' ? { left: '1.5rem' } : { right: '1.5rem' }}
|
||||
>
|
||||
<MessageOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
footer={null}
|
||||
closeIcon={<CloseOutlined />}
|
||||
title={t.chat.title}
|
||||
centered
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex flex-col gap-3 h-80 overflow-y-auto py-2 px-1"
|
||||
>
|
||||
{messages.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`chat-bubble ${
|
||||
m.from === 'user'
|
||||
? 'self-end bg-brand text-ink-950'
|
||||
: 'self-start bg-surface-strong text-primary'
|
||||
}`}
|
||||
>
|
||||
{m.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-3 border-t border-subtle pt-3">
|
||||
<Input
|
||||
size="large"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onPressEnter={handleSend}
|
||||
placeholder={t.chat.placeholder}
|
||||
/>
|
||||
<Button type="primary" size="large" icon={<SendOutlined />} onClick={handleSend}>
|
||||
{t.chat.send}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
// نوار مزایا: ضمانت کالا، مشاوره رایگان، خرید اقساطی، بستهبندی ایمن.
|
||||
// Service benefits: warranty, consultation, installments, and packaging.
|
||||
|
||||
import {
|
||||
SafetyCertificateOutlined,
|
||||
@@ -1,17 +1,18 @@
|
||||
'use client';
|
||||
|
||||
// رندر آیتمهای شناور (چیپس/نوشابه) دور دستگاه با حرکت تصادفی و ملایم GSAP.
|
||||
// Animate decorative snack images with gentle randomized motion.
|
||||
|
||||
import type {FloatingSnack} from '@/types/domain';
|
||||
import {useEffect, useRef} from 'react';
|
||||
import {gsap} from 'gsap';
|
||||
|
||||
/**
|
||||
* Renders the centralized `floatingSnacks` config as absolutely-positioned
|
||||
* 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}) {
|
||||
const containerRef = useRef(null);
|
||||
export default function FloatingItems({items}: {items: FloatingSnack[]}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nodes = containerRef.current?.querySelectorAll('[data-float-item]');
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
// فوتر سایت: درباره ما کوتاه، لینکهای شبکه اجتماعی، دسترسی سریع، و اطلاعات تماس/آدرس.
|
||||
// Company information, quick links, social links, and contact details.
|
||||
|
||||
import { PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
||||
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="#showcase" className="hover:text-brand-400">{t.nav.showcase}</a></li>
|
||||
<li><a href="#about" className="hover:text-brand-400">{t.nav.about}</a></li>
|
||||
{/*<li><a href="#catalog" className="hover:text-brand-400">{t.nav.catalog}</a></li>*/}
|
||||
<li><a href="#locations" className="hover:text-brand-400">{t.nav.locations}</a></li>
|
||||
{/*<li><a href="#video" className="hover:text-brand-400">{t.nav.video}</a></li>*/}
|
||||
{/*<li><a href="#map" className="hover:text-brand-400">{t.nav.map}</a></li>*/}
|
||||
{/*<li><a href="#support" className="hover:text-brand-400">{t.nav.support}</a></li>*/}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
'use client';
|
||||
|
||||
// هدر ثابت بالای صفحه: لوگو، منوی ناوبری، سوییچ زبان/تم، دکمه ورود به پنل کاربری و آیکون سبد خرید.
|
||||
// Fixed navigation with language, theme, account, and cart controls.
|
||||
|
||||
import {useState, useEffect} from 'react';
|
||||
import Image from 'next/image';
|
||||
import {Badge, Button} from 'antd';
|
||||
import {ShoppingCartOutlined, MenuOutlined, CloseOutlined, UserOutlined} from '@ant-design/icons';
|
||||
import {useSelector, useDispatch} from 'react-redux';
|
||||
import {useAppSelector, useAppDispatch} from '@/store/hooks';
|
||||
import {selectCartCount, openDrawer} from '@/store/cartSlice';
|
||||
import {useLanguage} from '@/app/context/LanguageContext';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import LanguageToggle from './LanguageToggle';
|
||||
|
||||
export default function Header() {
|
||||
const dispatch = useDispatch();
|
||||
const cartCount = useSelector(selectCartCount);
|
||||
const dispatch = useAppDispatch();
|
||||
const cartCount = useAppSelector(selectCartCount);
|
||||
const {t} = useLanguage();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
@@ -23,11 +23,7 @@ export default function Header() {
|
||||
{href: '#hero', label: t.nav.home},
|
||||
{href: '#showcase', label: t.nav.showcase},
|
||||
{href: '#about', label: t.nav.about},
|
||||
// {href: '#catalog', label: t.nav.catalog},
|
||||
{href: '#locations', label: t.nav.locations},
|
||||
// {href: '#video', label: t.nav.video},
|
||||
// {href: '#map', label: t.nav.map},
|
||||
// {href: '#support', label: t.nav.support},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -36,6 +32,11 @@ export default function Header() {
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
console.log(
|
||||
"%c👋 Hamed Abdollahzade · Frontend Developer",
|
||||
"font-size:14px;font-weight:600;"
|
||||
);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed top-0 inset-x-0 z-50 transition-all duration-300 ${
|
||||
@@ -75,16 +76,15 @@ export default function Header() {
|
||||
{t.nav.login}
|
||||
</Button>
|
||||
|
||||
{/* faz 1 */}
|
||||
{/* <Badge count={cartCount} size="small" offset={[-2, 2]}>
|
||||
<Badge count={cartCount} size="small" offset={[-2, 2]}>
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<ShoppingCartOutlined/>}
|
||||
onClick={() => dispatch(openDrawer())}
|
||||
aria-label="cart"
|
||||
aria-label={t.cart.title}
|
||||
/>
|
||||
</Badge>*/}
|
||||
</Badge>
|
||||
|
||||
<button
|
||||
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">
|
||||
<LanguageToggle/>
|
||||
<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}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1,25 +1,20 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* سکشن اول سایت (Hero) — ایده کلی از صفحه وندینگ سایت Sielaff گرفته شده:
|
||||
* یک اسلایدر تمامعرض که هر ۵ ثانیه بهصورت خودکار به اسلاید بعدی میرود،
|
||||
* همراه با یک شکل مورب رنگی (بهجای قرمز آنها، رنگ سبز برند خودمان #00a693)،
|
||||
* عنوان بزرگ دو خطی، و دکمههای فلش قبلی/بعدی برای ناوبری دستی.
|
||||
*/
|
||||
/** Full-width product carousel with autoplay and manual navigation. */
|
||||
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {LeftOutlined, RightOutlined} from '@ant-design/icons';
|
||||
import {useLanguage} from '@/app/context/LanguageContext';
|
||||
|
||||
const AUTOPLAY_DELAY = 5000; // هر ۵ ثانیه اسلاید عوض میشود
|
||||
const AUTOPLAY_DELAY = 5000; // Advance every five seconds.
|
||||
|
||||
export default function HeroSection() {
|
||||
const {t, dir} = useLanguage();
|
||||
const slides = t.hero.slides;
|
||||
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(() => {
|
||||
setActiveIndex((prev) => (prev + 1) % slides.length);
|
||||
}, [slides.length]);
|
||||
@@ -28,9 +23,7 @@ export default function HeroSection() {
|
||||
setActiveIndex((prev) => (prev - 1 + slides.length) % slides.length);
|
||||
}, [slides.length]);
|
||||
|
||||
// تایمر پخش خودکار: هر بار که اسلاید عوض میشود، تایمر قبلی پاک و یک تایمر
|
||||
// جدید ۵ ثانیهای ست میشود. با کلیک دستی روی فلشها هم همین تابع دوباره
|
||||
// صدا زده میشود تا شمارش از نو شروع شود (تجربه کاربری بهتر).
|
||||
// Restart the autoplay timer after manual navigation.
|
||||
const restartAutoplay = useCallback(() => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = setInterval(goToNext, AUTOPLAY_DELAY);
|
||||
@@ -43,10 +36,10 @@ export default function HeroSection() {
|
||||
};
|
||||
}, [restartAutoplay]);
|
||||
|
||||
const handleManualNav = (direction) => {
|
||||
const handleManualNav = (direction: 'next' | 'prev') => {
|
||||
if (direction === 'next') goToNext();
|
||||
else goToPrev();
|
||||
restartAutoplay(); // شمارش ۵ ثانیه بعد از کلیک دستی از نو شروع شود
|
||||
restartAutoplay(); // Reset the delay after a manual click.
|
||||
};
|
||||
|
||||
const slide = slides[activeIndex];
|
||||
@@ -54,7 +47,7 @@ export default function HeroSection() {
|
||||
return (
|
||||
<section id="hero" className="relative h-screen w-full overflow-hidden bg-page">
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* شکل مورب تزئینی سمت راست/چپ — نسخه سبز برند بهجای قرمز Sielaff */}
|
||||
{/* Decorative brand-colored shape. */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div
|
||||
className="absolute inset-y-0 w-1/3 hidden md:block"
|
||||
@@ -68,8 +61,8 @@ export default function HeroSection() {
|
||||
/>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* اسلایدهای عکس — همه اسلایدها روی هم قرار دارند و فقط با opacity */}
|
||||
{/* محو/نمایان میشوند (transition نرم بهجای پرش ناگهانی) */}
|
||||
{/* Fade between overlapping product images. */}
|
||||
{/* Use opacity transitions for smooth slide changes. */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className="absolute inset-0">
|
||||
{slides.map((s, i) => (
|
||||
@@ -88,7 +81,7 @@ export default function HeroSection() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* لایهی نیمهشفاف پشت متن، برای خوانایی روی هر عکسی */}
|
||||
{/* Overlay for text readability. */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
@@ -98,7 +91,7 @@ export default function HeroSection() {
|
||||
/>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* متن روی اسلاید: کیکر کوچک + عنوان بزرگ دو خطی + توضیح + دکمه CTA */}
|
||||
{/* Active slide headline, description, and call to action. */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div
|
||||
className="relative z-10 h-full flex flex-col justify-end md:justify-end px-6 md:px-16 pb-28 md:pb-72">
|
||||
@@ -124,7 +117,7 @@ export default function HeroSection() {
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* فلشهای ناوبری دستی (قبلی / بعدی) — وسط دو طرف */}
|
||||
{/* Previous and next slide controls. */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<button
|
||||
onClick={() => handleManualNav('prev')}
|
||||
@@ -141,7 +134,7 @@ export default function HeroSection() {
|
||||
<LeftOutlined/>
|
||||
</button>
|
||||
|
||||
{/* نقطههای شمارشگر اسلاید پایین صفحه — کلیک روی هرکدام مستقیم به همان اسلاید میرود */}
|
||||
{/* Jump directly to a slide using the indicators. */}
|
||||
<div className="absolute bottom-8 inset-x-0 z-20 flex justify-center gap-2">
|
||||
{slides.map((s, i) => (
|
||||
<button
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import type {Machine} from '@/types/domain';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {gsap} from 'gsap';
|
||||
import {ScrollTrigger} from 'gsap/ScrollTrigger';
|
||||
import {Modal, Button, message} from 'antd';
|
||||
import {ShoppingCartOutlined} from '@ant-design/icons';
|
||||
import {useDispatch} from 'react-redux';
|
||||
import {useAppDispatch} from '@/store/hooks';
|
||||
import VendingCard from './VendingCard';
|
||||
import {showcaseVideoSrc} from '@/data/content';
|
||||
import {useLanguage} from '@/app/context/LanguageContext';
|
||||
@@ -14,12 +15,12 @@ import {addToCart} from '@/store/cartSlice';
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
export default function HorizontalShowcase() {
|
||||
const sectionRef = useRef(null);
|
||||
const trackRef = useRef(null);
|
||||
const videoRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
const {t} = useLanguage();
|
||||
const [quickViewMachine, setQuickViewMachine] = useState(null);
|
||||
const [quickViewMachine, setQuickViewMachine] = useState<Machine | null>(null);
|
||||
const [videoAvailable, setVideoAvailable] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,10 +34,10 @@ export default function HorizontalShowcase() {
|
||||
Math.max(track.scrollWidth - window.innerWidth, 0)
|
||||
);
|
||||
|
||||
let animationFrameId = null;
|
||||
let animationFrameId: number | null = null;
|
||||
let latestProgress = 0;
|
||||
|
||||
const syncVideo = (progress) => {
|
||||
const syncVideo = (progress: number) => {
|
||||
latestProgress = progress;
|
||||
if (animationFrameId !== null) return;
|
||||
|
||||
@@ -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';
|
||||
|
||||
// دکمه سوییچ زبان فارسی/انگلیسی.
|
||||
// Switch between Persian and English.
|
||||
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
// گرید «مکان مناسب برای نصب» — هتلها، مدارس، داروخانهها، بیمارستانها، شیفت شب، محصولات تخصصی.
|
||||
// Suggested installation locations for vending machines.
|
||||
|
||||
import {
|
||||
HomeOutlined,
|
||||
@@ -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';
|
||||
|
||||
import type {Machine} from '@/types/domain';
|
||||
import {useState} from 'react';
|
||||
import {Modal, Button, message} from 'antd';
|
||||
import {ShoppingCartOutlined, EyeOutlined} from '@ant-design/icons';
|
||||
import {useDispatch} from 'react-redux';
|
||||
import {useAppDispatch} from '@/store/hooks';
|
||||
import {useLanguage} from '@/app/context/LanguageContext';
|
||||
import {addToCart} from '@/store/cartSlice';
|
||||
|
||||
export default function MobileShowcase() {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const {t, dir} = useLanguage();
|
||||
const [quickViewMachine, setQuickViewMachine] = useState(null);
|
||||
const [quickViewMachine, setQuickViewMachine] = useState<Machine | null>(null);
|
||||
|
||||
const handleAddToCart = (machine) => {
|
||||
const handleAddToCart = (machine: Machine) => {
|
||||
dispatch(
|
||||
addToCart({
|
||||
id: machine.id,
|
||||
@@ -7,14 +7,14 @@ import MobileShowcase from './MobileShowcase';
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export default function ShowcaseSection() {
|
||||
const [isMobile, setIsMobile] = useState(null);
|
||||
const [isMobile, setIsMobile] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(
|
||||
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`
|
||||
);
|
||||
|
||||
const handleChange = (event) => setIsMobile(event.matches);
|
||||
const handleChange = (event: MediaQueryListEvent) => setIsMobile(event.matches);
|
||||
|
||||
setIsMobile(mediaQuery.matches);
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
@@ -1,119 +0,0 @@
|
||||
'use client';
|
||||
|
||||
// فرم ثبت تیکت پشتیبانی با اعتبارسنجی antd؛ ارسال واقعی به بکاند در بلوک TODO داخل کد مشخص شده.
|
||||
|
||||
import { Form, Input, Select, Button, message } from 'antd';
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function TicketForm() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleFinish = async (values) => {
|
||||
// -------------------------------------------------------------------
|
||||
// TODO: Connect to the real ticketing/support API.
|
||||
//
|
||||
// import axios from 'axios';
|
||||
//
|
||||
// try {
|
||||
// await axios.post('/api/tickets', values);
|
||||
// } catch (err) {
|
||||
// message.error('...');
|
||||
// return;
|
||||
// }
|
||||
|
||||
// api() <===> todo : use this func to fetch ...
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
console.log('Ticket submitted (placeholder):', values);
|
||||
message.success(t.ticket.successMsg);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="support" className="relative px-6 md:px-16 py-20 md:py-28 bg-surface border-t border-subtle">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="text-center mb-10">
|
||||
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.ticket.eyebrow}</span>
|
||||
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text mt-3 mb-3">
|
||||
{t.ticket.title}
|
||||
</h2>
|
||||
<p className="text-secondary text-sm md:text-base">{t.ticket.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleFinish}
|
||||
requiredMark={false}
|
||||
initialValues={{ priority: 'medium' }}
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-x-4">
|
||||
<Form.Item
|
||||
label={<span className="text-secondary">{t.ticket.name}</span>}
|
||||
name="name"
|
||||
rules={[{ required: true, message: t.ticket.requiredMsg }]}
|
||||
>
|
||||
<Input size="large" placeholder={t.ticket.namePlaceholder} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-secondary">{t.ticket.phone}</span>}
|
||||
name="phone"
|
||||
rules={[
|
||||
{ required: true, message: t.ticket.requiredMsg },
|
||||
{ pattern: /^[0-9+\s-]{7,15}$/, message: t.ticket.invalidPhone },
|
||||
]}
|
||||
>
|
||||
<Input size="large" placeholder={t.ticket.phonePlaceholder} dir="ltr" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-x-4">
|
||||
<Form.Item
|
||||
label={<span className="text-secondary">{t.ticket.email}</span>}
|
||||
name="email"
|
||||
rules={[{ type: 'email', message: t.ticket.invalidEmail }]}
|
||||
>
|
||||
<Input size="large" placeholder={t.ticket.emailPlaceholder} dir="ltr" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-secondary">{t.ticket.priority}</span>}
|
||||
name="priority"
|
||||
>
|
||||
<Select
|
||||
size="large"
|
||||
options={t.ticket.priorities.map((p) => ({ value: p.value, label: p.label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-secondary">{t.ticket.subject}</span>}
|
||||
name="subject"
|
||||
rules={[{ required: true, message: t.ticket.requiredMsg }]}
|
||||
>
|
||||
<Input size="large" placeholder={t.ticket.subjectPlaceholder} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-secondary">{t.ticket.message}</span>}
|
||||
name="message"
|
||||
rules={[{ required: true, message: t.ticket.requiredMsg }]}
|
||||
>
|
||||
<TextArea rows={5} placeholder={t.ticket.messagePlaceholder} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-0">
|
||||
<Button type="primary" size="large" htmlType="submit" block>
|
||||
{t.ticket.submit}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* سکشن «مشتریانی که به ما اعتماد کردهاند» — یک نوار لوگوی بیپایان (marquee)
|
||||
* که بهآرامی و پیوسته حرکت میکند. برای ساخت افکت حلقهی بیپایان، لیست
|
||||
* لوگوها را دو بار پشتسرهم رندر میکنیم و با انیمیشن CSS آن را جابهجا میکنیم.
|
||||
*
|
||||
* نکته فنی: مسیر (track) همیشه جهت LTR دارد، چون منطق «حرکت به چپ» انیمیشن
|
||||
* فرض بر همین جهت است — دقیقاً همان مشکلی که در اسکرول افقی محصولات داشتیم و
|
||||
* حلش کردیم. اینجا هم برای جلوگیری از همان باگ، جهت را ثابت نگه داشتیم.
|
||||
*/
|
||||
|
||||
import { useLanguage } from '@/app/context/LanguageContext';
|
||||
|
||||
export default function TrustedBySection() {
|
||||
const { t } = useLanguage();
|
||||
const clients = t.trustedBy.clients;
|
||||
// لیست لوگوها را دو برابر میکنیم تا وقتی نیمه اول با انیمیشن رد شد،
|
||||
// نیمه دوم دقیقاً از همانجا ادامه بدهد و چشم متوجه پرش/شکاف نشود.
|
||||
const doubledClients = [...clients, ...clients];
|
||||
|
||||
return (
|
||||
<section className="relative px-6 md:px-16 py-16 md:py-20 bg-page border-t border-subtle overflow-hidden">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-10">
|
||||
<span className="text-brand-400 text-sm font-semibold tracking-wide">{t.trustedBy.eyebrow}</span>
|
||||
<h2 className="text-xl md:text-2xl font-extrabold text-primary mt-2">{t.trustedBy.title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* یک محو تدریجی (fade mask) دو طرف نوار، تا لبههای لوگوها بریده بهنظر نرسد */}
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="flex w-max animate-marquee gap-10" dir="ltr">
|
||||
{doubledClients.map((client, i) => (
|
||||
<div
|
||||
key={`${client.id}-${i}`}
|
||||
className="w-40 md:w-48 shrink-0 flex items-center justify-center bg-surface border border-subtle rounded-xl p-4 opacity-80 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src={`/images/clients/client-${((i % clients.length) + 1)}.svg`}
|
||||
alt={client.name}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,25 @@
|
||||
'use client';
|
||||
|
||||
|
||||
import type {Machine} from '@/types/domain';
|
||||
import type {MouseEvent} from 'react';
|
||||
import {useRef} from 'react';
|
||||
import {Button, message} from 'antd';
|
||||
import {ShoppingCartOutlined, EyeOutlined} from '@ant-design/icons';
|
||||
import {useDispatch} from 'react-redux';
|
||||
import {useAppDispatch} from '@/store/hooks';
|
||||
import {addToCart} from '@/store/cartSlice';
|
||||
import {useLanguage} from '@/app/context/LanguageContext';
|
||||
import FloatingItems from './FloatingItems';
|
||||
import {floatingSnacks} from '@/data/content';
|
||||
|
||||
export default function VendingCard({machine, onQuickView}) {
|
||||
const dispatch = useDispatch();
|
||||
export default function VendingCard({machine, onQuickView}: {machine: Machine; onQuickView?: (machine: Machine) => void}) {
|
||||
const dispatch = useAppDispatch();
|
||||
const {t, dir} = useLanguage();
|
||||
const stageRef = useRef(null);
|
||||
const lensRef = useRef(null);
|
||||
const revealRef = useRef(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const lensRef = useRef<HTMLDivElement>(null);
|
||||
const revealRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
const rect = stage.getBoundingClientRect();
|
||||
@@ -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
|
||||
// business copy lives here, per locale. Components read via useLanguage().
|
||||
// Asset paths (images/gifs/video) are locale-independent and stay the same.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const machinesFa = [
|
||||
const machinesFa: Machine[] = [
|
||||
{
|
||||
id: 'rm-35',
|
||||
name: 'روبات مارکت ۳۵ کانال',
|
||||
shortLabel: '۳۵ کانال',
|
||||
priceLabel: '4,200,000,000 ریال',
|
||||
priceNumeric: 4200000000,
|
||||
priceLabel: '4,180,000,000 ریال',
|
||||
priceNumeric: 4180000000,
|
||||
description: 'یخچالدار، ۳۵ کانال اختصاصی، سیستم خنککننده قدرتمند، تحویل نرم، مدیریت آنلاین ۲۴ ساعته.',
|
||||
highlights: ['یخچالدار', '۳۵ کانال اختصاصی', 'تحویل نرم', 'مدیریت آنلاین ۲۴ ساعته'],
|
||||
image: '/images/machine-35-base.webp',
|
||||
@@ -43,7 +45,7 @@ const machinesFa = [
|
||||
},
|
||||
];
|
||||
|
||||
const machinesEn = [
|
||||
const machinesEn: Machine[] = [
|
||||
{
|
||||
id: 'rm-35',
|
||||
name: 'Robot Market RM-35',
|
||||
@@ -82,7 +84,7 @@ const machinesEn = [
|
||||
},
|
||||
];
|
||||
|
||||
export const content = {
|
||||
export const content: Record<Locale, SiteContent> = {
|
||||
fa: {
|
||||
dir: 'rtl',
|
||||
lang: 'fa',
|
||||
@@ -96,10 +98,6 @@ export const content = {
|
||||
showcase: 'دستگاهها',
|
||||
about: 'درباره ما',
|
||||
locations: 'مکانهای نصب',
|
||||
catalog: 'کاتالوگ محصولات',
|
||||
video: 'ویدیوی معرفی',
|
||||
map: 'نقشه',
|
||||
support: 'پشتیبانی',
|
||||
contact: 'تماس با ما',
|
||||
login: 'ورود به پنل کاربری',
|
||||
},
|
||||
@@ -148,18 +146,6 @@ export const content = {
|
||||
gifAlt: (name) => `نمایش عملکرد ${name}`,
|
||||
},
|
||||
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: [
|
||||
{
|
||||
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: {
|
||||
aboutLabel: 'روبات مارکت',
|
||||
aboutText: 'ارائهدهنده دستگاههای وندینگ هوشمند برای کسبوکارهای مدرن — محصولی از شرکت نگین سازان پیشگام رادمهر آتوسا.',
|
||||
@@ -386,10 +264,6 @@ export const content = {
|
||||
showcase: 'Machines',
|
||||
about: 'About Us',
|
||||
locations: 'Where to Install',
|
||||
catalog: 'Product Catalog',
|
||||
video: 'Intro Video',
|
||||
map: 'Map',
|
||||
support: 'Support',
|
||||
contact: 'Contact',
|
||||
login: 'Sign In',
|
||||
},
|
||||
@@ -438,19 +312,6 @@ export const content = {
|
||||
gifAlt: (name) => `${name} in action`,
|
||||
},
|
||||
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: [
|
||||
{
|
||||
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: {
|
||||
aboutLabel: 'Robot Market',
|
||||
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).
|
||||
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: '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},
|
||||
@@ -676,15 +429,3 @@ export const floatingSnacks = [
|
||||
];
|
||||
|
||||
export const showcaseVideoSrc = '/video/showcase-placeholder.mp4';
|
||||
|
||||
// Dedicated company introduction video (different from the horizontal-showcase
|
||||
// scroll-scrubbed background video above). Drop the real file at this path.
|
||||
export const introVideoSrc = '/video/company-intro-placeholder.mp4';
|
||||
export const introVideoPoster = '/images/video-poster.svg';
|
||||
|
||||
// Placeholder coordinates for Toos Industrial Town, Mashhad (approximate).
|
||||
// Replace with the exact factory pin — see README.
|
||||
export const mapLocations = {
|
||||
factory: {lat: 36.42, lng: 59.445},
|
||||
salesOffice: {lat: 36.3110, lng: 59.5683}, // approximate central Mashhad placeholder
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"jsx": "react"
|
||||
}
|
||||
}
|
||||
@@ -43,11 +43,11 @@ api.interceptors.response.use(
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
// message.error('خطایی در سرور رخ داد. لطفاً بعداً تلاش کنید.');
|
||||
// TODO: Display a server error message.
|
||||
}
|
||||
} else if (error.request) {
|
||||
// درخواست ارسال شد ولی هیچ جوابی نیومد (قطعی اینترنت، سرور خاموش و ...)
|
||||
// TODO: message.error('اتصال با سرور برقرار نشد.');
|
||||
// The request was sent, but no response arrived.
|
||||
// TODO: Display a network connection error.
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -1,5 +1,7 @@
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
|
||||
|
||||
// Next.js 14 loads its configuration as JavaScript.
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
|
||||
|
||||
@@ -19,9 +19,13 @@
|
||||
"react-redux": "9.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.19.43",
|
||||
"@types/react": "18.3.31",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"autoprefixer": "10.4.19",
|
||||
"postcss": "8.4.39",
|
||||
"tailwindcss": "3.4.6"
|
||||
"tailwindcss": "3.4.6",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -576,6 +580,44 @@
|
||||
"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": {
|
||||
"version": "0.0.3",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
|
||||
@@ -6,22 +6,27 @@
|
||||
"dev": "next dev -H 0.0.0.0",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"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",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"antd": "5.19.1",
|
||||
"@ant-design/icons": "5.3.7",
|
||||
"@reduxjs/toolkit": "2.2.7",
|
||||
"react-redux": "9.1.2",
|
||||
"axios": "1.7.2",
|
||||
"gsap": "3.12.5"
|
||||
"react-redux": "9.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.19.43",
|
||||
"@types/react": "18.3.31",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"autoprefixer": "10.4.19",
|
||||
"postcss": "8.4.39",
|
||||
"tailwindcss": "3.4.6"
|
||||
"tailwindcss": "3.4.6",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 354 KiB After Width: | Height: | Size: 1.1 MiB |
@@ -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 |
@@ -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';
|
||||
|
||||
const initialState = {
|
||||
const initialState: CartState = {
|
||||
items: [], // { id, name, priceNumeric, priceLabel, qty, image }
|
||||
isDrawerOpen: false,
|
||||
};
|
||||
@@ -9,7 +12,7 @@ const cartSlice = createSlice({
|
||||
name: 'cart',
|
||||
initialState,
|
||||
reducers: {
|
||||
addToCart: (state, action) => {
|
||||
addToCart: (state, action: PayloadAction<CartProduct>) => {
|
||||
const product = action.payload;
|
||||
const existing = state.items.find((item) => item.id === product.id);
|
||||
if (existing) {
|
||||
@@ -19,14 +22,20 @@ const cartSlice = createSlice({
|
||||
}
|
||||
state.isDrawerOpen = true;
|
||||
},
|
||||
removeFromCart: (state, action) => {
|
||||
removeFromCart: (state, action: PayloadAction<string>) => {
|
||||
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);
|
||||
if (item) item.qty += 1;
|
||||
},
|
||||
decrementQty: (state, action) => {
|
||||
decrementQty: (state, action: PayloadAction<string>) => {
|
||||
const item = state.items.find((item) => item.id === action.payload);
|
||||
if (item && item.qty > 1) {
|
||||
item.qty -= 1;
|
||||
@@ -52,6 +61,7 @@ const cartSlice = createSlice({
|
||||
export const {
|
||||
addToCart,
|
||||
removeFromCart,
|
||||
setQuantity,
|
||||
incrementQty,
|
||||
decrementQty,
|
||||
clearCart,
|
||||
@@ -61,11 +71,11 @@ export const {
|
||||
} = cartSlice.actions;
|
||||
|
||||
// Selectors
|
||||
export const selectCartItems = (state) => state.cart.items;
|
||||
export const selectCartCount = (state) =>
|
||||
export const selectCartItems = (state: RootState) => state.cart.items;
|
||||
export const selectCartCount = (state: RootState) =>
|
||||
state.cart.items.reduce((sum, item) => sum + item.qty, 0);
|
||||
export const selectCartTotal = (state) =>
|
||||
export const selectCartTotal = (state: RootState) =>
|
||||
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;
|
||||
@@ -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>();
|
||||
@@ -10,3 +10,7 @@ export const makeStore = () =>
|
||||
|
||||
// Singleton store for the client (App Router renders providers on the client tree).
|
||||
export const store = makeStore();
|
||||
|
||||
export type AppStore = ReturnType<typeof makeStore>;
|
||||
export type RootState = ReturnType<AppStore["getState"]>;
|
||||
export type AppDispatch = AppStore["dispatch"];
|
||||
@@ -1,5 +1,5 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
import type {Config} from 'tailwindcss';
|
||||
const config: Config = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./app/**/*.{js,jsx,ts,tsx}',
|
||||
@@ -55,3 +55,5 @@ module.exports = {
|
||||
preflight: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 }[];
|
||||
};
|
||||
}
|
||||