Compare commits
4
Commits
f49ef11c9c
...
e6103ef6fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6103ef6fd | ||
|
|
dc64b41c80 | ||
|
|
812029fe84 | ||
|
|
7c55b3916c |
@@ -5,99 +5,99 @@ name: Build & Deploy (develop)
|
|||||||
# - Direct pushes to develop
|
# - Direct pushes to develop
|
||||||
# main is intentionally NOT included yet (no prod target configured)
|
# main is intentionally NOT included yet (no prod target configured)
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- develop
|
- develop
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- develop
|
- develop
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Resolve commit SHA
|
- name: Resolve commit SHA
|
||||||
id: vars
|
id: vars
|
||||||
run: |
|
run: |
|
||||||
# For PRs, gitea.sha is a temporary merge commit — use the real
|
# For PRs, gitea.sha is a temporary merge commit — use the real
|
||||||
# PR head commit instead so tags map to actual git history.
|
# PR head commit instead so tags map to actual git history.
|
||||||
FULL_SHA="${{ gitea.event.pull_request.head.sha || gitea.sha }}"
|
FULL_SHA="${{ gitea.event.pull_request.head.sha || gitea.sha }}"
|
||||||
echo "sha=${FULL_SHA:0:7}" >> "$GITHUB_OUTPUT"
|
echo "sha=${FULL_SHA:0:7}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build Docker image (tagged by commit SHA)
|
- name: Build Docker image (tagged by commit SHA)
|
||||||
run: |
|
run: |
|
||||||
docker build -t robot-market:${{ steps.vars.outputs.sha }} .
|
docker build -t robot-market:${{ steps.vars.outputs.sha }} .
|
||||||
|
|
||||||
- name: Save previous tag for rollback
|
- name: Save previous tag for rollback
|
||||||
id: backup
|
id: backup
|
||||||
run: |
|
run: |
|
||||||
if [ -f /opt/robot-market-front/.env ]; then
|
if [ -f /opt/robot-market-front/.env ]; then
|
||||||
cp /opt/robot-market-front/.env /opt/robot-market-front/.env.previous
|
cp /opt/robot-market-front/.env /opt/robot-market-front/.env.previous
|
||||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Point compose at this build
|
- name: Point compose at this build
|
||||||
run: |
|
run: |
|
||||||
echo "IMAGE_TAG=${{ steps.vars.outputs.sha }}" > /opt/robot-market-front/.env
|
echo "IMAGE_TAG=${{ steps.vars.outputs.sha }}" > /opt/robot-market-front/.env
|
||||||
|
|
||||||
- name: Deploy via compose
|
- name: Deploy via compose
|
||||||
id: deploy
|
id: deploy
|
||||||
run: |
|
run: |
|
||||||
docker compose --env-file /opt/robot-market-front/.env \
|
docker compose --env-file /opt/robot-market-front/.env \
|
||||||
-f /opt/robot-market-front/docker-compose.yml \
|
-f /opt/robot-market-front/docker-compose.yml \
|
||||||
up -d --force-recreate
|
up -d --force-recreate
|
||||||
|
|
||||||
- name: Health check (retries for ~30s)
|
- name: Health check (retries for ~30s)
|
||||||
id: health
|
id: health
|
||||||
run: |
|
run: |
|
||||||
for i in $(seq 1 15); do
|
for i in $(seq 1 15); do
|
||||||
if docker exec robot-market node -e "
|
if docker exec robot-market node -e "
|
||||||
require('http').get('http://localhost:3000', res => {
|
require('http').get('http://localhost:3000', res => {
|
||||||
process.exit(res.statusCode < 400 ? 0 : 1);
|
process.exit(res.statusCode < 400 ? 0 : 1);
|
||||||
}).on('error', () => process.exit(1));
|
}).on('error', () => process.exit(1));
|
||||||
" 2>/dev/null; then
|
" 2>/dev/null; then
|
||||||
echo "Container is healthy"
|
echo "Container is healthy"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Not ready yet, retrying..."
|
echo "Not ready yet, retrying..."
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
echo "Health check failed after 30s"
|
echo "Health check failed after 30s"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Roll back after deployment or health check 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') }}
|
if: ${{ failure() && steps.backup.outputs.available == 'true' && (steps.deploy.outcome == 'failure' || steps.health.outcome == 'failure') }}
|
||||||
run: |
|
run: |
|
||||||
if [ -f /opt/robot-market-front/.env.previous ]; then
|
if [ -f /opt/robot-market-front/.env.previous ]; then
|
||||||
cp /opt/robot-market-front/.env.previous /opt/robot-market-front/.env
|
cp /opt/robot-market-front/.env.previous /opt/robot-market-front/.env
|
||||||
docker compose --env-file /opt/robot-market-front/.env \
|
docker compose --env-file /opt/robot-market-front/.env \
|
||||||
-f /opt/robot-market-front/docker-compose.yml \
|
-f /opt/robot-market-front/docker-compose.yml \
|
||||||
up -d --force-recreate
|
up -d --force-recreate
|
||||||
echo "Rolled back to previous image."
|
echo "Rolled back to previous image."
|
||||||
else
|
else
|
||||||
echo "No previous deploy recorded — nothing to roll back to."
|
echo "No previous deploy recorded — nothing to roll back to."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Clean up dangling (untagged) layers
|
- name: Clean up dangling (untagged) layers
|
||||||
run: docker image prune -f
|
run: docker image prune -f
|
||||||
|
|
||||||
- name: Prune old SHA-tagged images (keep last 5)
|
- name: Prune old SHA-tagged images (keep last 5)
|
||||||
run: |
|
run: |
|
||||||
KEEP=5
|
KEEP=5
|
||||||
CURRENT_TAG=$(grep IMAGE_TAG /opt/robot-market-front/.env 2>/dev/null | cut -d= -f2)
|
CURRENT_TAG=$(grep IMAGE_TAG /opt/robot-market-front/.env 2>/dev/null | cut -d= -f2)
|
||||||
PREVIOUS_TAG=$(grep IMAGE_TAG /opt/robot-market-front/.env.previous 2>/dev/null | cut -d= -f2)
|
PREVIOUS_TAG=$(grep IMAGE_TAG /opt/robot-market-front/.env.previous 2>/dev/null | cut -d= -f2)
|
||||||
|
|
||||||
docker images robot-market --format '{{.Tag}} {{.CreatedAt}}' \
|
docker images robot-market --format '{{.Tag}} {{.CreatedAt}}' \
|
||||||
| sort -t" " -k2 -r \
|
| sort -t" " -k2 -r \
|
||||||
| tail -n +$((KEEP + 1)) \
|
| tail -n +$((KEEP + 1)) \
|
||||||
| cut -f1 \
|
| cut -f1 \
|
||||||
| while read -r tag; do
|
| while read -r tag; do
|
||||||
if [ "$tag" != "$CURRENT_TAG" ] && [ "$tag" != "$PREVIOUS_TAG" ] && [ "$tag" != "<none>" ]; then
|
if [ "$tag" != "$CURRENT_TAG" ] && [ "$tag" != "$PREVIOUS_TAG" ] && [ "$tag" != "<none>" ]; then
|
||||||
echo "Removing old image robot-market:$tag"
|
echo "Removing old image robot-market:$tag"
|
||||||
docker rmi "robot-market:$tag" 2>/dev/null || true
|
docker rmi "robot-market:$tag" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -4,34 +4,34 @@ name: Rollback (develop)
|
|||||||
# Leave "sha" empty to roll back to the previous deploy.
|
# Leave "sha" empty to roll back to the previous deploy.
|
||||||
# Fill "sha" in (short 7-char form, e.g. a3f9c21) to roll back to a specific past build.
|
# Fill "sha" in (short 7-char form, e.g. a3f9c21) to roll back to a specific past build.
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
sha:
|
sha:
|
||||||
description: "Short commit SHA to roll back to (empty = previous deploy)"
|
description: 'Short commit SHA to roll back to (empty = previous deploy)'
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ''
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
rollback:
|
rollback:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Determine target tag
|
- name: Determine target tag
|
||||||
run: |
|
run: |
|
||||||
if [ -n "${{ gitea.event.inputs.sha }}" ]; then
|
if [ -n "${{ gitea.event.inputs.sha }}" ]; then
|
||||||
TAG="${{ gitea.event.inputs.sha }}"
|
TAG="${{ gitea.event.inputs.sha }}"
|
||||||
else
|
else
|
||||||
TAG=$(grep IMAGE_TAG /opt/robot-market-front/.env.previous | cut -d= -f2)
|
TAG=$(grep IMAGE_TAG /opt/robot-market-front/.env.previous | cut -d= -f2)
|
||||||
fi
|
fi
|
||||||
echo "Rolling back to: $TAG"
|
echo "Rolling back to: $TAG"
|
||||||
echo "IMAGE_TAG=$TAG" > /opt/robot-market-front/.env
|
echo "IMAGE_TAG=$TAG" > /opt/robot-market-front/.env
|
||||||
|
|
||||||
- name: Verify that image actually exists locally
|
- name: Verify that image actually exists locally
|
||||||
run: |
|
run: |
|
||||||
TAG=$(grep IMAGE_TAG /opt/robot-market-front/.env | cut -d= -f2)
|
TAG=$(grep IMAGE_TAG /opt/robot-market-front/.env | cut -d= -f2)
|
||||||
docker image inspect robot-market:$TAG > /dev/null
|
docker image inspect robot-market:$TAG > /dev/null
|
||||||
|
|
||||||
- name: Redeploy target image
|
- name: Redeploy target image
|
||||||
run: |
|
run: |
|
||||||
docker compose --env-file /opt/robot-market-front/.env \
|
docker compose --env-file /opt/robot-market-front/.env \
|
||||||
-f /opt/robot-market-front/docker-compose.yml \
|
-f /opt/robot-market-front/docker-compose.yml \
|
||||||
up -d --force-recreate
|
up -d --force-recreate
|
||||||
|
|||||||
+26
-26
@@ -1,34 +1,34 @@
|
|||||||
name: Runner Test
|
name: Runner Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [main]
|
||||||
workflow_dispatch: # lets you trigger it manually from the UI too
|
workflow_dispatch: # lets you trigger it manually from the UI too
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
hello:
|
hello:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Print basic info
|
- name: Print basic info
|
||||||
run: |
|
run: |
|
||||||
echo "✅ Runner is working!"
|
echo "✅ Runner is working!"
|
||||||
echo "Hostname: $(hostname)"
|
echo "Hostname: $(hostname)"
|
||||||
echo "Date: $(date)"
|
echo "Date: $(date)"
|
||||||
echo "User: $(whoami)"
|
echo "User: $(whoami)"
|
||||||
pwd
|
pwd
|
||||||
ls -la
|
ls -la
|
||||||
|
|
||||||
- name: Test env variables
|
- name: Test env variables
|
||||||
run: |
|
run: |
|
||||||
echo "Branch: ${{ github.ref_name }}"
|
echo "Branch: ${{ github.ref_name }}"
|
||||||
echo "Commit SHA: ${{ github.sha }}"
|
echo "Commit SHA: ${{ github.sha }}"
|
||||||
echo "Actor: ${{ github.actor }}"
|
echo "Actor: ${{ github.actor }}"
|
||||||
|
|
||||||
- name: Simulate a build step
|
- name: Simulate a build step
|
||||||
run: |
|
run: |
|
||||||
mkdir -p build
|
mkdir -p build
|
||||||
echo "Build artifact content" > build/output.txt
|
echo "Build artifact content" > build/output.txt
|
||||||
cat build/output.txt
|
cat build/output.txt
|
||||||
|
|||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="PrettierConfiguration">
|
||||||
|
<option name="myConfigurationMode" value="MANUAL" />
|
||||||
|
<option name="myRunOnSave" value="true" />
|
||||||
|
<option name="myFilesPattern" value="{**/*,*}.{js,jsx,ts,tsx,json,css,md,yml,yaml}" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+105
-94
@@ -4,20 +4,11 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="feat: add cart and product pages, quantity controls, and first-visit development notice">
|
<list default="true" id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="chore: configure prettier for consistent WebStorm formatting">
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/app/products/[id]/page.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/app/products/[id]/page.tsx" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/components/CartPage.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/CartPage.tsx" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/components/Footer.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/Footer.tsx" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/components/Header.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/Header.tsx" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/components/Header.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/Header.tsx" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/components/HorizontalShowcase.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/HorizontalShowcase.tsx" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/components/ProductsPage.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/ProductsPage.tsx" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/components/MobileShowcase.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/MobileShowcase.tsx" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/components/VendingCard.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/VendingCard.tsx" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/components/ProductDetails.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/ProductDetails.tsx" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/components/ShowcaseSection.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/ShowcaseSection.tsx" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/data/commerce.ts" beforeDir="false" afterPath="$PROJECT_DIR$/data/commerce.ts" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/data/content.ts" beforeDir="false" afterPath="$PROJECT_DIR$/data/content.ts" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/types/domain.ts" beforeDir="false" afterPath="$PROJECT_DIR$/types/domain.ts" afterDir="false" />
|
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
@@ -39,61 +30,61 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="NextEditCompletionFeaturesState">
|
<component name="NextEditCompletionFeaturesState">
|
||||||
<decayedCancelled>
|
<decayedCancelled>
|
||||||
<entry key="MS100" value="5.636161152320107E-5" />
|
<entry key="MS100" value="1.00000002799996" />
|
||||||
<entry key="MS500" value="0.2302465263814015" />
|
<entry key="MS500" value="1.0308625269201679" />
|
||||||
<entry key="S2" value="1.9612794450498428" />
|
<entry key="S2" value="1.419138804605157" />
|
||||||
<entry key="S5" value="3.4102363921166443" />
|
<entry key="S5" value="1.706225098993773" />
|
||||||
<entry key="S10" value="4.5184216736456175" />
|
<entry key="S10" value="1.8403720003627995" />
|
||||||
<entry key="S30" value="6.985270524998366" />
|
<entry key="S30" value="1.9436780599416141" />
|
||||||
<entry key="S60" value="9.574915616687461" />
|
<entry key="S60" value="1.9714309342107725" />
|
||||||
<entry key="M2" value="13.91167227171946" />
|
<entry key="M2" value="1.9856119592470316" />
|
||||||
<entry key="M5" value="22.10409689222133" />
|
<entry key="M5" value="1.9942197493841074" />
|
||||||
<entry key="M10" value="27.65756793067821" />
|
<entry key="M10" value="1.9971056861735406" />
|
||||||
<entry key="M15" value="30.196404421732346" />
|
<entry key="M15" value="1.9980696550155925" />
|
||||||
<entry key="M30" value="34.20114919146407" />
|
<entry key="M30" value="2.0012745114120687" />
|
||||||
<entry key="H1" value="38.596318396422" />
|
<entry key="H1" value="2.3118877948380296" />
|
||||||
<entry key="H2" value="42.58982972864479" />
|
<entry key="H2" value="5.83125251451162" />
|
||||||
<entry key="H4" value="45.40405035457018" />
|
<entry key="H4" value="15.618267793474967" />
|
||||||
<entry key="D1" value="48.33665386421948" />
|
<entry key="D1" value="41.54712189597287" />
|
||||||
<entry key="W1" value="48.9035231768996" />
|
<entry key="W1" value="49.52131853352149" />
|
||||||
</decayedCancelled>
|
</decayedCancelled>
|
||||||
<decayedSelected>
|
<decayedSelected>
|
||||||
<entry key="MS100" value="1.0" />
|
<entry key="MS100" value="0.0011613350732448452" />
|
||||||
<entry key="MS500" value="1.0" />
|
<entry key="MS500" value="0.25881623096034434" />
|
||||||
<entry key="S2" value="1.0" />
|
<entry key="S2" value="0.713260012934514" />
|
||||||
<entry key="S5" value="1.0" />
|
<entry key="S5" value="0.8735728959166943" />
|
||||||
<entry key="S10" value="1.0000000000004026" />
|
<entry key="S10" value="0.9346512161853181" />
|
||||||
<entry key="S30" value="1.0000738385287775" />
|
<entry key="S30" value="0.9777245612102327" />
|
||||||
<entry key="S60" value="1.0085929348174816" />
|
<entry key="S60" value="0.9887995556280518" />
|
||||||
<entry key="M2" value="1.0926980842168899" />
|
<entry key="M2" value="0.9943840081316935" />
|
||||||
<entry key="M5" value="1.3862142586684925" />
|
<entry key="M5" value="0.9977498071513805" />
|
||||||
<entry key="M10" value="1.6214613895235106" />
|
<entry key="M10" value="0.9988742699420636" />
|
||||||
<entry key="M15" value="1.7282426350470148" />
|
<entry key="M15" value="0.9992493798311916" />
|
||||||
<entry key="M30" value="1.853371334793368" />
|
<entry key="M30" value="0.9997460137253545" />
|
||||||
<entry key="H1" value="1.9237809993680148" />
|
<entry key="H1" value="1.0153819861272508" />
|
||||||
<entry key="H2" value="1.9611352659059051" />
|
<entry key="H2" value="1.176335070049973" />
|
||||||
<entry key="H4" value="1.9803750638943771" />
|
<entry key="H4" value="1.593942303679243" />
|
||||||
<entry key="D1" value="1.9967021063244244" />
|
<entry key="D1" value="2.6336150184150653" />
|
||||||
<entry key="W1" value="1.9995282050855745" />
|
<entry key="W1" value="2.9430127254959557" />
|
||||||
</decayedSelected>
|
</decayedSelected>
|
||||||
<decayedShown>
|
<decayedShown>
|
||||||
<entry key="MS100" value="5.673108319866789E-5" />
|
<entry key="MS100" value="0.21022428084806025" />
|
||||||
<entry key="MS500" value="0.2755608628441334" />
|
<entry key="MS500" value="0.7991095607909064" />
|
||||||
<entry key="S2" value="2.398322303413965" />
|
<entry key="S2" value="1.7721727513021905" />
|
||||||
<entry key="S5" value="4.121762152287479" />
|
<entry key="S5" value="2.3865471045731828" />
|
||||||
<entry key="S10" value="5.358118199319534" />
|
<entry key="S10" value="2.6678930006041393" />
|
||||||
<entry key="S30" value="7.924594218734951" />
|
<entry key="S30" value="2.883094316496053" />
|
||||||
<entry key="S60" value="10.550212525549737" />
|
<entry key="S60" value="2.9407336156210007" />
|
||||||
<entry key="M2" value="14.985181048549935" />
|
<entry key="M2" value="2.9701604456532973" />
|
||||||
<entry key="M5" value="23.480472743765223" />
|
<entry key="M5" value="2.988014265270419" />
|
||||||
<entry key="M10" value="29.27333974135673" />
|
<entry key="M10" value="2.9939987814247395" />
|
||||||
<entry key="M15" value="31.92062506542455" />
|
<entry key="M15" value="2.99599746633024" />
|
||||||
<entry key="M30" value="36.05236671041284" />
|
<entry key="M30" value="3.000359206839734" />
|
||||||
<entry key="H1" value="40.518972305385134" />
|
<entry key="H1" value="3.326929972202293" />
|
||||||
<entry key="H2" value="44.55038420189922" />
|
<entry key="H2" value="7.007369966880669" />
|
||||||
<entry key="H4" value="47.38412968080923" />
|
<entry key="H4" value="17.212038704105343" />
|
||||||
<entry key="D1" value="50.33330581758772" />
|
<entry key="D1" value="44.18068209867156" />
|
||||||
<entry key="W1" value="50.9030441942503" />
|
<entry key="W1" value="52.46432230547936" />
|
||||||
</decayedShown>
|
</decayedShown>
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectColorInfo">{
|
<component name="ProjectColorInfo">{
|
||||||
@@ -105,36 +96,37 @@
|
|||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
<option name="showLibraryContents" value="true" />
|
<option name="showLibraryContents" value="true" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PropertiesComponent">{
|
<component name="PropertiesComponent"><![CDATA[{
|
||||||
"keyToString": {
|
"keyToString": {
|
||||||
"ASKED_MARK_IGNORED_FILES_AS_EXCLUDED": "true",
|
"ASKED_MARK_IGNORED_FILES_AS_EXCLUDED": "true",
|
||||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||||
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
|
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
|
||||||
"git-widget-placeholder": "develop",
|
"git-widget-placeholder": "develop",
|
||||||
"javascript.nodejs.core.library.configured.version": "24.20.0",
|
"javascript.nodejs.core.library.configured.version": "24.20.0",
|
||||||
"javascript.nodejs.core.library.typings.version": "24.13.3",
|
"javascript.nodejs.core.library.typings.version": "24.13.3",
|
||||||
"javascript.preferred.runtime.type.id": "node",
|
"javascript.preferred.runtime.type.id": "node",
|
||||||
"js.debugger.nextJs.config.created.client": "true",
|
"js.debugger.nextJs.config.created.client": "true",
|
||||||
"js.debugger.nextJs.config.created.server": "true",
|
"js.debugger.nextJs.config.created.server": "true",
|
||||||
"junie.onboarding.icon.badge.shown": "true",
|
"junie.onboarding.icon.badge.shown": "true",
|
||||||
"kronk.content.tree.expanded.paths": "",
|
"kronk.content.tree.expanded.paths": "",
|
||||||
"last_opened_file_path": "/home/hamed/Documents/Projects/GItLabRepo/device_web",
|
"last_opened_file_path": "/home/hamed/Documents/Projects/GItLabRepo/device_web",
|
||||||
"node.js.detected.package.eslint": "true",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.detected.package.tslint": "true",
|
"node.js.detected.package.tslint": "true",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
"nodejs_package_manager_path": "npm",
|
"nodejs_package_manager_path": "npm",
|
||||||
"settings.editor.selected.configurable": "editor.preferences.fonts.default",
|
"prettierjs.PrettierConfiguration.Package": "/home/hamed/Documents/Projects/GIthubRepo/robot-market-landing/node_modules/prettier",
|
||||||
"to.speed.mode.migration.done": "true",
|
"settings.editor.selected.configurable": "settings.javascript.prettier",
|
||||||
"ts.external.directory.path": "/home/hamed/Documents/Projects/GIthubRepo/robot-market-landing/node_modules/typescript/lib",
|
"to.speed.mode.migration.done": "true",
|
||||||
"vue.rearranger.settings.migration": "true"
|
"ts.external.directory.path": "/home/hamed/Documents/Projects/GIthubRepo/robot-market-landing/node_modules/typescript/lib",
|
||||||
|
"vue.rearranger.settings.migration": "true"
|
||||||
}
|
}
|
||||||
}</component>
|
}]]></component>
|
||||||
<component name="RecentsManager">
|
<component name="RecentsManager">
|
||||||
<key name="MoveFile.RECENT_KEYS">
|
<key name="MoveFile.RECENT_KEYS">
|
||||||
<recent name="D:\Project\GitHubRepo\robot-market-landing\public\images\icons" />
|
<recent name="D:\Project\GitHubRepo\robot-market-landing\public\images\icons" />
|
||||||
@@ -516,7 +508,23 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1789451442320</updated>
|
<updated>1789451442320</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="38" />
|
<task id="LOCAL-00038" summary="feat: add customer authentication and order dashboard">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1789537009629</created>
|
||||||
|
<option name="number" value="00038" />
|
||||||
|
<option name="presentableId" value="LOCAL-00038" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1789537009629</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00039" summary="chore: configure prettier for consistent WebStorm formatting">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1789555937414</created>
|
||||||
|
<option name="number" value="00039" />
|
||||||
|
<option name="presentableId" value="LOCAL-00039" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1789555937414</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="40" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -540,6 +548,9 @@
|
|||||||
<MESSAGE value="refactor: remove disabled home sections and their unused assets" />
|
<MESSAGE value="refactor: remove disabled home sections and their unused assets" />
|
||||||
<MESSAGE value="refactor: migrate project to TypeScript and translate comments to English" />
|
<MESSAGE value="refactor: migrate project to TypeScript and translate comments to English" />
|
||||||
<MESSAGE value="feat: add cart and product pages, quantity controls, and first-visit development notice" />
|
<MESSAGE value="feat: add cart and product pages, quantity controls, and first-visit development notice" />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="feat: add cart and product pages, quantity controls, and first-visit development notice" />
|
<MESSAGE value="feat: add customer authentication and order dashboard" />
|
||||||
|
<MESSAGE value="chore: format project automatically before starting dev server" />
|
||||||
|
<MESSAGE value="chore: configure prettier for consistent WebStorm formatting" />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="chore: configure prettier for consistent WebStorm formatting" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
docs
|
||||||
|
public
|
||||||
|
.idea
|
||||||
|
.agents
|
||||||
|
.codex
|
||||||
|
*.tsbuildinfo
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"printWidth": 120,
|
||||||
|
"bracketSpacing": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
+6
-1
@@ -1,8 +1,13 @@
|
|||||||
import type {Metadata} from 'next';
|
import type {Metadata} from 'next';
|
||||||
import CartPage from '@/components/CartPage';
|
import CartPage from '@/components/CartPage';
|
||||||
|
import ProtectedPage from '@/components/auth/ProtectedPage';
|
||||||
|
|
||||||
export const metadata: Metadata = {title: 'سبد خرید | روبات مارکت'};
|
export const metadata: Metadata = {title: 'سبد خرید | روبات مارکت'};
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <CartPage/>;
|
return (
|
||||||
|
<ProtectedPage>
|
||||||
|
<CartPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type {ReactNode} from 'react';
|
||||||
|
import type {AuthSession, CustomerProfile} from '@/types/account';
|
||||||
|
import {createContext, useCallback, useContext, useEffect, useRef, useState} from 'react';
|
||||||
|
import {AUTH_STORAGE_KEY, loginWithOtp, loginWithPassword, requestOtp} from '@/services/auth';
|
||||||
|
import {saveProfile} from '@/services/profile';
|
||||||
|
import AuthModal from '@/components/auth/AuthModal';
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
session: AuthSession | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
ready: boolean;
|
||||||
|
loginOpen: boolean;
|
||||||
|
openLogin: (onSuccess?: () => void) => void;
|
||||||
|
closeLogin: () => void;
|
||||||
|
sendOtp: (phone: string) => Promise<void>;
|
||||||
|
verifyOtp: (phone: string, code: string) => Promise<void>;
|
||||||
|
loginPassword: (phone: string, password: string) => Promise<void>;
|
||||||
|
updateProfile: (profile: CustomerProfile) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({children}: {children: ReactNode}) {
|
||||||
|
const [session, setSession] = useState<AuthSession | null>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [loginOpen, setLoginOpen] = useState(false);
|
||||||
|
const successCallback = useRef<(() => void) | undefined>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = window.localStorage.getItem(AUTH_STORAGE_KEY);
|
||||||
|
if (saved) setSession(JSON.parse(saved) as AuthSession);
|
||||||
|
} catch {
|
||||||
|
window.localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||||
|
} finally {
|
||||||
|
setReady(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const persist = useCallback((next: AuthSession | null) => {
|
||||||
|
setSession(next);
|
||||||
|
if (next) {
|
||||||
|
window.localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(next));
|
||||||
|
window.localStorage.setItem('rm-auth-token', next.accessToken);
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||||
|
window.localStorage.removeItem('rm-auth-token');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const finishLogin = useCallback(
|
||||||
|
(next: AuthSession) => {
|
||||||
|
persist(next);
|
||||||
|
setLoginOpen(false);
|
||||||
|
const callback = successCallback.current;
|
||||||
|
successCallback.current = undefined;
|
||||||
|
callback?.();
|
||||||
|
},
|
||||||
|
[persist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openLogin = useCallback((onSuccess?: () => void) => {
|
||||||
|
successCallback.current = onSuccess;
|
||||||
|
setLoginOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeLogin = useCallback(() => {
|
||||||
|
successCallback.current = undefined;
|
||||||
|
setLoginOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value: AuthContextValue = {
|
||||||
|
session,
|
||||||
|
isAuthenticated: Boolean(session),
|
||||||
|
ready,
|
||||||
|
loginOpen,
|
||||||
|
openLogin,
|
||||||
|
closeLogin,
|
||||||
|
sendOtp: async phone => {
|
||||||
|
await requestOtp(phone);
|
||||||
|
},
|
||||||
|
verifyOtp: async (phone, code) => finishLogin(await loginWithOtp(phone, code)),
|
||||||
|
loginPassword: async (phone, password) => finishLogin(await loginWithPassword(phone, password)),
|
||||||
|
updateProfile: async profile => {
|
||||||
|
if (!session) return;
|
||||||
|
const saved = await saveProfile(profile);
|
||||||
|
persist({...session, profile: saved});
|
||||||
|
},
|
||||||
|
logout: () => persist(null),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<AuthModal />
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const value = useContext(AuthContext);
|
||||||
|
if (!value) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ export function LanguageProvider({children}: {children: ReactNode}) {
|
|||||||
window.localStorage.setItem('rm-locale', locale);
|
window.localStorage.setItem('rm-locale', locale);
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
const toggleLocale = () => setLocale((l) => (l === 'fa' ? 'en' : 'fa'));
|
const toggleLocale = () => setLocale(l => (l === 'fa' ? 'en' : 'fa'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LanguageContext.Provider
|
<LanguageContext.Provider
|
||||||
|
|||||||
@@ -14,11 +14,9 @@ interface ThemeContextValue {
|
|||||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
export function ThemeProvider({children}: {children: ReactNode}) {
|
export function ThemeProvider({children}: {children: ReactNode}) {
|
||||||
|
|
||||||
const [theme, setTheme] = useState<Theme>('light');
|
const [theme, setTheme] = useState<Theme>('light');
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = window.localStorage.getItem('rm-theme');
|
const saved = window.localStorage.getItem('rm-theme');
|
||||||
if (saved === 'light' || saved === 'dark') {
|
if (saved === 'light' || saved === 'dark') {
|
||||||
@@ -29,7 +27,6 @@ export function ThemeProvider({children}: {children: ReactNode}) {
|
|||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.remove('light', 'dark');
|
root.classList.remove('light', 'dark');
|
||||||
@@ -37,13 +34,9 @@ export function ThemeProvider({children}: {children: ReactNode}) {
|
|||||||
window.localStorage.setItem('rm-theme', theme);
|
window.localStorage.setItem('rm-theme', theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
|
const toggleTheme = () => setTheme(t => (t === 'dark' ? 'light' : 'dark'));
|
||||||
|
|
||||||
return (
|
return <ThemeContext.Provider value={{theme, setTheme, toggleTheme, mounted}}>{children}</ThemeContext.Provider>;
|
||||||
<ThemeContext.Provider value={{theme, setTheme, toggleTheme, mounted}}>
|
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type {ReactNode} from 'react';
|
||||||
|
import ProtectedPage from '@/components/auth/ProtectedPage';
|
||||||
|
import DashboardShell from '@/components/dashboard/DashboardShell';
|
||||||
|
|
||||||
|
export default function Layout({children}: {children: ReactNode}) {
|
||||||
|
return (
|
||||||
|
<ProtectedPage>
|
||||||
|
<DashboardShell>{children}</DashboardShell>
|
||||||
|
</ProtectedPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useEffect, useRef, useState} from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {Alert, App, Button, Empty, Skeleton, Steps} from 'antd';
|
||||||
|
import {DownloadOutlined, FileExcelOutlined, PrinterOutlined} from '@ant-design/icons';
|
||||||
|
import type {CustomerOrder} from '@/types/account';
|
||||||
|
import {useAuth} from '@/app/context/AuthContext';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {accountCopy, orderStatusLabels, paymentStatusLabels} from '@/data/account';
|
||||||
|
import {getOrder} from '@/services/orders';
|
||||||
|
import {downloadInvoiceExcel, downloadInvoicePdf} from '@/lib/invoice';
|
||||||
|
import OrderStatusTag from '@/components/dashboard/OrderStatusTag';
|
||||||
|
import InvoiceDocument from '@/components/dashboard/InvoiceDocument';
|
||||||
|
|
||||||
|
export default function OrderDetailsPage({params}: {params: {id: string}}) {
|
||||||
|
const auth = useAuth();
|
||||||
|
const {message} = App.useApp();
|
||||||
|
const {locale} = useLanguage();
|
||||||
|
const copy = accountCopy[locale];
|
||||||
|
const [order, setOrder] = useState<CustomerOrder | null | undefined>(null);
|
||||||
|
const [exporting, setExporting] = useState<'pdf' | 'excel' | null>(null);
|
||||||
|
const invoiceRef = useRef<HTMLDivElement>(null);
|
||||||
|
const numberLocale = locale === 'fa' ? 'fa-IR' : 'en-US';
|
||||||
|
const money = (value: number) => `${value.toLocaleString(numberLocale)} ${locale === 'fa' ? 'ریال' : 'Rials'}`;
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth.session) getOrder(params.id, auth.session.profile).then(setOrder);
|
||||||
|
}, [auth.session, params.id]);
|
||||||
|
if (order === null) return <Skeleton active />;
|
||||||
|
if (!order)
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Empty description={copy.notFound} />
|
||||||
|
<Link href="/dashboard/orders" className="text-brand-400 block text-center mt-5">
|
||||||
|
{copy.backToOrders}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const exportPdf = async () => {
|
||||||
|
if (!invoiceRef.current) return;
|
||||||
|
setExporting('pdf');
|
||||||
|
try {
|
||||||
|
await downloadInvoicePdf(invoiceRef.current, order.invoice!.number);
|
||||||
|
} catch {
|
||||||
|
await message.error('ساخت فایل PDF انجام نشد. دوباره تلاش کنید.');
|
||||||
|
} finally {
|
||||||
|
setExporting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const exportExcel = async () => {
|
||||||
|
setExporting('excel');
|
||||||
|
try {
|
||||||
|
await downloadInvoiceExcel(order, order.invoice!.number);
|
||||||
|
} catch {
|
||||||
|
await message.error('ساخت فایل Excel انجام نشد. دوباره تلاش کنید.');
|
||||||
|
} finally {
|
||||||
|
setExporting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link href="/dashboard/orders" className="text-brand-400 hover:underline">
|
||||||
|
{copy.backToOrders}
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-wrap justify-between gap-4 items-center mt-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-secondary">{copy.orderNumber}</p>
|
||||||
|
<h1 className="text-3xl font-extrabold text-primary" dir="ltr">
|
||||||
|
{order.number}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<OrderStatusTag status={order.status} />
|
||||||
|
</div>
|
||||||
|
<div className="grid sm:grid-cols-3 gap-4 mt-7">
|
||||||
|
<div className="rounded-xl border border-subtle bg-elevated p-4">
|
||||||
|
<span className="text-sm text-secondary">{copy.orderDate}</span>
|
||||||
|
<strong className="block text-primary mt-1">{order.createdAt}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-subtle bg-elevated p-4">
|
||||||
|
<span className="text-sm text-secondary">{copy.lastUpdate}</span>
|
||||||
|
<strong className="block text-primary mt-1">{order.updatedAt}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-subtle bg-elevated p-4">
|
||||||
|
<span className="text-sm text-secondary">{copy.paymentStatus}</span>
|
||||||
|
<strong className="block text-primary mt-1">
|
||||||
|
{paymentStatusLabels[locale][order.paymentStatus]}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-subtle bg-elevated p-5 md:p-7 mt-7">
|
||||||
|
<h2 className="text-xl font-bold text-primary mb-6">{copy.timeline}</h2>
|
||||||
|
<Steps
|
||||||
|
direction="vertical"
|
||||||
|
current={order.events.length - 1}
|
||||||
|
items={order.events.map(event => ({
|
||||||
|
title: orderStatusLabels[locale][event.status],
|
||||||
|
description: `${event.date} — ${event.note}`,
|
||||||
|
status: event.completed ? 'finish' : 'wait',
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-subtle bg-elevated p-5 md:p-7 mt-7">
|
||||||
|
<h2 className="text-xl font-bold text-primary mb-5">{copy.orderItems}</h2>
|
||||||
|
<div className="space-y-5">
|
||||||
|
{order.items.map(item => (
|
||||||
|
<article
|
||||||
|
key={item.id}
|
||||||
|
className="flex flex-wrap sm:flex-nowrap gap-5 border-b border-subtle last:border-0 pb-5 last:pb-0"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={item.image}
|
||||||
|
alt={item.name}
|
||||||
|
className="w-24 h-28 object-contain rounded-xl bg-surface"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-bold text-primary">{item.name}</h3>
|
||||||
|
<p className="text-sm text-secondary mt-1" dir="ltr">
|
||||||
|
{item.model}
|
||||||
|
</p>
|
||||||
|
<p className="text-secondary mt-3">
|
||||||
|
{copy.modules}: {item.modules.join('، ')}
|
||||||
|
</p>
|
||||||
|
<p className="text-primary mt-3">
|
||||||
|
{copy.quantity}: {item.quantity.toLocaleString(numberLocale)} · {copy.total}:{' '}
|
||||||
|
{money(item.quantity * item.unitPrice)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-7">
|
||||||
|
<h2 className="text-xl font-bold text-primary mb-5">{copy.invoice}</h2>
|
||||||
|
{!order.invoice ? (
|
||||||
|
<Alert type="info" showIcon message={copy.invoicePending} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-3 mb-5 invoice-actions">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
loading={exporting === 'pdf'}
|
||||||
|
onClick={exportPdf}
|
||||||
|
>
|
||||||
|
{copy.downloadPdf}
|
||||||
|
</Button>
|
||||||
|
<Button icon={<FileExcelOutlined />} loading={exporting === 'excel'} onClick={exportExcel}>
|
||||||
|
{copy.downloadExcel}
|
||||||
|
</Button>
|
||||||
|
<Button icon={<PrinterOutlined />} onClick={() => window.print()}>
|
||||||
|
{copy.print}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-subtle">
|
||||||
|
<InvoiceDocument ref={invoiceRef} order={order} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
import {Empty, Skeleton} from 'antd';
|
||||||
|
import type {CustomerOrder} from '@/types/account';
|
||||||
|
import {useAuth} from '@/app/context/AuthContext';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {accountCopy} from '@/data/account';
|
||||||
|
import {getOrders} from '@/services/orders';
|
||||||
|
import OrderCard from '@/components/dashboard/OrderCard';
|
||||||
|
|
||||||
|
export default function OrdersPage() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const {locale} = useLanguage();
|
||||||
|
const copy = accountCopy[locale];
|
||||||
|
const [orders, setOrders] = useState<CustomerOrder[] | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth.session) getOrders(auth.session.profile).then(setOrders);
|
||||||
|
}, [auth.session]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="text-3xl font-extrabold text-primary">{copy.orders}</h1>
|
||||||
|
<p className="text-secondary mt-3 mb-7">{copy.dashboardDescription}</p>
|
||||||
|
{!orders ? (
|
||||||
|
<Skeleton active />
|
||||||
|
) : orders.length ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{orders.map(order => (
|
||||||
|
<OrderCard key={order.id} order={order} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Empty description={copy.emptyOrders} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useEffect, useMemo, useState} from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {Progress, Skeleton} from 'antd';
|
||||||
|
import {CheckCircleOutlined, ClockCircleOutlined, FileTextOutlined} from '@ant-design/icons';
|
||||||
|
import type {CustomerOrder} from '@/types/account';
|
||||||
|
import {useAuth} from '@/app/context/AuthContext';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {accountCopy} from '@/data/account';
|
||||||
|
import {getOrders} from '@/services/orders';
|
||||||
|
import OrderCard from '@/components/dashboard/OrderCard';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const {locale} = useLanguage();
|
||||||
|
const copy = accountCopy[locale];
|
||||||
|
const [orders, setOrders] = useState<CustomerOrder[] | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth.session) getOrders(auth.session.profile).then(setOrders);
|
||||||
|
}, [auth.session]);
|
||||||
|
const completion = useMemo(() => {
|
||||||
|
if (!auth.session) return 0;
|
||||||
|
const profile = auth.session.profile;
|
||||||
|
const required = [
|
||||||
|
profile.firstName,
|
||||||
|
profile.lastName,
|
||||||
|
profile.province,
|
||||||
|
profile.city,
|
||||||
|
profile.address,
|
||||||
|
profile.postalCode,
|
||||||
|
];
|
||||||
|
return Math.round((required.filter(Boolean).length / required.length) * 100);
|
||||||
|
}, [auth.session]);
|
||||||
|
if (!orders) return <Skeleton active />;
|
||||||
|
const activeOrders = orders.filter(order => !['delivered', 'cancelled'].includes(order.status)).length;
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
label: copy.profileCompletion,
|
||||||
|
value: `${completion.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')}٪`,
|
||||||
|
icon: <CheckCircleOutlined />,
|
||||||
|
extra: <Progress percent={completion} showInfo={false} size="small" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: copy.activeOrders,
|
||||||
|
value: activeOrders.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US'),
|
||||||
|
icon: <ClockCircleOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: copy.totalOrders,
|
||||||
|
value: orders.length.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US'),
|
||||||
|
icon: <FileTextOutlined />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="text-3xl font-extrabold text-primary">
|
||||||
|
{copy.welcome} {auth.session?.profile.firstName}
|
||||||
|
</h1>
|
||||||
|
<p className="text-secondary mt-3">{copy.dashboardDescription}</p>
|
||||||
|
<div className="grid sm:grid-cols-3 gap-4 mt-8">
|
||||||
|
{cards.map(card => (
|
||||||
|
<div key={card.label} className="rounded-2xl border border-subtle bg-elevated p-5">
|
||||||
|
<div className="text-brand-400 text-2xl">{card.icon}</div>
|
||||||
|
<p className="text-secondary text-sm mt-4">{card.label}</p>
|
||||||
|
<strong className="text-2xl text-primary block mt-1">{card.value}</strong>
|
||||||
|
{card.extra && <div className="mt-3">{card.extra}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 mt-10 mb-5">
|
||||||
|
<h2 className="text-xl font-bold text-primary">{copy.recentOrders}</h2>
|
||||||
|
<Link href="/dashboard/orders" className="text-brand-400">
|
||||||
|
{copy.viewAll}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{orders.slice(0, 2).map(order => (
|
||||||
|
<OrderCard key={order.id} order={order} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
import {Alert, App, Button, Form, Input, Radio} from 'antd';
|
||||||
|
import type {CustomerProfile} from '@/types/account';
|
||||||
|
import {useAuth} from '@/app/context/AuthContext';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {accountCopy} from '@/data/account';
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const {message} = App.useApp();
|
||||||
|
const {locale} = useLanguage();
|
||||||
|
const copy = accountCopy[locale];
|
||||||
|
const [form] = Form.useForm<CustomerProfile>();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const customerType = Form.useWatch('type', form);
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth.session) form.setFieldsValue(auth.session.profile);
|
||||||
|
}, [auth.session, form]);
|
||||||
|
const submit = async (values: CustomerProfile) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await auth.updateProfile(values);
|
||||||
|
message.success(copy.saved);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="text-3xl font-extrabold text-primary">{copy.profile}</h1>
|
||||||
|
<p className="text-secondary mt-3 mb-7">{copy.invoicePending}</p>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={submit}
|
||||||
|
className="rounded-2xl border border-subtle bg-elevated p-5 md:p-7"
|
||||||
|
>
|
||||||
|
<Form.Item name="type" label={copy.customerType} rules={[{required: true}]}>
|
||||||
|
<Radio.Group
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
label: copy.individual,
|
||||||
|
value: 'individual',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: copy.corporate,
|
||||||
|
value: 'corporate',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<div className="grid md:grid-cols-2 gap-x-5">
|
||||||
|
<Form.Item name="firstName" label={copy.firstName} rules={[{required: true}]}>
|
||||||
|
<Input size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="lastName" label={copy.lastName} rules={[{required: true}]}>
|
||||||
|
<Input size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="phone" label={copy.phone}>
|
||||||
|
<Input size="large" dir="ltr" disabled />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="email" label={copy.email} rules={[{type: 'email'}]}>
|
||||||
|
<Input size="large" dir="ltr" />
|
||||||
|
</Form.Item>
|
||||||
|
{customerType === 'corporate' && (
|
||||||
|
<>
|
||||||
|
<Form.Item name="companyName" label={copy.companyName} rules={[{required: true}]}>
|
||||||
|
<Input size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="nationalId" label={copy.nationalId} rules={[{required: true}]}>
|
||||||
|
<Input size="large" dir="ltr" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="economicCode" label={copy.economicCode}>
|
||||||
|
<Input size="large" dir="ltr" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Form.Item name="province" label={copy.province} rules={[{required: true}]}>
|
||||||
|
<Input size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="city" label={copy.city} rules={[{required: true}]}>
|
||||||
|
<Input size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="postalCode" label={copy.postalCode} rules={[{required: true}]}>
|
||||||
|
<Input size="large" dir="ltr" maxLength={10} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="preferredCallTime" label={copy.preferredCallTime}>
|
||||||
|
<Input size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Form.Item name="address" label={copy.address} rules={[{required: true}]}>
|
||||||
|
<Input.TextArea rows={4} />
|
||||||
|
</Form.Item>
|
||||||
|
<Alert type="info" showIcon message={copy.verifiedPhone} className="mb-6" />
|
||||||
|
<Button type="primary" htmlType="submit" size="large" loading={saving}>
|
||||||
|
{copy.save}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+129
-4
@@ -169,8 +169,9 @@ html[dir='rtl'] body {
|
|||||||
height: 190px;
|
height: 190px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 2px solid rgba(0, 166, 147, 0.9);
|
border: 2px solid rgba(0, 166, 147, 0.9);
|
||||||
box-shadow: 0 0 0 4px rgba(0, 166, 147, 0.15),
|
box-shadow:
|
||||||
0 0 40px 6px rgba(0, 166, 147, 0.45);
|
0 0 0 4px rgba(0, 166, 147, 0.15),
|
||||||
|
0 0 40px 6px rgba(0, 166, 147, 0.45);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -211,11 +212,135 @@ html[dir='rtl'] body {
|
|||||||
animation: caretBlink 0.9s steps(1) infinite;
|
animation: caretBlink 0.9s steps(1) infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invoice-document {
|
||||||
|
width: 900px;
|
||||||
|
min-height: 1120px;
|
||||||
|
padding: 48px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #12332e;
|
||||||
|
font-family: var(--font-vazirmatn), Tahoma, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-head,
|
||||||
|
.invoice-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-head {
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 2px solid #00a693;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-head img {
|
||||||
|
width: 150px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-head h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-head p,
|
||||||
|
.invoice-meta p {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-meta {
|
||||||
|
margin: 28px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-meta > div {
|
||||||
|
flex: 1;
|
||||||
|
line-height: 1.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-document table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-document th {
|
||||||
|
background: #e9f8f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-document th,
|
||||||
|
.invoice-document td {
|
||||||
|
border: 1px solid #b9ded7;
|
||||||
|
padding: 12px 9px;
|
||||||
|
text-align: start;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-document td small {
|
||||||
|
display: block;
|
||||||
|
color: #55756f;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-totals {
|
||||||
|
width: 380px;
|
||||||
|
margin-inline-start: auto;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-totals p {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 9px 0;
|
||||||
|
border-bottom: 1px solid #dcebe8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-totals .grand {
|
||||||
|
color: #006d61;
|
||||||
|
font-size: 16px;
|
||||||
|
border-bottom: 2px solid #00a693;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-note {
|
||||||
|
margin-top: 40px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f2faf8;
|
||||||
|
border-radius: 10px;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body * {
|
||||||
|
visibility: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-document,
|
||||||
|
.invoice-document * {
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-document {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-height: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-actions {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes caretBlink {
|
@keyframes caretBlink {
|
||||||
0%, 49% {
|
0%,
|
||||||
|
49% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
50%, 100% {
|
50%,
|
||||||
|
100% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-14
@@ -13,31 +13,26 @@ const vazirmatn = Vazirmatn({
|
|||||||
display: 'swap',
|
display: 'swap',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'روبات مارکت | دستگاههای وندینگ هوشمند',
|
title: 'روبات مارکت | دستگاههای وندینگ هوشمند',
|
||||||
description:
|
description: 'روبات مارکت؛ دستگاههای فروش خودکار هوشمند با مدیریت آنلاین، تحویل نرم و طراحی مدرن.',
|
||||||
'روبات مارکت؛ دستگاههای فروش خودکار هوشمند با مدیریت آنلاین، تحویل نرم و طراحی مدرن.',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: '#00a693',
|
themeColor: '#00a693',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default function RootLayout({children}: {children: ReactNode}) {
|
export default function RootLayout({children}: {children: ReactNode}) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="fa" dir="rtl" className={`${vazirmatn.variable} light`} suppressHydrationWarning>
|
<html lang="fa" dir="rtl" className={`${vazirmatn.variable} light`} suppressHydrationWarning>
|
||||||
<body className="bg-page text-primary antialiased transition-colors duration-300">
|
<body className="bg-page text-primary antialiased transition-colors duration-300">
|
||||||
<Providers>
|
<Providers>
|
||||||
<Header/>
|
<Header />
|
||||||
{children}
|
{children}
|
||||||
<Footer/>
|
<Footer />
|
||||||
<DevelopmentNotice/>
|
<DevelopmentNotice />
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useEffect} from 'react';
|
||||||
|
import {useRouter} from 'next/navigation';
|
||||||
|
import {Button} from 'antd';
|
||||||
|
import {useAuth} from '@/app/context/AuthContext';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {accountCopy} from '@/data/account';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const {locale} = useLanguage();
|
||||||
|
const copy = accountCopy[locale];
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth.isAuthenticated) router.replace('/dashboard');
|
||||||
|
else if (auth.ready) auth.openLogin(() => router.replace('/dashboard'));
|
||||||
|
}, [auth.isAuthenticated, auth.ready, auth.openLogin, router]);
|
||||||
|
return (
|
||||||
|
<main className="max-w-2xl mx-auto min-h-[70vh] px-5 pt-32 pb-20 text-center">
|
||||||
|
<div className="rounded-3xl border border-subtle bg-elevated p-10">
|
||||||
|
<h1 className="text-3xl font-extrabold text-primary">{copy.login}</h1>
|
||||||
|
<p className="text-secondary mt-3 mb-7">{copy.protectedDescription}</p>
|
||||||
|
<Button type="primary" size="large" onClick={() => auth.openLogin(() => router.replace('/dashboard'))}>
|
||||||
|
{copy.openLogin}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+6
-7
@@ -1,18 +1,17 @@
|
|||||||
import LocationsSection from '@/components/LocationsSection';
|
import LocationsSection from '@/components/LocationsSection';
|
||||||
import ShowcaseSection from "@/components/ShowcaseSection";
|
import ShowcaseSection from '@/components/ShowcaseSection';
|
||||||
import FeaturesSection from '@/components/FeaturesSection';
|
import FeaturesSection from '@/components/FeaturesSection';
|
||||||
import AboutSection from '@/components/AboutSection';
|
import AboutSection from '@/components/AboutSection';
|
||||||
import HeroSection from '@/components/HeroSection';
|
import HeroSection from '@/components/HeroSection';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="relative bg-page">
|
<main className="relative bg-page">
|
||||||
<HeroSection/>
|
<HeroSection />
|
||||||
<ShowcaseSection/>
|
<ShowcaseSection />
|
||||||
<FeaturesSection/>
|
<FeaturesSection />
|
||||||
<AboutSection/>
|
<AboutSection />
|
||||||
<LocationsSection/>
|
<LocationsSection />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import type {Metadata} from 'next';
|
|||||||
import {notFound} from 'next/navigation';
|
import {notFound} from 'next/navigation';
|
||||||
import {content} from '@/data/content';
|
import {content} from '@/data/content';
|
||||||
import ProductDetails from '@/components/ProductDetails';
|
import ProductDetails from '@/components/ProductDetails';
|
||||||
|
import ProtectedPage from '@/components/auth/ProtectedPage';
|
||||||
|
|
||||||
type Props = { params: { id: string } };
|
type Props = {params: {id: string}};
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return content.fa.machines.map(machine => ({id: machine.id}));
|
return content.fa.machines.map(machine => ({id: machine.id}));
|
||||||
@@ -17,5 +18,9 @@ export function generateMetadata({params}: Props): Metadata {
|
|||||||
|
|
||||||
export default function ProductPage({params}: Props) {
|
export default function ProductPage({params}: Props) {
|
||||||
if (!content.fa.machines.some(machine => machine.id === params.id)) notFound();
|
if (!content.fa.machines.some(machine => machine.id === params.id)) notFound();
|
||||||
return <ProductDetails id={params.id}/>;
|
return (
|
||||||
|
<ProtectedPage>
|
||||||
|
<ProductDetails id={params.id} />
|
||||||
|
</ProtectedPage>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,14 @@ export const metadata: Metadata = {title: 'همه محصولات | روبات م
|
|||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="min-h-screen pt-28 px-6" aria-busy="true"><div className="h-60 rounded-3xl bg-surface animate-pulse"/></div>}>
|
<Suspense
|
||||||
<ProductsPage/>
|
fallback={
|
||||||
|
<div className="min-h-screen pt-28 px-6" aria-busy="true">
|
||||||
|
<div className="h-60 rounded-3xl bg-surface animate-pulse" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProductsPage />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-4
@@ -5,13 +5,14 @@ import type {ReactNode} from 'react';
|
|||||||
|
|
||||||
import {Provider as ReduxProvider} from 'react-redux';
|
import {Provider as ReduxProvider} from 'react-redux';
|
||||||
import type {ThemeConfig} from 'antd';
|
import type {ThemeConfig} from 'antd';
|
||||||
import {ConfigProvider, theme as antdThemeApi} from 'antd';
|
import {App as AntdApp, ConfigProvider, theme as antdThemeApi} from 'antd';
|
||||||
import faIR from 'antd/locale/fa_IR';
|
import faIR from 'antd/locale/fa_IR';
|
||||||
import enUS from 'antd/locale/en_US';
|
import enUS from 'antd/locale/en_US';
|
||||||
import {store} from '@/store/store';
|
import {store} from '@/store/store';
|
||||||
import {ThemeProvider, useTheme} from './context/ThemeContext';
|
import {ThemeProvider, useTheme} from './context/ThemeContext';
|
||||||
import {LanguageProvider, useLanguage} from './context/LanguageContext';
|
import {LanguageProvider, useLanguage} from './context/LanguageContext';
|
||||||
import CartPersistence from '@/components/CartPersistence';
|
import CartPersistence from '@/components/CartPersistence';
|
||||||
|
import {AuthProvider} from './context/AuthContext';
|
||||||
|
|
||||||
function AntdBridge({children}: {children: ReactNode}) {
|
function AntdBridge({children}: {children: ReactNode}) {
|
||||||
const {theme} = useTheme();
|
const {theme} = useTheme();
|
||||||
@@ -43,7 +44,7 @@ function AntdBridge({children}: {children: ReactNode}) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider direction={dir} locale={locale === 'fa' ? faIR : enUS} theme={antdTheme}>
|
<ConfigProvider direction={dir} locale={locale === 'fa' ? faIR : enUS} theme={antdTheme}>
|
||||||
{children}
|
<AntdApp>{children}</AntdApp>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -51,10 +52,12 @@ function AntdBridge({children}: {children: ReactNode}) {
|
|||||||
export default function Providers({children}: {children: ReactNode}) {
|
export default function Providers({children}: {children: ReactNode}) {
|
||||||
return (
|
return (
|
||||||
<ReduxProvider store={store}>
|
<ReduxProvider store={store}>
|
||||||
<CartPersistence/>
|
<CartPersistence />
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<AntdBridge>{children}</AntdBridge>
|
<AntdBridge>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</AntdBridge>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ReduxProvider>
|
</ReduxProvider>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function AboutSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded(v => !v)}
|
||||||
className="mt-6 inline-flex items-center gap-2 text-brand-400 hover:text-brand-300 text-sm font-semibold transition-colors"
|
className="mt-6 inline-flex items-center gap-2 text-brand-400 hover:text-brand-300 text-sm font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
{expanded ? t.about.less : t.about.more}
|
{expanded ? t.about.less : t.about.more}
|
||||||
|
|||||||
+39
-13
@@ -19,13 +19,17 @@ export default function CartPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-7xl min-h-[65vh] px-5 md:px-8 pt-28 pb-20">
|
<main className="mx-auto max-w-7xl min-h-[65vh] px-5 md:px-8 pt-28 pb-20">
|
||||||
<Link href="/products" className="text-brand-400 hover:underline">{copy.continueShopping}</Link>
|
<Link href="/products" className="text-brand-400 hover:underline">
|
||||||
|
{copy.continueShopping}
|
||||||
|
</Link>
|
||||||
<h1 className="text-3xl md:text-4xl font-extrabold text-primary mt-6">{t.cart.title}</h1>
|
<h1 className="text-3xl md:text-4xl font-extrabold text-primary mt-6">{t.cart.title}</h1>
|
||||||
<p className="text-secondary mt-3 mb-10">{copy.cartDescription}</p>
|
<p className="text-secondary mt-3 mb-10">{copy.cartDescription}</p>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="rounded-3xl border border-subtle bg-surface p-10 text-center">
|
<div className="rounded-3xl border border-subtle bg-surface p-10 text-center">
|
||||||
<Empty description={t.cart.empty}/>
|
<Empty description={t.cart.empty} />
|
||||||
<Link href="/products" className="inline-block mt-6 text-brand-400 hover:underline">{copy.continueShopping}</Link>
|
<Link href="/products" className="inline-block mt-6 text-brand-400 hover:underline">
|
||||||
|
{copy.continueShopping}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid lg:grid-cols-[minmax(0,1fr),340px] gap-8 items-start">
|
<div className="grid lg:grid-cols-[minmax(0,1fr),340px] gap-8 items-start">
|
||||||
@@ -33,20 +37,40 @@ export default function CartPage() {
|
|||||||
{items.map(item => {
|
{items.map(item => {
|
||||||
const product = t.machines.find(machine => machine.id === item.id) ?? item;
|
const product = t.machines.find(machine => machine.id === item.id) ?? item;
|
||||||
return (
|
return (
|
||||||
<article key={item.id} className="rounded-2xl border border-subtle bg-surface p-5 flex flex-wrap sm:flex-nowrap gap-5 items-center">
|
<article
|
||||||
|
key={item.id}
|
||||||
|
className="rounded-2xl border border-subtle bg-surface p-5 flex flex-wrap sm:flex-nowrap gap-5 items-center"
|
||||||
|
>
|
||||||
<Link href={`/products/${item.id}`} className="shrink-0">
|
<Link href={`/products/${item.id}`} className="shrink-0">
|
||||||
<img src={product.image} alt={product.name} className="w-24 h-32 object-contain"/>
|
<img
|
||||||
|
src={product.image}
|
||||||
|
alt={product.name}
|
||||||
|
className="w-24 h-32 object-contain"
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Link href={`/products/${item.id}`} className="font-bold text-lg text-primary hover:text-brand-400">{product.name}</Link>
|
<Link
|
||||||
<p className="text-secondary text-sm mt-2">{copy.unitPrice}: {money(item.priceNumeric)}</p>
|
href={`/products/${item.id}`}
|
||||||
|
className="font-bold text-lg text-primary hover:text-brand-400"
|
||||||
|
>
|
||||||
|
{product.name}
|
||||||
|
</Link>
|
||||||
|
<p className="text-secondary text-sm mt-2">
|
||||||
|
{copy.unitPrice}: {money(item.priceNumeric)}
|
||||||
|
</p>
|
||||||
<div className="flex flex-wrap gap-3 items-center mt-4">
|
<div className="flex flex-wrap gap-3 items-center mt-4">
|
||||||
<QuantityControl product={product}/>
|
<QuantityControl product={product} />
|
||||||
<Button danger type="text" icon={<DeleteOutlined/>}
|
<Button
|
||||||
aria-label={`${copy.remove}: ${product.name}`}
|
danger
|
||||||
onClick={() => dispatch(removeFromCart(item.id))}/>
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
aria-label={`${copy.remove}: ${product.name}`}
|
||||||
|
onClick={() => dispatch(removeFromCart(item.id))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-primary font-semibold">{copy.lineTotal}: {money(item.qty * item.priceNumeric)}</p>
|
<p className="mt-4 text-primary font-semibold">
|
||||||
|
{copy.lineTotal}: {money(item.qty * item.priceNumeric)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -59,7 +83,9 @@ export default function CartPage() {
|
|||||||
<strong className="text-primary">{money(total)}</strong>
|
<strong className="text-primary">{money(total)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-secondary text-sm leading-7 my-5">{copy.checkoutUnavailable}</p>
|
<p className="text-secondary text-sm leading-7 my-5">{copy.checkoutUnavailable}</p>
|
||||||
<Button type="primary" size="large" disabled block>{t.cart.checkout}</Button>
|
<Button type="primary" size="large" disabled block>
|
||||||
|
{t.cart.checkout}
|
||||||
|
</Button>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,13 +19,25 @@ export default function CartPersistence() {
|
|||||||
if (Array.isArray(saved)) {
|
if (Array.isArray(saved)) {
|
||||||
const items: CartItem[] = [];
|
const items: CartItem[] = [];
|
||||||
for (const entry of saved) {
|
for (const entry of saved) {
|
||||||
if (!entry || typeof entry !== 'object' || typeof entry.id !== 'string'
|
if (
|
||||||
|| !Number.isSafeInteger(entry.qty) || entry.qty < 1) continue;
|
!entry ||
|
||||||
|
typeof entry !== 'object' ||
|
||||||
|
typeof entry.id !== 'string' ||
|
||||||
|
!Number.isSafeInteger(entry.qty) ||
|
||||||
|
entry.qty < 1
|
||||||
|
)
|
||||||
|
continue;
|
||||||
const machine = content.fa.machines.find(item => item.id === entry.id);
|
const machine = content.fa.machines.find(item => item.id === entry.id);
|
||||||
if (!machine || items.some(item => item.id === machine.id)) continue;
|
if (!machine || items.some(item => item.id === machine.id)) continue;
|
||||||
// Restore identifiers and quantities; use current catalog prices and images.
|
// Restore identifiers and quantities; use current catalog prices and images.
|
||||||
items.push({id: machine.id, name: machine.name, image: machine.image,
|
items.push({
|
||||||
priceNumeric: machine.priceNumeric, priceLabel: machine.priceLabel, qty: entry.qty});
|
id: machine.id,
|
||||||
|
name: machine.name,
|
||||||
|
image: machine.image,
|
||||||
|
priceNumeric: machine.priceNumeric,
|
||||||
|
priceLabel: machine.priceLabel,
|
||||||
|
qty: entry.qty,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
store.dispatch(restoreCart(items));
|
store.dispatch(restoreCart(items));
|
||||||
}
|
}
|
||||||
@@ -35,7 +47,10 @@ export default function CartPersistence() {
|
|||||||
|
|
||||||
return store.subscribe(() => {
|
return store.subscribe(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(store.getState().cart.items.map(({id, qty}) => ({id, qty}))));
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify(store.getState().cart.items.map(({id, qty}) => ({id, qty}))),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the in-memory cart usable when storage is full or disabled.
|
// Keep the in-memory cart usable when storage is full or disabled.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,18 @@ export default function DevelopmentNotice() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} centered title={copy.developmentTitle} onCancel={dismiss}
|
<Modal
|
||||||
footer={<Button type="primary" onClick={dismiss}>{copy.acknowledge}</Button>}>
|
open={open}
|
||||||
<ToolOutlined className="text-brand-400 text-3xl my-4"/>
|
centered
|
||||||
|
title={copy.developmentTitle}
|
||||||
|
onCancel={dismiss}
|
||||||
|
footer={
|
||||||
|
<Button type="primary" onClick={dismiss}>
|
||||||
|
{copy.acknowledge}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ToolOutlined className="text-brand-400 text-3xl my-4" />
|
||||||
<p className="text-secondary leading-8">{copy.developmentMessage}</p>
|
<p className="text-secondary leading-8">{copy.developmentMessage}</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,43 +2,38 @@
|
|||||||
|
|
||||||
// Service benefits: warranty, consultation, installments, and packaging.
|
// Service benefits: warranty, consultation, installments, and packaging.
|
||||||
|
|
||||||
import {
|
import {SafetyCertificateOutlined, CustomerServiceOutlined, CreditCardOutlined, InboxOutlined} from '@ant-design/icons';
|
||||||
SafetyCertificateOutlined,
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
CustomerServiceOutlined,
|
|
||||||
CreditCardOutlined,
|
|
||||||
InboxOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { useLanguage } from '@/app/context/LanguageContext';
|
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
safety: SafetyCertificateOutlined,
|
safety: SafetyCertificateOutlined,
|
||||||
consulting: CustomerServiceOutlined,
|
consulting: CustomerServiceOutlined,
|
||||||
installment: CreditCardOutlined,
|
installment: CreditCardOutlined,
|
||||||
packaging: InboxOutlined,
|
packaging: InboxOutlined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FeaturesSection() {
|
export default function FeaturesSection() {
|
||||||
const { t } = useLanguage();
|
const {t} = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative px-6 md:px-16 py-16 md:py-20 bg-surface border-t border-subtle">
|
<section className="relative px-6 md:px-16 py-16 md:py-20 bg-surface border-t border-subtle">
|
||||||
<div className="max-w-6xl mx-auto grid sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
<div className="max-w-6xl mx-auto grid sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
{t.features.map((feature) => {
|
{t.features.map(feature => {
|
||||||
const Icon = ICONS[feature.icon];
|
const Icon = ICONS[feature.icon];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={feature.id}
|
key={feature.id}
|
||||||
className="group bg-surface border border-subtle rounded-2xl p-6 text-center hover:border-brand-500/60 hover:bg-surface-strong transition-colors"
|
className="group bg-surface border border-subtle rounded-2xl p-6 text-center hover:border-brand-500/60 hover:bg-surface-strong transition-colors"
|
||||||
>
|
>
|
||||||
<div className="mx-auto mb-4 w-14 h-14 rounded-xl bg-brand/15 border border-subtle flex items-center justify-center text-brand-400 text-2xl group-hover:bg-brand/25 transition-colors">
|
<div className="mx-auto mb-4 w-14 h-14 rounded-xl bg-brand/15 border border-subtle flex items-center justify-center text-brand-400 text-2xl group-hover:bg-brand/25 transition-colors">
|
||||||
{Icon && <Icon />}
|
{Icon && <Icon />}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-primary font-bold text-base mb-2">{feature.title}</h3>
|
<h3 className="text-primary font-bold text-base mb-2">{feature.title}</h3>
|
||||||
<p className="text-secondary text-sm leading-6">{feature.description}</p>
|
<p className="text-secondary text-sm leading-6">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
</section>
|
||||||
})}
|
);
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ export default function FloatingItems({items}: {items: FloatingSnack[]}) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => tweens.forEach((t) => t.kill());
|
return () => tweens.forEach(t => t.kill());
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="absolute inset-0 pointer-events-none hidden md:block">
|
<div ref={containerRef} className="absolute inset-0 pointer-events-none hidden md:block">
|
||||||
{items.map((item) => (
|
{items.map(item => (
|
||||||
<img
|
<img
|
||||||
key={item.id}
|
key={item.id}
|
||||||
data-float-item
|
data-float-item
|
||||||
|
|||||||
+81
-65
@@ -4,77 +4,93 @@
|
|||||||
|
|
||||||
import {commerceCopy} from '@/data/commerce';
|
import {commerceCopy} from '@/data/commerce';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
import {PhoneOutlined, EnvironmentOutlined} from '@ant-design/icons';
|
||||||
import { useLanguage } from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const { t, locale } = useLanguage();
|
const {t, locale} = useLanguage();
|
||||||
const c = t.contactInfo;
|
const c = t.contactInfo;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer id="contact" className="relative border-t border-subtle bg-elevated">
|
<footer id="contact" className="relative border-t border-subtle bg-elevated">
|
||||||
<div className="max-w-6xl mx-auto px-6 md:px-16 py-14 grid md:grid-cols-3 gap-10 text-center md:text-start">
|
<div className="max-w-6xl mx-auto px-6 md:px-16 py-14 grid md:grid-cols-3 gap-10 text-center md:text-start">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-primary font-bold text-lg mb-3">{t.footer.aboutLabel}</h3>
|
<h3 className="text-primary font-bold text-lg mb-3">{t.footer.aboutLabel}</h3>
|
||||||
<p className="text-secondary text-sm leading-7">{t.footer.aboutText}</p>
|
<p className="text-secondary text-sm leading-7">{t.footer.aboutText}</p>
|
||||||
<div className="flex flex-col gap-2 mt-4">
|
<div className="flex flex-col gap-2 mt-4">
|
||||||
{c.socialLinks.map((link) => (
|
{c.socialLinks.map(link => (
|
||||||
<a
|
<a
|
||||||
key={link.id}
|
key={link.id}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-brand-400 hover:text-brand-300 text-sm transition-colors"
|
className="text-brand-400 hover:text-brand-300 text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-primary font-bold text-lg mb-3">{t.footer.quickLinks}</h3>
|
<h3 className="text-primary font-bold text-lg mb-3">{t.footer.quickLinks}</h3>
|
||||||
<ul className="flex flex-col gap-2 text-sm text-secondary">
|
<ul className="flex flex-col gap-2 text-sm text-secondary">
|
||||||
<li><Link href="/#hero" className="hover:text-brand-400">{t.nav.home}</Link></li>
|
<li>
|
||||||
<li><Link href="/products" className="hover:text-brand-400">{commerceCopy[locale].allProducts}</Link></li>
|
<Link href="/#hero" className="hover:text-brand-400">
|
||||||
<li><Link href="/#about" className="hover:text-brand-400">{t.nav.about}</Link></li>
|
{t.nav.home}
|
||||||
<li><Link href="/#locations" className="hover:text-brand-400">{t.nav.locations}</Link></li>
|
</Link>
|
||||||
</ul>
|
</li>
|
||||||
</div>
|
<li>
|
||||||
|
<Link href="/products" className="hover:text-brand-400">
|
||||||
|
{commerceCopy[locale].allProducts}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/#about" className="hover:text-brand-400">
|
||||||
|
{t.nav.about}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/#locations" className="hover:text-brand-400">
|
||||||
|
{t.nav.locations}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-primary font-bold text-lg mb-3">{t.footer.contactTitle}</h3>
|
<h3 className="text-primary font-bold text-lg mb-3">{t.footer.contactTitle}</h3>
|
||||||
<div className="flex flex-col gap-4 text-sm text-secondary">
|
<div className="flex flex-col gap-4 text-sm text-secondary">
|
||||||
<a
|
<a
|
||||||
href={`tel:${c.orderPhoneHref}`}
|
href={`tel:${c.orderPhoneHref}`}
|
||||||
className="flex items-center justify-center md:justify-start gap-2 hover:text-brand-400 transition-colors"
|
className="flex items-center justify-center md:justify-start gap-2 hover:text-brand-400 transition-colors"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
>
|
>
|
||||||
<PhoneOutlined />
|
<PhoneOutlined />
|
||||||
<span>{c.orderPhone}</span>
|
<span>{c.orderPhone}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div className="flex items-start justify-center md:justify-start gap-2">
|
<div className="flex items-start justify-center md:justify-start gap-2">
|
||||||
<EnvironmentOutlined className="mt-0.5 shrink-0" />
|
<EnvironmentOutlined className="mt-0.5 shrink-0" />
|
||||||
<div className="text-start">
|
<div className="text-start">
|
||||||
<p className="text-primary-90 font-semibold mb-1">{t.footer.salesLabel}</p>
|
<p className="text-primary-90 font-semibold mb-1">{t.footer.salesLabel}</p>
|
||||||
<p className="leading-7">{c.salesOfficeAddress}</p>
|
<p className="leading-7">{c.salesOfficeAddress}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-center md:justify-start gap-2">
|
||||||
|
<EnvironmentOutlined className="mt-0.5 shrink-0" />
|
||||||
|
<div className="text-start">
|
||||||
|
<p className="text-primary-90 font-semibold mb-1">{t.footer.factoryLabel}</p>
|
||||||
|
<p className="leading-7">{c.factoryAddress}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="border-t border-subtle py-5 text-center text-xs text-secondary">
|
||||||
<div className="flex items-start justify-center md:justify-start gap-2">
|
© {new Date().getFullYear()} {t.footer.aboutLabel}. {t.footer.rights}
|
||||||
<EnvironmentOutlined className="mt-0.5 shrink-0" />
|
|
||||||
<div className="text-start">
|
|
||||||
<p className="text-primary-90 font-semibold mb-1">{t.footer.factoryLabel}</p>
|
|
||||||
<p className="leading-7">{c.factoryAddress}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</footer>
|
||||||
</div>
|
);
|
||||||
</div>
|
|
||||||
<div className="border-t border-subtle py-5 text-center text-xs text-secondary">
|
|
||||||
© {new Date().getFullYear()} {t.footer.aboutLabel}. {t.footer.rights}
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-42
@@ -12,6 +12,9 @@ import {useAppSelector} from '@/store/hooks';
|
|||||||
import {selectCartCount} from '@/store/cartSlice';
|
import {selectCartCount} from '@/store/cartSlice';
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
import {commerceCopy} from '@/data/commerce';
|
import {commerceCopy} from '@/data/commerce';
|
||||||
|
import {accountCopy} from '@/data/account';
|
||||||
|
import {useAuth} from '@/app/context/AuthContext';
|
||||||
|
import ProtectedLink from './auth/ProtectedLink';
|
||||||
import ThemeToggle from './ThemeToggle';
|
import ThemeToggle from './ThemeToggle';
|
||||||
import LanguageToggle from './LanguageToggle';
|
import LanguageToggle from './LanguageToggle';
|
||||||
|
|
||||||
@@ -19,6 +22,8 @@ export default function Header() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const cartCount = useAppSelector(selectCartCount);
|
const cartCount = useAppSelector(selectCartCount);
|
||||||
const {t, locale} = useLanguage();
|
const {t, locale} = useLanguage();
|
||||||
|
const auth = useAuth();
|
||||||
|
const account = accountCopy[locale];
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
@@ -28,6 +33,9 @@ export default function Header() {
|
|||||||
{href: '/#about', label: t.nav.about},
|
{href: '/#about', label: t.nav.about},
|
||||||
{href: '/#locations', label: t.nav.locations},
|
{href: '/#locations', label: t.nav.locations},
|
||||||
];
|
];
|
||||||
|
const accountLabel = auth.session?.profile.firstName
|
||||||
|
? `${auth.session.profile.firstName} ${auth.session.profile.lastName}`.trim()
|
||||||
|
: auth.session?.profile.phone || account.login;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => setScrolled(window.scrollY > 24);
|
const onScroll = () => setScrolled(window.scrollY > 24);
|
||||||
@@ -36,24 +44,23 @@ export default function Header() {
|
|||||||
return () => window.removeEventListener('scroll', onScroll);
|
return () => window.removeEventListener('scroll', onScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
console.log(
|
console.log('%c👋 Hamed Abdollahzade · Frontend Developer', 'font-size:14px;font-weight:600;');
|
||||||
"%c👋 Hamed Abdollahzade · Frontend Developer",
|
|
||||||
"font-size:14px;font-weight:600;"
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={`fixed top-0 inset-x-0 z-50 transition-all duration-300 ${
|
className={`fixed top-0 inset-x-0 z-50 transition-all duration-300 ${
|
||||||
scrolled || pathname !== '/' ? 'bg-page-85 backdrop-blur-md shadow-[0_1px_0_0_rgba(0,166,147,0.25)]' : 'bg-transparent'
|
scrolled || pathname !== '/'
|
||||||
|
? 'bg-page-85 backdrop-blur-md shadow-[0_1px_0_0_rgba(0,166,147,0.25)]'
|
||||||
|
: 'bg-transparent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mx-auto max-w-7xl px-5 md:px-8 h-20 flex items-center justify-between gap-4">
|
<div className="mx-auto max-w-7xl px-5 md:px-8 h-20 flex items-center justify-between gap-4">
|
||||||
<Link href="/#hero" onClick={() => setMobileOpen(false)} className="flex items-center gap-2 shrink-0">
|
<Link href="/#hero" onClick={() => setMobileOpen(false)} className="flex items-center gap-2 shrink-0">
|
||||||
<Image src="/images/icons/logo.png" alt="روبات مارکت" width={150} height={42} priority/>
|
<Image src="/images/icons/logo.png" alt="روبات مارکت" width={150} height={42} priority />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden xl:flex items-center gap-5 2xl:gap-7">
|
<nav className="hidden xl:flex items-center gap-5 2xl:gap-7">
|
||||||
{navLinks.map((link) => (
|
{navLinks.map(link => (
|
||||||
<Link
|
<Link
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
@@ -66,41 +73,47 @@ export default function Header() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-2 md:gap-3">
|
<div className="flex items-center gap-2 md:gap-3">
|
||||||
<div className="hidden md:flex items-center gap-2">
|
<div className="hidden md:flex items-center gap-2">
|
||||||
<LanguageToggle/>
|
<LanguageToggle />
|
||||||
<ThemeToggle/>
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
{/*FAZ1*/}
|
||||||
|
{/*{auth.isAuthenticated ? (*/}
|
||||||
|
{/* <Button className="hidden sm:inline-flex" icon={<UserOutlined />} href="/dashboard">*/}
|
||||||
|
{/* {accountLabel}*/}
|
||||||
|
{/* </Button>*/}
|
||||||
|
{/*) : (*/}
|
||||||
|
{/* <Button*/}
|
||||||
|
{/* className="hidden sm:inline-flex"*/}
|
||||||
|
{/* icon={<UserOutlined />}*/}
|
||||||
|
{/* onClick={() => auth.openLogin()}*/}
|
||||||
|
{/* >*/}
|
||||||
|
{/* {account.login}*/}
|
||||||
|
{/* </Button>*/}
|
||||||
|
{/*)}*/}
|
||||||
|
|
||||||
<Button
|
{/*<Badge count={cartCount} size="small" offset={[-2, 2]}>*/}
|
||||||
className="hidden sm:inline-flex"
|
{/* <ProtectedLink*/}
|
||||||
icon={<UserOutlined/>}
|
{/* href="/cart"*/}
|
||||||
href="https://panel.my-rm.com"
|
{/* onClick={() => setMobileOpen(false)}*/}
|
||||||
target="_blank"
|
{/* className="w-11 h-11 rounded-full border border-subtle bg-elevated inline-flex items-center justify-center text-xl text-primary hover:text-brand-400"*/}
|
||||||
rel="noopener noreferrer"
|
{/* >*/}
|
||||||
>
|
{/* <ShoppingCartOutlined />*/}
|
||||||
{t.nav.login}
|
{/* </ProtectedLink>*/}
|
||||||
</Button>
|
{/*</Badge>*/}
|
||||||
|
|
||||||
<Badge count={cartCount} size="small" offset={[-2, 2]}>
|
|
||||||
<Link href="/cart" onClick={() => setMobileOpen(false)} aria-label={t.cart.title}
|
|
||||||
className="w-11 h-11 rounded-full border border-subtle bg-elevated inline-flex items-center justify-center text-xl text-primary hover:text-brand-400">
|
|
||||||
<ShoppingCartOutlined/>
|
|
||||||
</Link>
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="xl:hidden text-brand-400 text-2xl"
|
className="xl:hidden text-brand-400 text-2xl"
|
||||||
onClick={() => setMobileOpen((v) => !v)}
|
onClick={() => setMobileOpen(v => !v)}
|
||||||
aria-label="menu"
|
aria-label="menu"
|
||||||
>
|
>
|
||||||
{mobileOpen ? <CloseOutlined/> : <MenuOutlined/>}
|
{mobileOpen ? <CloseOutlined /> : <MenuOutlined />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mobileOpen && (
|
{mobileOpen && (
|
||||||
<nav
|
<nav className="xl:hidden bg-elevated-95 backdrop-blur-md border-t border-subtle px-5 py-4 flex flex-col gap-4 max-h-[calc(100vh-5rem)] overflow-y-auto">
|
||||||
className="xl:hidden bg-elevated-95 backdrop-blur-md border-t border-subtle px-5 py-4 flex flex-col gap-4 max-h-[calc(100vh-5rem)] overflow-y-auto">
|
{navLinks.map(link => (
|
||||||
{navLinks.map((link) => (
|
|
||||||
<Link
|
<Link
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
@@ -111,18 +124,30 @@ export default function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<div className="flex items-center gap-3 pt-2 border-t border-subtle">
|
<div className="flex items-center gap-3 pt-2 border-t border-subtle">
|
||||||
<LanguageToggle/>
|
<LanguageToggle />
|
||||||
<ThemeToggle/>
|
<ThemeToggle />
|
||||||
<Button
|
{/*FAZ1*/}
|
||||||
icon={<UserOutlined/>}
|
{/*{auth.isAuthenticated ? (*/}
|
||||||
className="flex-1"
|
{/* <Button*/}
|
||||||
href="https://panel.my-rm.com"
|
{/* icon={<UserOutlined />}*/}
|
||||||
target="_blank"
|
{/* className="flex-1"*/}
|
||||||
rel="noopener noreferrer"
|
{/* href="/dashboard"*/}
|
||||||
onClick={() => setMobileOpen(false)}
|
{/* onClick={() => setMobileOpen(false)}*/}
|
||||||
>
|
{/* >*/}
|
||||||
{t.nav.login}
|
{/* {accountLabel}*/}
|
||||||
</Button>
|
{/* </Button>*/}
|
||||||
|
{/*) : (*/}
|
||||||
|
{/* <Button*/}
|
||||||
|
{/* icon={<UserOutlined />}*/}
|
||||||
|
{/* className="flex-1"*/}
|
||||||
|
{/* onClick={() => {*/}
|
||||||
|
{/* setMobileOpen(false);*/}
|
||||||
|
{/* auth.openLogin();*/}
|
||||||
|
{/* }}*/}
|
||||||
|
{/* >*/}
|
||||||
|
{/* {account.login}*/}
|
||||||
|
{/* </Button>*/}
|
||||||
|
{/*)}*/}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+28
-18
@@ -5,22 +5,23 @@
|
|||||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
import {LeftOutlined, RightOutlined} from '@ant-design/icons';
|
import {LeftOutlined, RightOutlined} from '@ant-design/icons';
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {accountCopy} from '@/data/account';
|
||||||
|
|
||||||
const AUTOPLAY_DELAY = 5000; // Advance every five seconds.
|
const AUTOPLAY_DELAY = 5000; // Advance every five seconds.
|
||||||
|
|
||||||
export default function HeroSection() {
|
export default function HeroSection() {
|
||||||
const {t, dir} = useLanguage();
|
const {t, dir, locale} = useLanguage();
|
||||||
const slides = t.hero.slides;
|
const slides = t.hero.slides;
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
// Wrap around after the last slide.
|
// Wrap around after the last slide.
|
||||||
const goToNext = useCallback(() => {
|
const goToNext = useCallback(() => {
|
||||||
setActiveIndex((prev) => (prev + 1) % slides.length);
|
setActiveIndex(prev => (prev + 1) % slides.length);
|
||||||
}, [slides.length]);
|
}, [slides.length]);
|
||||||
|
|
||||||
const goToPrev = useCallback(() => {
|
const goToPrev = useCallback(() => {
|
||||||
setActiveIndex((prev) => (prev - 1 + slides.length) % slides.length);
|
setActiveIndex(prev => (prev - 1 + slides.length) % slides.length);
|
||||||
}, [slides.length]);
|
}, [slides.length]);
|
||||||
|
|
||||||
// Restart the autoplay timer after manual navigation.
|
// Restart the autoplay timer after manual navigation.
|
||||||
@@ -93,26 +94,35 @@ export default function HeroSection() {
|
|||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
{/* Active slide headline, description, and call to action. */}
|
{/* Active slide headline, description, and call to action. */}
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
<div
|
<div className="relative z-10 h-full flex flex-col justify-end md:justify-end px-6 md:px-16 pb-28 md:pb-72">
|
||||||
className="relative z-10 h-full flex flex-col justify-end md:justify-end px-6 md:px-16 pb-28 md:pb-72">
|
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
<span className="text-brand-400 font-semibold tracking-widest text-sm uppercase">
|
<span className="text-brand-400 font-semibold tracking-widest text-sm uppercase">
|
||||||
{slide.kicker}
|
{slide.kicker}
|
||||||
</span>
|
</span>
|
||||||
<h1 className="mt-3 text-3xl sm:text-4xl md:text-6xl font-extrabold leading-tight text-white drop-shadow-[0_4px_12px_rgba(0,0,0,0.9)]">
|
<h1 className="mt-3 text-3xl sm:text-4xl md:text-6xl font-extrabold leading-tight text-white drop-shadow-[0_4px_12px_rgba(0,0,0,0.9)]">
|
||||||
{slide.titleLines.map((line, i) => (
|
{slide.titleLines.map((line, i) => (
|
||||||
<span key={i} className="block">
|
<span key={i} className="block">
|
||||||
{line}
|
{line}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-5 text-white/90 text-base md:text-lg max-w-xl drop-shadow-lg">{slide.sub}</p>
|
<p className="mt-5 text-white/90 text-base md:text-lg max-w-xl drop-shadow-lg">{slide.sub}</p>
|
||||||
<a
|
<div className="mt-8 flex flex-wrap gap-3">
|
||||||
href="#showcase"
|
<a
|
||||||
className="mt-8 inline-flex items-center gap-2 bg-brand hover:bg-brand-400 text-ink-950 font-bold px-8 py-3.5 rounded-full transition-colors shadow-brandGlow"
|
href="#showcase"
|
||||||
>
|
className="inline-flex items-center gap-2 bg-brand hover:bg-brand-400 text-ink-950 font-bold px-8 py-3.5 rounded-full transition-colors shadow-brandGlow"
|
||||||
{t.hero.cta}
|
>
|
||||||
</a>
|
{t.hero.cta}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://panel.my-rm.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white border border-white/30 font-bold px-8 py-3.5 rounded-full transition-colors backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
{accountCopy[locale].robotMasterPanel}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -124,14 +134,14 @@ export default function HeroSection() {
|
|||||||
aria-label="previous slide"
|
aria-label="previous slide"
|
||||||
className="hidden sm:flex absolute top-1/2 -translate-y-1/2 start-4 z-20 w-11 h-11 rounded-full bg-page-70 border border-subtle items-center justify-center text-primary hover:bg-brand hover:text-ink-950 transition-colors"
|
className="hidden sm:flex absolute top-1/2 -translate-y-1/2 start-4 z-20 w-11 h-11 rounded-full bg-page-70 border border-subtle items-center justify-center text-primary hover:bg-brand hover:text-ink-950 transition-colors"
|
||||||
>
|
>
|
||||||
<RightOutlined/>
|
<RightOutlined />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleManualNav('next')}
|
onClick={() => handleManualNav('next')}
|
||||||
aria-label="next slide"
|
aria-label="next slide"
|
||||||
className="hidden sm:flex absolute top-1/2 -translate-y-1/2 end-4 z-20 w-11 h-11 rounded-full bg-page-70 border border-subtle items-center justify-center text-primary hover:bg-brand hover:text-ink-950 transition-colors"
|
className="hidden sm:flex absolute top-1/2 -translate-y-1/2 end-4 z-20 w-11 h-11 rounded-full bg-page-70 border border-subtle items-center justify-center text-primary hover:bg-brand hover:text-ink-950 transition-colors"
|
||||||
>
|
>
|
||||||
<LeftOutlined/>
|
<LeftOutlined />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Jump directly to a slide using the indicators. */}
|
{/* Jump directly to a slide using the indicators. */}
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ export default function HorizontalShowcase() {
|
|||||||
|
|
||||||
if (!track || !section) return;
|
if (!track || !section) return;
|
||||||
|
|
||||||
const scrollDistance = () => (
|
const scrollDistance = () => Math.max(track.scrollWidth - window.innerWidth, 0);
|
||||||
Math.max(track.scrollWidth - window.innerWidth, 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
let animationFrameId: number | null = null;
|
let animationFrameId: number | null = null;
|
||||||
let latestProgress = 0;
|
let latestProgress = 0;
|
||||||
@@ -57,7 +55,7 @@ export default function HorizontalShowcase() {
|
|||||||
scrub: 1,
|
scrub: 1,
|
||||||
pin: true,
|
pin: true,
|
||||||
invalidateOnRefresh: true,
|
invalidateOnRefresh: true,
|
||||||
onUpdate: (self) => syncVideo(self.progress),
|
onUpdate: self => syncVideo(self.progress),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,18 +95,22 @@ export default function HorizontalShowcase() {
|
|||||||
<div className="relative z-20 h-40 pt-20 bg-page-85 backdrop-blur-md">
|
<div className="relative z-20 h-40 pt-20 bg-page-85 backdrop-blur-md">
|
||||||
<div className="max-w-7xl h-full mx-auto px-8 flex justify-between items-center gap-4">
|
<div className="max-w-7xl h-full mx-auto px-8 flex justify-between items-center gap-4">
|
||||||
<h2 className="text-2xl font-bold text-primary">{copy.featuredProducts}</h2>
|
<h2 className="text-2xl font-bold text-primary">{copy.featuredProducts}</h2>
|
||||||
<Link href="/products" className="text-brand-400 hover:underline">{copy.allProducts}</Link>
|
<Link href="/products" className="text-brand-400 hover:underline">
|
||||||
|
{copy.allProducts}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={trackRef}
|
<div
|
||||||
className="horizontal-track relative z-10 [&_.horizontal-panel]:h-full [&_.spotlight-stage]:max-h-[calc(100vh-12rem)]"
|
ref={trackRef}
|
||||||
style={{height: 'calc(100vh - 10rem)'}} dir="ltr">
|
className="horizontal-track relative z-10 [&_.horizontal-panel]:h-full [&_.spotlight-stage]:max-h-[calc(100vh-12rem)]"
|
||||||
{featuredMachines(t.machines).map((machine) => (
|
style={{height: 'calc(100vh - 10rem)'}}
|
||||||
<VendingCard key={machine.id} machine={machine}/>
|
dir="ltr"
|
||||||
|
>
|
||||||
|
{featuredMachines(t.machines).map(machine => (
|
||||||
|
<VendingCard key={machine.id} machine={machine} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
|
|
||||||
// Switch between Persian and English.
|
// Switch between Persian and English.
|
||||||
|
|
||||||
import { Button, Tooltip } from 'antd';
|
import {Button, Tooltip} from 'antd';
|
||||||
import { useLanguage } from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
|
||||||
export default function LanguageToggle() {
|
export default function LanguageToggle() {
|
||||||
const { locale, toggleLocale } = useLanguage();
|
const {locale, toggleLocale} = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title={locale === 'fa' ? 'Switch to English' : 'تغییر به فارسی'}>
|
<Tooltip title={locale === 'fa' ? 'Switch to English' : 'تغییر به فارسی'}>
|
||||||
<Button shape="circle" size="large" onClick={toggleLocale} aria-label="toggle language">
|
<Button shape="circle" size="large" onClick={toggleLocale} aria-label="toggle language">
|
||||||
{locale === 'fa' ? 'EN' : 'فا'}
|
{locale === 'fa' ? 'EN' : 'فا'}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,52 +3,52 @@
|
|||||||
// Suggested installation locations for vending machines.
|
// Suggested installation locations for vending machines.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
HomeOutlined,
|
HomeOutlined,
|
||||||
ReadOutlined,
|
ReadOutlined,
|
||||||
ShopOutlined,
|
ShopOutlined,
|
||||||
MedicineBoxOutlined,
|
MedicineBoxOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useLanguage } from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
hotel: HomeOutlined,
|
hotel: HomeOutlined,
|
||||||
school: ReadOutlined,
|
school: ReadOutlined,
|
||||||
pharmacy: ShopOutlined,
|
pharmacy: ShopOutlined,
|
||||||
hospital: MedicineBoxOutlined,
|
hospital: MedicineBoxOutlined,
|
||||||
night: ClockCircleOutlined,
|
night: ClockCircleOutlined,
|
||||||
specialty: AppstoreOutlined,
|
specialty: AppstoreOutlined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LocationsSection() {
|
export default function LocationsSection() {
|
||||||
const { t } = useLanguage();
|
const {t} = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="locations" className="relative px-6 md:px-16 py-20 md:py-28 bg-surface border-t border-subtle">
|
<section id="locations" className="relative px-6 md:px-16 py-20 md:py-28 bg-surface border-t border-subtle">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text">{t.locations.title}</h2>
|
<h2 className="text-2xl md:text-4xl font-extrabold brand-gradient-text">{t.locations.title}</h2>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
||||||
{t.locations.items.map((loc) => {
|
|
||||||
const Icon = ICONS[loc.icon];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={loc.id}
|
|
||||||
className="bg-page-60 border border-subtle rounded-2xl p-6 hover:border-brand-500/60 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-brand/15 border border-subtle flex items-center justify-center text-brand-400 text-xl mb-4">
|
|
||||||
{Icon && <Icon />}
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-primary font-bold text-base mb-2">{loc.title}</h3>
|
|
||||||
<p className="text-secondary text-sm leading-7">{loc.description}</p>
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
</div>
|
{t.locations.items.map(loc => {
|
||||||
);
|
const Icon = ICONS[loc.icon];
|
||||||
})}
|
return (
|
||||||
</div>
|
<div
|
||||||
</div>
|
key={loc.id}
|
||||||
</section>
|
className="bg-page-60 border border-subtle rounded-2xl p-6 hover:border-brand-500/60 transition-colors"
|
||||||
);
|
>
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-brand/15 border border-subtle flex items-center justify-center text-brand-400 text-xl mb-4">
|
||||||
|
{Icon && <Icon />}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-primary font-bold text-base mb-2">{loc.title}</h3>
|
||||||
|
<p className="text-secondary text-sm leading-7">{loc.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ export default function MobileShowcase() {
|
|||||||
<section id="showcase" className="relative w-full py-8 px-4" dir={dir}>
|
<section id="showcase" className="relative w-full py-8 px-4" dir={dir}>
|
||||||
<div className="flex gap-4 overflow-x-auto snap-x snap-mandatory pb-4">
|
<div className="flex gap-4 overflow-x-auto snap-x snap-mandatory pb-4">
|
||||||
{featuredMachines(t.machines).map(machine => (
|
{featuredMachines(t.machines).map(machine => (
|
||||||
<article key={machine.id} className="w-[90%] shrink-0 snap-start bg-surface border border-subtle rounded-2xl p-4">
|
<article
|
||||||
|
key={machine.id}
|
||||||
|
className="w-[90%] shrink-0 snap-start bg-surface border border-subtle rounded-2xl p-4"
|
||||||
|
>
|
||||||
<Link href={`/products/${machine.id}`} className="flex gap-4 items-center">
|
<Link href={`/products/${machine.id}`} className="flex gap-4 items-center">
|
||||||
<img src={machine.image} alt={machine.name} className="w-24 h-28 object-contain shrink-0"/>
|
<img src={machine.image} alt={machine.name} className="w-24 h-28 object-contain shrink-0" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="text-brand-400 text-xs font-semibold">{machine.shortLabel}</span>
|
<span className="text-brand-400 text-xs font-semibold">{machine.shortLabel}</span>
|
||||||
<h3 className="text-lg font-bold text-primary mt-1">{machine.name}</h3>
|
<h3 className="text-lg font-bold text-primary mt-1">{machine.name}</h3>
|
||||||
@@ -24,7 +27,7 @@ export default function MobileShowcase() {
|
|||||||
</Link>
|
</Link>
|
||||||
<p className="text-base font-bold text-primary my-4">{machine.priceLabel}</p>
|
<p className="text-base font-bold text-primary my-4">{machine.priceLabel}</p>
|
||||||
<div className="flex flex-wrap justify-between items-center gap-3">
|
<div className="flex flex-wrap justify-between items-center gap-3">
|
||||||
<QuantityControl product={machine}/>
|
<QuantityControl product={machine} />
|
||||||
<Link href={`/products/${machine.id}`} className="text-sm text-brand-400 hover:underline">
|
<Link href={`/products/${machine.id}`} className="text-sm text-brand-400 hover:underline">
|
||||||
{commerceCopy[locale].details}
|
{commerceCopy[locale].details}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -17,40 +17,67 @@ export default function ProductDetails({id}: {id: string}) {
|
|||||||
return (
|
return (
|
||||||
<main className="max-w-7xl mx-auto px-5 md:px-8 pt-28 pb-20">
|
<main className="max-w-7xl mx-auto px-5 md:px-8 pt-28 pb-20">
|
||||||
<nav aria-label={copy.details} className="text-sm text-secondary flex flex-wrap gap-3 mb-8">
|
<nav aria-label={copy.details} className="text-sm text-secondary flex flex-wrap gap-3 mb-8">
|
||||||
<Link href="/" className="hover:text-brand-400">{t.nav.home}</Link>
|
<Link href="/" className="hover:text-brand-400">
|
||||||
|
{t.nav.home}
|
||||||
|
</Link>
|
||||||
<span aria-hidden="true">/</span>
|
<span aria-hidden="true">/</span>
|
||||||
<Link href="/products" className="hover:text-brand-400">{t.nav.showcase}</Link>
|
<Link href="/products" className="hover:text-brand-400">
|
||||||
<span aria-hidden="true">/</span><span>{machine.shortLabel}</span>
|
{t.nav.showcase}
|
||||||
|
</Link>
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
<span>{machine.shortLabel}</span>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="grid md:grid-cols-2 gap-10 lg:gap-16 items-start">
|
<div className="grid md:grid-cols-2 gap-10 lg:gap-16 items-start">
|
||||||
<section aria-label={copy.gallery} className="rounded-3xl border border-subtle bg-surface p-6">
|
<section aria-label={copy.gallery} className="rounded-3xl border border-subtle bg-surface p-6">
|
||||||
<img src={alternate ? machine.imageAlt : machine.image} alt={`${machine.name} — ${alternate ? copy.alternateImage : copy.baseImage}`}
|
<img
|
||||||
className="w-full h-72 md:h-[450px] object-contain"/>
|
src={alternate ? machine.imageAlt : machine.image}
|
||||||
|
alt={`${machine.name} — ${alternate ? copy.alternateImage : copy.baseImage}`}
|
||||||
|
className="w-full h-72 md:h-[450px] object-contain"
|
||||||
|
/>
|
||||||
<div className="flex justify-center gap-3 mt-6">
|
<div className="flex justify-center gap-3 mt-6">
|
||||||
{[false, true].map(value => (
|
{[false, true].map(value => (
|
||||||
<button key={String(value)} onClick={() => setAlternate(value)} aria-pressed={alternate === value}
|
<button
|
||||||
aria-label={value ? copy.alternateImage : copy.baseImage}
|
key={String(value)}
|
||||||
className={`rounded-xl border p-2 ${alternate === value ? 'border-brand bg-brand/10' : 'border-subtle'}`}>
|
onClick={() => setAlternate(value)}
|
||||||
<img src={value ? machine.imageAlt : machine.image} alt="" className="h-20 w-20 object-contain"/>
|
aria-pressed={alternate === value}
|
||||||
|
aria-label={value ? copy.alternateImage : copy.baseImage}
|
||||||
|
className={`rounded-xl border p-2 ${alternate === value ? 'border-brand bg-brand/10' : 'border-subtle'}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={value ? machine.imageAlt : machine.image}
|
||||||
|
alt=""
|
||||||
|
className="h-20 w-20 object-contain"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<p className="text-brand-400 font-semibold">{machine.shortLabel}</p>
|
<p className="text-brand-400 font-semibold">{machine.shortLabel}</p>
|
||||||
<h1 className="text-3xl lg:text-4xl font-extrabold text-primary mt-3 leading-relaxed">{machine.name}</h1>
|
<h1 className="text-3xl lg:text-4xl font-extrabold text-primary mt-3 leading-relaxed">
|
||||||
|
{machine.name}
|
||||||
|
</h1>
|
||||||
<p className="text-secondary mt-5 leading-8">{machine.description}</p>
|
<p className="text-secondary mt-5 leading-8">{machine.description}</p>
|
||||||
<p className="text-sm text-secondary mt-4">{copy.model}: <span dir="ltr">{machine.id.toUpperCase()}</span></p>
|
<p className="text-sm text-secondary mt-4">
|
||||||
|
{copy.model}: <span dir="ltr">{machine.id.toUpperCase()}</span>
|
||||||
|
</p>
|
||||||
<h2 className="font-bold text-primary text-xl mt-8 mb-4">{copy.specifications}</h2>
|
<h2 className="font-bold text-primary text-xl mt-8 mb-4">{copy.specifications}</h2>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{machine.highlights.map(feature => <li key={feature} className="flex gap-3 text-secondary"><CheckCircleOutlined className="text-brand-400"/>{feature}</li>)}
|
{machine.highlights.map(feature => (
|
||||||
|
<li key={feature} className="flex gap-3 text-secondary">
|
||||||
|
<CheckCircleOutlined className="text-brand-400" />
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="border-t border-subtle mt-8 pt-6">
|
<div className="border-t border-subtle mt-8 pt-6">
|
||||||
<p className="text-sm text-secondary">{copy.unitPrice}</p>
|
<p className="text-sm text-secondary">{copy.unitPrice}</p>
|
||||||
<p className="text-2xl font-bold text-primary mt-2">{machine.priceLabel}</p>
|
<p className="text-2xl font-bold text-primary mt-2">{machine.priceLabel}</p>
|
||||||
<div className="mt-5 flex flex-wrap gap-4 items-center">
|
<div className="mt-5 flex flex-wrap gap-4 items-center">
|
||||||
<QuantityControl product={machine}/>
|
<QuantityControl product={machine} />
|
||||||
<Link href="/cart" className="text-brand-400 font-semibold hover:underline">{copy.cartLink}</Link>
|
<Link href="/cart" className="text-brand-400 font-semibold hover:underline">
|
||||||
|
{copy.cartLink}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-secondary text-sm leading-7 mt-5">{copy.checkoutUnavailable}</p>
|
<p className="text-secondary text-sm leading-7 mt-5">{copy.checkoutUnavailable}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+45
-14
@@ -32,15 +32,29 @@ export default function ProductsPage() {
|
|||||||
<p className="text-secondary mt-4 leading-7">{copy.productsDescription}</p>
|
<p className="text-secondary mt-4 leading-7">{copy.productsDescription}</p>
|
||||||
<div className="mt-8 mb-8 rounded-2xl border border-subtle bg-surface p-5 flex flex-wrap items-end gap-4">
|
<div className="mt-8 mb-8 rounded-2xl border border-subtle bg-surface p-5 flex flex-wrap items-end gap-4">
|
||||||
<div className="flex flex-col gap-2 w-full sm:w-72">
|
<div className="flex flex-col gap-2 w-full sm:w-72">
|
||||||
<label htmlFor="product-category" className="text-sm font-semibold text-primary">{copy.category}</label>
|
<label htmlFor="product-category" className="text-sm font-semibold text-primary">
|
||||||
<select id="product-category" value={category} onChange={event => setCategory(event.target.value)}
|
{copy.category}
|
||||||
className="w-full border border-subtle bg-elevated text-primary rounded-xl px-4 py-3 focus:outline-brand">
|
</label>
|
||||||
|
<select
|
||||||
|
id="product-category"
|
||||||
|
value={category}
|
||||||
|
onChange={event => setCategory(event.target.value)}
|
||||||
|
className="w-full border border-subtle bg-elevated text-primary rounded-xl px-4 py-3 focus:outline-brand"
|
||||||
|
>
|
||||||
<option value="">{copy.allCategories}</option>
|
<option value="">{copy.allCategories}</option>
|
||||||
{category && !knownCategory && <option value={category}>{copy.unknownCategory}</option>}
|
{category && !knownCategory && <option value={category}>{copy.unknownCategory}</option>}
|
||||||
{productCategories.map(item => <option key={item.id} value={item.id}>{item.label[locale]}</option>)}
|
{productCategories.map(item => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.label[locale]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{category && <button onClick={() => setCategory('')} className="text-brand-400 py-3 hover:underline">{copy.clearFilters}</button>}
|
{category && (
|
||||||
|
<button onClick={() => setCategory('')} className="text-brand-400 py-3 hover:underline">
|
||||||
|
{copy.clearFilters}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<p aria-live="polite" aria-atomic="true" className="text-secondary text-sm py-3 sm:ms-auto">
|
<p aria-live="polite" aria-atomic="true" className="text-secondary text-sm py-3 sm:ms-auto">
|
||||||
{products.length.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')} {copy.results}
|
{products.length.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')} {copy.results}
|
||||||
</p>
|
</p>
|
||||||
@@ -48,22 +62,39 @@ export default function ProductsPage() {
|
|||||||
{products.length ? (
|
{products.length ? (
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{products.map(machine => (
|
{products.map(machine => (
|
||||||
<article key={machine.id} className="flex flex-col rounded-3xl border border-subtle bg-surface p-5">
|
<article
|
||||||
|
key={machine.id}
|
||||||
|
className="flex flex-col rounded-3xl border border-subtle bg-surface p-5"
|
||||||
|
>
|
||||||
<Link href={`/products/${machine.id}`} className="rounded-2xl bg-page p-5">
|
<Link href={`/products/${machine.id}`} className="rounded-2xl bg-page p-5">
|
||||||
<img src={machine.image} alt={machine.name} className="w-full h-60 object-contain"/>
|
<img src={machine.image} alt={machine.name} className="w-full h-60 object-contain" />
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-brand-400 text-xs mt-5">{productCategories.find(item => item.id === machine.category)?.label[locale]}</span>
|
<span className="text-brand-400 text-xs mt-5">
|
||||||
<h2 className="text-xl font-bold text-primary mt-2"><Link href={`/products/${machine.id}`}>{machine.name}</Link></h2>
|
{productCategories.find(item => item.id === machine.category)?.label[locale]}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-xl font-bold text-primary mt-2">
|
||||||
|
<Link href={`/products/${machine.id}`}>{machine.name}</Link>
|
||||||
|
</h2>
|
||||||
<p className="text-secondary text-sm leading-7 mt-3 mb-5">{machine.description}</p>
|
<p className="text-secondary text-sm leading-7 mt-3 mb-5">{machine.description}</p>
|
||||||
<p className="text-primary text-lg font-bold mt-auto mb-4">{machine.priceLabel}</p>
|
<p className="text-primary text-lg font-bold mt-auto mb-4">{machine.priceLabel}</p>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
{/*FAZ1*/}
|
||||||
<QuantityControl product={machine}/>
|
{/*<div className="flex flex-wrap items-center justify-between gap-3">*/}
|
||||||
<Link href={`/products/${machine.id}`} className="text-brand-400 text-sm hover:underline">{copy.details}</Link>
|
{/* <QuantityControl product={machine} />*/}
|
||||||
</div>
|
{/* <Link*/}
|
||||||
|
{/* href={`/products/${machine.id}`}*/}
|
||||||
|
{/* className="text-brand-400 text-sm hover:underline"*/}
|
||||||
|
{/* >*/}
|
||||||
|
{/* {copy.details}*/}
|
||||||
|
{/* </Link>*/}
|
||||||
|
{/*</div>*/}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : <div className="text-center text-secondary border border-subtle rounded-2xl p-12">{copy.noProducts}</div>}
|
) : (
|
||||||
|
<div className="text-center text-secondary border border-subtle rounded-2xl p-12">
|
||||||
|
{copy.noProducts}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,26 +7,50 @@ import {useAppDispatch, useAppSelector} from '@/store/hooks';
|
|||||||
import {addToCart, decrementQty} from '@/store/cartSlice';
|
import {addToCart, decrementQty} from '@/store/cartSlice';
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
import {commerceCopy} from '@/data/commerce';
|
import {commerceCopy} from '@/data/commerce';
|
||||||
|
import {useAuth} from '@/app/context/AuthContext';
|
||||||
|
|
||||||
export default function QuantityControl({product}: {product: CartProduct}) {
|
export default function QuantityControl({product}: {product: CartProduct}) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const {locale} = useLanguage();
|
const {locale} = useLanguage();
|
||||||
const copy = commerceCopy[locale];
|
const copy = commerceCopy[locale];
|
||||||
|
const auth = useAuth();
|
||||||
const quantity = useAppSelector(state => state.cart.items.find(item => item.id === product.id)?.qty ?? 0);
|
const quantity = useAppSelector(state => state.cart.items.find(item => item.id === product.id)?.qty ?? 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div role="group" aria-label={`${copy.quantity}: ${product.name}`}
|
<div
|
||||||
className="inline-flex items-center gap-3 rounded-xl border border-subtle bg-page p-1" dir="ltr">
|
role="group"
|
||||||
<Button icon={<MinusOutlined/>} disabled={quantity === 0}
|
aria-label={`${copy.quantity}: ${product.name}`}
|
||||||
aria-label={`${copy.decrease}: ${product.name}`}
|
className="inline-flex items-center gap-3 rounded-xl border border-subtle bg-page p-1"
|
||||||
onClick={() => dispatch(decrementQty(product.id))}/>
|
dir="ltr"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<MinusOutlined />}
|
||||||
|
disabled={quantity === 0}
|
||||||
|
aria-label={`${copy.decrease}: ${product.name}`}
|
||||||
|
onClick={() => (auth.isAuthenticated ? dispatch(decrementQty(product.id)) : auth.openLogin())}
|
||||||
|
/>
|
||||||
<span aria-live="polite" aria-atomic="true" className="min-w-8 text-center font-bold text-primary">
|
<span aria-live="polite" aria-atomic="true" className="min-w-8 text-center font-bold text-primary">
|
||||||
{quantity.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')}
|
{quantity.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')}
|
||||||
</span>
|
</span>
|
||||||
<Button type="primary" icon={<PlusOutlined/>}
|
<Button
|
||||||
aria-label={`${copy.increase}: ${product.name}`}
|
type="primary"
|
||||||
onClick={() => dispatch(addToCart({id: product.id, name: product.name, image: product.image,
|
icon={<PlusOutlined />}
|
||||||
priceLabel: product.priceLabel, priceNumeric: product.priceNumeric}))}/>
|
aria-label={`${copy.increase}: ${product.name}`}
|
||||||
|
onClick={() => {
|
||||||
|
const add = () =>
|
||||||
|
dispatch(
|
||||||
|
addToCart({
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
image: product.image,
|
||||||
|
priceLabel: product.priceLabel,
|
||||||
|
priceNumeric: product.priceNumeric,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (auth.isAuthenticated) add();
|
||||||
|
else auth.openLogin(add);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ export default function ShowcaseSection() {
|
|||||||
const [isMobile, setIsMobile] = useState<boolean | null>(null);
|
const [isMobile, setIsMobile] = useState<boolean | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mediaQuery = window.matchMedia(
|
const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||||
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChange = (event: MediaQueryListEvent) => setIsMobile(event.matches);
|
const handleChange = (event: MediaQueryListEvent) => setIsMobile(event.matches);
|
||||||
|
|
||||||
@@ -28,15 +26,17 @@ export default function ShowcaseSection() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (isMobile === null) return null;
|
if (isMobile === null) return null;
|
||||||
if (!isMobile) return <HorizontalShowcase/>;
|
if (!isMobile) return <HorizontalShowcase />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="max-w-7xl mx-auto px-5 md:px-8 py-8 flex flex-wrap justify-between items-center gap-4">
|
<div className="max-w-7xl mx-auto px-5 md:px-8 py-8 flex flex-wrap justify-between items-center gap-4">
|
||||||
<h2 className="text-2xl font-bold text-primary">{copy.featuredProducts}</h2>
|
<h2 className="text-2xl font-bold text-primary">{copy.featuredProducts}</h2>
|
||||||
<Link href="/products" className="text-brand-400 hover:underline">{copy.allProducts}</Link>
|
<Link href="/products" className="text-brand-400 hover:underline">
|
||||||
|
{copy.allProducts}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<MobileShowcase/>
|
<MobileShowcase />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-16
@@ -1,22 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import {Button, Tooltip} from 'antd';
|
||||||
import { Button, Tooltip } from 'antd';
|
import {SunOutlined, MoonOutlined} from '@ant-design/icons';
|
||||||
import { SunOutlined, MoonOutlined } from '@ant-design/icons';
|
import {useTheme} from '@/app/context/ThemeContext';
|
||||||
import { useTheme } from '@/app/context/ThemeContext';
|
|
||||||
|
|
||||||
export default function ThemeToggle() {
|
export default function ThemeToggle() {
|
||||||
const { theme, toggleTheme } = useTheme();
|
const {theme, toggleTheme} = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title={theme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
<Tooltip title={theme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
||||||
<Button
|
<Button
|
||||||
shape="circle"
|
shape="circle"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
aria-label="toggle theme"
|
aria-label="toggle theme"
|
||||||
icon={theme === 'dark' ? <SunOutlined /> : <MoonOutlined />}
|
icon={theme === 'dark' ? <SunOutlined /> : <MoonOutlined />}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-20
@@ -1,11 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
import type {Machine} from '@/types/domain';
|
import type {Machine} from '@/types/domain';
|
||||||
import type {MouseEvent} from 'react';
|
import type {MouseEvent} from 'react';
|
||||||
import {useRef} from 'react';
|
import {useRef} from 'react';
|
||||||
import Link from 'next/link';
|
|
||||||
import QuantityControl from './QuantityControl';
|
import QuantityControl from './QuantityControl';
|
||||||
|
import ProtectedLink from './auth/ProtectedLink';
|
||||||
import {commerceCopy} from '@/data/commerce';
|
import {commerceCopy} from '@/data/commerce';
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
import FloatingItems from './FloatingItems';
|
import FloatingItems from './FloatingItems';
|
||||||
@@ -45,29 +44,27 @@ export default function VendingCard({machine}: {machine: Machine}) {
|
|||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
className="spotlight-stage relative h-48 md:h-[500px] flex items-center justify-center"
|
className="spotlight-stage relative h-48 md:h-[500px] flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<FloatingItems items={floatingSnacks}/>
|
<FloatingItems items={floatingSnacks} />
|
||||||
|
|
||||||
<img src={machine.image} alt={machine.name} className="base-layer z-10"/>
|
<img src={machine.image} alt={machine.name} className="base-layer z-10" />
|
||||||
<img
|
<img
|
||||||
ref={revealRef}
|
ref={revealRef}
|
||||||
src={machine.imageAlt}
|
src={machine.imageAlt}
|
||||||
alt={`${machine.name} - ${t.machineActions.altVariant}`}
|
alt={`${machine.name} - ${t.machineActions.altVariant}`}
|
||||||
className="reveal-layer z-20"
|
className="reveal-layer z-20"
|
||||||
/>
|
/>
|
||||||
<div ref={lensRef} className="spotlight-lens"/>
|
<div ref={lensRef} className="spotlight-lens" />
|
||||||
|
|
||||||
<span
|
<span className="absolute hidden md:block top-3 z-30 text-[11px] px-3 py-1 rounded-full bg-brand/15 border border-subtle text-brand-400 start-3">
|
||||||
className="absolute hidden md:block top-3 z-30 text-[11px] px-3 py-1 rounded-full bg-brand/15 border border-subtle text-brand-400 start-3">
|
{t.machineActions.hoverHint}
|
||||||
{t.machineActions.hoverHint}
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col justify-center items-center gap-5 ">
|
<div className="flex flex-col justify-center items-center gap-5 ">
|
||||||
<span className="text-brand-400 text-sm font-semibold tracking-wide">{machine.shortLabel}</span>
|
<span className="text-brand-400 text-sm font-semibold tracking-wide">{machine.shortLabel}</span>
|
||||||
<h3 className="text-2xl md:text-4xl font-extrabold text-primary">{machine.name}</h3>
|
<h3 className="text-2xl md:text-4xl font-extrabold text-primary">{machine.name}</h3>
|
||||||
|
|
||||||
<div
|
<div className="grid grid-cols-[auto,1fr] gap-4 items-start bg-surface border border-subtle rounded-2xl p-4">
|
||||||
className="grid grid-cols-[auto,1fr] gap-4 items-start bg-surface border border-subtle rounded-2xl p-4">
|
|
||||||
<img
|
<img
|
||||||
src={machine.gif}
|
src={machine.gif}
|
||||||
alt={t.machineActions.gifAlt(machine.name)}
|
alt={t.machineActions.gifAlt(machine.name)}
|
||||||
@@ -77,9 +74,11 @@ export default function VendingCard({machine}: {machine: Machine}) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="flex flex-wrap gap-2">
|
<ul className="flex flex-wrap gap-2">
|
||||||
{machine.highlights.map((h) => (
|
{machine.highlights.map(h => (
|
||||||
<li key={h}
|
<li
|
||||||
className="text-xs md:text-sm text-brand-400 bg-brand/10 border border-subtle rounded-full px-3 py-1">
|
key={h}
|
||||||
|
className="text-xs md:text-sm text-brand-400 bg-brand/10 border border-subtle rounded-full px-3 py-1"
|
||||||
|
>
|
||||||
{h}
|
{h}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -87,12 +86,16 @@ export default function VendingCard({machine}: {machine: Machine}) {
|
|||||||
|
|
||||||
<p className="text-xl md:text-2xl font-bold text-primary mt-2">{machine.priceLabel}</p>
|
<p className="text-xl md:text-2xl font-bold text-primary mt-2">{machine.priceLabel}</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-2 ">
|
{/*FAZ1*/}
|
||||||
<QuantityControl product={machine}/>
|
{/*<div className="flex flex-wrap items-center gap-2 mt-2 ">*/}
|
||||||
<Link href={`/products/${machine.id}`} className="px-5 py-3 rounded-xl border border-subtle text-primary hover:text-brand-400">
|
{/* <QuantityControl product={machine} />*/}
|
||||||
{commerceCopy[locale].details}
|
{/* <ProtectedLink*/}
|
||||||
</Link>
|
{/* href={`/products/${machine.id}`}*/}
|
||||||
</div>
|
{/* className="px-5 py-3 rounded-xl border border-subtle text-primary hover:text-brand-400"*/}
|
||||||
|
{/* >*/}
|
||||||
|
{/* {commerceCopy[locale].details}*/}
|
||||||
|
{/* </ProtectedLink>*/}
|
||||||
|
{/*</div>*/}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
import {Alert, App, Button, Form, Input, Modal, Segmented, Typography} from 'antd';
|
||||||
|
import {LockOutlined, MobileOutlined, SafetyCertificateOutlined} from '@ant-design/icons';
|
||||||
|
import {useAuth} from '@/app/context/AuthContext';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {accountCopy} from '@/data/account';
|
||||||
|
|
||||||
|
type LoginMode = 'otp' | 'password';
|
||||||
|
|
||||||
|
export default function AuthModal() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const {message} = App.useApp();
|
||||||
|
const {locale} = useLanguage();
|
||||||
|
const copy = accountCopy[locale];
|
||||||
|
const [mode, setMode] = useState<LoginMode>('otp');
|
||||||
|
const [step, setStep] = useState<'phone' | 'code'>('phone');
|
||||||
|
const [phone, setPhone] = useState('09123456789');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth.loginOpen) {
|
||||||
|
setMode('otp');
|
||||||
|
setStep('phone');
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
}, [auth.loginOpen]);
|
||||||
|
|
||||||
|
const submit = async (values: {phone?: string; code?: string; password?: string}) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const currentPhone = (values.phone || phone).replace(/\s/g, '');
|
||||||
|
if (mode === 'otp' && step === 'phone') {
|
||||||
|
await auth.sendOtp(currentPhone);
|
||||||
|
setPhone(currentPhone);
|
||||||
|
setStep('code');
|
||||||
|
message.success(copy.otpSent);
|
||||||
|
} else if (mode === 'otp') {
|
||||||
|
await auth.verifyOtp(currentPhone, values.code || '');
|
||||||
|
} else {
|
||||||
|
await auth.loginPassword(currentPhone, values.password || '');
|
||||||
|
}
|
||||||
|
} catch (caught) {
|
||||||
|
setError(
|
||||||
|
caught instanceof Error && caught.message === 'INVALID_PHONE'
|
||||||
|
? copy.invalidPhone
|
||||||
|
: copy.invalidCredentials,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={auth.loginOpen}
|
||||||
|
onCancel={auth.closeLogin}
|
||||||
|
footer={null}
|
||||||
|
centered
|
||||||
|
title={copy.login}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Segmented<LoginMode>
|
||||||
|
block
|
||||||
|
value={mode}
|
||||||
|
onChange={value => {
|
||||||
|
setMode(value);
|
||||||
|
setStep('phone');
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{label: copy.otpLogin, value: 'otp'},
|
||||||
|
{
|
||||||
|
label: copy.passwordLogin,
|
||||||
|
value: 'password',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Alert
|
||||||
|
className="mt-5"
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message={copy.demoTitle}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<div>{copy.demoOtp}</div>
|
||||||
|
<div>{copy.demoPassword}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{error && <Alert className="mt-4" type="error" showIcon message={error} />}
|
||||||
|
<Form
|
||||||
|
layout="vertical"
|
||||||
|
className="mt-5"
|
||||||
|
onFinish={submit}
|
||||||
|
initialValues={{phone: '09123456789', code: '12345', password: '123456'}}
|
||||||
|
>
|
||||||
|
{(mode === 'password' || step === 'phone') && (
|
||||||
|
<Form.Item
|
||||||
|
name="phone"
|
||||||
|
label={copy.phone}
|
||||||
|
rules={[{required: true, pattern: /^09\d{9}$/, message: copy.invalidPhone}]}
|
||||||
|
>
|
||||||
|
<Input dir="ltr" size="large" maxLength={11} prefix={<MobileOutlined />} />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
{mode === 'password' && (
|
||||||
|
<Form.Item name="password" label={copy.password} rules={[{required: true}]}>
|
||||||
|
<Input.Password dir="ltr" size="large" prefix={<LockOutlined />} />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
{mode === 'otp' && step === 'code' && (
|
||||||
|
<>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
{copy.phone}: <span dir="ltr">{phone}</span>
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Form.Item name="code" label={copy.otp} rules={[{required: true}]}>
|
||||||
|
<Input dir="ltr" size="large" maxLength={5} prefix={<SafetyCertificateOutlined />} />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button htmlType="submit" type="primary" size="large" block loading={loading}>
|
||||||
|
{mode === 'otp' ? (step === 'phone' ? copy.sendOtp : copy.verifyAndLogin) : copy.loginButton}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type {MouseEvent, ReactNode} from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {useRouter} from 'next/navigation';
|
||||||
|
import {useAuth} from '@/app/context/AuthContext';
|
||||||
|
|
||||||
|
export default function ProtectedLink({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
const auth = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
onClick?.();
|
||||||
|
if (!auth.isAuthenticated) {
|
||||||
|
event.preventDefault();
|
||||||
|
auth.openLogin(() => router.push(href));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Link href={href} className={className} onClick={handleClick}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type {ReactNode} from 'react';
|
||||||
|
import {useEffect, useRef} from 'react';
|
||||||
|
import {Button, Skeleton} from 'antd';
|
||||||
|
import {LockOutlined} from '@ant-design/icons';
|
||||||
|
import {useAuth} from '@/app/context/AuthContext';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {accountCopy} from '@/data/account';
|
||||||
|
|
||||||
|
export default function ProtectedPage({children}: {children: ReactNode}) {
|
||||||
|
const auth = useAuth();
|
||||||
|
const {locale} = useLanguage();
|
||||||
|
const copy = accountCopy[locale];
|
||||||
|
const prompted = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth.ready && !auth.isAuthenticated && !prompted.current) {
|
||||||
|
prompted.current = true;
|
||||||
|
auth.openLogin();
|
||||||
|
}
|
||||||
|
}, [auth]);
|
||||||
|
|
||||||
|
if (!auth.ready)
|
||||||
|
return (
|
||||||
|
<main className="max-w-5xl mx-auto min-h-[70vh] px-5 pt-32">
|
||||||
|
<Skeleton active />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
if (!auth.isAuthenticated)
|
||||||
|
return (
|
||||||
|
<main className="max-w-3xl mx-auto min-h-[70vh] px-5 pt-32 pb-20 text-center">
|
||||||
|
<div className="rounded-3xl border border-subtle bg-surface p-10">
|
||||||
|
<LockOutlined className="text-4xl text-brand-400" />
|
||||||
|
<h1 className="text-2xl font-extrabold text-primary mt-5">{copy.protectedTitle}</h1>
|
||||||
|
<p className="text-secondary mt-3 mb-7">{copy.protectedDescription}</p>
|
||||||
|
<Button type="primary" size="large" onClick={() => auth.openLogin()}>
|
||||||
|
{copy.openLogin}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type {ReactNode} from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {usePathname, useRouter} from 'next/navigation';
|
||||||
|
import {Button} from 'antd';
|
||||||
|
import {DashboardOutlined, FileTextOutlined, LogoutOutlined, UserOutlined} from '@ant-design/icons';
|
||||||
|
import {useAuth} from '@/app/context/AuthContext';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {accountCopy} from '@/data/account';
|
||||||
|
|
||||||
|
export default function DashboardShell({children}: {children: ReactNode}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const auth = useAuth();
|
||||||
|
const {locale} = useLanguage();
|
||||||
|
const copy = accountCopy[locale];
|
||||||
|
const links = [
|
||||||
|
{href: '/dashboard', label: copy.dashboard, icon: <DashboardOutlined />},
|
||||||
|
{href: '/dashboard/orders', label: copy.orders, icon: <FileTextOutlined />},
|
||||||
|
{href: '/dashboard/profile', label: copy.profile, icon: <UserOutlined />},
|
||||||
|
];
|
||||||
|
const active = (href: string) => (href === '/dashboard' ? pathname === href : pathname.startsWith(href));
|
||||||
|
return (
|
||||||
|
<main className="max-w-7xl mx-auto min-h-[75vh] px-5 md:px-8 pt-28 pb-20">
|
||||||
|
<div className="grid lg:grid-cols-[240px,minmax(0,1fr)] gap-7 items-start">
|
||||||
|
<aside className="lg:sticky lg:top-28 rounded-2xl border border-subtle bg-elevated p-4">
|
||||||
|
<div className="px-3 py-3 border-b border-subtle mb-3">
|
||||||
|
<p className="font-bold text-primary">{auth.session?.profile.firstName || copy.account}</p>
|
||||||
|
<p className="text-sm text-secondary mt-1" dir="ltr">
|
||||||
|
{auth.session?.profile.phone}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<nav className="flex lg:flex-col gap-2 overflow-x-auto no-scrollbar">
|
||||||
|
{links.map(link => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className={`flex items-center gap-2 whitespace-nowrap px-4 py-3 rounded-xl transition-colors ${active(link.href) ? 'bg-brand text-ink-950 font-bold' : 'text-secondary hover:bg-surface-strong hover:text-primary'}`}
|
||||||
|
>
|
||||||
|
{link.icon}
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<Button
|
||||||
|
className="mt-3"
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<LogoutOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
auth.logout();
|
||||||
|
router.push('/');
|
||||||
|
}}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
{copy.logout}
|
||||||
|
</Button>
|
||||||
|
</aside>
|
||||||
|
<section className="min-w-0">{children}</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {forwardRef} from 'react';
|
||||||
|
import type {CustomerOrder} from '@/types/account';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {accountCopy} from '@/data/account';
|
||||||
|
|
||||||
|
const InvoiceDocument = forwardRef<HTMLDivElement, {order: CustomerOrder}>(({order}, ref) => {
|
||||||
|
const {locale} = useLanguage();
|
||||||
|
const copy = accountCopy[locale];
|
||||||
|
const invoice = order.invoice!;
|
||||||
|
const numberLocale = locale === 'fa' ? 'fa-IR' : 'en-US';
|
||||||
|
const money = (value: number) => `${value.toLocaleString(numberLocale)} ${locale === 'fa' ? 'ریال' : 'Rials'}`;
|
||||||
|
const subtotal = order.items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
|
||||||
|
const total = subtotal - invoice.discount + invoice.shipping + invoice.tax;
|
||||||
|
const buyerName =
|
||||||
|
order.customer.type === 'corporate'
|
||||||
|
? order.customer.companyName
|
||||||
|
: `${order.customer.firstName} ${order.customer.lastName}`;
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="invoice-document" dir={locale === 'fa' ? 'rtl' : 'ltr'}>
|
||||||
|
<div className="invoice-head">
|
||||||
|
<img src="/images/icons/logo.png" alt="Robot Market" />
|
||||||
|
<div>
|
||||||
|
<h2>{copy.invoice}</h2>
|
||||||
|
<p>
|
||||||
|
{copy.invoiceNumber}: <span dir="ltr">{invoice.number}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="invoice-meta">
|
||||||
|
<div>
|
||||||
|
<strong>{copy.seller}</strong>
|
||||||
|
<p>{copy.sellerName}</p>
|
||||||
|
<p>{copy.sellerPhone}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{copy.buyer}</strong>
|
||||||
|
<p>{buyerName}</p>
|
||||||
|
<p dir="ltr">{order.customer.phone}</p>
|
||||||
|
<p>
|
||||||
|
{order.customer.city}، {order.customer.address}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
{copy.issueDate}: {invoice.issueDate}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{copy.validUntil}: {invoice.validUntil}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>{copy.orderItems}</th>
|
||||||
|
<th>{copy.modules}</th>
|
||||||
|
<th>{copy.quantity}</th>
|
||||||
|
<th>{copy.unitPrice}</th>
|
||||||
|
<th>{copy.total}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{order.items.map((item, index) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>{index + 1}</td>
|
||||||
|
<td>
|
||||||
|
{item.name}
|
||||||
|
<small>{item.model}</small>
|
||||||
|
</td>
|
||||||
|
<td>{item.modules.join('، ')}</td>
|
||||||
|
<td>{item.quantity}</td>
|
||||||
|
<td>{money(item.unitPrice)}</td>
|
||||||
|
<td>{money(item.unitPrice * item.quantity)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="invoice-totals">
|
||||||
|
<p>
|
||||||
|
<span>{copy.subtotal}</span>
|
||||||
|
<strong>{money(subtotal)}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span>{copy.discount}</span>
|
||||||
|
<strong>{money(invoice.discount)}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span>{copy.shipping}</span>
|
||||||
|
<strong>{money(invoice.shipping)}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span>{copy.tax}</span>
|
||||||
|
<strong>{money(invoice.tax)}</strong>
|
||||||
|
</p>
|
||||||
|
<p className="grand">
|
||||||
|
<span>{copy.finalTotal}</span>
|
||||||
|
<strong>{money(total)}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="invoice-note">این پیشفاکتور پس از هماهنگی تلفنی و تأیید ادمین روبات مارکت صادر شده است.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
InvoiceDocument.displayName = 'InvoiceDocument';
|
||||||
|
export default InvoiceDocument;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type {CustomerOrder} from '@/types/account';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {accountCopy, paymentStatusLabels} from '@/data/account';
|
||||||
|
import OrderStatusTag from './OrderStatusTag';
|
||||||
|
|
||||||
|
export default function OrderCard({order}: {order: CustomerOrder}) {
|
||||||
|
const {locale} = useLanguage();
|
||||||
|
const copy = accountCopy[locale];
|
||||||
|
return (
|
||||||
|
<article className="rounded-2xl border border-subtle bg-elevated p-5">
|
||||||
|
<div className="flex flex-wrap justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-secondary">{copy.orderNumber}</p>
|
||||||
|
<strong className="text-primary" dir="ltr">
|
||||||
|
{order.number}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<OrderStatusTag status={order.status} />
|
||||||
|
</div>
|
||||||
|
<div className="grid sm:grid-cols-3 gap-4 mt-5 pt-5 border-t border-subtle text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-secondary block">{copy.orderDate}</span>
|
||||||
|
<span className="text-primary">{order.createdAt}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-secondary block">{copy.paymentStatus}</span>
|
||||||
|
<span className="text-primary">{paymentStatusLabels[locale][order.paymentStatus]}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-secondary block">{copy.orderItems}</span>
|
||||||
|
<span className="text-primary">
|
||||||
|
{order.items
|
||||||
|
.reduce((sum, item) => sum + item.quantity, 0)
|
||||||
|
.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/orders/${order.id}`}
|
||||||
|
className="inline-block text-brand-400 font-semibold mt-5 hover:underline"
|
||||||
|
>
|
||||||
|
{copy.details}
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {Tag} from 'antd';
|
||||||
|
import type {OrderStatus} from '@/types/account';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import {orderStatusLabels} from '@/data/account';
|
||||||
|
|
||||||
|
export default function OrderStatusTag({status}: {status: OrderStatus}) {
|
||||||
|
const {locale} = useLanguage();
|
||||||
|
const color =
|
||||||
|
status === 'cancelled'
|
||||||
|
? 'red'
|
||||||
|
: status === 'delivered'
|
||||||
|
? 'green'
|
||||||
|
: status === 'proforma_issued'
|
||||||
|
? 'cyan'
|
||||||
|
: 'gold';
|
||||||
|
return <Tag color={color}>{orderStatusLabels[locale][status]}</Tag>;
|
||||||
|
}
|
||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
import type {Locale} from '@/types/domain';
|
||||||
|
import type {OrderStatus, PaymentStatus} from '@/types/account';
|
||||||
|
|
||||||
|
export const orderStatusLabels: Record<Locale, Record<OrderStatus, string>> = {
|
||||||
|
fa: {
|
||||||
|
registered: 'ثبتشده',
|
||||||
|
under_review: 'در حال بررسی',
|
||||||
|
needs_information: 'نیازمند تکمیل اطلاعات',
|
||||||
|
approved: 'تأییدشده',
|
||||||
|
proforma_issued: 'پیشفاکتور صادرشده',
|
||||||
|
awaiting_payment: 'در انتظار پرداخت',
|
||||||
|
preparing: 'در حال آمادهسازی',
|
||||||
|
ready_to_ship: 'آماده ارسال',
|
||||||
|
shipped: 'ارسالشده',
|
||||||
|
delivered: 'تحویلشده',
|
||||||
|
cancelled: 'لغوشده',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
registered: 'Registered',
|
||||||
|
under_review: 'Under review',
|
||||||
|
needs_information: 'More information required',
|
||||||
|
approved: 'Approved',
|
||||||
|
proforma_issued: 'Proforma issued',
|
||||||
|
awaiting_payment: 'Awaiting payment',
|
||||||
|
preparing: 'Preparing',
|
||||||
|
ready_to_ship: 'Ready to ship',
|
||||||
|
shipped: 'Shipped',
|
||||||
|
delivered: 'Delivered',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const paymentStatusLabels: Record<Locale, Record<PaymentStatus, string>> = {
|
||||||
|
fa: {not_payable: 'در انتظار تأیید', awaiting_payment: 'در انتظار پرداخت', paid: 'پرداختشده', failed: 'ناموفق'},
|
||||||
|
en: {not_payable: 'Pending approval', awaiting_payment: 'Awaiting payment', paid: 'Paid', failed: 'Failed'},
|
||||||
|
};
|
||||||
|
|
||||||
|
const fa = {
|
||||||
|
login: 'ورود',
|
||||||
|
account: 'حساب کاربری',
|
||||||
|
dashboard: 'داشبورد',
|
||||||
|
profile: 'اطلاعات حساب',
|
||||||
|
orders: 'سفارشها',
|
||||||
|
logout: 'خروج',
|
||||||
|
robotMasterPanel: 'پنل روبات مستر',
|
||||||
|
otpLogin: 'ورود با کد یکبارمصرف',
|
||||||
|
passwordLogin: 'ورود با رمز عبور',
|
||||||
|
phone: 'شماره موبایل',
|
||||||
|
password: 'رمز عبور',
|
||||||
|
otp: 'کد تأیید',
|
||||||
|
sendOtp: 'دریافت کد',
|
||||||
|
verifyAndLogin: 'تأیید و ورود',
|
||||||
|
loginButton: 'ورود به حساب',
|
||||||
|
demoTitle: 'اطلاعات ورود آزمایشی',
|
||||||
|
demoOtp: 'کد نمونه: ۱۲۳۴۵',
|
||||||
|
demoPassword: 'شماره ۰۹۱۲۳۴۵۶۷۸۹ و رمز ۱۲۳۴۵۶',
|
||||||
|
otpSent: 'کد آزمایشی برای این شماره آماده است.',
|
||||||
|
invalidPhone: 'شماره موبایل معتبر وارد کنید.',
|
||||||
|
invalidCredentials: 'شماره یا رمز عبور صحیح نیست.',
|
||||||
|
protectedTitle: 'برای ادامه وارد حساب شوید',
|
||||||
|
protectedDescription: 'مشاهده جزئیات دستگاه، سبد خرید و داشبورد فقط پس از ورود امکانپذیر است.',
|
||||||
|
openLogin: 'ورود به حساب',
|
||||||
|
welcome: 'خوش آمدید',
|
||||||
|
dashboardDescription: 'اطلاعات حساب و روند سفارشهای خود را از اینجا مدیریت کنید.',
|
||||||
|
profileCompletion: 'تکمیل اطلاعات',
|
||||||
|
activeOrders: 'سفارش فعال',
|
||||||
|
totalOrders: 'همه سفارشها',
|
||||||
|
recentOrders: 'آخرین سفارشها',
|
||||||
|
viewAll: 'مشاهده همه',
|
||||||
|
save: 'ذخیره اطلاعات',
|
||||||
|
saved: 'اطلاعات با موفقیت ذخیره شد.',
|
||||||
|
individual: 'شخص حقیقی',
|
||||||
|
corporate: 'شخص حقوقی',
|
||||||
|
customerType: 'نوع خریدار',
|
||||||
|
firstName: 'نام',
|
||||||
|
lastName: 'نام خانوادگی',
|
||||||
|
email: 'ایمیل',
|
||||||
|
province: 'استان',
|
||||||
|
city: 'شهر',
|
||||||
|
address: 'نشانی کامل',
|
||||||
|
postalCode: 'کد پستی',
|
||||||
|
preferredCallTime: 'زمان مناسب تماس',
|
||||||
|
companyName: 'نام شرکت',
|
||||||
|
nationalId: 'شناسه ملی',
|
||||||
|
economicCode: 'کد اقتصادی',
|
||||||
|
verifiedPhone: 'شماره تأییدشده و قابل ویرایش نیست.',
|
||||||
|
orderNumber: 'شماره سفارش',
|
||||||
|
orderDate: 'تاریخ ثبت',
|
||||||
|
lastUpdate: 'آخرین بهروزرسانی',
|
||||||
|
orderStatus: 'وضعیت سفارش',
|
||||||
|
paymentStatus: 'وضعیت پرداخت',
|
||||||
|
details: 'مشاهده جزئیات',
|
||||||
|
orderItems: 'اقلام سفارش',
|
||||||
|
quantity: 'تعداد',
|
||||||
|
unitPrice: 'قیمت واحد',
|
||||||
|
total: 'جمع کل',
|
||||||
|
modules: 'ماژولها و امکانات',
|
||||||
|
timeline: 'روند سفارش',
|
||||||
|
invoice: 'پیشفاکتور',
|
||||||
|
invoicePending: 'پیشفاکتور پس از بررسی تلفنی و تأیید ادمین در این بخش قرار میگیرد.',
|
||||||
|
downloadPdf: 'دانلود PDF',
|
||||||
|
downloadExcel: 'دانلود Excel',
|
||||||
|
print: 'چاپ',
|
||||||
|
invoiceNumber: 'شماره پیشفاکتور',
|
||||||
|
issueDate: 'تاریخ صدور',
|
||||||
|
validUntil: 'اعتبار تا',
|
||||||
|
buyer: 'خریدار',
|
||||||
|
seller: 'فروشنده',
|
||||||
|
subtotal: 'جمع اقلام',
|
||||||
|
discount: 'تخفیف',
|
||||||
|
shipping: 'هزینه ارسال',
|
||||||
|
tax: 'مالیات',
|
||||||
|
finalTotal: 'مبلغ نهایی',
|
||||||
|
sellerName: 'شرکت نگین سازان پیشگام رادمهر آتوسا (روبات مارکت)',
|
||||||
|
sellerPhone: 'تلفن سفارش: ۰۹۱۵۱۶۰۱۸۴۷',
|
||||||
|
emptyOrders: 'هنوز سفارشی ثبت نشده است.',
|
||||||
|
backToOrders: 'بازگشت به سفارشها',
|
||||||
|
notFound: 'سفارش پیدا نشد.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const accountCopy: Record<Locale, typeof fa> = {
|
||||||
|
fa,
|
||||||
|
en: {
|
||||||
|
login: 'Login',
|
||||||
|
account: 'Account',
|
||||||
|
dashboard: 'Dashboard',
|
||||||
|
profile: 'Profile',
|
||||||
|
orders: 'Orders',
|
||||||
|
logout: 'Log out',
|
||||||
|
robotMasterPanel: 'Robot Master Panel',
|
||||||
|
otpLogin: 'Login with OTP',
|
||||||
|
passwordLogin: 'Login with password',
|
||||||
|
phone: 'Mobile number',
|
||||||
|
password: 'Password',
|
||||||
|
otp: 'Verification code',
|
||||||
|
sendOtp: 'Send code',
|
||||||
|
verifyAndLogin: 'Verify and login',
|
||||||
|
loginButton: 'Login',
|
||||||
|
demoTitle: 'Demo credentials',
|
||||||
|
demoOtp: 'Demo code: 12345',
|
||||||
|
demoPassword: 'Phone 09123456789 and password 123456',
|
||||||
|
otpSent: 'The demo code is ready for this number.',
|
||||||
|
invalidPhone: 'Enter a valid Iranian mobile number.',
|
||||||
|
invalidCredentials: 'Incorrect phone number or password.',
|
||||||
|
protectedTitle: 'Log in to continue',
|
||||||
|
protectedDescription: 'Product details, cart, and dashboard are available after login.',
|
||||||
|
openLogin: 'Login',
|
||||||
|
welcome: 'Welcome',
|
||||||
|
dashboardDescription: 'Manage your account details and follow your orders.',
|
||||||
|
profileCompletion: 'Profile completion',
|
||||||
|
activeOrders: 'Active orders',
|
||||||
|
totalOrders: 'All orders',
|
||||||
|
recentOrders: 'Recent orders',
|
||||||
|
viewAll: 'View all',
|
||||||
|
save: 'Save profile',
|
||||||
|
saved: 'Your profile was saved.',
|
||||||
|
individual: 'Individual',
|
||||||
|
corporate: 'Company',
|
||||||
|
customerType: 'Customer type',
|
||||||
|
firstName: 'First name',
|
||||||
|
lastName: 'Last name',
|
||||||
|
email: 'Email',
|
||||||
|
province: 'Province',
|
||||||
|
city: 'City',
|
||||||
|
address: 'Full address',
|
||||||
|
postalCode: 'Postal code',
|
||||||
|
preferredCallTime: 'Preferred call time',
|
||||||
|
companyName: 'Company name',
|
||||||
|
nationalId: 'National ID',
|
||||||
|
economicCode: 'Economic code',
|
||||||
|
verifiedPhone: 'This verified number cannot be edited.',
|
||||||
|
orderNumber: 'Order number',
|
||||||
|
orderDate: 'Created',
|
||||||
|
lastUpdate: 'Last update',
|
||||||
|
orderStatus: 'Order status',
|
||||||
|
paymentStatus: 'Payment status',
|
||||||
|
details: 'View details',
|
||||||
|
orderItems: 'Order items',
|
||||||
|
quantity: 'Quantity',
|
||||||
|
unitPrice: 'Unit price',
|
||||||
|
total: 'Total',
|
||||||
|
modules: 'Modules and options',
|
||||||
|
timeline: 'Order progress',
|
||||||
|
invoice: 'Proforma invoice',
|
||||||
|
invoicePending: 'The invoice appears here after phone review and admin approval.',
|
||||||
|
downloadPdf: 'Download PDF',
|
||||||
|
downloadExcel: 'Download Excel',
|
||||||
|
print: 'Print',
|
||||||
|
invoiceNumber: 'Invoice number',
|
||||||
|
issueDate: 'Issue date',
|
||||||
|
validUntil: 'Valid until',
|
||||||
|
buyer: 'Buyer',
|
||||||
|
seller: 'Seller',
|
||||||
|
subtotal: 'Subtotal',
|
||||||
|
discount: 'Discount',
|
||||||
|
shipping: 'Shipping',
|
||||||
|
tax: 'Tax',
|
||||||
|
finalTotal: 'Grand total',
|
||||||
|
sellerName: 'Negin Sazan Pishgam Radmehr Atousa (Robot Market)',
|
||||||
|
sellerPhone: 'Sales: 09151601847',
|
||||||
|
emptyOrders: 'No orders have been registered.',
|
||||||
|
backToOrders: 'Back to orders',
|
||||||
|
notFound: 'Order not found.',
|
||||||
|
},
|
||||||
|
};
|
||||||
+64
-63
@@ -1,70 +1,71 @@
|
|||||||
import type {Locale} from '@/types/domain';
|
import type {Locale} from '@/types/domain';
|
||||||
|
|
||||||
const fa = {
|
const fa = {
|
||||||
allProducts: 'همه محصولات',
|
allProducts: 'همه محصولات',
|
||||||
featuredProducts: 'محصولات منتخب',
|
featuredProducts: 'محصولات منتخب',
|
||||||
productsDescription: 'دستگاه مناسب خود را انتخاب کنید و مشخصات آن را بررسی کنید.',
|
productsDescription: 'دستگاه مناسب خود را انتخاب کنید و مشخصات آن را بررسی کنید.',
|
||||||
category: 'دستهبندی محصولات',
|
category: 'دستهبندی محصولات',
|
||||||
allCategories: 'همه دستهبندیها',
|
allCategories: 'همه دستهبندیها',
|
||||||
clearFilters: 'پاک کردن فیلتر',
|
clearFilters: 'پاک کردن فیلتر',
|
||||||
noProducts: 'محصولی با این دستهبندی پیدا نشد.',
|
noProducts: 'محصولی با این دستهبندی پیدا نشد.',
|
||||||
unknownCategory: 'دستهبندی نامعتبر',
|
unknownCategory: 'دستهبندی نامعتبر',
|
||||||
results: 'محصول',
|
results: 'محصول',
|
||||||
details: 'جزئیات محصول',
|
details: 'جزئیات محصول',
|
||||||
increase: 'افزایش تعداد',
|
increase: 'افزایش تعداد',
|
||||||
decrease: 'کاهش تعداد',
|
decrease: 'کاهش تعداد',
|
||||||
quantity: 'تعداد',
|
quantity: 'تعداد',
|
||||||
remove: 'حذف از سبد',
|
remove: 'حذف از سبد',
|
||||||
continueShopping: 'مشاهده دستگاهها',
|
continueShopping: 'مشاهده دستگاهها',
|
||||||
cartDescription: 'دستگاههای انتخابشده و تعداد آنها را اینجا بررسی کنید.',
|
cartDescription: 'دستگاههای انتخابشده و تعداد آنها را اینجا بررسی کنید.',
|
||||||
summary: 'خلاصه سبد خرید',
|
summary: 'خلاصه سبد خرید',
|
||||||
unitPrice: 'قیمت هر دستگاه',
|
unitPrice: 'قیمت هر دستگاه',
|
||||||
lineTotal: 'جمع این دستگاه',
|
lineTotal: 'جمع این دستگاه',
|
||||||
currency: 'ریال',
|
currency: 'ریال',
|
||||||
cartLink: 'مشاهده سبد خرید',
|
cartLink: 'مشاهده سبد خرید',
|
||||||
specifications: 'ویژگیهای دستگاه',
|
specifications: 'ویژگیهای دستگاه',
|
||||||
model: 'مدل دستگاه',
|
model: 'مدل دستگاه',
|
||||||
gallery: 'تصاویر دستگاه',
|
gallery: 'تصاویر دستگاه',
|
||||||
baseImage: 'نمای اصلی',
|
baseImage: 'نمای اصلی',
|
||||||
alternateImage: 'نمای جایگزین',
|
alternateImage: 'نمای جایگزین',
|
||||||
developmentTitle: 'روبات مارکت در حال توسعه است',
|
developmentTitle: 'روبات مارکت در حال توسعه است',
|
||||||
developmentMessage: 'به روبات مارکت خوش آمدید. سایت در حال توسعه است و هنوز تمام بخشهای آن تکمیل نشدهاند.',
|
developmentMessage: 'به روبات مارکت خوش آمدید. سایت در حال توسعه است و هنوز تمام بخشهای آن تکمیل نشدهاند.',
|
||||||
acknowledge: 'متوجه شدم، ادامه میدهم',
|
acknowledge: 'متوجه شدم، ادامه میدهم',
|
||||||
checkoutUnavailable: 'ثبت نهایی سفارش و پرداخت آنلاین هنوز فعال نیست.',
|
checkoutUnavailable: 'ثبت نهایی سفارش و پرداخت آنلاین هنوز فعال نیست.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const commerceCopy: Record<Locale, Record<keyof typeof fa, string>> = {
|
export const commerceCopy: Record<Locale, Record<keyof typeof fa, string>> = {
|
||||||
fa,
|
fa,
|
||||||
en: {
|
en: {
|
||||||
allProducts: 'All products',
|
allProducts: 'All products',
|
||||||
featuredProducts: 'Featured products',
|
featuredProducts: 'Featured products',
|
||||||
productsDescription: 'Choose a machine and explore its specifications.',
|
productsDescription: 'Choose a machine and explore its specifications.',
|
||||||
category: 'Product category',
|
category: 'Product category',
|
||||||
allCategories: 'All categories',
|
allCategories: 'All categories',
|
||||||
clearFilters: 'Clear filter',
|
clearFilters: 'Clear filter',
|
||||||
noProducts: 'No products found in this category.',
|
noProducts: 'No products found in this category.',
|
||||||
unknownCategory: 'Unknown category',
|
unknownCategory: 'Unknown category',
|
||||||
results: 'products',
|
results: 'products',
|
||||||
details: 'Product details',
|
details: 'Product details',
|
||||||
increase: 'Increase quantity',
|
increase: 'Increase quantity',
|
||||||
decrease: 'Decrease quantity',
|
decrease: 'Decrease quantity',
|
||||||
quantity: 'Quantity',
|
quantity: 'Quantity',
|
||||||
remove: 'Remove from cart',
|
remove: 'Remove from cart',
|
||||||
continueShopping: 'Browse machines',
|
continueShopping: 'Browse machines',
|
||||||
cartDescription: 'Review your selected machines and adjust their quantities.',
|
cartDescription: 'Review your selected machines and adjust their quantities.',
|
||||||
summary: 'Cart summary',
|
summary: 'Cart summary',
|
||||||
unitPrice: 'Unit price',
|
unitPrice: 'Unit price',
|
||||||
lineTotal: 'Item total',
|
lineTotal: 'Item total',
|
||||||
currency: 'Rials',
|
currency: 'Rials',
|
||||||
cartLink: 'View cart',
|
cartLink: 'View cart',
|
||||||
specifications: 'Machine features',
|
specifications: 'Machine features',
|
||||||
model: 'Machine model',
|
model: 'Machine model',
|
||||||
gallery: 'Machine images',
|
gallery: 'Machine images',
|
||||||
baseImage: 'Main view',
|
baseImage: 'Main view',
|
||||||
alternateImage: 'Alternate view',
|
alternateImage: 'Alternate view',
|
||||||
developmentTitle: 'Robot Market is under development',
|
developmentTitle: 'Robot Market is under development',
|
||||||
developmentMessage: 'Welcome to Robot Market. Our website is still under development and some features are not yet complete. You can explore machines and add them to your cart, but order submission and online payment are not available yet.',
|
developmentMessage:
|
||||||
acknowledge: 'Got it, continue',
|
'Welcome to Robot Market. Our website is still under development and some features are not yet complete. You can explore machines and add them to your cart, but order submission and online payment are not available yet.',
|
||||||
checkoutUnavailable: 'Order submission and online payment are not available yet.',
|
acknowledge: 'Got it, continue',
|
||||||
},
|
checkoutUnavailable: 'Order submission and online payment are not available yet.',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+48
-34
@@ -56,7 +56,8 @@ const machinesEn: Machine[] = [
|
|||||||
shortLabel: '35 Channels',
|
shortLabel: '35 Channels',
|
||||||
priceLabel: '4,180,000,000 Rials',
|
priceLabel: '4,180,000,000 Rials',
|
||||||
priceNumeric: 4180000000,
|
priceNumeric: 4180000000,
|
||||||
description: 'Refrigerated, 35 dedicated channels, powerful cooling system, soft delivery, 24/7 online management.',
|
description:
|
||||||
|
'Refrigerated, 35 dedicated channels, powerful cooling system, soft delivery, 24/7 online management.',
|
||||||
highlights: ['Refrigerated', '35 dedicated channels', 'Soft delivery', '24/7 online management'],
|
highlights: ['Refrigerated', '35 dedicated channels', 'Soft delivery', '24/7 online management'],
|
||||||
image: '/images/machine-35-base.webp',
|
image: '/images/machine-35-base.webp',
|
||||||
imageAlt: '/images/machine-35-white.webp',
|
imageAlt: '/images/machine-35-white.webp',
|
||||||
@@ -96,8 +97,7 @@ export const content: Record<Locale, SiteContent> = {
|
|||||||
lang: 'fa',
|
lang: 'fa',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'روبات مارکت | دستگاههای وندینگ هوشمند',
|
title: 'روبات مارکت | دستگاههای وندینگ هوشمند',
|
||||||
description:
|
description: 'روبات مارکت؛ دستگاههای فروش خودکار هوشمند با مدیریت آنلاین، تحویل نرم و طراحی مدرن.',
|
||||||
'روبات مارکت؛ دستگاههای فروش خودکار هوشمند با مدیریت آنلاین، تحویل نرم و طراحی مدرن.',
|
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
home: 'خانه',
|
home: 'خانه',
|
||||||
@@ -140,7 +140,7 @@ export const content: Record<Locale, SiteContent> = {
|
|||||||
total: 'جمع کل',
|
total: 'جمع کل',
|
||||||
checkout: 'پرداخت / تسویه حساب',
|
checkout: 'پرداخت / تسویه حساب',
|
||||||
checkoutToast: 'درگاه پرداخت بهزودی متصل میشود.',
|
checkoutToast: 'درگاه پرداخت بهزودی متصل میشود.',
|
||||||
addToast: (name) => `${name} به سبد خرید اضافه شد`,
|
addToast: name => `${name} به سبد خرید اضافه شد`,
|
||||||
},
|
},
|
||||||
machineActions: {
|
machineActions: {
|
||||||
addToCart: 'افزودن به سبد خرید',
|
addToCart: 'افزودن به سبد خرید',
|
||||||
@@ -148,7 +148,7 @@ export const content: Record<Locale, SiteContent> = {
|
|||||||
wishlistRemove: 'حذف از علاقهمندیها',
|
wishlistRemove: 'حذف از علاقهمندیها',
|
||||||
hoverHint: 'نشانگر را روی دستگاه حرکت دهید',
|
hoverHint: 'نشانگر را روی دستگاه حرکت دهید',
|
||||||
altVariant: 'رنگ جایگزین',
|
altVariant: 'رنگ جایگزین',
|
||||||
gifAlt: (name) => `نمایش عملکرد ${name}`,
|
gifAlt: name => `نمایش عملکرد ${name}`,
|
||||||
},
|
},
|
||||||
machines: machinesFa,
|
machines: machinesFa,
|
||||||
features: [
|
features: [
|
||||||
@@ -156,25 +156,25 @@ export const content: Record<Locale, SiteContent> = {
|
|||||||
id: 'warranty',
|
id: 'warranty',
|
||||||
icon: 'safety',
|
icon: 'safety',
|
||||||
title: 'ضمانت کالا',
|
title: 'ضمانت کالا',
|
||||||
description: 'کلیه محصولات شرکت دارای یک سال خدمات پس از فروش میباشند.'
|
description: 'کلیه محصولات شرکت دارای یک سال خدمات پس از فروش میباشند.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'consulting',
|
id: 'consulting',
|
||||||
icon: 'consulting',
|
icon: 'consulting',
|
||||||
title: 'مشاوره تخصصی و رایگان',
|
title: 'مشاوره تخصصی و رایگان',
|
||||||
description: 'خدمات مشاوره رایگان در مورد نوع دستگاه و چیدمان محصولات.'
|
description: 'خدمات مشاوره رایگان در مورد نوع دستگاه و چیدمان محصولات.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'installment',
|
id: 'installment',
|
||||||
icon: 'installment',
|
icon: 'installment',
|
||||||
title: 'امکان خرید اقساطی',
|
title: 'امکان خرید اقساطی',
|
||||||
description: 'امکان خرید اقساط با شرایط آسان و مناسب جهت کلیه مشاغل.'
|
description: 'امکان خرید اقساط با شرایط آسان و مناسب جهت کلیه مشاغل.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'packaging',
|
id: 'packaging',
|
||||||
icon: 'packaging',
|
icon: 'packaging',
|
||||||
title: 'بستهبندی ایمن',
|
title: 'بستهبندی ایمن',
|
||||||
description: 'با درخواست شما، محصولات با بستهبندی باکس پالت با هزینهای اندک ارسال میشود.'
|
description: 'با درخواست شما، محصولات با بستهبندی باکس پالت با هزینهای اندک ارسال میشود.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
about: {
|
about: {
|
||||||
@@ -200,43 +200,48 @@ export const content: Record<Locale, SiteContent> = {
|
|||||||
id: 'hotels',
|
id: 'hotels',
|
||||||
icon: 'hotel',
|
icon: 'hotel',
|
||||||
title: 'هتلها و مراکز اقامتی',
|
title: 'هتلها و مراکز اقامتی',
|
||||||
description: 'هر جایی که جمعیت زیادی در صف انتظار قرار دارند، بهجز درآمد، روبات مارکت جزو امکانات رفاهی آن مکان محسوب شده و میزان رضایت مراجعان را نیز افزایش میدهد.'
|
description:
|
||||||
|
'هر جایی که جمعیت زیادی در صف انتظار قرار دارند، بهجز درآمد، روبات مارکت جزو امکانات رفاهی آن مکان محسوب شده و میزان رضایت مراجعان را نیز افزایش میدهد.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'schools',
|
id: 'schools',
|
||||||
icon: 'school',
|
icon: 'school',
|
||||||
title: 'مراکز آموزشی و مدارس',
|
title: 'مراکز آموزشی و مدارس',
|
||||||
description: 'در صورتی که هر یک از دانشآموزان و دانشجویان برای خرید مایحتاج خود به خارج از مجموعه مراجعه کنند و خدایناکرده اتفاقی بیفتد، مسئولیت آن مستقیماً به عهده مدیر آموزشگاه است. در نتیجه وجود یک روبات مارکت در هر آموزشگاه ضروری است.'
|
description:
|
||||||
|
'در صورتی که هر یک از دانشآموزان و دانشجویان برای خرید مایحتاج خود به خارج از مجموعه مراجعه کنند و خدایناکرده اتفاقی بیفتد، مسئولیت آن مستقیماً به عهده مدیر آموزشگاه است. در نتیجه وجود یک روبات مارکت در هر آموزشگاه ضروری است.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pharmacies',
|
id: 'pharmacies',
|
||||||
icon: 'pharmacy',
|
icon: 'pharmacy',
|
||||||
title: 'داروخانهها و سوپرمارکتها',
|
title: 'داروخانهها و سوپرمارکتها',
|
||||||
description: 'برای فروش محصولات جنسی و پیشگیری، ویژه مشتریانی که برای خرید آن معذوریت دارند.'
|
description: 'برای فروش محصولات جنسی و پیشگیری، ویژه مشتریانی که برای خرید آن معذوریت دارند.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hospitals',
|
id: 'hospitals',
|
||||||
icon: 'hospital',
|
icon: 'hospital',
|
||||||
title: 'بیمارستانها و مراکز درمانی',
|
title: 'بیمارستانها و مراکز درمانی',
|
||||||
description: 'برای فروش محصولات جنسی و پیشگیری، ویژه مشتریانی که برای خرید آن معذوریت دارند.'
|
description: 'برای فروش محصولات جنسی و پیشگیری، ویژه مشتریانی که برای خرید آن معذوریت دارند.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'night-shift',
|
id: 'night-shift',
|
||||||
icon: 'night',
|
icon: 'night',
|
||||||
title: 'کشیک شیفت شب',
|
title: 'کشیک شیفت شب',
|
||||||
description: 'امکان تعبیه روبات مارکت پشت شیشه سکوریت مغازهها نیز وجود دارد تا در زمان تعطیلی فروشگاه و حتی نیمهشبها، روبات زحمتکش در حال خدمتدهی به مشتریان عزیز شما باشد.'
|
description:
|
||||||
|
'امکان تعبیه روبات مارکت پشت شیشه سکوریت مغازهها نیز وجود دارد تا در زمان تعطیلی فروشگاه و حتی نیمهشبها، روبات زحمتکش در حال خدمتدهی به مشتریان عزیز شما باشد.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'specialty',
|
id: 'specialty',
|
||||||
icon: 'specialty',
|
icon: 'specialty',
|
||||||
title: 'محصولات تخصصی',
|
title: 'محصولات تخصصی',
|
||||||
description: 'اگر تولیدکننده هستید یا نماینده تولیدکننده محصولی خاص هستید، یک روبات مارکت میتواند مسئولیت معرفی و عرضه محصولات تخصصی شما را نیز بر عهده بگیرد.'
|
description:
|
||||||
|
'اگر تولیدکننده هستید یا نماینده تولیدکننده محصولی خاص هستید، یک روبات مارکت میتواند مسئولیت معرفی و عرضه محصولات تخصصی شما را نیز بر عهده بگیرد.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
aboutLabel: 'روبات مارکت',
|
aboutLabel: 'روبات مارکت',
|
||||||
aboutText: 'ارائهدهنده دستگاههای وندینگ هوشمند برای کسبوکارهای مدرن — محصولی از شرکت نگین سازان پیشگام رادمهر آتوسا.',
|
aboutText:
|
||||||
|
'ارائهدهنده دستگاههای وندینگ هوشمند برای کسبوکارهای مدرن — محصولی از شرکت نگین سازان پیشگام رادمهر آتوسا.',
|
||||||
quickLinks: 'دسترسی سریع',
|
quickLinks: 'دسترسی سریع',
|
||||||
contactTitle: 'تماس با ما',
|
contactTitle: 'تماس با ما',
|
||||||
factoryLabel: 'کارخانه',
|
factoryLabel: 'کارخانه',
|
||||||
@@ -246,7 +251,8 @@ export const content: Record<Locale, SiteContent> = {
|
|||||||
contactInfo: {
|
contactInfo: {
|
||||||
orderPhone: '۰۹۱۵۱۶۰۱۸۴۷',
|
orderPhone: '۰۹۱۵۱۶۰۱۸۴۷',
|
||||||
orderPhoneHref: '09151601847',
|
orderPhoneHref: '09151601847',
|
||||||
factoryAddress: 'مشهد، کیلومتر ۸ جاده مشهد - چناران، دور برگردان اصلی شهرک صنعتی توس به سمت مشهد، بعد از نیروگاه، اولین فرعی سمت راست (خیابان کاله)، اولین کوچه سمت راست، پلاک ۱۱۰، شرکت نگین سازان پیشگام رادمهر آتوسا',
|
factoryAddress:
|
||||||
|
'مشهد، کیلومتر ۸ جاده مشهد - چناران، دور برگردان اصلی شهرک صنعتی توس به سمت مشهد، بعد از نیروگاه، اولین فرعی سمت راست (خیابان کاله)، اولین کوچه سمت راست، پلاک ۱۱۰، شرکت نگین سازان پیشگام رادمهر آتوسا',
|
||||||
salesOfficeAddress: 'مشهد، نبش پیامبر اعظم ۷، جنب بیمارستان رضوی، برج نخل، واحد ۴۰۱',
|
salesOfficeAddress: 'مشهد، نبش پیامبر اعظم ۷، جنب بیمارستان رضوی، برج نخل، واحد ۴۰۱',
|
||||||
socialLinks: [
|
socialLinks: [
|
||||||
{id: 'instagram', label: 'صفحه اینستاگرام', href: 'http://instagram.com/vending_machin_life'},
|
{id: 'instagram', label: 'صفحه اینستاگرام', href: 'http://instagram.com/vending_machin_life'},
|
||||||
@@ -305,7 +311,7 @@ export const content: Record<Locale, SiteContent> = {
|
|||||||
total: 'Total',
|
total: 'Total',
|
||||||
checkout: 'Checkout',
|
checkout: 'Checkout',
|
||||||
checkoutToast: 'Payment gateway will be connected soon.',
|
checkoutToast: 'Payment gateway will be connected soon.',
|
||||||
addToast: (name) => `${name} added to cart`,
|
addToast: name => `${name} added to cart`,
|
||||||
},
|
},
|
||||||
machineActions: {
|
machineActions: {
|
||||||
addToCart: 'Add to Cart',
|
addToCart: 'Add to Cart',
|
||||||
@@ -313,7 +319,7 @@ export const content: Record<Locale, SiteContent> = {
|
|||||||
wishlistRemove: 'Remove from Wishlist',
|
wishlistRemove: 'Remove from Wishlist',
|
||||||
hoverHint: 'Move your cursor over the machine',
|
hoverHint: 'Move your cursor over the machine',
|
||||||
altVariant: 'Alternate color',
|
altVariant: 'Alternate color',
|
||||||
gifAlt: (name) => `${name} in action`,
|
gifAlt: name => `${name} in action`,
|
||||||
},
|
},
|
||||||
machines: machinesEn,
|
machines: machinesEn,
|
||||||
features: [
|
features: [
|
||||||
@@ -321,25 +327,25 @@ export const content: Record<Locale, SiteContent> = {
|
|||||||
id: 'warranty',
|
id: 'warranty',
|
||||||
icon: 'safety',
|
icon: 'safety',
|
||||||
title: 'Product Warranty',
|
title: 'Product Warranty',
|
||||||
description: 'All our products come with one year of after-sales service.'
|
description: 'All our products come with one year of after-sales service.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'consulting',
|
id: 'consulting',
|
||||||
icon: 'consulting',
|
icon: 'consulting',
|
||||||
title: 'Free Expert Consulting',
|
title: 'Free Expert Consulting',
|
||||||
description: 'Free consulting on the right machine type and product layout.'
|
description: 'Free consulting on the right machine type and product layout.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'installment',
|
id: 'installment',
|
||||||
icon: 'installment',
|
icon: 'installment',
|
||||||
title: 'Installment Purchase',
|
title: 'Installment Purchase',
|
||||||
description: 'Flexible, easy installment plans suitable for any business.'
|
description: 'Flexible, easy installment plans suitable for any business.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'packaging',
|
id: 'packaging',
|
||||||
icon: 'packaging',
|
icon: 'packaging',
|
||||||
title: 'Secure Packaging',
|
title: 'Secure Packaging',
|
||||||
description: 'On request, products ship in pallet-box packaging for a small fee.'
|
description: 'On request, products ship in pallet-box packaging for a small fee.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
about: {
|
about: {
|
||||||
@@ -350,12 +356,12 @@ export const content: Record<Locale, SiteContent> = {
|
|||||||
paragraphs: [
|
paragraphs: [
|
||||||
'Negin Sazan Pishgam Radmehr Atousa Co. (registration No. 84094), also known as "Ideh Avaran Pishgam Atousa" (No. 63097) and "Pishgam Gostar Negin Atousa" (No. 73416), began operations in 2017 producing precision saffron-processing machines. After building a nationwide saffron distribution network, in 2022 the company entered the vending-machine industry with saffron-dedicated machines under the "Robot Market" brand.',
|
'Negin Sazan Pishgam Radmehr Atousa Co. (registration No. 84094), also known as "Ideh Avaran Pishgam Atousa" (No. 63097) and "Pishgam Gostar Negin Atousa" (No. 73416), began operations in 2017 producing precision saffron-processing machines. After building a nationwide saffron distribution network, in 2022 the company entered the vending-machine industry with saffron-dedicated machines under the "Robot Market" brand.',
|
||||||
'In 2023, cooling capability was added to preserve saffron quality, and Robot Market gained the ability to sell snacks and cold drinks as well. By spring 2024, with the wide rollout of "Robot Market" machines, these robots were introduced as an official retailer of snacks and cold drinks in Iran.',
|
'In 2023, cooling capability was added to preserve saffron quality, and Robot Market gained the ability to sell snacks and cold drinks as well. By spring 2024, with the wide rollout of "Robot Market" machines, these robots were introduced as an official retailer of snacks and cold drinks in Iran.',
|
||||||
'Robot Market is not just a name — it is a symbol of a modern, forward-looking way of thinking in the vending industry. What happens inside Robot Market is the result of our engineers\' technical expertise combined with today\'s technology. Every Robot Market machine is a tireless, intelligent assistant that bridges consumer needs and your business goals with precision and speed.',
|
"Robot Market is not just a name — it is a symbol of a modern, forward-looking way of thinking in the vending industry. What happens inside Robot Market is the result of our engineers' technical expertise combined with today's technology. Every Robot Market machine is a tireless, intelligent assistant that bridges consumer needs and your business goals with precision and speed.",
|
||||||
'Our commitment is to build high-quality machines with modern design and flawless performance, delivering a different experience of shopping and revenue generation. Robot Market stands for trust, innovation, and continuous progress in the vending industry.',
|
'Our commitment is to build high-quality machines with modern design and flawless performance, delivering a different experience of shopping and revenue generation. Robot Market stands for trust, innovation, and continuous progress in the vending industry.',
|
||||||
'Hundreds of hours of engineering work and operational testing go into every Robot Market unit. We believe durability and machine lifespan are the most important measures of our customers\' investment. That is why, from selecting the best materials and electronic components to final assembly, we follow strict standards so our machines perform reliably in any condition.',
|
"Hundreds of hours of engineering work and operational testing go into every Robot Market unit. We believe durability and machine lifespan are the most important measures of our customers' investment. That is why, from selecting the best materials and electronic components to final assembly, we follow strict standards so our machines perform reliably in any condition.",
|
||||||
'Our smart machines offer a simple yet advanced user interface for an enjoyable experience. Secure payment systems and internet connectivity for online management and price monitoring set Robot Market apart from competitors. We have made the purchase process as simple as a few taps, so your customers can access what they need, any time of day, in the shortest time possible. Robot Market is not just a manufacturer — it is a trusted advisor for growing your business, designing automated sales solutions tailored to your needs.',
|
'Our smart machines offer a simple yet advanced user interface for an enjoyable experience. Secure payment systems and internet connectivity for online management and price monitoring set Robot Market apart from competitors. We have made the purchase process as simple as a few taps, so your customers can access what they need, any time of day, in the shortest time possible. Robot Market is not just a manufacturer — it is a trusted advisor for growing your business, designing automated sales solutions tailored to your needs.',
|
||||||
'Whether you run a large retail chain or manage a small office, our machines let you increase sales without time or location limits, and without worrying about staffing issues.',
|
'Whether you run a large retail chain or manage a small office, our machines let you increase sales without time or location limits, and without worrying about staffing issues.',
|
||||||
'Behind Robot Market\'s specialized, forward-looking products lies technology that replaces foreign alternatives — meeting domestic needs today, and poised to compete meaningfully in export markets in the near future. We take pride in building an Iranian product that competes with global standards — a small but firm step toward self-sufficiency and economic growth.',
|
"Behind Robot Market's specialized, forward-looking products lies technology that replaces foreign alternatives — meeting domestic needs today, and poised to compete meaningfully in export markets in the near future. We take pride in building an Iranian product that competes with global standards — a small but firm step toward self-sufficiency and economic growth.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
locations: {
|
locations: {
|
||||||
@@ -365,43 +371,50 @@ export const content: Record<Locale, SiteContent> = {
|
|||||||
id: 'hotels',
|
id: 'hotels',
|
||||||
icon: 'hotel',
|
icon: 'hotel',
|
||||||
title: 'Hotels & Accommodations',
|
title: 'Hotels & Accommodations',
|
||||||
description: 'Anywhere large crowds wait in line — beyond the revenue, Robot Market becomes an amenity that increases guest satisfaction.'
|
description:
|
||||||
|
'Anywhere large crowds wait in line — beyond the revenue, Robot Market becomes an amenity that increases guest satisfaction.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'schools',
|
id: 'schools',
|
||||||
icon: 'school',
|
icon: 'school',
|
||||||
title: 'Schools & Educational Centers',
|
title: 'Schools & Educational Centers',
|
||||||
description: 'If a student has to leave campus to buy something and, God forbid, an incident occurs, responsibility falls directly on the school administrator. A Robot Market on-site is essential.'
|
description:
|
||||||
|
'If a student has to leave campus to buy something and, God forbid, an incident occurs, responsibility falls directly on the school administrator. A Robot Market on-site is essential.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pharmacies',
|
id: 'pharmacies',
|
||||||
icon: 'pharmacy',
|
icon: 'pharmacy',
|
||||||
title: 'Pharmacies & Supermarkets',
|
title: 'Pharmacies & Supermarkets',
|
||||||
description: 'For selling personal/health products discreetly, for customers who prefer not to purchase them in person.'
|
description:
|
||||||
|
'For selling personal/health products discreetly, for customers who prefer not to purchase them in person.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hospitals',
|
id: 'hospitals',
|
||||||
icon: 'hospital',
|
icon: 'hospital',
|
||||||
title: 'Hospitals & Medical Centers',
|
title: 'Hospitals & Medical Centers',
|
||||||
description: 'For selling personal/health products discreetly, for customers who prefer not to purchase them in person.'
|
description:
|
||||||
|
'For selling personal/health products discreetly, for customers who prefer not to purchase them in person.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'night-shift',
|
id: 'night-shift',
|
||||||
icon: 'night',
|
icon: 'night',
|
||||||
title: 'Night-Shift Coverage',
|
title: 'Night-Shift Coverage',
|
||||||
description: 'Robot Market can be installed behind a shop\'s security glass, serving customers even after closing time and in the middle of the night.'
|
description:
|
||||||
|
"Robot Market can be installed behind a shop's security glass, serving customers even after closing time and in the middle of the night.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'specialty',
|
id: 'specialty',
|
||||||
icon: 'specialty',
|
icon: 'specialty',
|
||||||
title: 'Specialty Products',
|
title: 'Specialty Products',
|
||||||
description: 'If you manufacture or represent a specific product line, a Robot Market machine can take on introducing and offering your specialty products too.'
|
description:
|
||||||
|
'If you manufacture or represent a specific product line, a Robot Market machine can take on introducing and offering your specialty products too.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
aboutLabel: 'Robot Market',
|
aboutLabel: 'Robot Market',
|
||||||
aboutText: 'Smart vending machines for modern businesses — a product of Negin Sazan Pishgam Radmehr Atousa Co.',
|
aboutText:
|
||||||
|
'Smart vending machines for modern businesses — a product of Negin Sazan Pishgam Radmehr Atousa Co.',
|
||||||
quickLinks: 'Quick Links',
|
quickLinks: 'Quick Links',
|
||||||
contactTitle: 'Contact Us',
|
contactTitle: 'Contact Us',
|
||||||
factoryLabel: 'Factory',
|
factoryLabel: 'Factory',
|
||||||
@@ -411,7 +424,8 @@ export const content: Record<Locale, SiteContent> = {
|
|||||||
contactInfo: {
|
contactInfo: {
|
||||||
orderPhone: '09151601847',
|
orderPhone: '09151601847',
|
||||||
orderPhoneHref: '09151601847',
|
orderPhoneHref: '09151601847',
|
||||||
factoryAddress: 'Mashhad, Mashhad–Chenaran Rd. km 8, main roundabout of Toos Industrial Town toward Mashhad, after the power plant, 1st street on the right (Kaleh St.), 1st alley on the right, No. 110 — Negin Sazan Pishgam Radmehr Atousa Co.',
|
factoryAddress:
|
||||||
|
'Mashhad, Mashhad–Chenaran Rd. km 8, main roundabout of Toos Industrial Town toward Mashhad, after the power plant, 1st street on the right (Kaleh St.), 1st alley on the right, No. 110 — Negin Sazan Pishgam Radmehr Atousa Co.',
|
||||||
salesOfficeAddress: 'Mashhad, corner of Payambar-e Azam 7, next to Razavi Hospital, Nakhl Tower, Unit 401',
|
salesOfficeAddress: 'Mashhad, corner of Payambar-e Azam 7, next to Razavi Hospital, Nakhl Tower, Unit 401',
|
||||||
socialLinks: [
|
socialLinks: [
|
||||||
{id: 'instagram', label: 'Instagram', href: 'http://instagram.com/vending_machin_life'},
|
{id: 'instagram', label: 'Instagram', href: 'http://instagram.com/vending_machin_life'},
|
||||||
|
|||||||
+10
-10
@@ -1,11 +1,11 @@
|
|||||||
services:
|
services:
|
||||||
robot-market:
|
robot-market:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
container_name: robot-market
|
container_name: robot-market
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- '3000:3000'
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+7
-16
@@ -1,6 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'https://api.example.com',
|
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'https://api.example.com',
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
@@ -9,32 +8,26 @@ const api = axios.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
api.interceptors.request.use(
|
api.interceptors.request.use(
|
||||||
(config) => {
|
config => {
|
||||||
const token =
|
const token = typeof window !== 'undefined' ? window.localStorage.getItem('rm-auth-token') : null;
|
||||||
typeof window !== 'undefined' ? window.localStorage.getItem('rm-auth-token') : null;
|
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('[API request]', config.method?.toUpperCase(), config.url);
|
// console.log('[API request]', config.method?.toUpperCase(), config.url);
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error)
|
error => Promise.reject(error),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
response => response,
|
||||||
|
|
||||||
(error) => {
|
error => {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
|
|
||||||
const status = error.response.status;
|
const status = error.response.status;
|
||||||
|
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
@@ -50,10 +43,8 @@ api.interceptors.response.use(
|
|||||||
// TODO: Display a network connection error.
|
// TODO: Display a network connection error.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type {CustomerOrder} from '@/types/account';
|
||||||
|
|
||||||
|
export async function downloadInvoicePdf(element: HTMLElement, filename: string) {
|
||||||
|
const [{default: html2canvas}, {jsPDF}] = await Promise.all([import('html2canvas'), import('jspdf')]);
|
||||||
|
const canvas = await html2canvas(element, {scale: 2, backgroundColor: '#ffffff', useCORS: true});
|
||||||
|
const image = canvas.toDataURL('image/jpeg', 0.95);
|
||||||
|
const pdf = new jsPDF({orientation: 'portrait', unit: 'mm', format: 'a4'});
|
||||||
|
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||||
|
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||||
|
const imageHeight = (canvas.height * pageWidth) / canvas.width;
|
||||||
|
let remaining = imageHeight;
|
||||||
|
let position = 0;
|
||||||
|
pdf.addImage(image, 'JPEG', 0, position, pageWidth, imageHeight);
|
||||||
|
remaining -= pageHeight;
|
||||||
|
while (remaining > 0) {
|
||||||
|
position = remaining - imageHeight;
|
||||||
|
pdf.addPage();
|
||||||
|
pdf.addImage(image, 'JPEG', 0, position, pageWidth, imageHeight);
|
||||||
|
remaining -= pageHeight;
|
||||||
|
}
|
||||||
|
pdf.save(`${filename}.pdf`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadInvoiceExcel(order: CustomerOrder, filename: string) {
|
||||||
|
const ExcelJS = await import('exceljs');
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet('پیش فاکتور', {views: [{rightToLeft: true}]});
|
||||||
|
sheet.columns = [
|
||||||
|
{header: 'نام دستگاه', key: 'name', width: 34},
|
||||||
|
{header: 'مدل', key: 'model', width: 14},
|
||||||
|
{header: 'امکانات', key: 'modules', width: 44},
|
||||||
|
{header: 'تعداد', key: 'quantity', width: 10},
|
||||||
|
{header: 'قیمت واحد (ریال)', key: 'unitPrice', width: 22},
|
||||||
|
{header: 'جمع (ریال)', key: 'total', width: 22},
|
||||||
|
];
|
||||||
|
order.items.forEach(item =>
|
||||||
|
sheet.addRow({
|
||||||
|
name: item.name,
|
||||||
|
model: item.model,
|
||||||
|
modules: item.modules.join('، '),
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitPrice: item.unitPrice,
|
||||||
|
total: item.unitPrice * item.quantity,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const subtotal = order.items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
|
||||||
|
const total =
|
||||||
|
subtotal - (order.invoice?.discount || 0) + (order.invoice?.shipping || 0) + (order.invoice?.tax || 0);
|
||||||
|
sheet.addRow({name: 'مبلغ نهایی', total});
|
||||||
|
sheet.getRow(1).font = {bold: true, color: {argb: 'FFFFFFFF'}};
|
||||||
|
sheet.getRow(1).fill = {type: 'pattern', pattern: 'solid', fgColor: {argb: 'FF00A693'}};
|
||||||
|
sheet.getRow(sheet.rowCount).font = {bold: true, color: {argb: 'FF006D61'}};
|
||||||
|
sheet.getColumn('unitPrice').numFmt = '#,##0';
|
||||||
|
sheet.getColumn('total').numFmt = '#,##0';
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${filename}.xlsx`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
+1
-7
@@ -5,13 +5,7 @@ const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
|
||||||
transpilePackages: [
|
transpilePackages: ['antd', '@ant-design/icons', 'rc-util', 'rc-pagination', 'rc-picker'],
|
||||||
'antd',
|
|
||||||
'@ant-design/icons',
|
|
||||||
'rc-util',
|
|
||||||
'rc-pagination',
|
|
||||||
'rc-picker'
|
|
||||||
],
|
|
||||||
|
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
|
||||||
|
|||||||
Generated
+1188
-2
File diff suppressed because it is too large
Load Diff
+37
-30
@@ -1,32 +1,39 @@
|
|||||||
{
|
{
|
||||||
"name": "robot-market-showcase",
|
"name": "robot-market-showcase",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -H 0.0.0.0",
|
"format": "prettier --write .",
|
||||||
"build": "next build",
|
"format:check": "prettier --check .",
|
||||||
"start": "next start",
|
"predev": "npm run format",
|
||||||
"lint": "next lint",
|
"dev": "next dev -H 0.0.0.0",
|
||||||
"typecheck": "tsc --noEmit"
|
"build": "next build",
|
||||||
},
|
"start": "next start",
|
||||||
"dependencies": {
|
"lint": "next lint",
|
||||||
"@ant-design/icons": "5.3.7",
|
"typecheck": "tsc --noEmit"
|
||||||
"@reduxjs/toolkit": "2.2.7",
|
},
|
||||||
"antd": "5.19.1",
|
"dependencies": {
|
||||||
"axios": "1.7.2",
|
"@ant-design/icons": "5.3.7",
|
||||||
"gsap": "3.12.5",
|
"@reduxjs/toolkit": "2.2.7",
|
||||||
"next": "14.2.5",
|
"antd": "5.19.1",
|
||||||
"react": "18.3.1",
|
"axios": "1.7.2",
|
||||||
"react-dom": "18.3.1",
|
"exceljs": "^4.4.0",
|
||||||
"react-redux": "9.1.2"
|
"gsap": "3.12.5",
|
||||||
},
|
"html2canvas": "^1.4.1",
|
||||||
"devDependencies": {
|
"jspdf": "^2.5.2",
|
||||||
"@types/node": "20.19.43",
|
"next": "14.2.5",
|
||||||
"@types/react": "18.3.31",
|
"react": "18.3.1",
|
||||||
"@types/react-dom": "18.3.7",
|
"react-dom": "18.3.1",
|
||||||
"autoprefixer": "10.4.19",
|
"react-redux": "9.1.2"
|
||||||
"postcss": "8.4.39",
|
},
|
||||||
"tailwindcss": "3.4.6",
|
"devDependencies": {
|
||||||
"typescript": "5.9.3"
|
"@types/node": "20.19.43",
|
||||||
}
|
"@types/react": "18.3.31",
|
||||||
|
"@types/react-dom": "18.3.7",
|
||||||
|
"autoprefixer": "10.4.19",
|
||||||
|
"postcss": "8.4.39",
|
||||||
|
"prettier": "3.9.6",
|
||||||
|
"tailwindcss": "3.4.6",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type {AuthSession, CustomerProfile} from '@/types/account';
|
||||||
|
|
||||||
|
export const AUTH_STORAGE_KEY = 'rm-auth-session';
|
||||||
|
const DEMO_PHONE = '09123456789';
|
||||||
|
const DEMO_PASSWORD = '123456';
|
||||||
|
const DEMO_OTP = '12345';
|
||||||
|
|
||||||
|
const wait = (milliseconds = 450) => new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||||
|
const emptyProfile = (phone: string): CustomerProfile => ({
|
||||||
|
type: 'individual',
|
||||||
|
phone,
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
province: '',
|
||||||
|
city: '',
|
||||||
|
address: '',
|
||||||
|
postalCode: '',
|
||||||
|
preferredCallTime: '',
|
||||||
|
companyName: '',
|
||||||
|
nationalId: '',
|
||||||
|
economicCode: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
export function isIranianMobile(phone: string) {
|
||||||
|
return /^09\d{9}$/.test(phone.replace(/\s/g, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestOtp(phone: string): Promise<{expiresIn: number}> {
|
||||||
|
await wait();
|
||||||
|
if (!isIranianMobile(phone)) throw new Error('INVALID_PHONE');
|
||||||
|
/*
|
||||||
|
POST /auth/otp/request
|
||||||
|
Request: { "phone": "09123456789" }
|
||||||
|
Response: { "success": true, "expiresIn": 120 }
|
||||||
|
*/
|
||||||
|
return {expiresIn: 120};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginWithOtp(phone: string, code: string): Promise<AuthSession> {
|
||||||
|
await wait();
|
||||||
|
if (!isIranianMobile(phone) || code !== DEMO_OTP) throw new Error('INVALID_CREDENTIALS');
|
||||||
|
/*
|
||||||
|
POST /auth/otp/verify
|
||||||
|
Request: { "phone": "09123456789", "code": "12345" }
|
||||||
|
Response: { "accessToken": "...", "user": { "phone": "09123456789", "firstName": "", "lastName": "" } }
|
||||||
|
In production, prefer a Secure, HttpOnly session cookie instead of localStorage.
|
||||||
|
*/
|
||||||
|
return {accessToken: `mock-${phone}`, profile: emptyProfile(phone)};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginWithPassword(phone: string, password: string): Promise<AuthSession> {
|
||||||
|
await wait();
|
||||||
|
if (phone !== DEMO_PHONE || password !== DEMO_PASSWORD) throw new Error('INVALID_CREDENTIALS');
|
||||||
|
/*
|
||||||
|
POST /auth/password/login
|
||||||
|
Request: { "phone": "09123456789", "password": "123456" }
|
||||||
|
Response: { "accessToken": "...", "user": { "phone": "09123456789", "firstName": "حامد", "lastName": "عبداللهزاده" } }
|
||||||
|
*/
|
||||||
|
return {accessToken: `mock-${phone}`, profile: {...emptyProfile(phone), firstName: 'کاربر', lastName: 'آزمایشی'}};
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type {CustomerOrder, CustomerProfile} from '@/types/account';
|
||||||
|
|
||||||
|
const wait = () => new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
function createOrders(customer: CustomerProfile): CustomerOrder[] {
|
||||||
|
const buyer = {
|
||||||
|
...customer,
|
||||||
|
firstName: customer.firstName || 'کاربر',
|
||||||
|
lastName: customer.lastName || 'روبات مارکت',
|
||||||
|
city: customer.city || 'مشهد',
|
||||||
|
address: customer.address || 'نشانی خریدار پس از تکمیل پروفایل ثبت میشود',
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'order-128',
|
||||||
|
number: 'RM-1405-00128',
|
||||||
|
createdAt: '1405/06/12',
|
||||||
|
updatedAt: '1405/06/15',
|
||||||
|
status: 'proforma_issued',
|
||||||
|
paymentStatus: 'awaiting_payment',
|
||||||
|
customer: buyer,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'rm-48',
|
||||||
|
name: 'دستگاه وندینگ روبات مارکت ۴۸',
|
||||||
|
model: 'RM-48',
|
||||||
|
image: '/images/machine-48-base.webp',
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: 4850000000,
|
||||||
|
modules: ['کارتخوان بانکی', 'سیستم سرمایش', 'برچسب اختصاصی برند'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
events: [
|
||||||
|
{status: 'registered', date: '1405/06/12', note: 'درخواست شما ثبت شد.', completed: true},
|
||||||
|
{
|
||||||
|
status: 'under_review',
|
||||||
|
date: '1405/06/13',
|
||||||
|
note: 'کارشناس فروش درخواست را بررسی کرد.',
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{status: 'approved', date: '1405/06/14', note: 'جزئیات سفارش تلفنی تأیید شد.', completed: true},
|
||||||
|
{status: 'proforma_issued', date: '1405/06/15', note: 'پیشفاکتور قابل دریافت است.', completed: true},
|
||||||
|
],
|
||||||
|
invoice: {
|
||||||
|
number: 'PF-1405-0081',
|
||||||
|
issueDate: '1405/06/15',
|
||||||
|
validUntil: '1405/06/22',
|
||||||
|
approvedAt: '1405/06/15',
|
||||||
|
discount: 50000000,
|
||||||
|
shipping: 25000000,
|
||||||
|
tax: 480000000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'order-127',
|
||||||
|
number: 'RM-1405-00127',
|
||||||
|
createdAt: '1405/06/08',
|
||||||
|
updatedAt: '1405/06/10',
|
||||||
|
status: 'under_review',
|
||||||
|
paymentStatus: 'not_payable',
|
||||||
|
customer: buyer,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'rm-35',
|
||||||
|
name: 'دستگاه وندینگ روبات مارکت ۳۵',
|
||||||
|
model: 'RM-35',
|
||||||
|
image: '/images/machine-35-base.webp',
|
||||||
|
quantity: 2,
|
||||||
|
unitPrice: 3950000000,
|
||||||
|
modules: ['کارتخوان بانکی'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
events: [
|
||||||
|
{status: 'registered', date: '1405/06/08', note: 'درخواست شما ثبت شد.', completed: true},
|
||||||
|
{
|
||||||
|
status: 'under_review',
|
||||||
|
date: '1405/06/10',
|
||||||
|
note: 'کارشناس برای هماهنگی با شما تماس میگیرد.',
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrders(customer: CustomerProfile): Promise<CustomerOrder[]> {
|
||||||
|
await wait();
|
||||||
|
/*
|
||||||
|
GET /orders
|
||||||
|
Response: { "orders": [{ "id": "order-128", "number": "RM-1405-00128", "status": "proforma_issued", "paymentStatus": "awaiting_payment" }] }
|
||||||
|
*/
|
||||||
|
return createOrders(customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrder(id: string, customer: CustomerProfile): Promise<CustomerOrder | undefined> {
|
||||||
|
await wait();
|
||||||
|
/*
|
||||||
|
GET /orders/:id
|
||||||
|
Response: { "order": { "id": "order-128", "items": [], "events": [], "invoice": { "approvedAt": "1405/06/15" } } }
|
||||||
|
The backend must verify that the signed-in customer owns this order.
|
||||||
|
*/
|
||||||
|
return createOrders(customer).find(order => order.id === id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type {CustomerProfile} from '@/types/account';
|
||||||
|
|
||||||
|
const wait = () => new Promise(resolve => setTimeout(resolve, 350));
|
||||||
|
|
||||||
|
export async function saveProfile(profile: CustomerProfile): Promise<CustomerProfile> {
|
||||||
|
await wait();
|
||||||
|
/*
|
||||||
|
PATCH /me
|
||||||
|
Request: { "type": "corporate", "firstName": "...", "lastName": "...", "companyName": "...", "address": "..." }
|
||||||
|
Response: { "user": { "phone": "09123456789", "type": "corporate", "firstName": "...", "lastName": "..." } }
|
||||||
|
*/
|
||||||
|
return {...profile};
|
||||||
|
}
|
||||||
+45
-53
@@ -1,70 +1,62 @@
|
|||||||
import type {PayloadAction} from '@reduxjs/toolkit';
|
import type {PayloadAction} from '@reduxjs/toolkit';
|
||||||
import type {CartItem, CartProduct, CartState} from '@/types/domain';
|
import type {CartItem, CartProduct, CartState} from '@/types/domain';
|
||||||
import type {RootState} from './store';
|
import type {RootState} from './store';
|
||||||
import { createSlice } from '@reduxjs/toolkit';
|
import {createSlice} from '@reduxjs/toolkit';
|
||||||
|
|
||||||
const initialState: CartState = {
|
const initialState: CartState = {
|
||||||
items: [], // { id, name, priceNumeric, priceLabel, qty, image }
|
items: [], // { id, name, priceNumeric, priceLabel, qty, image }
|
||||||
};
|
};
|
||||||
|
|
||||||
const cartSlice = createSlice({
|
const cartSlice = createSlice({
|
||||||
name: 'cart',
|
name: 'cart',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
restoreCart: (state, action: PayloadAction<CartItem[]>) => {
|
restoreCart: (state, action: PayloadAction<CartItem[]>) => {
|
||||||
state.items = action.payload;
|
state.items = action.payload;
|
||||||
|
},
|
||||||
|
addToCart: (state, action: PayloadAction<CartProduct>) => {
|
||||||
|
const product = action.payload;
|
||||||
|
const existing = state.items.find(item => item.id === product.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.qty += 1;
|
||||||
|
} else {
|
||||||
|
state.items.push({...product, qty: 1});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeFromCart: (state, action: PayloadAction<string>) => {
|
||||||
|
state.items = state.items.filter(item => item.id !== action.payload);
|
||||||
|
},
|
||||||
|
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: PayloadAction<string>) => {
|
||||||
|
const item = state.items.find(item => item.id === action.payload);
|
||||||
|
if (item && item.qty > 1) {
|
||||||
|
item.qty -= 1;
|
||||||
|
} else if (item) {
|
||||||
|
state.items = state.items.filter(i => i.id !== action.payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearCart: state => {
|
||||||
|
state.items = [];
|
||||||
|
},
|
||||||
},
|
},
|
||||||
addToCart: (state, action: PayloadAction<CartProduct>) => {
|
|
||||||
const product = action.payload;
|
|
||||||
const existing = state.items.find((item) => item.id === product.id);
|
|
||||||
if (existing) {
|
|
||||||
existing.qty += 1;
|
|
||||||
} else {
|
|
||||||
state.items.push({ ...product, qty: 1 });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
removeFromCart: (state, action: PayloadAction<string>) => {
|
|
||||||
state.items = state.items.filter((item) => item.id !== action.payload);
|
|
||||||
},
|
|
||||||
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: PayloadAction<string>) => {
|
|
||||||
const item = state.items.find((item) => item.id === action.payload);
|
|
||||||
if (item && item.qty > 1) {
|
|
||||||
item.qty -= 1;
|
|
||||||
} else if (item) {
|
|
||||||
state.items = state.items.filter((i) => i.id !== action.payload);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
clearCart: (state) => {
|
|
||||||
state.items = [];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const {
|
export const {restoreCart, addToCart, removeFromCart, setQuantity, incrementQty, decrementQty, clearCart} =
|
||||||
restoreCart,
|
cartSlice.actions;
|
||||||
addToCart,
|
|
||||||
removeFromCart,
|
|
||||||
setQuantity,
|
|
||||||
incrementQty,
|
|
||||||
decrementQty,
|
|
||||||
clearCart,
|
|
||||||
} = cartSlice.actions;
|
|
||||||
|
|
||||||
// Selectors
|
// Selectors
|
||||||
export const selectCartItems = (state: RootState) => state.cart.items;
|
export const selectCartItems = (state: RootState) => state.cart.items;
|
||||||
export const selectCartCount = (state: RootState) =>
|
export const selectCartCount = (state: RootState) => state.cart.items.reduce((sum, item) => sum + item.qty, 0);
|
||||||
state.cart.items.reduce((sum, item) => sum + item.qty, 0);
|
|
||||||
export const selectCartTotal = (state: RootState) =>
|
export const selectCartTotal = (state: RootState) =>
|
||||||
state.cart.items.reduce((sum, item) => sum + item.qty * item.priceNumeric, 0);
|
state.cart.items.reduce((sum, item) => sum + item.qty * item.priceNumeric, 0);
|
||||||
|
|
||||||
export default cartSlice.reducer;
|
export default cartSlice.reducer;
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import {useDispatch, useSelector} from 'react-redux';
|
||||||
import type { AppDispatch, RootState } from './store';
|
import type {AppDispatch, RootState} from './store';
|
||||||
|
|
||||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
||||||
export const useAppSelector = useSelector.withTypes<RootState>();
|
export const useAppSelector = useSelector.withTypes<RootState>();
|
||||||
|
|||||||
+8
-8
@@ -1,16 +1,16 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit';
|
import {configureStore} from '@reduxjs/toolkit';
|
||||||
import cartReducer from './cartSlice';
|
import cartReducer from './cartSlice';
|
||||||
|
|
||||||
export const makeStore = () =>
|
export const makeStore = () =>
|
||||||
configureStore({
|
configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
cart: cartReducer,
|
cart: cartReducer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Singleton store for the client (App Router renders providers on the client tree).
|
// Singleton store for the client (App Router renders providers on the client tree).
|
||||||
export const store = makeStore();
|
export const store = makeStore();
|
||||||
|
|
||||||
export type AppStore = ReturnType<typeof makeStore>;
|
export type AppStore = ReturnType<typeof makeStore>;
|
||||||
export type RootState = ReturnType<AppStore["getState"]>;
|
export type RootState = ReturnType<AppStore['getState']>;
|
||||||
export type AppDispatch = AppStore["dispatch"];
|
export type AppDispatch = AppStore['dispatch'];
|
||||||
|
|||||||
+49
-52
@@ -1,59 +1,56 @@
|
|||||||
import type {Config} from 'tailwindcss';
|
import type {Config} from 'tailwindcss';
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
content: [
|
content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}'],
|
||||||
'./app/**/*.{js,jsx,ts,tsx}',
|
theme: {
|
||||||
'./components/**/*.{js,jsx,ts,tsx}',
|
extend: {
|
||||||
],
|
colors: {
|
||||||
theme: {
|
brand: {
|
||||||
extend: {
|
DEFAULT: '#00a693',
|
||||||
colors: {
|
50: '#e6f7f4',
|
||||||
brand: {
|
100: '#c0ebe3',
|
||||||
DEFAULT: '#00a693',
|
200: '#8fdccc',
|
||||||
50: '#e6f7f4',
|
300: '#5cccb4',
|
||||||
100: '#c0ebe3',
|
400: '#2eba9c',
|
||||||
200: '#8fdccc',
|
500: '#00a693',
|
||||||
300: '#5cccb4',
|
600: '#008c7c',
|
||||||
400: '#2eba9c',
|
700: '#017065',
|
||||||
500: '#00a693',
|
800: '#08554e',
|
||||||
600: '#008c7c',
|
900: '#0b3d38',
|
||||||
700: '#017065',
|
950: '#062522',
|
||||||
800: '#08554e',
|
},
|
||||||
900: '#0b3d38',
|
ink: {
|
||||||
950: '#062522',
|
950: '#04120f',
|
||||||
|
900: '#071e19',
|
||||||
|
800: '#0c2b24',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
vazir: ['var(--font-vazirmatn)', 'Tahoma', 'sans-serif'],
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
floaty: {
|
||||||
|
'0%, 100%': {transform: 'translateY(0) rotate(0deg)'},
|
||||||
|
'50%': {transform: 'translateY(-18px) rotate(4deg)'},
|
||||||
|
},
|
||||||
|
pulseSoft: {
|
||||||
|
'0%, 100%': {opacity: '0.6'},
|
||||||
|
'50%': {opacity: '1'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
floaty: 'floaty 6s ease-in-out infinite',
|
||||||
|
pulseSoft: 'pulseSoft 2.4s ease-in-out infinite',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
brandGlow: '0 0 60px -10px rgba(0, 166, 147, 0.55)',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
ink: {
|
|
||||||
950: '#04120f',
|
|
||||||
900: '#071e19',
|
|
||||||
800: '#0c2b24',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
vazir: ['var(--font-vazirmatn)', 'Tahoma', 'sans-serif'],
|
|
||||||
},
|
|
||||||
keyframes: {
|
|
||||||
floaty: {
|
|
||||||
'0%, 100%': { transform: 'translateY(0) rotate(0deg)' },
|
|
||||||
'50%': { transform: 'translateY(-18px) rotate(4deg)' },
|
|
||||||
},
|
|
||||||
pulseSoft: {
|
|
||||||
'0%, 100%': { opacity: '0.6' },
|
|
||||||
'50%': { opacity: '1' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
floaty: 'floaty 6s ease-in-out infinite',
|
|
||||||
pulseSoft: 'pulseSoft 2.4s ease-in-out infinite',
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
brandGlow: '0 0 60px -10px rgba(0, 166, 147, 0.55)',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
plugins: [],
|
||||||
plugins: [],
|
corePlugins: {
|
||||||
corePlugins: {
|
preflight: true,
|
||||||
preflight: true,
|
},
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
+31
-20
@@ -1,22 +1,33 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": false,
|
"allowJs": false,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [{ "name": "next" }],
|
"plugins": [{"name": "next"}],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": { "@/*": ["./*"] }
|
"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"],
|
"include": [
|
||||||
"exclude": ["node_modules", "docs", "out"]
|
"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,75 @@
|
|||||||
|
export type CustomerType = 'individual' | 'corporate';
|
||||||
|
export type OrderStatus =
|
||||||
|
| 'registered'
|
||||||
|
| 'under_review'
|
||||||
|
| 'needs_information'
|
||||||
|
| 'approved'
|
||||||
|
| 'proforma_issued'
|
||||||
|
| 'awaiting_payment'
|
||||||
|
| 'preparing'
|
||||||
|
| 'ready_to_ship'
|
||||||
|
| 'shipped'
|
||||||
|
| 'delivered'
|
||||||
|
| 'cancelled';
|
||||||
|
export type PaymentStatus = 'not_payable' | 'awaiting_payment' | 'paid' | 'failed';
|
||||||
|
|
||||||
|
export interface CustomerProfile {
|
||||||
|
type: CustomerType;
|
||||||
|
phone: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
province: string;
|
||||||
|
city: string;
|
||||||
|
address: string;
|
||||||
|
postalCode: string;
|
||||||
|
preferredCallTime: string;
|
||||||
|
companyName: string;
|
||||||
|
nationalId: string;
|
||||||
|
economicCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthSession {
|
||||||
|
accessToken: string;
|
||||||
|
profile: CustomerProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
model: string;
|
||||||
|
image: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
modules: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderEvent {
|
||||||
|
status: OrderStatus;
|
||||||
|
date: string;
|
||||||
|
note: string;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProformaInvoice {
|
||||||
|
number: string;
|
||||||
|
issueDate: string;
|
||||||
|
validUntil: string;
|
||||||
|
approvedAt: string;
|
||||||
|
discount: number;
|
||||||
|
shipping: number;
|
||||||
|
tax: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerOrder {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
status: OrderStatus;
|
||||||
|
paymentStatus: PaymentStatus;
|
||||||
|
items: OrderItem[];
|
||||||
|
customer: CustomerProfile;
|
||||||
|
events: OrderEvent[];
|
||||||
|
invoice?: ProformaInvoice;
|
||||||
|
}
|
||||||
+68
-61
@@ -4,79 +4,86 @@ export type Theme = 'light' | 'dark';
|
|||||||
export type ProductCategory = 'warm-vending';
|
export type ProductCategory = 'warm-vending';
|
||||||
|
|
||||||
export interface Machine {
|
export interface Machine {
|
||||||
id: string;
|
id: string;
|
||||||
category: ProductCategory;
|
category: ProductCategory;
|
||||||
name: string;
|
name: string;
|
||||||
shortLabel: string;
|
shortLabel: string;
|
||||||
priceLabel: string;
|
priceLabel: string;
|
||||||
priceNumeric: number;
|
priceNumeric: number;
|
||||||
description: string;
|
description: string;
|
||||||
highlights: string[];
|
highlights: string[];
|
||||||
image: string;
|
image: string;
|
||||||
imageAlt: string;
|
imageAlt: string;
|
||||||
gif: string;
|
gif: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CartProduct = Pick<Machine, 'id' | 'name' | 'priceNumeric' | 'priceLabel' | 'image'>;
|
export type CartProduct = Pick<Machine, 'id' | 'name' | 'priceNumeric' | 'priceLabel' | 'image'>;
|
||||||
export interface CartItem extends CartProduct { qty: number }
|
export interface CartItem extends CartProduct {
|
||||||
export interface CartState { items: CartItem[] }
|
qty: number;
|
||||||
|
}
|
||||||
|
export interface CartState {
|
||||||
|
items: CartItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface FloatingSnack {
|
export interface FloatingSnack {
|
||||||
id: string;
|
id: string;
|
||||||
image: string;
|
image: string;
|
||||||
size: number;
|
size: number;
|
||||||
top: string;
|
top: string;
|
||||||
left: string;
|
left: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
delay: number;
|
delay: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeatureIcon = 'safety' | 'consulting' | 'installment' | 'packaging';
|
export type FeatureIcon = 'safety' | 'consulting' | 'installment' | 'packaging';
|
||||||
export type LocationIcon = 'hotel' | 'school' | 'pharmacy' | 'hospital' | 'night' | 'specialty';
|
export type LocationIcon = 'hotel' | 'school' | 'pharmacy' | 'hospital' | 'night' | 'specialty';
|
||||||
|
|
||||||
interface InfoCard<Icon extends string> {
|
interface InfoCard<Icon extends string> {
|
||||||
id: string;
|
id: string;
|
||||||
icon: Icon;
|
icon: Icon;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SiteContent {
|
export interface SiteContent {
|
||||||
dir: Direction;
|
dir: Direction;
|
||||||
lang: Locale;
|
lang: Locale;
|
||||||
meta: { title: string; description: string };
|
meta: {title: string; description: string};
|
||||||
nav: Record<'home' | 'showcase' | 'about' | 'locations' | 'contact' | 'login', string>;
|
nav: Record<'home' | 'showcase' | 'about' | 'locations' | 'contact' | 'login', string>;
|
||||||
hero: {
|
hero: {
|
||||||
loading: string;
|
loading: string;
|
||||||
cta: string;
|
cta: string;
|
||||||
slides: { id: string; kicker: string; titleLines: string[]; sub: string; image: string }[];
|
slides: {id: string; kicker: string; titleLines: string[]; sub: string; image: string}[];
|
||||||
};
|
};
|
||||||
cart: {
|
cart: {
|
||||||
title: string;
|
title: string;
|
||||||
empty: string;
|
empty: string;
|
||||||
total: string;
|
total: string;
|
||||||
checkout: string;
|
checkout: string;
|
||||||
checkoutToast: string;
|
checkoutToast: string;
|
||||||
addToast: (name: string) => string;
|
addToast: (name: string) => string;
|
||||||
};
|
};
|
||||||
machineActions: {
|
machineActions: {
|
||||||
addToCart: string;
|
addToCart: string;
|
||||||
wishlistAdd: string;
|
wishlistAdd: string;
|
||||||
wishlistRemove: string;
|
wishlistRemove: string;
|
||||||
hoverHint: string;
|
hoverHint: string;
|
||||||
altVariant: string;
|
altVariant: string;
|
||||||
gifAlt: (name: string) => string;
|
gifAlt: (name: string) => string;
|
||||||
};
|
};
|
||||||
machines: Machine[];
|
machines: Machine[];
|
||||||
features: InfoCard<FeatureIcon>[];
|
features: InfoCard<FeatureIcon>[];
|
||||||
about: { eyebrow: string; legalName: string; more: string; less: string; paragraphs: string[] };
|
about: {eyebrow: string; legalName: string; more: string; less: string; paragraphs: string[]};
|
||||||
locations: { title: string; items: InfoCard<LocationIcon>[] };
|
locations: {title: string; items: InfoCard<LocationIcon>[]};
|
||||||
footer: Record<'aboutLabel' | 'aboutText' | 'quickLinks' | 'contactTitle' | 'factoryLabel' | 'salesLabel' | 'rights', string>;
|
footer: Record<
|
||||||
contactInfo: {
|
'aboutLabel' | 'aboutText' | 'quickLinks' | 'contactTitle' | 'factoryLabel' | 'salesLabel' | 'rights',
|
||||||
orderPhone: string;
|
string
|
||||||
orderPhoneHref: string;
|
>;
|
||||||
factoryAddress: string;
|
contactInfo: {
|
||||||
salesOfficeAddress: string;
|
orderPhone: string;
|
||||||
socialLinks: { id: string; label: string; href: string }[];
|
orderPhoneHref: string;
|
||||||
};
|
factoryAddress: string;
|
||||||
|
salesOfficeAddress: string;
|
||||||
|
socialLinks: {id: string; label: string; href: string}[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user