Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5a6b1b68d | |||
| f813cc4854 | |||
| 024a455ab3 | |||
| f687178238 | |||
| dc07eb9594 | |||
| 5e980cdfc0 | |||
| 665e3ca279 | |||
| c3ca39ed15 | |||
| dac59cd180 | |||
| c3ea07d6e4 | |||
| aea1d20fdc | |||
| 9f002433c7 | |||
| 5ba09c2ef1 | |||
| 631cac8c3c | |||
| 1a6a0dc495 | |||
| ffdc218e20 | |||
| 260429afba | |||
| ae5896d440 | |||
| aec5b21f98 | |||
| 57c83185da | |||
| cd1af30bbc | |||
| 38e3f6a5a2 | |||
| 5ae350e25b | |||
| 255695e8ae | |||
| a4975cdb2d | |||
| 639d5c305e | |||
| d0117f3171 | |||
| 2c15ae0062 | |||
| 861b762e18 | |||
| 234649c65e | |||
| a9222590ac | |||
| aec68eff34 |
@@ -68,6 +68,14 @@ REDIS_PORT=6381
|
|||||||
# ── Migrations ────────────────────────────────────────────────────────────────
|
# ── Migrations ────────────────────────────────────────────────────────────────
|
||||||
RUN_MIGRATIONS=true
|
RUN_MIGRATIONS=true
|
||||||
|
|
||||||
|
# ── System admin seed (admin panel login) ─────────────────────────────────────
|
||||||
|
# On every boot the seeder ensures this admin exists with these credentials.
|
||||||
|
# Username defaults to "admin" if not set. Password is required to enable
|
||||||
|
# password login — leave blank to force OTP-only login.
|
||||||
|
SEED_ADMIN_PHONE=09190345606
|
||||||
|
SEED_ADMIN_USERNAME=admin
|
||||||
|
SEED_ADMIN_PASSWORD=change-me-strong-admin-password
|
||||||
|
|
||||||
# ── Payment: ZarinPal ─────────────────────────────────────────────────────────
|
# ── Payment: ZarinPal ─────────────────────────────────────────────────────────
|
||||||
# Get your merchant ID from: https://panel.zarinpal.com → API → MerchantID
|
# Get your merchant ID from: https://panel.zarinpal.com → API → MerchantID
|
||||||
ZARINPAL_MERCHANT_ID=
|
ZARINPAL_MERCHANT_ID=
|
||||||
|
|||||||
+117
-29
@@ -158,7 +158,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: web/dashboard
|
working-directory: web/dashboard
|
||||||
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
|
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ --strict-ssl=false
|
||||||
|
|
||||||
- name: TypeScript check
|
- name: TypeScript check
|
||||||
working-directory: web/dashboard
|
working-directory: web/dashboard
|
||||||
@@ -188,7 +188,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: web/admin
|
working-directory: web/admin
|
||||||
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
|
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ --strict-ssl=false
|
||||||
|
|
||||||
- name: TypeScript check
|
- name: TypeScript check
|
||||||
working-directory: web/admin
|
working-directory: web/admin
|
||||||
@@ -218,7 +218,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: web/website
|
working-directory: web/website
|
||||||
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
|
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ --strict-ssl=false
|
||||||
|
|
||||||
- name: TypeScript check
|
- name: TypeScript check
|
||||||
working-directory: web/website
|
working-directory: web/website
|
||||||
@@ -248,7 +248,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: web/koja
|
working-directory: web/koja
|
||||||
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
|
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ --strict-ssl=false
|
||||||
|
|
||||||
- name: TypeScript check
|
- name: TypeScript check
|
||||||
working-directory: web/koja
|
working-directory: web/koja
|
||||||
@@ -310,45 +310,133 @@ jobs:
|
|||||||
DOCKER_BUILDKIT: 1
|
DOCKER_BUILDKIT: 1
|
||||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||||
|
|
||||||
- name: Start main services
|
- name: Stop old app containers
|
||||||
|
# The existing containers were created before compose labels were added,
|
||||||
|
# so Compose can't claim them and hits a name conflict on 'up'.
|
||||||
|
# This step removes only meezi's own 6 app containers — never touches
|
||||||
|
# postgres, redis, or any other project's containers.
|
||||||
run: |
|
run: |
|
||||||
docker compose up -d \
|
for name in meezi-api meezi-web meezi-website meezi-koja meezi-admin-api meezi-admin-web; do
|
||||||
--no-deps \
|
docker stop "$name" 2>/dev/null || true
|
||||||
postgres redis api web website koja
|
docker rm "$name" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
- name: Start admin services
|
- name: Attach infrastructure to meezi network
|
||||||
|
# postgres/redis may be on a different network (created before name:meezi
|
||||||
|
# was in the compose file). Disconnect/reconnect with service-name aliases
|
||||||
|
# so the API can resolve "Host=postgres" and "redis:6379".
|
||||||
|
# App containers are stopped at this point so the brief disconnect is safe.
|
||||||
|
run: |
|
||||||
|
docker network inspect meezi_default >/dev/null 2>&1 \
|
||||||
|
|| docker network create meezi_default
|
||||||
|
docker network disconnect meezi_default meezi-db 2>/dev/null || true
|
||||||
|
docker network disconnect meezi_default meezi-redis 2>/dev/null || true
|
||||||
|
docker network connect --alias postgres meezi_default meezi-db
|
||||||
|
docker network connect --alias redis meezi_default meezi-redis
|
||||||
|
echo "=== infra network state ==="
|
||||||
|
docker inspect meezi-db --format='meezi-db networks={{json .NetworkSettings.Networks}}' 2>&1 || true
|
||||||
|
docker inspect meezi-redis --format='meezi-redis networks={{json .NetworkSettings.Networks}}' 2>&1 || true
|
||||||
|
|
||||||
|
- name: Start API
|
||||||
|
# --no-deps skips all depends_on checks so compose starts api immediately
|
||||||
|
# without trying to verify postgres/redis health (they're not compose-managed).
|
||||||
|
run: docker compose up -d --no-deps api
|
||||||
|
|
||||||
|
- name: Wait for API healthy
|
||||||
|
# Poll ourselves so we can detect crashes early and print logs before
|
||||||
|
# restart-policy smothers them. Mirrors healthcheck: start_period=40s,
|
||||||
|
# interval=10s, retries=12 → up to 3 min total.
|
||||||
|
# Also checks RestartCount: restart:unless-stopped hides crashes behind
|
||||||
|
# rapid restarts, so state=exited is fleeting — a rising count tells us.
|
||||||
|
run: |
|
||||||
|
echo "Waiting for meezi-api (up to 3 min)..."
|
||||||
|
for i in $(seq 1 36); do
|
||||||
|
HEALTH=$(docker inspect --format='{{.State.Health.Status}}' meezi-api 2>/dev/null || echo "missing")
|
||||||
|
STATE=$(docker inspect --format='{{.State.Status}}' meezi-api 2>/dev/null || echo "missing")
|
||||||
|
RESTARTS=$(docker inspect --format='{{.RestartCount}}' meezi-api 2>/dev/null || echo "0")
|
||||||
|
echo " [$i/36] state=$STATE health=$HEALTH restarts=$RESTARTS"
|
||||||
|
[ "$HEALTH" = "healthy" ] && echo "✅ meezi-api healthy" && break
|
||||||
|
if [ "$STATE" = "exited" ] || [ "$STATE" = "dead" ]; then
|
||||||
|
echo "❌ meezi-api crashed (state=$STATE) — logs:"
|
||||||
|
docker logs meezi-api 2>&1 | tail -120
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$RESTARTS" -gt 1 ]; then
|
||||||
|
echo "❌ meezi-api crash-loop (restarts=$RESTARTS) — logs:"
|
||||||
|
docker logs meezi-api 2>&1 | tail -120
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[ "$i" = "36" ] && echo "❌ meezi-api timeout (3 min)" \
|
||||||
|
&& docker logs meezi-api 2>&1 | tail -80 && exit 1
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Start web services
|
||||||
|
# API is healthy at this point; start the three Next.js frontends.
|
||||||
|
run: docker compose up -d --no-deps web website koja
|
||||||
|
|
||||||
|
- name: Start admin API
|
||||||
run: |
|
run: |
|
||||||
docker compose \
|
docker compose \
|
||||||
-f docker-compose.yml \
|
-f docker-compose.yml \
|
||||||
-f docker-compose.admin.yml \
|
-f docker-compose.admin.yml \
|
||||||
up -d \
|
up -d --no-deps admin-api
|
||||||
--no-deps \
|
|
||||||
admin-api admin-web
|
|
||||||
|
|
||||||
- name: Wait for main API healthy
|
|
||||||
run: |
|
|
||||||
for i in $(seq 1 24); do
|
|
||||||
STATUS=$(docker inspect --format='{{.State.Health.Status}}' meezi-api 2>/dev/null || echo "missing")
|
|
||||||
echo " [$i/24] $STATUS"
|
|
||||||
[ "$STATUS" = "healthy" ] && echo "✅ meezi-api healthy" && break
|
|
||||||
[ "$i" = "24" ] && echo "❌ meezi-api timeout" && docker compose logs --tail=40 api && exit 1
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Wait for admin API healthy
|
- name: Wait for admin API healthy
|
||||||
run: |
|
run: |
|
||||||
for i in $(seq 1 24); do
|
echo "Waiting for meezi-admin-api (up to 3 min)..."
|
||||||
STATUS=$(docker inspect --format='{{.State.Health.Status}}' meezi-admin-api 2>/dev/null || echo "missing")
|
for i in $(seq 1 36); do
|
||||||
echo " [$i/24] $STATUS"
|
HEALTH=$(docker inspect --format='{{.State.Health.Status}}' meezi-admin-api 2>/dev/null || echo "missing")
|
||||||
[ "$STATUS" = "healthy" ] && echo "✅ meezi-admin-api healthy" && break
|
STATE=$(docker inspect --format='{{.State.Status}}' meezi-admin-api 2>/dev/null || echo "missing")
|
||||||
[ "$i" = "24" ] && echo "❌ meezi-admin-api timeout" && docker compose -f docker-compose.yml -f docker-compose.admin.yml logs --tail=40 admin-api && exit 1
|
RESTARTS=$(docker inspect --format='{{.RestartCount}}' meezi-admin-api 2>/dev/null || echo "0")
|
||||||
|
echo " [$i/36] state=$STATE health=$HEALTH restarts=$RESTARTS"
|
||||||
|
[ "$HEALTH" = "healthy" ] && echo "✅ meezi-admin-api healthy" && break
|
||||||
|
if [ "$STATE" = "exited" ] || [ "$STATE" = "dead" ]; then
|
||||||
|
echo "❌ meezi-admin-api crashed (state=$STATE) — logs:"
|
||||||
|
docker logs meezi-admin-api 2>&1 | tail -80
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$RESTARTS" -gt 1 ]; then
|
||||||
|
echo "❌ meezi-admin-api crash-loop (restarts=$RESTARTS) — logs:"
|
||||||
|
docker logs meezi-admin-api 2>&1 | tail -80
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[ "$i" = "36" ] && echo "❌ meezi-admin-api timeout (3 min)" \
|
||||||
|
&& docker logs meezi-admin-api 2>&1 | tail -80 && exit 1
|
||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Start admin web
|
||||||
|
run: |
|
||||||
|
docker compose \
|
||||||
|
-f docker-compose.yml \
|
||||||
|
-f docker-compose.admin.yml \
|
||||||
|
up -d --no-deps admin-web
|
||||||
|
|
||||||
- name: Show all running containers
|
- name: Show all running containers
|
||||||
if: always()
|
if: always()
|
||||||
run: docker compose -f docker-compose.yml -f docker-compose.admin.yml ps
|
run: docker compose -f docker-compose.yml -f docker-compose.admin.yml ps
|
||||||
|
|
||||||
- name: Prune old images
|
- name: Dump logs on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "=== meezi-api logs ==="
|
||||||
|
docker logs meezi-api --tail=120 2>&1 || true
|
||||||
|
echo "=== meezi-admin-api logs ==="
|
||||||
|
docker logs meezi-admin-api --tail=80 2>&1 || true
|
||||||
|
echo "=== meezi_default network ==="
|
||||||
|
docker network inspect meezi_default 2>&1 || true
|
||||||
|
echo "=== meezi-db network state ==="
|
||||||
|
docker inspect meezi-db --format='{{json .NetworkSettings.Networks}}' 2>&1 || true
|
||||||
|
echo "=== meezi-redis network state ==="
|
||||||
|
docker inspect meezi-redis --format='{{json .NetworkSettings.Networks}}' 2>&1 || true
|
||||||
|
|
||||||
|
- name: Prune dangling images
|
||||||
if: success()
|
if: success()
|
||||||
run: docker image prune -f
|
run: |
|
||||||
|
# Remove untagged (<none>) images left over from this and previous builds.
|
||||||
|
# --filter dangling=true only removes images with no tags; never touches
|
||||||
|
# other projects' named images (soroushasadi-site, drsousan, etc.).
|
||||||
|
docker image prune -f
|
||||||
|
echo "Disk after prune:"
|
||||||
|
df -h /
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
registry=https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
|
strict-ssl=false
|
||||||
|
legacy-peer-deps=true
|
||||||
@@ -28,7 +28,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Development}"
|
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Development}"
|
||||||
ASPNETCORE_URLS: http://+:8080
|
ASPNETCORE_URLS: http://+:8080
|
||||||
RUN_MIGRATIONS: "false"
|
RUN_MIGRATIONS: "${RUN_MIGRATIONS:-true}"
|
||||||
ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING:-Host=postgres;Port=5432;Database=meezi;Username=meezi;Password=meezi_local_pass}"
|
ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING:-Host=postgres;Port=5432;Database=meezi;Username=meezi;Password=meezi_local_pass}"
|
||||||
ConnectionStrings__Redis: redis:6379
|
ConnectionStrings__Redis: redis:6379
|
||||||
Jwt__Key: "${JWT_KEY:-dev-jwt-key-CHANGE-THIS-IN-PRODUCTION-min32chars}"
|
Jwt__Key: "${JWT_KEY:-dev-jwt-key-CHANGE-THIS-IN-PRODUCTION-min32chars}"
|
||||||
@@ -36,6 +36,9 @@ services:
|
|||||||
Cors__Origins__1: "${CORS_ORIGIN_0:-http://localhost:3101}"
|
Cors__Origins__1: "${CORS_ORIGIN_0:-http://localhost:3101}"
|
||||||
Kavenegar__ApiKey: "${KAVENEGAR_API_KEY:-}"
|
Kavenegar__ApiKey: "${KAVENEGAR_API_KEY:-}"
|
||||||
Kavenegar__SenderNumber: "${KAVENEGAR_SENDER:-90005671}"
|
Kavenegar__SenderNumber: "${KAVENEGAR_SENDER:-90005671}"
|
||||||
|
Seed__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
|
||||||
|
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
|
||||||
|
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
|
||||||
ports:
|
ports:
|
||||||
- "${ADMIN_API_PORT:-5081}:8080"
|
- "${ADMIN_API_PORT:-5081}:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
name: meezi # Lock project name — prevents runner workspace from overriding it
|
||||||
|
|
||||||
# Meezi — main stack (Postgres, Redis, API, Dashboard, Website, Koja)
|
# Meezi — main stack (Postgres, Redis, API, Dashboard, Website, Koja)
|
||||||
#
|
#
|
||||||
# All images/packages served from Nexus at mirror.soroushasadi.com:
|
# All images/packages served from Nexus at mirror.soroushasadi.com:
|
||||||
@@ -92,6 +94,9 @@ services:
|
|||||||
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
|
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
|
||||||
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
|
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
|
||||||
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
|
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
|
||||||
|
Seed__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
|
||||||
|
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
|
||||||
|
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT:-5080}:8080"
|
- "${API_PORT:-5080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,27 +1,40 @@
|
|||||||
ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
|
ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
|
||||||
|
|
||||||
|
# ==================== DEPS STAGE ====================
|
||||||
FROM ${NODE_IMAGE} AS deps
|
FROM ${NODE_IMAGE} AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY web/admin/package*.json ./
|
COPY web/admin/package*.json ./
|
||||||
|
|
||||||
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
|
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
# Install deps then ensure Alpine (musl) SWC binary is present
|
|
||||||
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \
|
RUN npm ci --legacy-peer-deps --ignore-scripts \
|
||||||
&& NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \
|
--registry ${NPM_REGISTRY} \
|
||||||
|
--strict-ssl=false \
|
||||||
|
&& NEXT_VER=$(node -p "require('./node_modules/next/package.json').version") \
|
||||||
&& ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \
|
&& ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \
|
||||||
|| npm install --no-save --ignore-scripts --registry ${NPM_REGISTRY} \
|
|| npm install --no-save --ignore-scripts \
|
||||||
|
--registry ${NPM_REGISTRY} \
|
||||||
|
--strict-ssl=false \
|
||||||
"@next/swc-linux-x64-musl@${NEXT_VER}"
|
"@next/swc-linux-x64-musl@${NEXT_VER}"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== BUILDER STAGE ====================
|
||||||
FROM ${NODE_IMAGE} AS builder
|
FROM ${NODE_IMAGE} AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG NEXT_PUBLIC_ADMIN_API_URL=http://localhost:5081
|
ARG NEXT_PUBLIC_ADMIN_API_URL=http://localhost:5081
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL
|
ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY web/admin/ .
|
COPY web/admin/ .
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== RUNNER STAGE ====================
|
||||||
FROM ${NODE_IMAGE} AS runner
|
FROM ${NODE_IMAGE} AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -38,5 +51,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
@@ -4,7 +4,7 @@ FROM ${NODE_IMAGE} AS deps
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY web/koja/package*.json ./
|
COPY web/koja/package*.json ./
|
||||||
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
|
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY}
|
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} --strict-ssl=false
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS builder
|
FROM ${NODE_IMAGE} AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
+23
-3
@@ -1,22 +1,41 @@
|
|||||||
ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
|
ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
|
||||||
|
|
||||||
|
# ==================== DEPS STAGE ====================
|
||||||
FROM ${NODE_IMAGE} AS deps
|
FROM ${NODE_IMAGE} AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY web/dashboard/package*.json ./
|
|
||||||
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
|
|
||||||
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY}
|
|
||||||
|
|
||||||
|
COPY web/dashboard/package*.json ./
|
||||||
|
|
||||||
|
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
|
|
||||||
|
# Use npm ci + ensure musl SWC binary (important on Alpine)
|
||||||
|
RUN npm ci --legacy-peer-deps --ignore-scripts \
|
||||||
|
--registry ${NPM_REGISTRY} \
|
||||||
|
--strict-ssl=false \
|
||||||
|
&& NEXT_VER=$(node -p "require('./node_modules/next/package.json').version") \
|
||||||
|
&& ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \
|
||||||
|
|| npm install --no-save --ignore-scripts \
|
||||||
|
--registry ${NPM_REGISTRY} \
|
||||||
|
--strict-ssl=false \
|
||||||
|
"@next/swc-linux-x64-musl@${NEXT_VER}"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== BUILDER STAGE ====================
|
||||||
FROM ${NODE_IMAGE} AS builder
|
FROM ${NODE_IMAGE} AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG NEXT_PUBLIC_API_URL=http://localhost:5080
|
ARG NEXT_PUBLIC_API_URL=http://localhost:5080
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY web/dashboard/ .
|
COPY web/dashboard/ .
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== RUNNER STAGE ====================
|
||||||
FROM ${NODE_IMAGE} AS runner
|
FROM ${NODE_IMAGE} AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -33,5 +52,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
@@ -1,29 +1,40 @@
|
|||||||
ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
|
ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
|
||||||
|
|
||||||
|
# ==================== DEPS STAGE ====================
|
||||||
FROM ${NODE_IMAGE} AS deps
|
FROM ${NODE_IMAGE} AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY web/website/package*.json ./
|
COPY web/website/package*.json ./
|
||||||
|
|
||||||
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
|
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
# Install deps then ensure Alpine (musl) SWC binary is present
|
|
||||||
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \
|
RUN npm ci --legacy-peer-deps --ignore-scripts \
|
||||||
&& NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \
|
--registry ${NPM_REGISTRY} \
|
||||||
|
--strict-ssl=false \
|
||||||
|
&& NEXT_VER=$(node -p "require('./node_modules/next/package.json').version") \
|
||||||
&& ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \
|
&& ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \
|
||||||
|| npm install --no-save --ignore-scripts --registry ${NPM_REGISTRY} \
|
|| npm install --no-save --ignore-scripts \
|
||||||
|
--registry ${NPM_REGISTRY} \
|
||||||
|
--strict-ssl=false \
|
||||||
"@next/swc-linux-x64-musl@${NEXT_VER}"
|
"@next/swc-linux-x64-musl@${NEXT_VER}"
|
||||||
|
|
||||||
|
# ==================== BUILDER STAGE ====================
|
||||||
FROM ${NODE_IMAGE} AS builder
|
FROM ${NODE_IMAGE} AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG MEEZI_API_URL=http://api:8080
|
ARG MEEZI_API_URL=http://api:8080
|
||||||
ENV MEEZI_API_URL=$MEEZI_API_URL
|
|
||||||
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3010
|
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3010
|
||||||
|
|
||||||
|
ENV MEEZI_API_URL=$MEEZI_API_URL
|
||||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY web/website/ .
|
COPY web/website/ .
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# ==================== RUNNER STAGE ====================
|
||||||
FROM ${NODE_IMAGE} AS runner
|
FROM ${NODE_IMAGE} AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -41,5 +52,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|||||||
COPY --from=builder /app/src/content ./src/content
|
COPY --from=builder /app/src/content ./src/content
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
@@ -38,6 +38,26 @@ public class AuthController : ControllerBase
|
|||||||
_verifyRegisterValidator = verifyRegisterValidator;
|
_verifyRegisterValidator = verifyRegisterValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("login")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> LoginWithPassword(
|
||||||
|
[FromBody] LoginWithPasswordRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
return BadRequest(ValidationError("Username and password are required."));
|
||||||
|
|
||||||
|
var (success, data, code, message, choices) = await _authService.LoginWithPasswordAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (!success && code == "CHOOSE_CAFE")
|
||||||
|
return Ok(new ApiResponse<CafeChoicesResponse>(false, choices, new ApiError("CHOOSE_CAFE", "Please select a café to continue.")));
|
||||||
|
|
||||||
|
if (!success)
|
||||||
|
return ErrorResult(code!, message!);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("send-otp")]
|
[HttpPost("send-otp")]
|
||||||
[EnableRateLimiting("auth-otp")]
|
[EnableRateLimiting("auth-otp")]
|
||||||
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
|
||||||
@@ -193,6 +213,9 @@ public class AuthController : ControllerBase
|
|||||||
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
|
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ApiResponse<object> ValidationError(string message) =>
|
||||||
|
new(false, null, new ApiError("VALIDATION_ERROR", message));
|
||||||
|
|
||||||
private IActionResult ErrorResult(string code, string message) => code switch
|
private IActionResult ErrorResult(string code, string message) => code switch
|
||||||
{
|
{
|
||||||
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
|
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Meezi.API.Models.Cafes;
|
using Meezi.API.Models.Cafes;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Core.Utilities;
|
||||||
using Meezi.Infrastructure.Branding;
|
using Meezi.Infrastructure.Branding;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
@@ -57,6 +58,21 @@ public class CafeSettingsController : CafeApiControllerBase
|
|||||||
if (cafe is null) return NotFoundError();
|
if (cafe is null) return NotFoundError();
|
||||||
|
|
||||||
if (request.Name is not null) cafe.Name = request.Name.Trim();
|
if (request.Name is not null) cafe.Name = request.Name.Trim();
|
||||||
|
|
||||||
|
if (request.Slug is not null)
|
||||||
|
{
|
||||||
|
var newSlug = request.Slug.Trim().ToLowerInvariant();
|
||||||
|
if (!SlugHelper.IsValidSlug(newSlug))
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("INVALID_SLUG", "Slug must be 2-80 lowercase letters, digits, or hyphens.")));
|
||||||
|
|
||||||
|
var taken = await _db.Cafes.AnyAsync(c => c.Slug == newSlug && c.Id != cafeId, ct);
|
||||||
|
if (taken)
|
||||||
|
return Conflict(new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("SLUG_TAKEN", "This Koja profile address is already in use. Please choose another.")));
|
||||||
|
|
||||||
|
cafe.Slug = newSlug;
|
||||||
|
}
|
||||||
if (request.Phone is not null) cafe.Phone = request.Phone.Trim();
|
if (request.Phone is not null) cafe.Phone = request.Phone.Trim();
|
||||||
if (request.Address is not null) cafe.Address = request.Address.Trim();
|
if (request.Address is not null) cafe.Address = request.Address.Trim();
|
||||||
if (request.City is not null) cafe.City = request.City.Trim();
|
if (request.City is not null) cafe.City = request.City.Trim();
|
||||||
@@ -71,6 +87,21 @@ public class CafeSettingsController : CafeApiControllerBase
|
|||||||
if (request.AllowBranchTaxOverride is bool allowTax)
|
if (request.AllowBranchTaxOverride is bool allowTax)
|
||||||
cafe.AllowBranchTaxOverride = allowTax;
|
cafe.AllowBranchTaxOverride = allowTax;
|
||||||
|
|
||||||
|
// Location: explicit null-clear flag OR new values
|
||||||
|
if (request.ClearLocation)
|
||||||
|
{
|
||||||
|
cafe.Latitude = null;
|
||||||
|
cafe.Longitude = null;
|
||||||
|
}
|
||||||
|
else if (request.Latitude.HasValue && request.Longitude.HasValue)
|
||||||
|
{
|
||||||
|
if (request.Latitude is < -90 or > 90 || request.Longitude is < -180 or > 180)
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("INVALID_LOCATION", "Latitude must be −90…90 and longitude −180…180.")));
|
||||||
|
cafe.Latitude = request.Latitude;
|
||||||
|
cafe.Longitude = request.Longitude;
|
||||||
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return Ok(new ApiResponse<CafeSettingsDto>(true, ToDto(cafe)));
|
return Ok(new ApiResponse<CafeSettingsDto>(true, ToDto(cafe)));
|
||||||
}
|
}
|
||||||
@@ -90,5 +121,7 @@ public class CafeSettingsController : CafeApiControllerBase
|
|||||||
cafe.PlanExpiresAt,
|
cafe.PlanExpiresAt,
|
||||||
CafeThemeMapping.FromJson(cafe.ThemeJson),
|
CafeThemeMapping.FromJson(cafe.ThemeJson),
|
||||||
cafe.DefaultTaxRate,
|
cafe.DefaultTaxRate,
|
||||||
cafe.AllowBranchTaxOverride);
|
cafe.AllowBranchTaxOverride,
|
||||||
|
cafe.Latitude,
|
||||||
|
cafe.Longitude);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Shared;
|
||||||
|
|
||||||
|
namespace Meezi.API.Controllers;
|
||||||
|
|
||||||
|
[Route("api/cafes/{cafeId}/demo")]
|
||||||
|
[Authorize]
|
||||||
|
public class DemoSeedController : CafeApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDemoSeedService _demoSeed;
|
||||||
|
|
||||||
|
public DemoSeedController(IDemoSeedService demoSeed)
|
||||||
|
{
|
||||||
|
_demoSeed = demoSeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Seeds demo menu, tables, and inventory for any café. Owner-only.</summary>
|
||||||
|
[HttpPost("seed")]
|
||||||
|
public async Task<IActionResult> Seed(
|
||||||
|
string cafeId,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||||
|
|
||||||
|
var result = await _demoSeed.SeedAsync(cafeId, ct);
|
||||||
|
return Ok(new ApiResponse<DemoSeedResult>(true, result));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Hr;
|
using Meezi.API.Models.Hr;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Core.Utilities;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
@@ -15,17 +18,20 @@ public class HrController : CafeApiControllerBase
|
|||||||
private readonly IValidator<CreateLeaveRequest> _leaveValidator;
|
private readonly IValidator<CreateLeaveRequest> _leaveValidator;
|
||||||
private readonly IValidator<ReviewLeaveRequest> _reviewValidator;
|
private readonly IValidator<ReviewLeaveRequest> _reviewValidator;
|
||||||
private readonly IValidator<CreateSalaryRequest> _salaryValidator;
|
private readonly IValidator<CreateSalaryRequest> _salaryValidator;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
public HrController(
|
public HrController(
|
||||||
IHrService hr,
|
IHrService hr,
|
||||||
IValidator<CreateLeaveRequest> leaveValidator,
|
IValidator<CreateLeaveRequest> leaveValidator,
|
||||||
IValidator<ReviewLeaveRequest> reviewValidator,
|
IValidator<ReviewLeaveRequest> reviewValidator,
|
||||||
IValidator<CreateSalaryRequest> salaryValidator)
|
IValidator<CreateSalaryRequest> salaryValidator,
|
||||||
|
AppDbContext db)
|
||||||
{
|
{
|
||||||
_hr = hr;
|
_hr = hr;
|
||||||
_leaveValidator = leaveValidator;
|
_leaveValidator = leaveValidator;
|
||||||
_reviewValidator = reviewValidator;
|
_reviewValidator = reviewValidator;
|
||||||
_salaryValidator = salaryValidator;
|
_salaryValidator = salaryValidator;
|
||||||
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("employees")]
|
[HttpGet("employees")]
|
||||||
@@ -201,4 +207,66 @@ public class HrController : CafeApiControllerBase
|
|||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
|
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Set or update username/password credentials for an employee. Owner/Manager only.</summary>
|
||||||
|
[HttpPut("employees/{employeeId}/credentials")]
|
||||||
|
public async Task<IActionResult> SetCredentials(
|
||||||
|
string cafeId,
|
||||||
|
string employeeId,
|
||||||
|
[FromBody] SetEmployeeCredentialsRequest request,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
|
var username = request.Username.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(username))
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Username is required.", "Username")));
|
||||||
|
|
||||||
|
if (request.Password.Length < 8)
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Password must be at least 8 characters.", "Password")));
|
||||||
|
|
||||||
|
var employee = await _db.Employees
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
|
||||||
|
|
||||||
|
if (employee is null) return NotFoundError();
|
||||||
|
|
||||||
|
// Check username uniqueness within the cafe (excluding the employee itself)
|
||||||
|
var conflict = await _db.Employees
|
||||||
|
.AnyAsync(e => e.CafeId == cafeId && e.Id != employeeId && e.DeletedAt == null
|
||||||
|
&& e.Username != null && e.Username.ToLower() == username, ct);
|
||||||
|
if (conflict)
|
||||||
|
return Conflict(new ApiResponse<object>(false, null, new ApiError("USERNAME_TAKEN", "This username is already in use by another employee.")));
|
||||||
|
|
||||||
|
employee.Username = username;
|
||||||
|
employee.PasswordHash = PasswordHasher.Hash(request.Password);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Remove username/password credentials from an employee. Owner/Manager only.</summary>
|
||||||
|
[HttpDelete("employees/{employeeId}/credentials")]
|
||||||
|
public async Task<IActionResult> RemoveCredentials(
|
||||||
|
string cafeId,
|
||||||
|
string employeeId,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
|
var employee = await _db.Employees
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
|
||||||
|
|
||||||
|
if (employee is null) return NotFoundError();
|
||||||
|
|
||||||
|
employee.Username = null;
|
||||||
|
employee.PasswordHash = null;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, null));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Menu;
|
using Meezi.API.Models.Menu;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Constants;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
@@ -15,17 +18,25 @@ public class MenuController : CafeApiControllerBase
|
|||||||
private readonly IMenuAi3dGenerationService _menuAi3d;
|
private readonly IMenuAi3dGenerationService _menuAi3d;
|
||||||
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
|
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
|
||||||
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
|
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
private const string CategoryLimitMessage =
|
||||||
|
"محدودیت دستهبندی پلن رایگان (۳ دسته). برای افزودن دستهبندی بیشتر، پلن خود را ارتقا دهید.";
|
||||||
|
private const string ItemLimitMessage =
|
||||||
|
"محدودیت آیتم منو پلن رایگان (۳۰ آیتم). برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
|
||||||
|
|
||||||
public MenuController(
|
public MenuController(
|
||||||
IMenuService menuService,
|
IMenuService menuService,
|
||||||
IMenuAi3dGenerationService menuAi3d,
|
IMenuAi3dGenerationService menuAi3d,
|
||||||
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
|
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
|
||||||
IValidator<CreateMenuItemRequest> createItemValidator)
|
IValidator<CreateMenuItemRequest> createItemValidator,
|
||||||
|
AppDbContext db)
|
||||||
{
|
{
|
||||||
_menuService = menuService;
|
_menuService = menuService;
|
||||||
_menuAi3d = menuAi3d;
|
_menuAi3d = menuAi3d;
|
||||||
_createCategoryValidator = createCategoryValidator;
|
_createCategoryValidator = createCategoryValidator;
|
||||||
_createItemValidator = createItemValidator;
|
_createItemValidator = createItemValidator;
|
||||||
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("categories")]
|
[HttpGet("categories")]
|
||||||
@@ -47,6 +58,17 @@ public class MenuController : CafeApiControllerBase
|
|||||||
var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
|
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||||
|
var max = PlanLimits.MaxMenuCategories(tier);
|
||||||
|
if (max != int.MaxValue)
|
||||||
|
{
|
||||||
|
var count = await _db.MenuCategories.CountAsync(
|
||||||
|
c => c.CafeId == cafeId && c.DeletedAt == null, cancellationToken);
|
||||||
|
if (count >= max)
|
||||||
|
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("PLAN_LIMIT_REACHED", CategoryLimitMessage)));
|
||||||
|
}
|
||||||
|
|
||||||
var data = await _menuService.CreateCategoryAsync(cafeId, request, cancellationToken);
|
var data = await _menuService.CreateCategoryAsync(cafeId, request, cancellationToken);
|
||||||
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
|
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
|
||||||
}
|
}
|
||||||
@@ -97,6 +119,17 @@ public class MenuController : CafeApiControllerBase
|
|||||||
var validation = await _createItemValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _createItemValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
|
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||||
|
var max = PlanLimits.MaxMenuItems(tier);
|
||||||
|
if (max != int.MaxValue)
|
||||||
|
{
|
||||||
|
var count = await _db.MenuItems.CountAsync(
|
||||||
|
i => i.CafeId == cafeId && i.DeletedAt == null, cancellationToken);
|
||||||
|
if (count >= max)
|
||||||
|
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("PLAN_LIMIT_REACHED", ItemLimitMessage)));
|
||||||
|
}
|
||||||
|
|
||||||
var data = await _menuService.CreateItemAsync(cafeId, request, cancellationToken);
|
var data = await _menuService.CreateItemAsync(cafeId, request, cancellationToken);
|
||||||
if (data is null) return NotFoundError("Category not found.");
|
if (data is null) return NotFoundError("Category not found.");
|
||||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
||||||
|
|||||||
@@ -367,4 +367,101 @@ public class PublicController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new ApiResponse<object>(true, null));
|
return Ok(new ApiResponse<object>(true, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all cafés that have a known location (Latitude/Longitude set).
|
||||||
|
/// Used by the marketing website SVG map to render blinking dots.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("map-markers")]
|
||||||
|
[EnableRateLimiting("public-read")]
|
||||||
|
public async Task<IActionResult> GetMapMarkers(
|
||||||
|
[FromServices] AppDbContext db,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var markers = await db.Cafes
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => c.DeletedAt == null && c.Latitude != null && c.Longitude != null)
|
||||||
|
.Select(c => new
|
||||||
|
{
|
||||||
|
c.Id,
|
||||||
|
c.Name,
|
||||||
|
c.Slug,
|
||||||
|
c.City,
|
||||||
|
c.Latitude,
|
||||||
|
c.Longitude,
|
||||||
|
c.LogoUrl
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, markers));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns cafés near a given coordinate, sorted by distance ascending.
|
||||||
|
/// Used by Koja guest page to show "nearby cafés" section.
|
||||||
|
/// At most <paramref name="limit"/> results (default 5, max 20).
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("nearby")]
|
||||||
|
[EnableRateLimiting("public-read")]
|
||||||
|
public async Task<IActionResult> GetNearbyCafes(
|
||||||
|
[FromQuery] double lat,
|
||||||
|
[FromQuery] double lng,
|
||||||
|
[FromQuery] string? excludeSlug,
|
||||||
|
[FromQuery] int limit,
|
||||||
|
[FromServices] AppDbContext db,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
limit = Math.Clamp(limit <= 0 ? 5 : limit, 1, 20);
|
||||||
|
|
||||||
|
// Pull all located cafés from DB (typically small set) and sort in memory with Haversine.
|
||||||
|
var cafes = await db.Cafes
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => c.DeletedAt == null
|
||||||
|
&& c.Latitude != null
|
||||||
|
&& c.Longitude != null
|
||||||
|
&& (excludeSlug == null || c.Slug != excludeSlug))
|
||||||
|
.Select(c => new
|
||||||
|
{
|
||||||
|
c.Id,
|
||||||
|
c.Name,
|
||||||
|
c.Slug,
|
||||||
|
c.City,
|
||||||
|
c.Latitude,
|
||||||
|
c.Longitude,
|
||||||
|
c.LogoUrl,
|
||||||
|
c.CoverImageUrl
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
static double ToRad(double deg) => deg * Math.PI / 180.0;
|
||||||
|
static double Haversine(double lat1, double lon1, double lat2, double lon2)
|
||||||
|
{
|
||||||
|
const double R = 6371; // km
|
||||||
|
var dLat = ToRad(lat2 - lat1);
|
||||||
|
var dLon = ToRad(lon2 - lon1);
|
||||||
|
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2)
|
||||||
|
+ Math.Cos(ToRad(lat1)) * Math.Cos(ToRad(lat2))
|
||||||
|
* Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
|
||||||
|
return R * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
|
var nearby = cafes
|
||||||
|
.Select(c => new
|
||||||
|
{
|
||||||
|
c.Id,
|
||||||
|
c.Name,
|
||||||
|
c.Slug,
|
||||||
|
c.City,
|
||||||
|
c.Latitude,
|
||||||
|
c.Longitude,
|
||||||
|
c.LogoUrl,
|
||||||
|
c.CoverImageUrl,
|
||||||
|
DistanceKm = Math.Round(Haversine(lat, lng, c.Latitude!.Value, c.Longitude!.Value), 1)
|
||||||
|
})
|
||||||
|
.OrderBy(c => c.DistanceKm)
|
||||||
|
.Take(limit)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, nearby));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<IQueueService, QueueService>();
|
services.AddScoped<IQueueService, QueueService>();
|
||||||
services.AddScoped<IShiftService, ShiftService>();
|
services.AddScoped<IShiftService, ShiftService>();
|
||||||
services.AddScoped<IExpenseService, ExpenseService>();
|
services.AddScoped<IExpenseService, ExpenseService>();
|
||||||
|
services.AddScoped<IDemoSeedService, DemoSeedService>();
|
||||||
services.AddScoped<ReceiptBuilder>();
|
services.AddScoped<ReceiptBuilder>();
|
||||||
services.AddScoped<IPrinterService, NetworkPrinterService>();
|
services.AddScoped<IPrinterService, NetworkPrinterService>();
|
||||||
services.AddHttpClient(nameof(PosDeviceService));
|
services.AddHttpClient(nameof(PosDeviceService));
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ namespace Meezi.API.Models.Auth;
|
|||||||
|
|
||||||
public record SendOtpRequest(string Phone);
|
public record SendOtpRequest(string Phone);
|
||||||
|
|
||||||
|
/// <summary>Username + password login (alternative to OTP). Optional cafeId to scope to a specific café.</summary>
|
||||||
|
public record LoginWithPasswordRequest(string Username, string Password, string? CafeId = null);
|
||||||
|
|
||||||
public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null);
|
public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null);
|
||||||
|
|
||||||
public record RefreshTokenRequest(string RefreshToken);
|
public record RefreshTokenRequest(string RefreshToken);
|
||||||
@@ -12,7 +15,8 @@ public record SwitchCafeRequest(string CafeId);
|
|||||||
public record SwitchBranchRequest(string? BranchId);
|
public record SwitchBranchRequest(string? BranchId);
|
||||||
|
|
||||||
/// <summary>Step 1 of self-registration: send OTP to a new phone number.</summary>
|
/// <summary>Step 1 of self-registration: send OTP to a new phone number.</summary>
|
||||||
public record RegisterRequest(string Phone, string CafeName);
|
/// <param name="Slug">Optional custom Koja slug (e.g. "lamiz-enghelab"). Auto-derived from CafeName if omitted.</param>
|
||||||
|
public record RegisterRequest(string Phone, string CafeName, string? Slug = null);
|
||||||
|
|
||||||
/// <summary>Step 2 of self-registration: verify OTP and create the cafe account.</summary>
|
/// <summary>Step 2 of self-registration: verify OTP and create the cafe account.</summary>
|
||||||
public record VerifyRegisterRequest(string Phone, string Code);
|
public record VerifyRegisterRequest(string Phone, string Code);
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ public record CafeSettingsDto(
|
|||||||
DateTime? PlanExpiresAt,
|
DateTime? PlanExpiresAt,
|
||||||
CafeThemeDto Theme,
|
CafeThemeDto Theme,
|
||||||
decimal DefaultTaxRate,
|
decimal DefaultTaxRate,
|
||||||
bool AllowBranchTaxOverride);
|
bool AllowBranchTaxOverride,
|
||||||
|
double? Latitude,
|
||||||
|
double? Longitude);
|
||||||
|
|
||||||
public record PatchCafeSettingsRequest(
|
public record PatchCafeSettingsRequest(
|
||||||
string? Name,
|
string? Name,
|
||||||
|
/// <summary>Custom Koja profile slug (e.g. "lamiz-enghelab"). Must be unique across all cafés.</summary>
|
||||||
|
string? Slug,
|
||||||
string? Phone,
|
string? Phone,
|
||||||
string? Address,
|
string? Address,
|
||||||
string? City,
|
string? City,
|
||||||
@@ -28,4 +32,10 @@ public record PatchCafeSettingsRequest(
|
|||||||
string? SnappfoodVendorId,
|
string? SnappfoodVendorId,
|
||||||
CafeThemeDto? Theme,
|
CafeThemeDto? Theme,
|
||||||
decimal? DefaultTaxRate,
|
decimal? DefaultTaxRate,
|
||||||
bool? AllowBranchTaxOverride);
|
bool? AllowBranchTaxOverride,
|
||||||
|
/// <summary>WGS-84 latitude. Send null to clear.</summary>
|
||||||
|
double? Latitude,
|
||||||
|
/// <summary>WGS-84 longitude. Send null to clear.</summary>
|
||||||
|
double? Longitude,
|
||||||
|
/// <summary>When true, Latitude and Longitude are explicitly being cleared (set to null).</summary>
|
||||||
|
bool ClearLocation = false);
|
||||||
|
|||||||
@@ -59,3 +59,6 @@ public record CreateSalaryRequest(
|
|||||||
decimal Deductions);
|
decimal Deductions);
|
||||||
|
|
||||||
public record TodayShiftDto(ShiftType ShiftType, string Label);
|
public record TodayShiftDto(ShiftType ShiftType, string Label);
|
||||||
|
|
||||||
|
/// <summary>Set or update username/password credentials for an employee.</summary>
|
||||||
|
public record SetEmployeeCredentialsRequest(string Username, string Password);
|
||||||
|
|||||||
@@ -303,8 +303,15 @@ public class AuthService : IAuthService
|
|||||||
|
|
||||||
var otp = Random.Shared.Next(100000, 999999).ToString();
|
var otp = Random.Shared.Next(100000, 999999).ToString();
|
||||||
await redis.StringSetAsync($"otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds));
|
await redis.StringSetAsync($"otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds));
|
||||||
// Store the cafe name alongside the OTP so verify-register can create the cafe
|
|
||||||
await redis.StringSetAsync($"reg_meta:{phone}", cafeName, TimeSpan.FromSeconds(OtpTtlSeconds));
|
// Determine the requested slug: use provided slug, or auto-derive from café name.
|
||||||
|
// Format stored: "cafeName||slug" (double-pipe delimiter). Slug may be empty.
|
||||||
|
var requestedSlug = string.IsNullOrWhiteSpace(request.Slug)
|
||||||
|
? Meezi.Core.Utilities.SlugHelper.Slugify(cafeName)
|
||||||
|
: request.Slug.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
var regMeta = $"{cafeName}||{requestedSlug}";
|
||||||
|
await redis.StringSetAsync($"reg_meta:{phone}", regMeta, TimeSpan.FromSeconds(OtpTtlSeconds));
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -342,10 +349,25 @@ public class AuthService : IAuthService
|
|||||||
if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code)
|
if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code)
|
||||||
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
|
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
|
||||||
|
|
||||||
var cafeName = (await redis.StringGetAsync($"reg_meta:{phone}")).ToString();
|
var regMetaRaw = (await redis.StringGetAsync($"reg_meta:{phone}")).ToString();
|
||||||
if (string.IsNullOrWhiteSpace(cafeName))
|
if (string.IsNullOrWhiteSpace(regMetaRaw))
|
||||||
return (false, null, "REGISTRATION_EXPIRED", "Registration session expired. Please start again.");
|
return (false, null, "REGISTRATION_EXPIRED", "Registration session expired. Please start again.");
|
||||||
|
|
||||||
|
// Parse "cafeName||slug" format (double-pipe delimiter)
|
||||||
|
string cafeName;
|
||||||
|
string? requestedSlug;
|
||||||
|
var sepIdx = regMetaRaw.IndexOf("||", StringComparison.Ordinal);
|
||||||
|
if (sepIdx >= 0)
|
||||||
|
{
|
||||||
|
cafeName = regMetaRaw[..sepIdx];
|
||||||
|
requestedSlug = regMetaRaw[(sepIdx + 2)..];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cafeName = regMetaRaw;
|
||||||
|
requestedSlug = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Double-check no owner was created in the meantime (race condition guard)
|
// Double-check no owner was created in the meantime (race condition guard)
|
||||||
var alreadyOwner = await _db.Employees
|
var alreadyOwner = await _db.Employees
|
||||||
.AnyAsync(e => e.Phone == phone && e.Role == EmployeeRole.Owner && e.DeletedAt == null, cancellationToken);
|
.AnyAsync(e => e.Phone == phone && e.Role == EmployeeRole.Owner && e.DeletedAt == null, cancellationToken);
|
||||||
@@ -356,8 +378,8 @@ public class AuthService : IAuthService
|
|||||||
return (false, null, "ALREADY_REGISTERED", "An account already exists for this phone number. Please sign in.");
|
return (false, null, "ALREADY_REGISTERED", "An account already exists for this phone number. Please sign in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a unique slug
|
// Generate a unique slug (try requested slug first, fall back to random)
|
||||||
var slug = await GenerateUniqueSlugAsync(cancellationToken);
|
var slug = await GenerateUniqueSlugAsync(requestedSlug, cancellationToken);
|
||||||
|
|
||||||
var cafe = new Cafe
|
var cafe = new Cafe
|
||||||
{
|
{
|
||||||
@@ -367,6 +389,15 @@ public class AuthService : IAuthService
|
|||||||
PlanTier = PlanTier.Free,
|
PlanTier = PlanTier.Free,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto-create a default main branch so the owner can start using the
|
||||||
|
// dashboard immediately without hitting the "select a branch" gate.
|
||||||
|
var defaultBranch = new Branch
|
||||||
|
{
|
||||||
|
CafeId = cafe.Id,
|
||||||
|
Name = cafeName,
|
||||||
|
IsActive = true,
|
||||||
|
};
|
||||||
|
|
||||||
var owner = new Employee
|
var owner = new Employee
|
||||||
{
|
{
|
||||||
CafeId = cafe.Id,
|
CafeId = cafe.Id,
|
||||||
@@ -376,6 +407,7 @@ public class AuthService : IAuthService
|
|||||||
};
|
};
|
||||||
|
|
||||||
_db.Cafes.Add(cafe);
|
_db.Cafes.Add(cafe);
|
||||||
|
_db.Branches.Add(defaultBranch);
|
||||||
_db.Employees.Add(owner);
|
_db.Employees.Add(owner);
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -392,17 +424,87 @@ public class AuthService : IAuthService
|
|||||||
return (true, tokens, null, null);
|
return (true, tokens, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> GenerateUniqueSlugAsync(CancellationToken ct)
|
private async Task<string> GenerateUniqueSlugAsync(string? preferred, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// Try the preferred/derived slug first
|
||||||
|
if (Meezi.Core.Utilities.SlugHelper.IsValidSlug(preferred))
|
||||||
|
{
|
||||||
|
if (!await _db.Cafes.AnyAsync(c => c.Slug == preferred, ct))
|
||||||
|
return preferred!;
|
||||||
|
|
||||||
|
// Preferred slug is taken — append a short random suffix
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
var candidate = $"{preferred}-{Guid.NewGuid().ToString("N")[..4]}";
|
||||||
|
if (!await _db.Cafes.AnyAsync(c => c.Slug == candidate, ct))
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full random fallback
|
||||||
string slug;
|
string slug;
|
||||||
do
|
do
|
||||||
{
|
{
|
||||||
// e.g. "cafe-a3f9b2c"
|
|
||||||
slug = "cafe-" + Guid.NewGuid().ToString("N")[..7];
|
slug = "cafe-" + Guid.NewGuid().ToString("N")[..7];
|
||||||
} while (await _db.Cafes.AnyAsync(c => c.Slug == slug, ct));
|
} while (await _db.Cafes.AnyAsync(c => c.Slug == slug, ct));
|
||||||
return slug;
|
return slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> LoginWithPasswordAsync(
|
||||||
|
LoginWithPasswordRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var username = request.Username.Trim();
|
||||||
|
|
||||||
|
var candidates = await _db.Employees
|
||||||
|
.Include(e => e.Cafe)
|
||||||
|
.Where(e => e.Username == username
|
||||||
|
&& e.PasswordHash != null
|
||||||
|
&& e.DeletedAt == null
|
||||||
|
&& e.Cafe.DeletedAt == null)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
|
||||||
|
|
||||||
|
// Constant-time verification (check all matches to avoid username enumeration)
|
||||||
|
var matched = candidates.Where(e => PasswordHasher.Verify(request.Password, e.PasswordHash!)).ToList();
|
||||||
|
|
||||||
|
if (matched.Count == 0)
|
||||||
|
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
|
||||||
|
|
||||||
|
// Scope to a specific café if requested
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.CafeId))
|
||||||
|
{
|
||||||
|
matched = matched.Where(e => e.CafeId == request.CafeId).ToList();
|
||||||
|
if (matched.Count == 0)
|
||||||
|
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple cafés — ask frontend to pick one
|
||||||
|
if (matched.Count > 1)
|
||||||
|
{
|
||||||
|
var choices = new CafeChoicesResponse(
|
||||||
|
matched
|
||||||
|
.Where(e => e.Cafe is not null)
|
||||||
|
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
|
||||||
|
.ToList());
|
||||||
|
return (false, null, "CHOOSE_CAFE", null, choices);
|
||||||
|
}
|
||||||
|
|
||||||
|
var employee = matched[0];
|
||||||
|
if (employee.Cafe is null)
|
||||||
|
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
|
||||||
|
|
||||||
|
var membershipDtos = matched
|
||||||
|
.Where(e => e.Cafe is not null)
|
||||||
|
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, null, cancellationToken);
|
||||||
|
return (true, tokens, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<AuthTokenResponse> IssueTokensAsync(
|
private async Task<AuthTokenResponse> IssueTokensAsync(
|
||||||
Core.Entities.Employee employee,
|
Core.Entities.Employee employee,
|
||||||
Core.Entities.Cafe cafe,
|
Core.Entities.Cafe cafe,
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using Meezi.Core.Entities;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Meezi.API.Services;
|
||||||
|
|
||||||
|
public record DemoSeedResult(
|
||||||
|
int CategoriesAdded,
|
||||||
|
int ItemsAdded,
|
||||||
|
int TablesAdded,
|
||||||
|
int IngredientsAdded,
|
||||||
|
bool TaxCreated);
|
||||||
|
|
||||||
|
public interface IDemoSeedService
|
||||||
|
{
|
||||||
|
Task<DemoSeedResult> SeedAsync(string cafeId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DemoSeedService : IDemoSeedService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ILogger<DemoSeedService> _logger;
|
||||||
|
|
||||||
|
public DemoSeedService(AppDbContext db, ILogger<DemoSeedService> logger)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DemoSeedResult> SeedAsync(string cafeId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// 1. Ensure 9% default tax
|
||||||
|
var taxId = $"{cafeId}_demo_tax";
|
||||||
|
var taxCreated = false;
|
||||||
|
if (!await _db.Taxes.AnyAsync(t => t.CafeId == cafeId && t.IsDefault, ct))
|
||||||
|
{
|
||||||
|
_db.Taxes.Add(new Tax
|
||||||
|
{
|
||||||
|
Id = taxId,
|
||||||
|
CafeId = cafeId,
|
||||||
|
Name = "مالیات ارزش افزوده",
|
||||||
|
Rate = 9,
|
||||||
|
IsDefault = true,
|
||||||
|
IsRequired = true,
|
||||||
|
IsCompound = false
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
taxCreated = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
taxId = await _db.Taxes
|
||||||
|
.Where(t => t.CafeId == cafeId && t.IsDefault)
|
||||||
|
.Select(t => t.Id)
|
||||||
|
.FirstAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Seed menu (categories + items) using café-agnostic seeder.
|
||||||
|
// useScopedIds=true prefixes all IDs with cafeId so multiple cafés
|
||||||
|
// can each have their own demo menu without primary-key collisions.
|
||||||
|
var beforeCats = await _db.MenuCategories.CountAsync(c => c.CafeId == cafeId, ct);
|
||||||
|
var beforeItems = await _db.MenuItems.CountAsync(i => i.CafeId == cafeId, ct);
|
||||||
|
await DemoMenuSeeder.EnsureMenuAsync(_db, cafeId, taxId, _logger, useScopedIds: true);
|
||||||
|
var afterCats = await _db.MenuCategories.CountAsync(c => c.CafeId == cafeId, ct);
|
||||||
|
var afterItems = await _db.MenuItems.CountAsync(i => i.CafeId == cafeId, ct);
|
||||||
|
|
||||||
|
// 3. Seed ingredients if warehouse is empty
|
||||||
|
var ingredientsAdded = 0;
|
||||||
|
if (!await _db.Ingredients.AnyAsync(i => i.CafeId == cafeId, ct))
|
||||||
|
{
|
||||||
|
var demoIngredients = BuildDemoIngredients(cafeId);
|
||||||
|
_db.Ingredients.AddRange(demoIngredients);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
ingredientsAdded = demoIngredients.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Seed 10 tables if no tables exist for this café's first active branch
|
||||||
|
var tablesAdded = 0;
|
||||||
|
if (!await _db.Tables.AnyAsync(t => t.CafeId == cafeId, ct))
|
||||||
|
{
|
||||||
|
var branchId = await _db.Branches
|
||||||
|
.Where(b => b.CafeId == cafeId && b.IsActive && b.DeletedAt == null)
|
||||||
|
.OrderBy(b => b.Id)
|
||||||
|
.Select(b => b.Id)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (branchId is not null)
|
||||||
|
{
|
||||||
|
var tables = BuildDemoTables(cafeId, branchId);
|
||||||
|
_db.Tables.AddRange(tables);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
tablesAdded = tables.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Demo seed complete for cafe {CafeId}: +{Cats} cats, +{Items} items, +{Tables} tables, +{Ing} ingredients, tax={TaxCreated}",
|
||||||
|
cafeId, afterCats - beforeCats, afterItems - beforeItems, tablesAdded, ingredientsAdded, taxCreated);
|
||||||
|
|
||||||
|
return new DemoSeedResult(
|
||||||
|
CategoriesAdded: afterCats - beforeCats,
|
||||||
|
ItemsAdded: afterItems - beforeItems,
|
||||||
|
TablesAdded: tablesAdded,
|
||||||
|
IngredientsAdded: ingredientsAdded,
|
||||||
|
TaxCreated: taxCreated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Ingredient> BuildDemoIngredients(string cafeId) =>
|
||||||
|
[
|
||||||
|
Ingredient(cafeId, "قهوه اسپرسو", "گرم", 2000, 500, 80, 2000),
|
||||||
|
Ingredient(cafeId, "شیر", "میلیلیتر", 10000, 2000, 15, 10000),
|
||||||
|
Ingredient(cafeId, "شکر", "گرم", 5000, 1000, 5, 5000),
|
||||||
|
Ingredient(cafeId, "وانیل", "میلیلیتر", 500, 100, 50, 500),
|
||||||
|
Ingredient(cafeId, "شکلات تلخ", "گرم", 1000, 200, 120, 1000),
|
||||||
|
Ingredient(cafeId, "خامه", "میلیلیتر", 2000, 500, 30, 2000),
|
||||||
|
Ingredient(cafeId, "دارچین", "گرم", 300, 50, 40, 300),
|
||||||
|
Ingredient(cafeId, "چای سیاه", "گرم", 1000, 200, 60, 1000),
|
||||||
|
Ingredient(cafeId, "آب معدنی", "میلیلیتر", 20000, 5000, 3, 20000),
|
||||||
|
Ingredient(cafeId, "نان تست", "عدد", 100, 20, 8000, 100),
|
||||||
|
Ingredient(cafeId, "تخممرغ", "عدد", 60, 12, 6000, 60),
|
||||||
|
Ingredient(cafeId, "کره", "گرم", 500, 100, 80, 500),
|
||||||
|
Ingredient(cafeId, "پنیر", "گرم", 1000, 200, 90, 1000),
|
||||||
|
Ingredient(cafeId, "اسپاتولا یخ", "عدد", 200, 50, 2000, 200),
|
||||||
|
Ingredient(cafeId, "سس کارامل", "میلیلیتر", 1000, 200, 60, 1000),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static Ingredient Ingredient(
|
||||||
|
string cafeId, string name, string unit,
|
||||||
|
decimal qty, decimal reorder, decimal cost, decimal par) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = $"{cafeId}_ing_{Guid.NewGuid():N}"[..36],
|
||||||
|
CafeId = cafeId,
|
||||||
|
Name = name,
|
||||||
|
Unit = unit,
|
||||||
|
QuantityOnHand = qty,
|
||||||
|
ReorderLevel = reorder,
|
||||||
|
UnitCost = cost,
|
||||||
|
ParLevel = par,
|
||||||
|
LowStockWarningPercent = 20m
|
||||||
|
};
|
||||||
|
|
||||||
|
private static List<Table> BuildDemoTables(string cafeId, string branchId)
|
||||||
|
{
|
||||||
|
var tables = new List<Table>();
|
||||||
|
// Floor 1: tables 1-4
|
||||||
|
for (var i = 1; i <= 4; i++)
|
||||||
|
tables.Add(Table(cafeId, branchId, i.ToString(), 4, "طبقه اول", i));
|
||||||
|
// Floor 2: tables 5-8
|
||||||
|
for (var i = 5; i <= 8; i++)
|
||||||
|
tables.Add(Table(cafeId, branchId, i.ToString(), 4, "طبقه دوم", i));
|
||||||
|
// VIP: tables 9-10
|
||||||
|
for (var i = 9; i <= 10; i++)
|
||||||
|
tables.Add(Table(cafeId, branchId, i.ToString(), 6, "VIP", i));
|
||||||
|
return tables;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Table Table(
|
||||||
|
string cafeId, string branchId, string number, int capacity, string floor, int sortOrder) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = $"{cafeId}_tbl_{Guid.NewGuid():N}"[..36],
|
||||||
|
CafeId = cafeId,
|
||||||
|
BranchId = branchId,
|
||||||
|
Number = number,
|
||||||
|
Capacity = capacity,
|
||||||
|
Floor = floor,
|
||||||
|
SortOrder = sortOrder,
|
||||||
|
QrCode = Guid.NewGuid().ToString("N"),
|
||||||
|
IsActive = true,
|
||||||
|
IsCleaning = false
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ public interface IAuthService
|
|||||||
VerifyOtpRequest request,
|
VerifyOtpRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> LoginWithPasswordAsync(
|
||||||
|
LoginWithPasswordRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync(
|
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync(
|
||||||
string employeeId, string targetCafeId,
|
string employeeId, string targetCafeId,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Meezi.API.Models.Auth;
|
using Meezi.API.Models.Auth;
|
||||||
using Meezi.Core.Utilities;
|
using Meezi.Core.Utilities;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace Meezi.API.Validators;
|
namespace Meezi.API.Validators;
|
||||||
|
|
||||||
@@ -51,6 +52,10 @@ public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
|
|||||||
.NotEmpty()
|
.NotEmpty()
|
||||||
.MaximumLength(100)
|
.MaximumLength(100)
|
||||||
.WithMessage("Cafe name must be between 1 and 100 characters.");
|
.WithMessage("Cafe name must be between 1 and 100 characters.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Slug)
|
||||||
|
.Must(s => s == null || SlugHelper.IsValidSlug(s))
|
||||||
|
.WithMessage("Slug must be 2-80 lowercase letters, digits, or hyphens (e.g. my-cafe).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Meezi.API.Models.Cafes;
|
using Meezi.API.Models.Cafes;
|
||||||
|
using Meezi.Core.Utilities;
|
||||||
|
|
||||||
namespace Meezi.API.Validators;
|
namespace Meezi.API.Validators;
|
||||||
|
|
||||||
@@ -8,6 +9,10 @@ public class PatchCafeSettingsRequestValidator : AbstractValidator<PatchCafeSett
|
|||||||
public PatchCafeSettingsRequestValidator()
|
public PatchCafeSettingsRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Name).MaximumLength(200).When(x => x.Name is not null);
|
RuleFor(x => x.Name).MaximumLength(200).When(x => x.Name is not null);
|
||||||
|
RuleFor(x => x.Slug)
|
||||||
|
.Must(s => s == null || SlugHelper.IsValidSlug(s))
|
||||||
|
.WithMessage("Slug must be 2-80 lowercase letters, digits, or hyphens (e.g. my-cafe).")
|
||||||
|
.When(x => x.Slug is not null);
|
||||||
RuleFor(x => x.Phone).MaximumLength(20).When(x => x.Phone is not null);
|
RuleFor(x => x.Phone).MaximumLength(20).When(x => x.Phone is not null);
|
||||||
RuleFor(x => x.Address).MaximumLength(500).When(x => x.Address is not null);
|
RuleFor(x => x.Address).MaximumLength(500).When(x => x.Address is not null);
|
||||||
RuleFor(x => x.City).MaximumLength(100).When(x => x.City is not null);
|
RuleFor(x => x.City).MaximumLength(100).When(x => x.City is not null);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public class CreateMenuCategoryRequestValidator : AbstractValidator<CreateMenuCa
|
|||||||
public CreateMenuCategoryRequestValidator()
|
public CreateMenuCategoryRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(200);
|
RuleFor(x => x.NameEn).MaximumLength(200).When(x => !string.IsNullOrWhiteSpace(x.NameEn));
|
||||||
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
|
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
|
||||||
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
|
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
|
||||||
RuleFor(x => x.Icon).MaximumLength(32).When(x => x.Icon is not null);
|
RuleFor(x => x.Icon).MaximumLength(32).When(x => x.Icon is not null);
|
||||||
@@ -39,7 +39,7 @@ public class CreateMenuItemRequestValidator : AbstractValidator<CreateMenuItemRe
|
|||||||
{
|
{
|
||||||
RuleFor(x => x.CategoryId).NotEmpty();
|
RuleFor(x => x.CategoryId).NotEmpty();
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(200);
|
RuleFor(x => x.NameEn).MaximumLength(200).When(x => !string.IsNullOrWhiteSpace(x.NameEn));
|
||||||
RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
|
RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
|
||||||
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
|
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.Admin.API.Models;
|
using Meezi.Admin.API.Models;
|
||||||
using Meezi.Admin.API.Services;
|
using Meezi.Admin.API.Services;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace Meezi.Admin.API.Controllers;
|
namespace Meezi.Admin.API.Controllers;
|
||||||
|
|
||||||
@@ -55,6 +58,39 @@ public class AdminAuthController : ControllerBase
|
|||||||
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("login")]
|
||||||
|
public async Task<IActionResult> LoginWithPassword(
|
||||||
|
[FromBody] LoginWithPasswordRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
return BadRequest(ValidationError("Username and password are required."));
|
||||||
|
|
||||||
|
var (success, data, code, message) = await _auth.LoginWithPasswordAsync(request, cancellationToken);
|
||||||
|
if (!success)
|
||||||
|
return ErrorResult(code!, message!);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("password")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> ChangePassword(
|
||||||
|
[FromBody] ChangePasswordRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var adminId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||||
|
?? User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrEmpty(adminId))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var (success, code, message) = await _auth.ChangePasswordAsync(adminId, request, cancellationToken);
|
||||||
|
if (!success)
|
||||||
|
return ErrorResult(code!, message!);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, null));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -75,6 +111,9 @@ public class AdminAuthController : ControllerBase
|
|||||||
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
|
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ApiResponse<object> ValidationError(string message) =>
|
||||||
|
new(false, null, new ApiError("VALIDATION_ERROR", message));
|
||||||
|
|
||||||
private IActionResult ErrorResult(string code, string message) =>
|
private IActionResult ErrorResult(string code, string message) =>
|
||||||
code switch
|
code switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ public record VerifyOtpRequest(string Phone, string Code);
|
|||||||
|
|
||||||
public record RefreshTokenRequest(string RefreshToken);
|
public record RefreshTokenRequest(string RefreshToken);
|
||||||
|
|
||||||
|
public record LoginWithPasswordRequest(string Username, string Password);
|
||||||
|
|
||||||
|
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
|
||||||
|
|
||||||
public record AuthTokenResponse(
|
public record AuthTokenResponse(
|
||||||
string AccessToken,
|
string AccessToken,
|
||||||
string RefreshToken,
|
string RefreshToken,
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ public interface IAdminAuthService
|
|||||||
VerifyOtpRequest request,
|
VerifyOtpRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithPasswordAsync(
|
||||||
|
LoginWithPasswordRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<(bool Success, string? ErrorCode, string? ErrorMessage)> ChangePasswordAsync(
|
||||||
|
string adminId,
|
||||||
|
ChangePasswordRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
|
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
|
||||||
RefreshTokenRequest request,
|
RefreshTokenRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
@@ -141,6 +150,49 @@ public class AdminAuthService : IAdminAuthService
|
|||||||
return (true, tokens, null, null);
|
return (true, tokens, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithPasswordAsync(
|
||||||
|
LoginWithPasswordRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var username = request.Username.Trim();
|
||||||
|
var admin = await _db.SystemAdmins
|
||||||
|
.FirstOrDefaultAsync(a => a.Username == username && a.IsActive && a.DeletedAt == null, cancellationToken);
|
||||||
|
|
||||||
|
if (admin is null || string.IsNullOrWhiteSpace(admin.PasswordHash))
|
||||||
|
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.");
|
||||||
|
|
||||||
|
if (!PasswordHasher.Verify(request.Password, admin.PasswordHash))
|
||||||
|
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.");
|
||||||
|
|
||||||
|
var tokens = await IssueTokensAsync(admin, cancellationToken);
|
||||||
|
return (true, tokens, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Success, string? ErrorCode, string? ErrorMessage)> ChangePasswordAsync(
|
||||||
|
string adminId,
|
||||||
|
ChangePasswordRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var admin = await _db.SystemAdmins
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == adminId && a.IsActive && a.DeletedAt == null, cancellationToken);
|
||||||
|
if (admin is null)
|
||||||
|
return (false, "NOT_FOUND", "Admin not found.");
|
||||||
|
|
||||||
|
// If a password is already set, require the current one
|
||||||
|
if (!string.IsNullOrWhiteSpace(admin.PasswordHash))
|
||||||
|
{
|
||||||
|
if (!PasswordHasher.Verify(request.CurrentPassword, admin.PasswordHash))
|
||||||
|
return (false, "INVALID_CREDENTIALS", "Current password is incorrect.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8)
|
||||||
|
return (false, "VALIDATION_ERROR", "New password must be at least 8 characters.");
|
||||||
|
|
||||||
|
admin.PasswordHash = PasswordHasher.Hash(request.NewPassword);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return (true, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<AuthTokenResponse> IssueTokensAsync(
|
private async Task<AuthTokenResponse> IssueTokensAsync(
|
||||||
Core.Entities.SystemAdmin admin,
|
Core.Entities.SystemAdmin admin,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ public class AdminWebsiteService(AppDbContext db) : IAdminWebsiteService
|
|||||||
var q = db.WebsiteBlogPosts.AsQueryable();
|
var q = db.WebsiteBlogPosts.AsQueryable();
|
||||||
if (published.HasValue) q = q.Where(p => p.IsPublished == published.Value);
|
if (published.HasValue) q = q.Where(p => p.IsPublished == published.Value);
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
var posts = await q.OrderByDescending(p => p.CreatedAt)
|
var posts = await q
|
||||||
|
.Include(p => p.Comments)
|
||||||
|
.OrderByDescending(p => p.CreatedAt)
|
||||||
.Skip((page - 1) * limit).Take(limit).ToListAsync(ct);
|
.Skip((page - 1) * limit).Take(limit).ToListAsync(ct);
|
||||||
return new { Posts = posts.Select(MapPost), Total = total, Page = page, Limit = limit };
|
return new { Posts = posts.Select(MapPost), Total = total, Page = page, Limit = limit };
|
||||||
}
|
}
|
||||||
@@ -162,5 +164,6 @@ public class AdminWebsiteService(AppDbContext db) : IAdminWebsiteService
|
|||||||
p.Id, p.Slug, p.TitleFa, p.TitleEn, p.ExcerptFa, p.ExcerptEn,
|
p.Id, p.Slug, p.TitleFa, p.TitleEn, p.ExcerptFa, p.ExcerptEn,
|
||||||
p.ContentFa, p.ContentEn, p.CategoryFa, p.CategoryEn, p.Author,
|
p.ContentFa, p.ContentEn, p.CategoryFa, p.CategoryEn, p.Author,
|
||||||
p.TagsJson, p.CoverImage, p.IsPublished, p.PublishedAt, p.ViewCount, p.CreatedAt,
|
p.TagsJson, p.CoverImage, p.IsPublished, p.PublishedAt, p.ViewCount, p.CreatedAt,
|
||||||
|
CommentCount = p.Comments?.Count ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ public class SendOtpRequestValidator : AbstractValidator<SendOtpRequest>
|
|||||||
{
|
{
|
||||||
public SendOtpRequestValidator()
|
public SendOtpRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Phone).Must(PhoneNormalizer.IsValidIranMobile).WithMessage("Invalid phone number.");
|
RuleFor(x => x.Phone)
|
||||||
|
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p)))
|
||||||
|
.WithMessage("Invalid phone number.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +18,9 @@ public class VerifyOtpRequestValidator : AbstractValidator<VerifyOtpRequest>
|
|||||||
{
|
{
|
||||||
public VerifyOtpRequestValidator()
|
public VerifyOtpRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Phone).Must(PhoneNormalizer.IsValidIranMobile);
|
RuleFor(x => x.Phone)
|
||||||
|
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p)))
|
||||||
|
.WithMessage("Invalid phone number.");
|
||||||
RuleFor(x => x.Code)
|
RuleFor(x => x.Code)
|
||||||
.Must(OtpNormalizer.IsValidSixDigitCode)
|
.Must(OtpNormalizer.IsValidSixDigitCode)
|
||||||
.WithMessage("OTP must be 6 digits.");
|
.WithMessage("OTP must be 6 digits.");
|
||||||
|
|||||||
@@ -54,4 +54,24 @@ public static class PlanLimits
|
|||||||
PlanTier.Enterprise => 100,
|
PlanTier.Enterprise => 100,
|
||||||
_ => 0
|
_ => 0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>Maximum active menu categories. Free tier is capped at 3; Pro+ is unlimited.</summary>
|
||||||
|
public static int MaxMenuCategories(PlanTier tier) => tier switch
|
||||||
|
{
|
||||||
|
PlanTier.Free => 3,
|
||||||
|
_ => int.MaxValue
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Maximum menu items. Free tier is capped at 30; Pro+ is unlimited.</summary>
|
||||||
|
public static int MaxMenuItems(PlanTier tier) => tier switch
|
||||||
|
{
|
||||||
|
PlanTier.Free => 30,
|
||||||
|
_ => int.MaxValue
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>CRM (customers, loyalty) is only available on Pro and above.</summary>
|
||||||
|
public static bool CanAccessCrm(PlanTier tier) => tier >= PlanTier.Pro;
|
||||||
|
|
||||||
|
/// <summary>Statistics and analytics dashboards are only available on Pro and above.</summary>
|
||||||
|
public static bool CanAccessStatistics(PlanTier tier) => tier >= PlanTier.Pro;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ public class Cafe : BaseEntity
|
|||||||
public string? InstagramHandle { get; set; }
|
public string? InstagramHandle { get; set; }
|
||||||
/// <summary>Cafe website URL, max 300 chars.</summary>
|
/// <summary>Cafe website URL, max 300 chars.</summary>
|
||||||
public string? WebsiteUrl { get; set; }
|
public string? WebsiteUrl { get; set; }
|
||||||
|
/// <summary>WGS-84 latitude (positive = north). Null until owner sets location.</summary>
|
||||||
|
public double? Latitude { get; set; }
|
||||||
|
/// <summary>WGS-84 longitude (positive = east). Null until owner sets location.</summary>
|
||||||
|
public double? Longitude { get; set; }
|
||||||
/// <summary>Default VAT/sales tax % for all branches unless branch override is allowed.</summary>
|
/// <summary>Default VAT/sales tax % for all branches unless branch override is allowed.</summary>
|
||||||
public decimal DefaultTaxRate { get; set; } = 9m;
|
public decimal DefaultTaxRate { get; set; } = 9m;
|
||||||
public bool AllowBranchTaxOverride { get; set; }
|
public bool AllowBranchTaxOverride { get; set; }
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ public class Employee : TenantEntity
|
|||||||
public decimal BaseSalary { get; set; }
|
public decimal BaseSalary { get; set; }
|
||||||
public string? PinCode { get; set; }
|
public string? PinCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional username for password-based dashboard/POS login (set by cafe admin).</summary>
|
||||||
|
public string? Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PBKDF2/SHA-256 hash. Null means password login is not enabled for this employee.</summary>
|
||||||
|
public string? PasswordHash { get; set; }
|
||||||
|
|
||||||
public Cafe Cafe { get; set; } = null!;
|
public Cafe Cafe { get; set; } = null!;
|
||||||
public Branch? Branch { get; set; }
|
public Branch? Branch { get; set; }
|
||||||
public ICollection<Order> Orders { get; set; } = [];
|
public ICollection<Order> Orders { get; set; } = [];
|
||||||
|
|||||||
@@ -5,4 +5,10 @@ public class SystemAdmin : BaseEntity
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string Phone { get; set; } = string.Empty;
|
public string Phone { get; set; } = string.Empty;
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Optional username for password-based login (alternative to OTP).</summary>
|
||||||
|
public string? Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PBKDF2/SHA-256 hash. Null means password login is not enabled.</summary>
|
||||||
|
public string? PasswordHash { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Meezi.Core.Utilities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PBKDF2/SHA-256 password hashing with no external dependencies.
|
||||||
|
/// Format stored: "{iterations}.{salt_b64}.{hash_b64}"
|
||||||
|
/// </summary>
|
||||||
|
public static class PasswordHasher
|
||||||
|
{
|
||||||
|
private const int SaltSize = 16; // 128-bit salt
|
||||||
|
private const int HashSize = 32; // 256-bit hash
|
||||||
|
private const int Iterations = 100_000; // NIST-recommended minimum
|
||||||
|
private static readonly HashAlgorithmName Algo = HashAlgorithmName.SHA256;
|
||||||
|
|
||||||
|
public static string Hash(string password)
|
||||||
|
{
|
||||||
|
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||||
|
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, Algo, HashSize);
|
||||||
|
return $"{Iterations}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Verify(string password, string storedHash)
|
||||||
|
{
|
||||||
|
var parts = storedHash.Split('.');
|
||||||
|
if (parts.Length != 3) return false;
|
||||||
|
if (!int.TryParse(parts[0], out var iterations)) return false;
|
||||||
|
|
||||||
|
byte[] salt, expectedHash;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
salt = Convert.FromBase64String(parts[1]);
|
||||||
|
expectedHash = Convert.FromBase64String(parts[2]);
|
||||||
|
}
|
||||||
|
catch (FormatException) { return false; }
|
||||||
|
|
||||||
|
var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, Algo, expectedHash.Length);
|
||||||
|
return CryptographicOperations.FixedTimeEquals(actual, expectedHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Meezi.Core.Utilities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts Persian/Arabic café names to URL-safe Latin slugs.
|
||||||
|
/// Used for Koja profile URLs (koja.meezi.ir/fa/cafe/{slug}).
|
||||||
|
/// </summary>
|
||||||
|
public static partial class SlugHelper
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<char, string> PersianToLatin = new()
|
||||||
|
{
|
||||||
|
// Alef variants
|
||||||
|
{ 'آ', "a" }, { 'ا', "a" }, { 'أ', "a" }, { 'إ', "a" },
|
||||||
|
// Ba, Pa, Ta, Tha
|
||||||
|
{ 'ب', "b" }, { 'پ', "p" }, { 'ت', "t" }, { 'ث', "s" },
|
||||||
|
// Jim, Che, He, Khe
|
||||||
|
{ 'ج', "j" }, { 'چ', "ch" }, { 'ح', "h" }, { 'خ', "kh" },
|
||||||
|
// Dal, Zal, Re, Ze, Zhe
|
||||||
|
{ 'د', "d" }, { 'ذ', "z" }, { 'ر', "r" }, { 'ز', "z" }, { 'ژ', "zh" },
|
||||||
|
// Sin, Shin, Sad, Zad
|
||||||
|
{ 'س', "s" }, { 'ش', "sh" }, { 'ص', "s" }, { 'ض', "z" },
|
||||||
|
// Ta, Za, Ain, Ghain
|
||||||
|
{ 'ط', "t" }, { 'ظ', "z" }, { 'ع', "a" }, { 'غ', "gh" },
|
||||||
|
// Fa, Ghaf, Kaf (Arabic+Persian), Gaf
|
||||||
|
{ 'ف', "f" }, { 'ق', "gh" }, { 'ک', "k" }, { 'ك', "k" }, { 'گ', "g" },
|
||||||
|
// Lam, Mim, Nun, Vav, He, Ye
|
||||||
|
{ 'ل', "l" }, { 'م', "m" }, { 'ن', "n" }, { 'و', "v" },
|
||||||
|
{ 'ه', "h" }, { 'ی', "i" }, { 'ي', "i" },
|
||||||
|
// Special
|
||||||
|
{ 'ئ', "y" }, { 'ء', "" }, { 'ة', "t" }, { 'ى', "a" }, { 'ؤ', "o" },
|
||||||
|
// Persian digits
|
||||||
|
{ '۰', "0" }, { '۱', "1" }, { '۲', "2" }, { '۳', "3" }, { '۴', "4" },
|
||||||
|
{ '۵', "5" }, { '۶', "6" }, { '۷', "7" }, { '۸', "8" }, { '۹', "9" },
|
||||||
|
// Arabic-Indic digits
|
||||||
|
{ '٠', "0" }, { '١', "1" }, { '٢', "2" }, { '٣', "3" }, { '٤', "4" },
|
||||||
|
{ '٥', "5" }, { '٦', "6" }, { '٧', "7" }, { '٨', "8" }, { '٩', "9" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a café name (Persian or Latin) to a URL-safe lowercase slug.
|
||||||
|
/// Returns an empty string if no valid characters can be extracted.
|
||||||
|
/// </summary>
|
||||||
|
public static string Slugify(string input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input)) return string.Empty;
|
||||||
|
|
||||||
|
var sb = new StringBuilder(input.Length * 2);
|
||||||
|
foreach (var ch in input)
|
||||||
|
{
|
||||||
|
if (PersianToLatin.TryGetValue(ch, out var latin))
|
||||||
|
{
|
||||||
|
sb.Append(latin);
|
||||||
|
}
|
||||||
|
else if (char.IsAsciiLetterOrDigit(ch))
|
||||||
|
{
|
||||||
|
sb.Append(char.ToLowerInvariant(ch));
|
||||||
|
}
|
||||||
|
else if (ch is ' ' or '-' or '_' or '\t')
|
||||||
|
{
|
||||||
|
sb.Append('-');
|
||||||
|
}
|
||||||
|
// else: skip punctuation/unsupported characters
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse consecutive hyphens and trim
|
||||||
|
return MultipleHyphen().Replace(sb.ToString(), "-").Trim('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the slug is a valid Koja URL slug:
|
||||||
|
/// 2–80 lowercase letters, digits, or internal hyphens. Must start and end with a letter/digit.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsValidSlug(string? slug)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(slug)) return false;
|
||||||
|
if (slug.Length < 2 || slug.Length > 80) return false;
|
||||||
|
return ValidSlugPattern().IsMatch(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"-{2,}")]
|
||||||
|
private static partial Regex MultipleHyphen();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^[a-z0-9][a-z0-9\-]*[a-z0-9]$")]
|
||||||
|
private static partial Regex ValidSlugPattern();
|
||||||
|
}
|
||||||
@@ -6,8 +6,23 @@ namespace Meezi.Infrastructure.Data;
|
|||||||
|
|
||||||
public static class DemoMenuSeeder
|
public static class DemoMenuSeeder
|
||||||
{
|
{
|
||||||
public static async Task EnsureMenuAsync(AppDbContext db, string cafeId, string taxId, ILogger logger)
|
/// <param name="useScopedIds">
|
||||||
|
/// When true, category and item IDs are prefixed with <paramref name="cafeId"/> so
|
||||||
|
/// multiple cafés can each have their own copy of the demo menu without a primary-key
|
||||||
|
/// collision. Pass false only for the legacy demo café (cafe_demo_001) whose IDs are
|
||||||
|
/// already in the database without a café prefix.
|
||||||
|
/// </param>
|
||||||
|
public static async Task EnsureMenuAsync(
|
||||||
|
AppDbContext db, string cafeId, string taxId, ILogger logger,
|
||||||
|
bool useScopedIds = false)
|
||||||
{
|
{
|
||||||
|
// When useScopedIds=true every row gets a deterministic ID that is unique per café:
|
||||||
|
// category → "{cafeId}_{catalogId}"
|
||||||
|
// item → "{cafeId}_{catalogId}"
|
||||||
|
// The catalog item's CategoryId is remapped through the same function.
|
||||||
|
string Scoped(string catalogId) =>
|
||||||
|
useScopedIds ? $"{cafeId}_{catalogId}" : catalogId;
|
||||||
|
|
||||||
if (!await db.Taxes.AnyAsync(t => t.Id == taxId && t.CafeId == cafeId))
|
if (!await db.Taxes.AnyAsync(t => t.Id == taxId && t.CafeId == cafeId))
|
||||||
{
|
{
|
||||||
db.Taxes.Add(new Tax
|
db.Taxes.Add(new Tax
|
||||||
@@ -29,7 +44,8 @@ public static class DemoMenuSeeder
|
|||||||
var categoriesAdded = 0;
|
var categoriesAdded = 0;
|
||||||
foreach (var cat in DemoMenuCatalog.Categories)
|
foreach (var cat in DemoMenuCatalog.Categories)
|
||||||
{
|
{
|
||||||
if (existingCategoryIds.TryGetValue(cat.Id, out var row))
|
var catId = Scoped(cat.Id);
|
||||||
|
if (existingCategoryIds.TryGetValue(catId, out var row))
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(row.Icon) && !string.IsNullOrWhiteSpace(cat.Icon))
|
if (string.IsNullOrWhiteSpace(row.Icon) && !string.IsNullOrWhiteSpace(cat.Icon))
|
||||||
row.Icon = cat.Icon;
|
row.Icon = cat.Icon;
|
||||||
@@ -46,7 +62,7 @@ public static class DemoMenuSeeder
|
|||||||
|
|
||||||
db.MenuCategories.Add(new MenuCategory
|
db.MenuCategories.Add(new MenuCategory
|
||||||
{
|
{
|
||||||
Id = cat.Id,
|
Id = catId,
|
||||||
CafeId = cafeId,
|
CafeId = cafeId,
|
||||||
Name = cat.Name,
|
Name = cat.Name,
|
||||||
NameEn = cat.NameEn,
|
NameEn = cat.NameEn,
|
||||||
@@ -69,14 +85,15 @@ public static class DemoMenuSeeder
|
|||||||
var itemsAdded = 0;
|
var itemsAdded = 0;
|
||||||
foreach (var item in DemoMenuCatalog.Items)
|
foreach (var item in DemoMenuCatalog.Items)
|
||||||
{
|
{
|
||||||
if (existingItemIds.Contains(item.Id))
|
var itemId = Scoped(item.Id);
|
||||||
|
if (existingItemIds.Contains(itemId))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
db.MenuItems.Add(new MenuItem
|
db.MenuItems.Add(new MenuItem
|
||||||
{
|
{
|
||||||
Id = item.Id,
|
Id = itemId,
|
||||||
CafeId = cafeId,
|
CafeId = cafeId,
|
||||||
CategoryId = item.CategoryId,
|
CategoryId = Scoped(item.CategoryId), // FK must point at scoped category ID
|
||||||
Name = item.Name,
|
Name = item.Name,
|
||||||
NameEn = item.NameEn,
|
NameEn = item.NameEn,
|
||||||
NameAr = item.NameAr,
|
NameAr = item.NameAr,
|
||||||
|
|||||||
+3299
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Meezi.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPasswordLogin : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PasswordHash",
|
||||||
|
table: "SystemAdmins",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Username",
|
||||||
|
table: "SystemAdmins",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PasswordHash",
|
||||||
|
table: "Employees",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Username",
|
||||||
|
table: "Employees",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PasswordHash",
|
||||||
|
table: "SystemAdmins");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Username",
|
||||||
|
table: "SystemAdmins");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PasswordHash",
|
||||||
|
table: "Employees");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Username",
|
||||||
|
table: "Employees");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Meezi.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260601120000_AddCafeLocation")]
|
||||||
|
public partial class AddCafeLocation : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "Latitude",
|
||||||
|
table: "Cafes",
|
||||||
|
type: "double precision",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "Longitude",
|
||||||
|
table: "Cafes",
|
||||||
|
type: "double precision",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Latitude",
|
||||||
|
table: "Cafes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Longitude",
|
||||||
|
table: "Cafes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -327,9 +327,15 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Property<bool>("IsVerified")
|
b.Property<bool>("IsVerified")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<double?>("Latitude")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
b.Property<string>("LogoUrl")
|
b.Property<string>("LogoUrl")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<double?>("Longitude")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
@@ -929,6 +935,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Property<string>("NationalId")
|
b.Property<string>("NationalId")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Phone")
|
b.Property<string>("Phone")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -939,6 +948,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Property<int>("Role")
|
b.Property<int>("Role")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("BranchId");
|
b.HasIndex("BranchId");
|
||||||
@@ -2119,11 +2131,17 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Phone")
|
b.Property<string>("Phone")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
.HasColumnType("character varying(20)");
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("Phone")
|
b.HasIndex("Phone")
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ using Meezi.Core.Constants;
|
|||||||
using Meezi.Core.Entities;
|
using Meezi.Core.Entities;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Platform;
|
using Meezi.Core.Platform;
|
||||||
|
using Meezi.Core.Utilities;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -17,18 +19,25 @@ public static class PlatformDataSeeder
|
|||||||
public static async Task SeedAsync(IServiceProvider services)
|
public static async Task SeedAsync(IServiceProvider services)
|
||||||
{
|
{
|
||||||
var env = services.GetRequiredService<IHostEnvironment>();
|
var env = services.GetRequiredService<IHostEnvironment>();
|
||||||
if (!env.IsDevelopment())
|
|
||||||
return;
|
|
||||||
|
|
||||||
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("PlatformDataSeeder");
|
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("PlatformDataSeeder");
|
||||||
await using var scope = services.CreateAsyncScope();
|
await using var scope = services.CreateAsyncScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||||
|
|
||||||
await EnsureCatalogUpgradesAsync(db, logger);
|
// Production-safe: ensure the platform owner's system-admin account exists
|
||||||
|
// on every boot (ALL environments) so the admin panel is reachable on a
|
||||||
|
// fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone".
|
||||||
|
await EnsureOwnerAdminAsync(db, config, logger);
|
||||||
|
|
||||||
if (!env.IsDevelopment())
|
if (!env.IsDevelopment())
|
||||||
|
{
|
||||||
|
// Production: also ensure integration settings (Kavenegar enabled/template,
|
||||||
|
// etc.) exist so the admin Integrations page is populated. Idempotent.
|
||||||
|
await EnsureIntegrationSettingsAsync(db, logger);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await EnsureCatalogUpgradesAsync(db, logger);
|
||||||
await SeedSystemAdminAsync(db, logger);
|
await SeedSystemAdminAsync(db, logger);
|
||||||
await SeedPlansAsync(db, logger);
|
await SeedPlansAsync(db, logger);
|
||||||
await SeedFeaturesAsync(db, logger);
|
await SeedFeaturesAsync(db, logger);
|
||||||
@@ -36,6 +45,77 @@ public static class PlatformDataSeeder
|
|||||||
await EnsureIntegrationSettingsAsync(db, logger);
|
await EnsureIntegrationSettingsAsync(db, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures the platform owner's system-admin account exists in EVERY environment
|
||||||
|
/// (including production), so the admin panel is reachable on a fresh deploy.
|
||||||
|
/// The phone is configurable via "Seed:SystemAdminPhone" (env Seed__SystemAdminPhone)
|
||||||
|
/// and defaults to the platform owner's number. Idempotent — never duplicates.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task EnsureOwnerAdminAsync(AppDbContext db, IConfiguration config, ILogger logger)
|
||||||
|
{
|
||||||
|
const string DefaultOwnerPhone = "09190345606";
|
||||||
|
const string DefaultAdminUsername = "admin";
|
||||||
|
|
||||||
|
var configuredPhone = config["Seed:SystemAdminPhone"];
|
||||||
|
var phone = PhoneNormalizer.Normalize(
|
||||||
|
string.IsNullOrWhiteSpace(configuredPhone) ? DefaultOwnerPhone : configuredPhone);
|
||||||
|
|
||||||
|
if (!PhoneNormalizer.IsValidIranMobile(phone))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Owner system-admin seed skipped — invalid phone '{Phone}'", phone);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var configuredUsername = config["Seed:SystemAdminUsername"];
|
||||||
|
var username = string.IsNullOrWhiteSpace(configuredUsername) ? DefaultAdminUsername : configuredUsername.Trim().ToLowerInvariant();
|
||||||
|
var defaultPassword = config["Seed:SystemAdminPassword"]; // optional — only set if provided
|
||||||
|
|
||||||
|
var existing = await db.SystemAdmins.FirstOrDefaultAsync(a => a.Phone == phone);
|
||||||
|
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
var admin = new SystemAdmin
|
||||||
|
{
|
||||||
|
Id = "sysadmin_owner",
|
||||||
|
Name = "مدیر سامانه",
|
||||||
|
Phone = phone,
|
||||||
|
IsActive = true,
|
||||||
|
Username = username,
|
||||||
|
PasswordHash = string.IsNullOrWhiteSpace(defaultPassword) ? null : PasswordHasher.Hash(defaultPassword)
|
||||||
|
};
|
||||||
|
db.SystemAdmins.Add(admin);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Seeded owner system admin with phone {Phone}, username '{Username}'", phone, username);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Owner system admin already seeded by another instance");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch existing admin: fill in missing username / password without overwriting set values
|
||||||
|
var patched = false;
|
||||||
|
if (string.IsNullOrWhiteSpace(existing.Username))
|
||||||
|
{
|
||||||
|
existing.Username = username;
|
||||||
|
patched = true;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(existing.PasswordHash) && !string.IsNullOrWhiteSpace(defaultPassword))
|
||||||
|
{
|
||||||
|
existing.PasswordHash = PasswordHasher.Hash(defaultPassword);
|
||||||
|
patched = true;
|
||||||
|
}
|
||||||
|
if (patched)
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Patched owner system admin credentials (username/password)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Idempotent plan/feature upgrades for all environments (including production).</summary>
|
/// <summary>Idempotent plan/feature upgrades for all environments (including production).</summary>
|
||||||
public static async Task EnsureCatalogUpgradesAsync(IServiceProvider services)
|
public static async Task EnsureCatalogUpgradesAsync(IServiceProvider services)
|
||||||
{
|
{
|
||||||
@@ -45,8 +125,46 @@ public static class PlatformDataSeeder
|
|||||||
await EnsureCatalogUpgradesAsync(db, logger);
|
await EnsureCatalogUpgradesAsync(db, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures every café has at least one active branch. Idempotent.
|
||||||
|
/// Creates a default branch named after the café for any café that has none.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task EnsureDefaultBranchesAsync(AppDbContext db, ILogger logger)
|
||||||
|
{
|
||||||
|
// Load café IDs that have zero branches in one query
|
||||||
|
var cafeIdsWithBranches = await db.Branches
|
||||||
|
.Where(b => b.DeletedAt == null)
|
||||||
|
.Select(b => b.CafeId)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var cafesWithoutBranch = await db.Cafes
|
||||||
|
.Where(c => c.DeletedAt == null && !cafeIdsWithBranches.Contains(c.Id))
|
||||||
|
.Select(c => new { c.Id, c.Name })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (cafesWithoutBranch.Count == 0) return;
|
||||||
|
|
||||||
|
foreach (var cafe in cafesWithoutBranch)
|
||||||
|
{
|
||||||
|
db.Branches.Add(new Branch
|
||||||
|
{
|
||||||
|
CafeId = cafe.Id,
|
||||||
|
Name = cafe.Name,
|
||||||
|
IsActive = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Created default branch for {Count} café(s) that had none", cafesWithoutBranch.Count);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task EnsureCatalogUpgradesAsync(AppDbContext db, ILogger logger)
|
private static async Task EnsureCatalogUpgradesAsync(AppDbContext db, ILogger logger)
|
||||||
{
|
{
|
||||||
|
// Ensure every café has at least one branch. Cafés registered before the
|
||||||
|
// auto-branch feature was added are patched on the first boot after upgrade.
|
||||||
|
await EnsureDefaultBranchesAsync(db, logger);
|
||||||
|
|
||||||
var featureAdds = new[]
|
var featureAdds = new[]
|
||||||
{
|
{
|
||||||
("menu_3d", "منوی سهبعدی", "3D menu", "growth"),
|
("menu_3d", "منوی سهبعدی", "3D menu", "growth"),
|
||||||
@@ -126,7 +244,7 @@ public static class PlatformDataSeeder
|
|||||||
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
|
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
|
||||||
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
|
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
|
||||||
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوهنگار"),
|
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوهنگار"),
|
||||||
S("integrations.kavenegar.otpTemplate", "verify", "integrations", "قالب OTP"),
|
S("integrations.kavenegar.otpTemplate", "meeziotp", "integrations", "قالب OTP"),
|
||||||
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
|
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
|
||||||
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
|
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
|
||||||
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
|
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
|
||||||
@@ -296,7 +414,7 @@ public static class PlatformDataSeeder
|
|||||||
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
|
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
|
||||||
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
|
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
|
||||||
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوهنگار"),
|
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوهنگار"),
|
||||||
S("integrations.kavenegar.otpTemplate", "verify", "integrations", "قالب OTP"),
|
S("integrations.kavenegar.otpTemplate", "meeziotp", "integrations", "قالب OTP"),
|
||||||
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
|
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
|
||||||
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
|
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
|
||||||
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
|
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
|
||||||
|
|||||||
@@ -50,8 +50,10 @@ public class SupportTicketService : ISupportTicketService
|
|||||||
string cafeId,
|
string cafeId,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await QueryTickets()
|
// NOTE: The Where MUST be applied on the EF entity set BEFORE the Select projection.
|
||||||
.Where(t => t.CafeId == cafeId)
|
// Applying Where() after Select() onto a DTO record causes an EF translation error
|
||||||
|
// because EF can't translate "new SupportTicketDto(...).CafeId == x".
|
||||||
|
return await QueryTickets(cafeId)
|
||||||
.OrderByDescending(t => t.UpdatedAt)
|
.OrderByDescending(t => t.UpdatedAt)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -119,11 +121,10 @@ public class SupportTicketService : ISupportTicketService
|
|||||||
SupportTicketStatus? status,
|
SupportTicketStatus? status,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var q = QueryTickets();
|
// status filter is applied on the entity before projection — safe for EF translation.
|
||||||
if (status.HasValue)
|
return await QueryTickets(cafeId: null, status: status)
|
||||||
q = q.Where(t => t.Status == status.Value);
|
.OrderByDescending(t => t.UpdatedAt)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
return await q.OrderByDescending(t => t.UpdatedAt).ToListAsync(cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SupportTicketDetailDto?> GetAdminAsync(
|
public async Task<SupportTicketDetailDto?> GetAdminAsync(
|
||||||
@@ -185,22 +186,36 @@ public class SupportTicketService : ISupportTicketService
|
|||||||
return await GetAdminAsync(ticketId, cancellationToken);
|
return await GetAdminAsync(ticketId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private IQueryable<SupportTicketDto> QueryTickets() =>
|
/// <summary>
|
||||||
_db.SupportTickets
|
/// Builds an EF-translatable query for support ticket list rows.
|
||||||
.AsNoTracking()
|
/// Filters are applied on the entity BEFORE the Select projection to avoid EF translation errors.
|
||||||
.Select(t => new SupportTicketDto(
|
/// </summary>
|
||||||
t.Id,
|
private IQueryable<SupportTicketDto> QueryTickets(
|
||||||
t.CafeId,
|
string? cafeId = null,
|
||||||
t.Cafe != null ? t.Cafe.Name : "",
|
SupportTicketStatus? status = null)
|
||||||
t.Subject,
|
{
|
||||||
t.Status,
|
var q = _db.SupportTickets.AsNoTracking().AsQueryable();
|
||||||
t.Priority,
|
|
||||||
t.CreatedByEmployeeId,
|
// Apply entity-level filters BEFORE Select so EF can translate them.
|
||||||
t.CreatedByEmployee != null ? t.CreatedByEmployee.Name : null,
|
if (cafeId is not null)
|
||||||
t.AssignedAdminId,
|
q = q.Where(t => t.CafeId == cafeId);
|
||||||
t.CreatedAt,
|
if (status.HasValue)
|
||||||
t.UpdatedAt,
|
q = q.Where(t => t.Status == status.Value);
|
||||||
t.Messages.Count));
|
|
||||||
|
return q.Select(t => new SupportTicketDto(
|
||||||
|
t.Id,
|
||||||
|
t.CafeId,
|
||||||
|
t.Cafe != null ? t.Cafe.Name : "",
|
||||||
|
t.Subject,
|
||||||
|
t.Status,
|
||||||
|
t.Priority,
|
||||||
|
t.CreatedByEmployeeId,
|
||||||
|
t.CreatedByEmployee != null ? t.CreatedByEmployee.Name : null,
|
||||||
|
t.AssignedAdminId,
|
||||||
|
t.CreatedAt,
|
||||||
|
t.UpdatedAt,
|
||||||
|
t.Messages.Count));
|
||||||
|
}
|
||||||
|
|
||||||
private static SupportTicketDto MapTicket(SupportTicket t) =>
|
private static SupportTicketDto MapTicket(SupportTicket t) =>
|
||||||
new(
|
new(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
using Meezi.Core.Discover;
|
using Meezi.Core.Discover;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
namespace Meezi.API.Tests;
|
namespace Meezi.API.Tests;
|
||||||
|
|
||||||
@@ -44,7 +45,8 @@ public class DiscoverFilterTests
|
|||||||
noise: "quiet",
|
noise: "quiet",
|
||||||
priceTier: "mid",
|
priceTier: "mid",
|
||||||
size: null,
|
size: null,
|
||||||
requireProfile: true);
|
requireProfile: true,
|
||||||
|
openNow: false);
|
||||||
Assert.Equal("تهران", f.City);
|
Assert.Equal("تهران", f.City);
|
||||||
Assert.Equal(4, f.MinRating);
|
Assert.Equal(4, f.MinRating);
|
||||||
Assert.Contains("modern", f.Themes!);
|
Assert.Contains("modern", f.Themes!);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Meezi.API.Security;
|
||||||
|
|
||||||
|
namespace Meezi.API.Tests;
|
||||||
|
|
||||||
|
/// <summary>Test double that allows every action and has no captcha configured.</summary>
|
||||||
|
internal sealed class NoOpAbuseProtectionService : IAbuseProtectionService
|
||||||
|
{
|
||||||
|
public bool IsCaptchaConfigured => false;
|
||||||
|
public string? CaptchaSiteKey => null;
|
||||||
|
|
||||||
|
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckAuthOtpByIpAsync(
|
||||||
|
string clientIp, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<(bool, string?, string?)>((true, null, null));
|
||||||
|
|
||||||
|
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckGuestOrderAsync(
|
||||||
|
string cafeId, string clientIp, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<(bool, string?, string?)>((true, null, null));
|
||||||
|
|
||||||
|
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckPublicWriteByIpAsync(
|
||||||
|
string clientIp, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<(bool, string?, string?)>((true, null, null));
|
||||||
|
|
||||||
|
public Task<(bool Ok, string? ErrorCode, string? Message)> VerifyCaptchaAsync(
|
||||||
|
string? captchaToken, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<(bool, string?, string?)>((true, null, null));
|
||||||
|
}
|
||||||
@@ -16,9 +16,17 @@ internal sealed class NoOpInventoryService : IInventoryService
|
|||||||
public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) =>
|
public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) =>
|
||||||
Task.FromResult<IngredientDto?>(null);
|
Task.FromResult<IngredientDto?>(null);
|
||||||
|
|
||||||
public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, CancellationToken ct = default) =>
|
public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, string? userId, CancellationToken ct = default) =>
|
||||||
Task.FromResult<IngredientDto?>(null);
|
Task.FromResult<IngredientDto?>(null);
|
||||||
|
|
||||||
|
public Task<InventoryPurchasesSummaryDto> GetPurchasesSummaryAsync(
|
||||||
|
string cafeId,
|
||||||
|
string branchId,
|
||||||
|
DateOnly from,
|
||||||
|
DateOnly to,
|
||||||
|
CancellationToken ct = default) =>
|
||||||
|
Task.FromResult(new InventoryPurchasesSummaryDto(0, 0, []));
|
||||||
|
|
||||||
public Task<MenuItemRecipeDto?> GetRecipeAsync(string cafeId, string menuItemId, CancellationToken ct = default) =>
|
public Task<MenuItemRecipeDto?> GetRecipeAsync(string cafeId, string menuItemId, CancellationToken ct = default) =>
|
||||||
Task.FromResult<MenuItemRecipeDto?>(null);
|
Task.FromResult<MenuItemRecipeDto?>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Entities;
|
||||||
|
|
||||||
namespace Meezi.API.Tests;
|
namespace Meezi.API.Tests;
|
||||||
|
|
||||||
@@ -10,4 +11,11 @@ internal sealed class NoOpLoyaltyService : ILoyaltyService
|
|||||||
decimal paidAmount,
|
decimal paidAmount,
|
||||||
CancellationToken ct = default) =>
|
CancellationToken ct = default) =>
|
||||||
Task.CompletedTask;
|
Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task<(bool Success, LoyaltyRedeemResult? Data, string? ErrorCode)> RedeemOnOrderAsync(
|
||||||
|
string cafeId,
|
||||||
|
Order order,
|
||||||
|
int pointsRequested,
|
||||||
|
CancellationToken ct = default) =>
|
||||||
|
Task.FromResult<(bool, LoyaltyRedeemResult?, string?)>((false, null, null));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Meezi.API.Services;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace Meezi.API.Tests;
|
||||||
|
|
||||||
|
/// <summary>Test double that stores nothing and returns no URL.</summary>
|
||||||
|
internal sealed class NoOpMediaStorageService : IMediaStorageService
|
||||||
|
{
|
||||||
|
public Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveMenuVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveTableImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveTableVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveCafeLogoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveCafeCoverAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveMenuModel3dAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveMenuModel3dFromBytesAsync(string cafeId, byte[] glbBytes, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveReviewPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveCafeGalleryPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
@@ -11,4 +11,7 @@ internal sealed class NoOpOrderNotificationService : IOrderNotificationService
|
|||||||
|
|
||||||
public Task NotifyOrderStatusChangedAsync(Order order, CancellationToken ct = default) =>
|
public Task NotifyOrderStatusChangedAsync(Order order, CancellationToken ct = default) =>
|
||||||
Task.CompletedTask;
|
Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task NotifyCallWaiterAsync(string cafeId, string tableId, string tableNumber, CancellationToken ct = default) =>
|
||||||
|
Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public class PrintingTests
|
|||||||
218_000m,
|
218_000m,
|
||||||
0m,
|
0m,
|
||||||
DateTime.UtcNow,
|
DateTime.UtcNow,
|
||||||
|
1,
|
||||||
[
|
[
|
||||||
new OrderItemDto("i1", "m1", "Espresso", 2, 100_000m, null),
|
new OrderItemDto("i1", "m1", "Espresso", 2, 100_000m, null),
|
||||||
new OrderItemDto("i2", "m2", "Void Latte", 1, 50_000m, null, true)
|
new OrderItemDto("i2", "m2", "Void Latte", 1, 50_000m, null, true)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
using Meezi.API.Models.Menu;
|
using Meezi.API.Models.Menu;
|
||||||
using Meezi.API.Models.Orders;
|
using Meezi.API.Models.Orders;
|
||||||
using Meezi.API.Models.Public;
|
using Meezi.API.Models.Public;
|
||||||
|
using Meezi.API.Security;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
using Meezi.Core.Entities;
|
using Meezi.Core.Entities;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -114,7 +116,11 @@ public class QrMenuTests
|
|||||||
var tables = new TableService(db, config, kds, identity);
|
var tables = new TableService(db, config, kds, identity);
|
||||||
var shifts = new ShiftService(db);
|
var shifts = new ShiftService(db);
|
||||||
var orders = new OrderService(db, kds, new NoOpSnappfood(), new NoOpDeliverySync(), shifts, TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService());
|
var orders = new OrderService(db, kds, new NoOpSnappfood(), new NoOpDeliverySync(), shifts, TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService());
|
||||||
var publicSvc = new PublicService(db, orders, new ReviewService(db), kds, branchMenu, identity);
|
var abuse = new NoOpAbuseProtectionService();
|
||||||
|
var http = new HttpContextAccessor();
|
||||||
|
var media = new NoOpMediaStorageService();
|
||||||
|
var reviews = new ReviewService(db, abuse, http, media);
|
||||||
|
var publicSvc = new PublicService(db, orders, reviews, kds, branchMenu, identity, abuse, http);
|
||||||
|
|
||||||
return (db, tables, publicSvc, cafeId, branchId, tableId, itemA, itemB, qrCode);
|
return (db, tables, publicSvc, cafeId, branchId, tableId, itemA, itemB, qrCode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1057,6 +1057,7 @@
|
|||||||
"fieldCategoryEn": "الفئة بالإنجليزية",
|
"fieldCategoryEn": "الفئة بالإنجليزية",
|
||||||
"fieldContentFa": "المحتوى بالفارسية (Markdown)",
|
"fieldContentFa": "المحتوى بالفارسية (Markdown)",
|
||||||
"fieldContentEn": "المحتوى بالإنجليزية (Markdown)",
|
"fieldContentEn": "المحتوى بالإنجليزية (Markdown)",
|
||||||
|
"fieldPublished": "منشور",
|
||||||
"commentsTitle": "إدارة التعليقات",
|
"commentsTitle": "إدارة التعليقات",
|
||||||
"noComments": "لا توجد تعليقات",
|
"noComments": "لا توجد تعليقات",
|
||||||
"approved": "موافق عليه",
|
"approved": "موافق عليه",
|
||||||
@@ -1093,7 +1094,14 @@
|
|||||||
"otp": "رمز التحقق",
|
"otp": "رمز التحقق",
|
||||||
"login": "دخول",
|
"login": "دخول",
|
||||||
"error": "فشل تسجيل الدخول",
|
"error": "فشل تسجيل الدخول",
|
||||||
"devHint": "في التطوير يُطبع الرمز في سجل Admin API."
|
"devHint": "في التطوير يُطبع الرمز في سجل Admin API.",
|
||||||
|
"tabOtp": "رمز مؤقت",
|
||||||
|
"tabPassword": "كلمة المرور",
|
||||||
|
"username": "اسم المستخدم",
|
||||||
|
"usernamePlaceholder": "اسم المستخدم",
|
||||||
|
"password": "كلمة المرور",
|
||||||
|
"passwordPlaceholder": "كلمة المرور",
|
||||||
|
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "نظرة عامة",
|
"title": "نظرة عامة",
|
||||||
|
|||||||
@@ -1058,6 +1058,7 @@
|
|||||||
"fieldCategoryEn": "Category (English)",
|
"fieldCategoryEn": "Category (English)",
|
||||||
"fieldContentFa": "Content (Persian, Markdown)",
|
"fieldContentFa": "Content (Persian, Markdown)",
|
||||||
"fieldContentEn": "Content (English, Markdown)",
|
"fieldContentEn": "Content (English, Markdown)",
|
||||||
|
"fieldPublished": "Published",
|
||||||
"commentsTitle": "Comment management",
|
"commentsTitle": "Comment management",
|
||||||
"noComments": "No comments found",
|
"noComments": "No comments found",
|
||||||
"approved": "Approved",
|
"approved": "Approved",
|
||||||
@@ -1086,7 +1087,14 @@
|
|||||||
"otp": "Verification code",
|
"otp": "Verification code",
|
||||||
"login": "Sign in",
|
"login": "Sign in",
|
||||||
"error": "Login failed",
|
"error": "Login failed",
|
||||||
"devHint": "In development the OTP is logged by Admin API (DEV admin OTP)."
|
"devHint": "In development the OTP is logged by Admin API (DEV admin OTP).",
|
||||||
|
"tabOtp": "One-time code",
|
||||||
|
"tabPassword": "Password",
|
||||||
|
"username": "Username",
|
||||||
|
"usernamePlaceholder": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"passwordPlaceholder": "Password",
|
||||||
|
"invalidCredentials": "Incorrect username or password."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Platform overview",
|
"title": "Platform overview",
|
||||||
|
|||||||
@@ -1058,6 +1058,7 @@
|
|||||||
"fieldCategoryEn": "دستهبندی انگلیسی",
|
"fieldCategoryEn": "دستهبندی انگلیسی",
|
||||||
"fieldContentFa": "محتوا فارسی (Markdown)",
|
"fieldContentFa": "محتوا فارسی (Markdown)",
|
||||||
"fieldContentEn": "محتوا انگلیسی (Markdown)",
|
"fieldContentEn": "محتوا انگلیسی (Markdown)",
|
||||||
|
"fieldPublished": "وضعیت انتشار",
|
||||||
"commentsTitle": "مدیریت نظرات",
|
"commentsTitle": "مدیریت نظرات",
|
||||||
"noComments": "نظری یافت نشد",
|
"noComments": "نظری یافت نشد",
|
||||||
"approved": "تأیید شده",
|
"approved": "تأیید شده",
|
||||||
@@ -1086,7 +1087,14 @@
|
|||||||
"otp": "کد تأیید",
|
"otp": "کد تأیید",
|
||||||
"login": "ورود",
|
"login": "ورود",
|
||||||
"error": "خطا در ورود",
|
"error": "خطا در ورود",
|
||||||
"devHint": "در حالت توسعه کد در لاگ Admin API چاپ میشود (DEV admin OTP)."
|
"devHint": "در حالت توسعه کد در لاگ Admin API چاپ میشود (DEV admin OTP).",
|
||||||
|
"tabOtp": "کد یکبارمصرف",
|
||||||
|
"tabPassword": "رمز عبور",
|
||||||
|
"username": "نام کاربری",
|
||||||
|
"usernamePlaceholder": "نام کاربری",
|
||||||
|
"password": "رمز عبور",
|
||||||
|
"passwordPlaceholder": "رمز عبور",
|
||||||
|
"invalidCredentials": "نام کاربری یا رمز عبور اشتباه است."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "خلاصه سامانه",
|
"title": "خلاصه سامانه",
|
||||||
|
|||||||
@@ -13,14 +13,25 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { LabeledField } from "@/components/ui/labeled-field";
|
import { LabeledField } from "@/components/ui/labeled-field";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
type LoginTab = "otp" | "password";
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
const t = useTranslations("admin.auth");
|
const t = useTranslations("admin.auth");
|
||||||
const tAuth = useTranslations("auth");
|
const tAuth = useTranslations("auth");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const setAuth = useAdminAuthStore((s) => s.setAuth);
|
const setAuth = useAdminAuthStore((s) => s.setAuth);
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<LoginTab>("otp");
|
||||||
|
|
||||||
|
// OTP state
|
||||||
const [phone, setPhone] = useState("09120000001");
|
const [phone, setPhone] = useState("09120000001");
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [step, setStep] = useState<"phone" | "otp">("phone");
|
const [otpStep, setOtpStep] = useState<"phone" | "otp">("phone");
|
||||||
|
|
||||||
|
// Password state
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -34,6 +45,8 @@ export default function AdminLoginPage() {
|
|||||||
case "INVALID_OTP":
|
case "INVALID_OTP":
|
||||||
case "VALIDATION_ERROR":
|
case "VALIDATION_ERROR":
|
||||||
return tAuth("invalidOtp");
|
return tAuth("invalidOtp");
|
||||||
|
case "INVALID_TOKEN":
|
||||||
|
return t("invalidCredentials");
|
||||||
default:
|
default:
|
||||||
return err.message;
|
return err.message;
|
||||||
}
|
}
|
||||||
@@ -46,7 +59,7 @@ export default function AdminLoginPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await adminPost("/api/admin/auth/send-otp", { phone });
|
await adminPost("/api/admin/auth/send-otp", { phone });
|
||||||
setStep("otp");
|
setOtpStep("otp");
|
||||||
setCode("");
|
setCode("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(authErrorMessage(e));
|
setError(authErrorMessage(e));
|
||||||
@@ -55,7 +68,7 @@ export default function AdminLoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const verify = async () => {
|
const verifyOtp = async () => {
|
||||||
const normalized = normalizeOtpInput(code);
|
const normalized = normalizeOtpInput(code);
|
||||||
if (normalized.length !== 6) {
|
if (normalized.length !== 6) {
|
||||||
setError(tAuth("invalidOtp"));
|
setError(tAuth("invalidOtp"));
|
||||||
@@ -77,6 +90,34 @@ export default function AdminLoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loginWithPassword = async () => {
|
||||||
|
if (!username.trim() || !password) {
|
||||||
|
setError(t("invalidCredentials"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await adminPost<AuthTokenResponse>("/api/admin/auth/login", {
|
||||||
|
username: username.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
setAuth(data);
|
||||||
|
router.push("/admin");
|
||||||
|
} catch (e) {
|
||||||
|
setError(authErrorMessage(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchTab = (next: LoginTab) => {
|
||||||
|
setTab(next);
|
||||||
|
setError(null);
|
||||||
|
setOtpStep("phone");
|
||||||
|
setCode("");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4" dir="rtl">
|
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4" dir="rtl">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
@@ -87,8 +128,36 @@ export default function AdminLoginPage() {
|
|||||||
<p className="text-center text-xs text-muted-foreground">{t("devHint")}</p>
|
<p className="text-center text-xs text-muted-foreground">{t("devHint")}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{step === "phone" ? (
|
{/* Tab switcher */}
|
||||||
|
<div className="flex border-b px-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
|
||||||
|
tab === "otp"
|
||||||
|
? "border-b-2 border-primary text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() => switchTab("otp")}
|
||||||
|
>
|
||||||
|
{t("tabOtp")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
|
||||||
|
tab === "password"
|
||||||
|
? "border-b-2 border-primary text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() => switchTab("password")}
|
||||||
|
>
|
||||||
|
{t("tabPassword")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4 pt-4">
|
||||||
|
{/* ───── OTP tab ───── */}
|
||||||
|
{tab === "otp" && otpStep === "phone" && (
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -111,12 +180,14 @@ export default function AdminLoginPage() {
|
|||||||
{loading ? "..." : t("sendOtp")}
|
{loading ? "..." : t("sendOtp")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{tab === "otp" && otpStep === "otp" && (
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!loading) void verify();
|
if (!loading) void verifyOtp();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LabeledField label={t("otp")} htmlFor="admin-login-otp">
|
<LabeledField label={t("otp")} htmlFor="admin-login-otp">
|
||||||
@@ -142,7 +213,7 @@ export default function AdminLoginPage() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStep("phone");
|
setOtpStep("phone");
|
||||||
setCode("");
|
setCode("");
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
@@ -151,6 +222,46 @@ export default function AdminLoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ───── Password tab ───── */}
|
||||||
|
{tab === "password" && (
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!loading) void loginWithPassword();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LabeledField label={t("username")} htmlFor="admin-login-username">
|
||||||
|
<Input
|
||||||
|
id="admin-login-username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder={t("usernamePlaceholder")}
|
||||||
|
dir="ltr"
|
||||||
|
className="text-start"
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</LabeledField>
|
||||||
|
<LabeledField label={t("password")} htmlFor="admin-login-password">
|
||||||
|
<Input
|
||||||
|
id="admin-login-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={t("passwordPlaceholder")}
|
||||||
|
dir="ltr"
|
||||||
|
className="text-start"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</LabeledField>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading || !username.trim() || !password}>
|
||||||
|
{loading ? "..." : t("login")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{error ? <p className="text-center text-sm text-destructive">{error}</p> : null}
|
{error ? <p className="text-center text-sm text-destructive">{error}</p> : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { Link } from "@/i18n/routing";
|
import { Link } from "@/i18n/routing";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
adminDelete,
|
adminDelete,
|
||||||
adminGet,
|
adminGet,
|
||||||
@@ -37,6 +38,57 @@ import {
|
|||||||
type TicketStatus,
|
type TicketStatus,
|
||||||
} from "@/components/support/ticket-status-badge";
|
} from "@/components/support/ticket-status-badge";
|
||||||
|
|
||||||
|
// iOS-style toggle switch used throughout this file
|
||||||
|
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
checked ? "bg-[#0F6E56]" : "bg-muted-foreground/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out",
|
||||||
|
checked ? "translate-x-5" : "translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styled single-select indicator (replaces raw <input type="radio">).
|
||||||
|
function RadioDot({
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
selected ? "border-[#0F6E56]" : "border-muted-foreground/40 hover:border-muted-foreground/70"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selected ? <span className="h-2.5 w-2.5 rounded-full bg-[#0F6E56]" /> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminDashboardScreen() {
|
export function AdminDashboardScreen() {
|
||||||
const t = useTranslations("admin.dashboard");
|
const t = useTranslations("admin.dashboard");
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
@@ -78,6 +130,50 @@ function StatCard({ label, value }: { label: string; value: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlanCard({ plan, onSave }: { plan: AdminPlan; onSave: (p: AdminPlan) => void }) {
|
||||||
|
const t = useTranslations("admin.plans");
|
||||||
|
const [price, setPrice] = useState(plan.monthlyPriceToman);
|
||||||
|
const [maxOrders, setMaxOrders] = useState(plan.limits.maxOrdersPerDay);
|
||||||
|
|
||||||
|
// Sync server values if they change (e.g. after successful save + refetch)
|
||||||
|
useEffect(() => { setPrice(plan.monthlyPriceToman); }, [plan.monthlyPriceToman]);
|
||||||
|
useEffect(() => { setMaxOrders(plan.limits.maxOrdersPerDay); }, [plan.limits.maxOrdersPerDay]);
|
||||||
|
|
||||||
|
const flush = () =>
|
||||||
|
onSave({ ...plan, monthlyPriceToman: price, limits: { ...plan.limits, maxOrdersPerDay: maxOrders } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="rounded-xl border border-border/80">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">{plan.displayNameFa}</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">{plan.tier}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="text-sm">
|
||||||
|
{t("monthlyPrice")}
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="mt-1"
|
||||||
|
value={price}
|
||||||
|
onChange={(e) => setPrice(Number(e.target.value))}
|
||||||
|
onBlur={flush}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
{t("maxOrders")}
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="mt-1"
|
||||||
|
value={maxOrders}
|
||||||
|
onChange={(e) => setMaxOrders(Number(e.target.value))}
|
||||||
|
onBlur={flush}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminPlansScreen() {
|
export function AdminPlansScreen() {
|
||||||
const t = useTranslations("admin.plans");
|
const t = useTranslations("admin.plans");
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -108,38 +204,7 @@ export function AdminPlansScreen() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-lg font-medium">{t("title")}</h1>
|
<h1 className="text-lg font-medium">{t("title")}</h1>
|
||||||
{plans.map((plan) => (
|
{plans.map((plan) => (
|
||||||
<Card key={plan.tier} className="rounded-xl border border-border/80">
|
<PlanCard key={plan.tier} plan={plan} onSave={(p) => save.mutate(p)} />
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-base">{plan.displayNameFa}</CardTitle>
|
|
||||||
<p className="text-xs text-muted-foreground">{plan.tier}</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<label className="text-sm">
|
|
||||||
{t("monthlyPrice")}
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="mt-1"
|
|
||||||
value={plan.monthlyPriceToman}
|
|
||||||
onChange={(e) => {
|
|
||||||
plan.monthlyPriceToman = Number(e.target.value);
|
|
||||||
}}
|
|
||||||
onBlur={() => save.mutate(plan)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="text-sm">
|
|
||||||
{t("maxOrders")}
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="mt-1"
|
|
||||||
defaultValue={plan.limits.maxOrdersPerDay}
|
|
||||||
onBlur={(e) => {
|
|
||||||
plan.limits.maxOrdersPerDay = Number(e.target.value);
|
|
||||||
save.mutate(plan);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -166,17 +231,34 @@ export function AdminSettingsScreen() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-lg font-medium">{t("title")}</h1>
|
<h1 className="text-lg font-medium">{t("title")}</h1>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{settings.map((s) => (
|
{settings.map((s) => {
|
||||||
<Card key={s.id} className="rounded-xl border border-border/80 p-4">
|
const isBool = s.value === "true" || s.value === "false";
|
||||||
<p className="text-xs text-muted-foreground">{s.key}</p>
|
return (
|
||||||
<p className="text-[11px] text-muted-foreground">{s.descriptionFa}</p>
|
<Card key={s.id} className="rounded-xl border border-border/80 p-4">
|
||||||
<Input
|
<div className={isBool ? "flex items-center justify-between gap-3" : undefined}>
|
||||||
className="mt-2"
|
<div className="min-w-0">
|
||||||
defaultValue={s.value}
|
<p className="text-sm font-medium text-foreground">{s.key}</p>
|
||||||
onBlur={(e) => save.mutate({ key: s.key, value: e.target.value })}
|
{s.descriptionFa ? (
|
||||||
/>
|
<p className="text-[11px] text-muted-foreground">{s.descriptionFa}</p>
|
||||||
</Card>
|
) : null}
|
||||||
))}
|
</div>
|
||||||
|
{isBool ? (
|
||||||
|
<Toggle
|
||||||
|
checked={s.value === "true"}
|
||||||
|
onChange={(v) => save.mutate({ key: s.key, value: String(v) })}
|
||||||
|
disabled={save.isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
className="mt-2"
|
||||||
|
defaultValue={s.value}
|
||||||
|
onBlur={(e) => save.mutate({ key: s.key, value: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -577,34 +659,30 @@ export function AdminIntegrationsScreen() {
|
|||||||
<Card key={g.id} className="rounded-xl border border-border/80 p-4 space-y-3">
|
<Card key={g.id} className="rounded-xl border border-border/80 p-4 space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<RadioDot
|
||||||
type="radio"
|
selected={activeGateway === g.id}
|
||||||
name="activeGateway"
|
onSelect={() => setActiveGateway(g.id)}
|
||||||
checked={activeGateway === g.id}
|
|
||||||
onChange={() => setActiveGateway(g.id)}
|
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">{g.displayNameFa}</span>
|
<span className="font-medium">{g.displayNameFa}</span>
|
||||||
{activeGateway === g.id ? (
|
{activeGateway === g.id ? (
|
||||||
<Badge className="bg-[#E1F5EE] text-[#0F6E56]">{t("active")}</Badge>
|
<Badge className="bg-[#E1F5EE] text-[#0F6E56]">{t("active")}</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<Toggle
|
||||||
type="checkbox"
|
|
||||||
checked={g.isEnabled}
|
checked={g.isEnabled}
|
||||||
onChange={(e) => updateGateway(g.id, { isEnabled: e.target.checked })}
|
onChange={(v) => updateGateway(g.id, { isEnabled: v })}
|
||||||
/>
|
/>
|
||||||
{t("enabled")}
|
<span>{t("enabled")}</span>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<input
|
<Toggle
|
||||||
type="checkbox"
|
|
||||||
checked={g.sandbox}
|
checked={g.sandbox}
|
||||||
onChange={(e) => updateGateway(g.id, { sandbox: e.target.checked })}
|
onChange={(v) => updateGateway(g.id, { sandbox: v })}
|
||||||
/>
|
/>
|
||||||
{t("sandbox")}
|
<span>{t("sandbox")}</span>
|
||||||
</label>
|
</div>
|
||||||
{g.id === "zarinpal" ? (
|
{g.id === "zarinpal" ? (
|
||||||
<label className="block text-sm">
|
<label className="block text-sm">
|
||||||
{t("merchantId")}
|
{t("merchantId")}
|
||||||
@@ -779,14 +857,13 @@ export function AdminIntegrationsScreen() {
|
|||||||
{t("kavenegarTitle")}
|
{t("kavenegarTitle")}
|
||||||
</p>
|
</p>
|
||||||
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
|
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<Toggle
|
||||||
type="checkbox"
|
|
||||||
checked={kavenegar.isEnabled}
|
checked={kavenegar.isEnabled}
|
||||||
onChange={(e) => setKavenegar((k) => ({ ...k, isEnabled: e.target.checked }))}
|
onChange={(v) => setKavenegar((k) => ({ ...k, isEnabled: v }))}
|
||||||
/>
|
/>
|
||||||
{t("enabled")}
|
<span>{t("enabled")}</span>
|
||||||
</label>
|
</div>
|
||||||
<label className="block text-sm">
|
<label className="block text-sm">
|
||||||
{t("apiKey")}
|
{t("apiKey")}
|
||||||
<Input
|
<Input
|
||||||
@@ -815,14 +892,13 @@ export function AdminIntegrationsScreen() {
|
|||||||
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
|
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
|
||||||
<p className="text-sm font-medium">{t("openAiTitle")}</p>
|
<p className="text-sm font-medium">{t("openAiTitle")}</p>
|
||||||
<p className="text-xs text-muted-foreground">{t("openAiHint")}</p>
|
<p className="text-xs text-muted-foreground">{t("openAiHint")}</p>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<Toggle
|
||||||
type="checkbox"
|
|
||||||
checked={openAi.isEnabled}
|
checked={openAi.isEnabled}
|
||||||
onChange={(e) => setOpenAi((o) => ({ ...o, isEnabled: e.target.checked }))}
|
onChange={(v) => setOpenAi((o) => ({ ...o, isEnabled: v }))}
|
||||||
/>
|
/>
|
||||||
{t("enabled")}
|
<span>{t("enabled")}</span>
|
||||||
</label>
|
</div>
|
||||||
<label className="block text-sm">
|
<label className="block text-sm">
|
||||||
{t("openAiApiKey")}
|
{t("openAiApiKey")}
|
||||||
<Input
|
<Input
|
||||||
@@ -836,35 +912,37 @@ export function AdminIntegrationsScreen() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="block text-sm">
|
<label className="block text-sm">
|
||||||
{t("openAiModel")}
|
{t("openAiModel")}
|
||||||
<Input
|
<select
|
||||||
className="mt-1"
|
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
value={openAi.model}
|
value={openAi.model}
|
||||||
onChange={(e) => setOpenAi((o) => ({ ...o, model: e.target.value }))}
|
onChange={(e) => setOpenAi((o) => ({ ...o, model: e.target.value }))}
|
||||||
/>
|
>
|
||||||
|
<option value="gpt-4o-mini">gpt-4o-mini (fast, cheap)</option>
|
||||||
|
<option value="gpt-4o">gpt-4o (best quality)</option>
|
||||||
|
<option value="gpt-4-turbo">gpt-4-turbo</option>
|
||||||
|
<option value="gpt-4">gpt-4</option>
|
||||||
|
<option value="gpt-3.5-turbo">gpt-3.5-turbo (legacy)</option>
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<Toggle
|
||||||
type="checkbox"
|
|
||||||
checked={openAi.coffeeAdvisorEnabled}
|
checked={openAi.coffeeAdvisorEnabled}
|
||||||
onChange={(e) =>
|
onChange={(v) => setOpenAi((o) => ({ ...o, coffeeAdvisorEnabled: v }))}
|
||||||
setOpenAi((o) => ({ ...o, coffeeAdvisorEnabled: e.target.checked }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{t("coffeeAdvisorEnabled")}
|
<span>{t("coffeeAdvisorEnabled")}</span>
|
||||||
</label>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
|
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
|
||||||
<p className="text-sm font-medium">{t("meshyTitle")}</p>
|
<p className="text-sm font-medium">{t("meshyTitle")}</p>
|
||||||
<p className="text-xs text-muted-foreground">{t("meshyHint")}</p>
|
<p className="text-xs text-muted-foreground">{t("meshyHint")}</p>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<Toggle
|
||||||
type="checkbox"
|
|
||||||
checked={meshy.isEnabled}
|
checked={meshy.isEnabled}
|
||||||
onChange={(e) => setMeshy((m) => ({ ...m, isEnabled: e.target.checked }))}
|
onChange={(v) => setMeshy((m) => ({ ...m, isEnabled: v }))}
|
||||||
/>
|
/>
|
||||||
{t("enabled")}
|
<span>{t("enabled")}</span>
|
||||||
</label>
|
</div>
|
||||||
<label className="block text-sm">
|
<label className="block text-sm">
|
||||||
{t("meshyApiKey")}
|
{t("meshyApiKey")}
|
||||||
<Input
|
<Input
|
||||||
@@ -876,14 +954,13 @@ export function AdminIntegrationsScreen() {
|
|||||||
onChange={(e) => setMeshy((m) => ({ ...m, apiKey: e.target.value }))}
|
onChange={(e) => setMeshy((m) => ({ ...m, apiKey: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<Toggle
|
||||||
type="checkbox"
|
|
||||||
checked={meshy.menu3dEnabled}
|
checked={meshy.menu3dEnabled}
|
||||||
onChange={(e) => setMeshy((m) => ({ ...m, menu3dEnabled: e.target.checked }))}
|
onChange={(v) => setMeshy((m) => ({ ...m, menu3dEnabled: v }))}
|
||||||
/>
|
/>
|
||||||
{t("menu3dEnabled")}
|
<span>{t("menu3dEnabled")}</span>
|
||||||
</label>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,11 +44,12 @@ export function AdminShell({ children }: { children: React.ReactNode }) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const user = useAdminAuthStore((s) => s.user);
|
const user = useAdminAuthStore((s) => s.user);
|
||||||
|
const hasHydrated = useAdminAuthStore((s) => s._hasHydrated);
|
||||||
const clearAuth = useAdminAuthStore((s) => s.clearAuth);
|
const clearAuth = useAdminAuthStore((s) => s.clearAuth);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.accessToken) router.replace("/admin/login");
|
if (hasHydrated && !user?.accessToken) router.replace("/admin/login");
|
||||||
}, [user, router]);
|
}, [user, hasHydrated, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-muted/30" dir="rtl">
|
<div className="flex min-h-screen bg-muted/30" dir="rtl">
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "@/i18n/routing";
|
||||||
import { adminDelete, adminGet, adminPatch, adminPost, adminPut } from "@/lib/api/admin-client";
|
import { adminDelete, adminGet, adminPatch, adminPost, adminPut } from "@/lib/api/admin-client";
|
||||||
import type {
|
import type {
|
||||||
AdminBlogPost,
|
AdminBlogPost,
|
||||||
@@ -15,6 +16,8 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { notify } from "@/lib/notify";
|
import { notify } from "@/lib/notify";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Link } from "@/i18n/routing";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
@@ -66,13 +69,13 @@ export function AdminBlogListScreen() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-lg font-medium">{t("blogTitle")}</h1>
|
<h1 className="text-lg font-medium">{t("blogTitle")}</h1>
|
||||||
<a
|
<Link
|
||||||
href="website/blog/new"
|
href="/admin/website/blog/new"
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
{t("newPost")}
|
{t("newPost")}
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -113,7 +116,7 @@ export function AdminBlogListScreen() {
|
|||||||
asChild
|
asChild
|
||||||
className="h-8 px-2 text-xs"
|
className="h-8 px-2 text-xs"
|
||||||
>
|
>
|
||||||
<a href={`website/blog/${post.id}`}>{t("edit")}</a>
|
<Link href={`/admin/website/blog/${post.id}`}>{t("edit")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -147,6 +150,74 @@ export function AdminBlogListScreen() {
|
|||||||
|
|
||||||
// ── Blog Post Editor ─────────────────────────────────────────────────────────
|
// ── Blog Post Editor ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// iOS-style toggle (mirrors the one in admin-screens.tsx).
|
||||||
|
function BlogToggle({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
checked ? "bg-[#0F6E56]" : "bg-muted-foreground/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out",
|
||||||
|
checked ? "translate-x-5" : "translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level so it keeps a stable component identity across renders.
|
||||||
|
// (Previously defined inside the editor, which remounted every input on each
|
||||||
|
// keystroke and dropped focus after a single character.)
|
||||||
|
function BlogField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
multiline,
|
||||||
|
dir,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
multiline?: boolean;
|
||||||
|
dir: "rtl" | "ltr";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
|
||||||
|
{multiline ? (
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="w-full resize-y rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
dir={dir}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
dir={dir}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface PostEditorProps {
|
interface PostEditorProps {
|
||||||
postId?: string; // undefined = new post
|
postId?: string; // undefined = new post
|
||||||
}
|
}
|
||||||
@@ -154,6 +225,7 @@ interface PostEditorProps {
|
|||||||
export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
|
export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
|
||||||
const t = useTranslations("admin.website");
|
const t = useTranslations("admin.website");
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
const isNew = !postId;
|
const isNew = !postId;
|
||||||
|
|
||||||
const { data: post } = useQuery({
|
const { data: post } = useQuery({
|
||||||
@@ -162,7 +234,7 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
|
|||||||
enabled: !isNew,
|
enabled: !isNew,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const emptyForm = {
|
||||||
slug: "",
|
slug: "",
|
||||||
titleFa: "",
|
titleFa: "",
|
||||||
titleEn: "",
|
titleEn: "",
|
||||||
@@ -173,85 +245,63 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
|
|||||||
author: "تیم میزی",
|
author: "تیم میزی",
|
||||||
categoryFa: "",
|
categoryFa: "",
|
||||||
categoryEn: "",
|
categoryEn: "",
|
||||||
});
|
isPublished: false,
|
||||||
|
};
|
||||||
|
|
||||||
// Sync fetched data into form once loaded
|
const [form, setForm] = useState(emptyForm);
|
||||||
const initialised = !isNew && post;
|
const [formReady, setFormReady] = useState(isNew);
|
||||||
const displayForm = initialised
|
|
||||||
? {
|
// Populate form from server data the first time it arrives
|
||||||
slug: post!.slug,
|
useEffect(() => {
|
||||||
titleFa: post!.titleFa,
|
if (post && !formReady) {
|
||||||
titleEn: post!.titleEn,
|
setForm({
|
||||||
excerptFa: post!.excerptFa,
|
slug: post.slug,
|
||||||
excerptEn: post!.excerptEn,
|
titleFa: post.titleFa,
|
||||||
contentFa: post!.contentFa,
|
titleEn: post.titleEn,
|
||||||
contentEn: post!.contentEn,
|
excerptFa: post.excerptFa,
|
||||||
author: post!.author,
|
excerptEn: post.excerptEn,
|
||||||
categoryFa: post!.categoryFa,
|
contentFa: post.contentFa,
|
||||||
categoryEn: post!.categoryEn,
|
contentEn: post.contentEn,
|
||||||
}
|
author: post.author,
|
||||||
: form;
|
categoryFa: post.categoryFa,
|
||||||
|
categoryEn: post.categoryEn,
|
||||||
|
isPublished: post.isPublished,
|
||||||
|
});
|
||||||
|
setFormReady(true);
|
||||||
|
}
|
||||||
|
}, [post, formReady]);
|
||||||
|
|
||||||
|
const setField = (key: keyof typeof form) => (v: string) =>
|
||||||
|
setForm((f) => ({ ...f, [key]: v }));
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: (data: typeof form) =>
|
mutationFn: () =>
|
||||||
isNew
|
isNew
|
||||||
? adminPost("/api/admin/website/posts", data)
|
? adminPost<{ id: string }>("/api/admin/website/posts", form)
|
||||||
: adminPut(`/api/admin/website/posts/${postId}`, data),
|
: adminPut<{ id: string }>(`/api/admin/website/posts/${postId}`, form),
|
||||||
onSuccess: () => {
|
onSuccess: (result) => {
|
||||||
qc.invalidateQueries({ queryKey: ["admin", "website", "blog"] });
|
qc.invalidateQueries({ queryKey: ["admin", "website", "blog"] });
|
||||||
notify.success(t("saved"));
|
notify.success(t("saved"));
|
||||||
|
// After creating a new post, go to its edit page so the user can
|
||||||
|
// continue editing and won't accidentally hit Save again (which would
|
||||||
|
// fail on the unique slug constraint).
|
||||||
|
if (isNew) router.push(`/admin/website/blog/${result.id}`);
|
||||||
},
|
},
|
||||||
onError: () => notify.error(t("errorGeneric")),
|
onError: () => notify.error(t("errorGeneric")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const Field = ({
|
if (!isNew && !formReady) {
|
||||||
label,
|
return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
|
||||||
value,
|
}
|
||||||
onChange,
|
|
||||||
multiline,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
multiline?: boolean;
|
|
||||||
}) => (
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
|
|
||||||
{multiline ? (
|
|
||||||
<textarea
|
|
||||||
rows={8}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="w-full resize-y rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-primary/30"
|
|
||||||
dir={label.toLowerCase().includes("fa") || label.includes("فارسی") ? "rtl" : "ltr"}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="h-9 text-sm"
|
|
||||||
dir={label.toLowerCase().includes("fa") || label.includes("فارسی") ? "rtl" : "ltr"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const current = initialised ? post! : form;
|
|
||||||
const setField = (key: keyof typeof form) => (v: string) => {
|
|
||||||
if (initialised) {
|
|
||||||
// We'd need local state override — keep it simple for demo
|
|
||||||
}
|
|
||||||
setForm((f) => ({ ...f, [key]: v }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<a href="." className="flex items-center gap-1.5">
|
<Link href="/admin/website/blog" className="flex items-center gap-1.5">
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
{t("backToBlog")}
|
{t("backToBlog")}
|
||||||
</a>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-lg font-medium">
|
<h1 className="text-lg font-medium">
|
||||||
{isNew ? t("newPost") : t("editPost")}
|
{isNew ? t("newPost") : t("editPost")}
|
||||||
@@ -261,80 +311,35 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
|
|||||||
<Card className="rounded-xl border-border/80">
|
<Card className="rounded-xl border-border/80">
|
||||||
<CardContent className="space-y-4 pt-5">
|
<CardContent className="space-y-4 pt-5">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Field
|
<BlogField label={t("fieldSlug")} value={form.slug} onChange={setField("slug")} dir="ltr" />
|
||||||
label={t("fieldSlug")}
|
<BlogField label={t("fieldAuthor")} value={form.author} onChange={setField("author")} dir="rtl" />
|
||||||
value={isNew ? form.slug : (post?.slug ?? "")}
|
|
||||||
onChange={setField("slug")}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label={t("fieldAuthor")}
|
|
||||||
value={isNew ? form.author : (post?.author ?? "")}
|
|
||||||
onChange={setField("author")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Field
|
<BlogField label={t("fieldTitleFa")} value={form.titleFa} onChange={setField("titleFa")} dir="rtl" />
|
||||||
label={t("fieldTitleFa")}
|
<BlogField label={t("fieldTitleEn")} value={form.titleEn} onChange={setField("titleEn")} dir="ltr" />
|
||||||
value={isNew ? form.titleFa : (post?.titleFa ?? "")}
|
|
||||||
onChange={setField("titleFa")}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label={t("fieldTitleEn")}
|
|
||||||
value={isNew ? form.titleEn : (post?.titleEn ?? "")}
|
|
||||||
onChange={setField("titleEn")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Field
|
<BlogField label={t("fieldExcerptFa")} value={form.excerptFa} onChange={setField("excerptFa")} dir="rtl" />
|
||||||
label={t("fieldExcerptFa")}
|
<BlogField label={t("fieldExcerptEn")} value={form.excerptEn} onChange={setField("excerptEn")} dir="ltr" />
|
||||||
value={isNew ? form.excerptFa : (post?.excerptFa ?? "")}
|
|
||||||
onChange={setField("excerptFa")}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label={t("fieldExcerptEn")}
|
|
||||||
value={isNew ? form.excerptEn : (post?.excerptEn ?? "")}
|
|
||||||
onChange={setField("excerptEn")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Field
|
<BlogField label={t("fieldCategoryFa")} value={form.categoryFa} onChange={setField("categoryFa")} dir="rtl" />
|
||||||
label={t("fieldCategoryFa")}
|
<BlogField label={t("fieldCategoryEn")} value={form.categoryEn} onChange={setField("categoryEn")} dir="ltr" />
|
||||||
value={isNew ? form.categoryFa : (post?.categoryFa ?? "")}
|
</div>
|
||||||
onChange={setField("categoryFa")}
|
<BlogField label={t("fieldContentFa")} value={form.contentFa} onChange={setField("contentFa")} dir="rtl" multiline />
|
||||||
/>
|
<BlogField label={t("fieldContentEn")} value={form.contentEn} onChange={setField("contentEn")} dir="ltr" multiline />
|
||||||
<Field
|
|
||||||
label={t("fieldCategoryEn")}
|
<div className="flex items-center justify-between rounded-lg border border-border/80 px-3 py-2">
|
||||||
value={isNew ? form.categoryEn : (post?.categoryEn ?? "")}
|
<span className="text-sm font-medium">{t("fieldPublished")}</span>
|
||||||
onChange={setField("categoryEn")}
|
<BlogToggle
|
||||||
|
checked={form.isPublished}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, isPublished: v }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Field
|
|
||||||
label={t("fieldContentFa")}
|
|
||||||
value={isNew ? form.contentFa : (post?.contentFa ?? "")}
|
|
||||||
onChange={setField("contentFa")}
|
|
||||||
multiline
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label={t("fieldContentEn")}
|
|
||||||
value={isNew ? form.contentEn : (post?.contentEn ?? "")}
|
|
||||||
onChange={setField("contentEn")}
|
|
||||||
multiline
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end pt-2">
|
<div className="flex justify-end pt-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => saveMut.mutate(isNew ? form : {
|
onClick={() => saveMut.mutate()}
|
||||||
slug: post?.slug ?? form.slug,
|
|
||||||
titleFa: post?.titleFa ?? form.titleFa,
|
|
||||||
titleEn: post?.titleEn ?? form.titleEn,
|
|
||||||
excerptFa: post?.excerptFa ?? form.excerptFa,
|
|
||||||
excerptEn: post?.excerptEn ?? form.excerptEn,
|
|
||||||
contentFa: post?.contentFa ?? form.contentFa,
|
|
||||||
contentEn: post?.contentEn ?? form.contentEn,
|
|
||||||
author: post?.author ?? form.author,
|
|
||||||
categoryFa: post?.categoryFa ?? form.categoryFa,
|
|
||||||
categoryEn: post?.categoryEn ?? form.categoryEn,
|
|
||||||
})}
|
|
||||||
disabled={saveMut.isPending}
|
disabled={saveMut.isPending}
|
||||||
className="min-w-[100px]"
|
className="min-w-[100px]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,15 +4,20 @@ import type { AuthTokenResponse } from "@/lib/api/types";
|
|||||||
|
|
||||||
interface AdminAuthState {
|
interface AdminAuthState {
|
||||||
user: AuthTokenResponse | null;
|
user: AuthTokenResponse | null;
|
||||||
|
/** True once Zustand has finished rehydrating from localStorage. */
|
||||||
|
_hasHydrated: boolean;
|
||||||
setAuth: (user: AuthTokenResponse) => void;
|
setAuth: (user: AuthTokenResponse) => void;
|
||||||
clearAuth: () => void;
|
clearAuth: () => void;
|
||||||
isAuthenticated: () => boolean;
|
isAuthenticated: () => boolean;
|
||||||
|
_setHasHydrated: (v: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAdminAuthStore = create<AdminAuthState>()(
|
export const useAdminAuthStore = create<AdminAuthState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
user: null,
|
user: null,
|
||||||
|
_hasHydrated: false,
|
||||||
|
_setHasHydrated: (v) => set({ _hasHydrated: v }),
|
||||||
setAuth: (user) => {
|
setAuth: (user) => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.setItem("meezi_admin_access_token", user.accessToken);
|
localStorage.setItem("meezi_admin_access_token", user.accessToken);
|
||||||
@@ -29,6 +34,11 @@ export const useAdminAuthStore = create<AdminAuthState>()(
|
|||||||
},
|
},
|
||||||
isAuthenticated: () => !!get().user?.accessToken,
|
isAuthenticated: () => !!get().user?.accessToken,
|
||||||
}),
|
}),
|
||||||
{ name: "meezi_admin_auth" }
|
{
|
||||||
|
name: "meezi_admin_auth",
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
state?._setHasHydrated(true);
|
||||||
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -45,7 +45,17 @@
|
|||||||
"chooseCafe": "اختر المقهى",
|
"chooseCafe": "اختر المقهى",
|
||||||
"chooseCafeSubtitle": "هذا الرقم لديه صلاحية على عدة مقاهٍ. اختر واحداً للمتابعة.",
|
"chooseCafeSubtitle": "هذا الرقم لديه صلاحية على عدة مقاهٍ. اختر واحداً للمتابعة.",
|
||||||
"createNewCafe": "إنشاء مقهى جديد",
|
"createNewCafe": "إنشاء مقهى جديد",
|
||||||
"createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟"
|
"createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟",
|
||||||
|
"tabOtp": "رمز مؤقت",
|
||||||
|
"tabPassword": "كلمة المرور",
|
||||||
|
"username": "اسم المستخدم",
|
||||||
|
"usernamePlaceholder": "اسم المستخدم",
|
||||||
|
"password": "كلمة المرور",
|
||||||
|
"passwordPlaceholder": "كلمة المرور",
|
||||||
|
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة.",
|
||||||
|
"kojaSlug": "عنوان الملف الشخصي في كوجا",
|
||||||
|
"kojaSlugHint": "يجد الزوار مقهاكم على هذا العنوان",
|
||||||
|
"kojaSlugPlaceholder": "مثال: my-cafe"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"owner": "المالك",
|
"owner": "المالك",
|
||||||
@@ -386,7 +396,8 @@
|
|||||||
"attendance": "الحضور",
|
"attendance": "الحضور",
|
||||||
"leave": "الإجازة",
|
"leave": "الإجازة",
|
||||||
"payroll": "الرواتب",
|
"payroll": "الرواتب",
|
||||||
"access": "صلاحيات الفروع"
|
"access": "صلاحيات الفروع",
|
||||||
|
"credentials": "بيانات الدخول"
|
||||||
},
|
},
|
||||||
"myAttendance": "حضوري",
|
"myAttendance": "حضوري",
|
||||||
"clockIn": "تسجيل دخول",
|
"clockIn": "تسجيل دخول",
|
||||||
@@ -396,7 +407,22 @@
|
|||||||
"paid": "مدفوع",
|
"paid": "مدفوع",
|
||||||
"markPaid": "تسجيل الدفع",
|
"markPaid": "تسجيل الدفع",
|
||||||
"employeeCount": "الموظفون",
|
"employeeCount": "الموظفون",
|
||||||
"monthYear": "شهر الرواتب"
|
"monthYear": "شهر الرواتب",
|
||||||
|
"credentials": {
|
||||||
|
"title": "بيانات دخول الموظفين",
|
||||||
|
"subtitle": "حدد اسم مستخدم وكلمة مرور لكل موظف حتى يتمكن من تسجيل الدخول دون رمز OTP.",
|
||||||
|
"selectEmployee": "اختر موظفاً أولاً",
|
||||||
|
"username": "اسم المستخدم",
|
||||||
|
"usernamePlaceholder": "مثال: ali_barista",
|
||||||
|
"password": "كلمة المرور (8 أحرف على الأقل)",
|
||||||
|
"passwordPlaceholder": "كلمة مرور جديدة",
|
||||||
|
"set": "حفظ بيانات الدخول",
|
||||||
|
"remove": "حذف بيانات الدخول",
|
||||||
|
"removeConfirm": "هل أنت متأكد؟ لن يتمكن الموظف من تسجيل الدخول بكلمة مرور بعد الآن.",
|
||||||
|
"saved": "تم حفظ بيانات الدخول.",
|
||||||
|
"removed": "تم حذف بيانات الدخول.",
|
||||||
|
"usernameTaken": "اسم المستخدم هذا مستخدم بالفعل."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"reviews": {
|
"reviews": {
|
||||||
"title": "تقييمات العملاء",
|
"title": "تقييمات العملاء",
|
||||||
@@ -993,7 +1019,8 @@
|
|||||||
"total": "المبلغ المستحق",
|
"total": "المبلغ المستحق",
|
||||||
"secureNote": "تتم المعالجة عبر بوابة دفع بنكية آمنة.",
|
"secureNote": "تتم المعالجة عبر بوابة دفع بنكية آمنة.",
|
||||||
"payTotal": "ادفع {total}",
|
"payTotal": "ادفع {total}",
|
||||||
"redirecting": "جارٍ التحويل إلى البوابة..."
|
"redirecting": "جارٍ التحويل إلى البوابة...",
|
||||||
|
"paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -1123,7 +1150,13 @@
|
|||||||
"uploadLogo": "رفع الشعار",
|
"uploadLogo": "رفع الشعار",
|
||||||
"uploadCover": "رفع الغلاف",
|
"uploadCover": "رفع الغلاف",
|
||||||
"saved": "تم حفظ الملف.",
|
"saved": "تم حفظ الملف.",
|
||||||
"reloginHint": "تم تحديث الخطة؛ سجّل الخروج والدخول إن لزم."
|
"reloginHint": "تم تحديث الخطة؛ سجّل الخروج والدخول إن لزم.",
|
||||||
|
"slug": "عنوان ملف كوجا",
|
||||||
|
"slugHint": "صفحة مقهاكم على كوجا — أحرف صغيرة وأرقام وشرطات فقط",
|
||||||
|
"slugPlaceholder": "my-cafe",
|
||||||
|
"slugTaken": "هذا العنوان مأخوذ. الرجاء اختيار عنوان آخر.",
|
||||||
|
"slugInvalid": "عنوان غير صالح. استخدم الأحرف الصغيرة والأرقام والشرطات فقط.",
|
||||||
|
"kojaUrl": "رابط كوجا"
|
||||||
},
|
},
|
||||||
"taraz": "تاراز (الضرائب)",
|
"taraz": "تاراز (الضرائب)",
|
||||||
"tarazHint": "إرسال فواتير الأمس إلى تاراز (وضع تجريبي).",
|
"tarazHint": "إرسال فواتير الأمس إلى تاراز (وضع تجريبي).",
|
||||||
|
|||||||
@@ -56,7 +56,17 @@
|
|||||||
"chooseCafe": "Choose a café",
|
"chooseCafe": "Choose a café",
|
||||||
"chooseCafeSubtitle": "This number has access to several cafés. Pick one to continue.",
|
"chooseCafeSubtitle": "This number has access to several cafés. Pick one to continue.",
|
||||||
"createNewCafe": "Create a new café",
|
"createNewCafe": "Create a new café",
|
||||||
"createNewCafeHint": "Want to start your own café with this number?"
|
"createNewCafeHint": "Want to start your own café with this number?",
|
||||||
|
"tabOtp": "One-time code",
|
||||||
|
"tabPassword": "Password",
|
||||||
|
"username": "Username",
|
||||||
|
"usernamePlaceholder": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"passwordPlaceholder": "Password",
|
||||||
|
"invalidCredentials": "Incorrect username or password.",
|
||||||
|
"kojaSlug": "Koja profile address",
|
||||||
|
"kojaSlugHint": "Customers will find your cafe at this address",
|
||||||
|
"kojaSlugPlaceholder": "e.g. my-cafe"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"owner": "Owner",
|
"owner": "Owner",
|
||||||
@@ -405,7 +415,8 @@
|
|||||||
"attendance": "Attendance",
|
"attendance": "Attendance",
|
||||||
"leave": "Leave",
|
"leave": "Leave",
|
||||||
"payroll": "Payroll",
|
"payroll": "Payroll",
|
||||||
"access": "Branch access"
|
"access": "Branch access",
|
||||||
|
"credentials": "Login credentials"
|
||||||
},
|
},
|
||||||
"myAttendance": "My attendance",
|
"myAttendance": "My attendance",
|
||||||
"clockIn": "Clock in",
|
"clockIn": "Clock in",
|
||||||
@@ -415,7 +426,22 @@
|
|||||||
"paid": "Paid",
|
"paid": "Paid",
|
||||||
"markPaid": "Mark paid",
|
"markPaid": "Mark paid",
|
||||||
"employeeCount": "Employees",
|
"employeeCount": "Employees",
|
||||||
"monthYear": "Payroll month"
|
"monthYear": "Payroll month",
|
||||||
|
"credentials": {
|
||||||
|
"title": "Employee login credentials",
|
||||||
|
"subtitle": "Set a username and password for each employee so they can sign in without an OTP.",
|
||||||
|
"selectEmployee": "Select an employee first",
|
||||||
|
"username": "Username",
|
||||||
|
"usernamePlaceholder": "e.g. ali_barista",
|
||||||
|
"password": "Password (min 8 characters)",
|
||||||
|
"passwordPlaceholder": "New password",
|
||||||
|
"set": "Save credentials",
|
||||||
|
"remove": "Remove credentials",
|
||||||
|
"removeConfirm": "Are you sure? The employee will no longer be able to sign in with a password.",
|
||||||
|
"saved": "Credentials saved.",
|
||||||
|
"removed": "Credentials removed.",
|
||||||
|
"usernameTaken": "This username is already taken."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"reviews": {
|
"reviews": {
|
||||||
"title": "Customer reviews",
|
"title": "Customer reviews",
|
||||||
@@ -1065,7 +1091,8 @@
|
|||||||
"total": "Amount due",
|
"total": "Amount due",
|
||||||
"secureNote": "Payment is processed through a secure bank gateway.",
|
"secureNote": "Payment is processed through a secure bank gateway.",
|
||||||
"payTotal": "Pay {total}",
|
"payTotal": "Pay {total}",
|
||||||
"redirecting": "Redirecting to gateway..."
|
"redirecting": "Redirecting to gateway...",
|
||||||
|
"paymentFailed": "Payment failed. Please try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -1205,7 +1232,13 @@
|
|||||||
"uploadLogo": "Upload logo",
|
"uploadLogo": "Upload logo",
|
||||||
"uploadCover": "Upload cover",
|
"uploadCover": "Upload cover",
|
||||||
"saved": "Profile saved.",
|
"saved": "Profile saved.",
|
||||||
"reloginHint": "Plan updated; sign out and in again if the badge looks wrong."
|
"reloginHint": "Plan updated; sign out and in again if the badge looks wrong.",
|
||||||
|
"slug": "Koja profile address",
|
||||||
|
"slugHint": "Your cafe page on Koja — lowercase letters, digits, hyphens only",
|
||||||
|
"slugPlaceholder": "my-cafe",
|
||||||
|
"slugTaken": "This address is already taken. Please choose another.",
|
||||||
|
"slugInvalid": "Invalid address. Use lowercase letters, digits, and hyphens only.",
|
||||||
|
"kojaUrl": "Koja URL"
|
||||||
},
|
},
|
||||||
"taraz": "Taraz (tax system)",
|
"taraz": "Taraz (tax system)",
|
||||||
"tarazHint": "Submit yesterday's invoices to Taraz (demo mode logs only).",
|
"tarazHint": "Submit yesterday's invoices to Taraz (demo mode logs only).",
|
||||||
|
|||||||
@@ -56,7 +56,17 @@
|
|||||||
"chooseCafe": "انتخاب کافه",
|
"chooseCafe": "انتخاب کافه",
|
||||||
"chooseCafeSubtitle": "این شماره به چند کافه دسترسی دارد. یکی را انتخاب کنید.",
|
"chooseCafeSubtitle": "این شماره به چند کافه دسترسی دارد. یکی را انتخاب کنید.",
|
||||||
"createNewCafe": "ایجاد کافه جدید",
|
"createNewCafe": "ایجاد کافه جدید",
|
||||||
"createNewCafeHint": "میخواهید کافه خودتان را با همین شماره راهاندازی کنید؟"
|
"createNewCafeHint": "میخواهید کافه خودتان را با همین شماره راهاندازی کنید؟",
|
||||||
|
"tabOtp": "کد یکبارمصرف",
|
||||||
|
"tabPassword": "رمز عبور",
|
||||||
|
"username": "نام کاربری",
|
||||||
|
"usernamePlaceholder": "نام کاربری",
|
||||||
|
"password": "رمز عبور",
|
||||||
|
"passwordPlaceholder": "رمز عبور",
|
||||||
|
"invalidCredentials": "نام کاربری یا رمز عبور اشتباه است.",
|
||||||
|
"kojaSlug": "آدرس پروفایل در کوجا",
|
||||||
|
"kojaSlugHint": "بازدیدکنندگان از این آدرس کافه شما را پیدا میکنند",
|
||||||
|
"kojaSlugPlaceholder": "مثال: cafe-roya"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"owner": "مالک",
|
"owner": "مالک",
|
||||||
@@ -405,7 +415,8 @@
|
|||||||
"attendance": "حضور و غیاب",
|
"attendance": "حضور و غیاب",
|
||||||
"leave": "مرخصی",
|
"leave": "مرخصی",
|
||||||
"payroll": "حقوق",
|
"payroll": "حقوق",
|
||||||
"access": "دسترسی شعب"
|
"access": "دسترسی شعب",
|
||||||
|
"credentials": "رمز ورود"
|
||||||
},
|
},
|
||||||
"myAttendance": "حضور من",
|
"myAttendance": "حضور من",
|
||||||
"clockIn": "ورود",
|
"clockIn": "ورود",
|
||||||
@@ -415,7 +426,22 @@
|
|||||||
"paid": "پرداخت شده",
|
"paid": "پرداخت شده",
|
||||||
"markPaid": "ثبت پرداخت",
|
"markPaid": "ثبت پرداخت",
|
||||||
"employeeCount": "تعداد کارمندان",
|
"employeeCount": "تعداد کارمندان",
|
||||||
"monthYear": "ماه حقوق"
|
"monthYear": "ماه حقوق",
|
||||||
|
"credentials": {
|
||||||
|
"title": "مدیریت رمز ورود کارمندان",
|
||||||
|
"subtitle": "برای هر کارمند میتوانید نام کاربری و رمز عبور تعریف کنید تا بدون نیاز به کد OTP وارد شوند.",
|
||||||
|
"selectEmployee": "ابتدا یک کارمند انتخاب کنید",
|
||||||
|
"username": "نام کاربری",
|
||||||
|
"usernamePlaceholder": "مثال: ali_barista",
|
||||||
|
"password": "رمز عبور (حداقل ۸ کاراکتر)",
|
||||||
|
"passwordPlaceholder": "رمز عبور جدید",
|
||||||
|
"set": "ذخیره رمز ورود",
|
||||||
|
"remove": "حذف رمز ورود",
|
||||||
|
"removeConfirm": "آیا مطمئنید؟ کارمند دیگر نمیتواند با رمز عبور وارد شود.",
|
||||||
|
"saved": "رمز ورود ذخیره شد.",
|
||||||
|
"removed": "رمز ورود حذف شد.",
|
||||||
|
"usernameTaken": "این نام کاربری قبلاً استفاده شده است."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"reviews": {
|
"reviews": {
|
||||||
"title": "نظرات مشتریان",
|
"title": "نظرات مشتریان",
|
||||||
@@ -1066,7 +1092,8 @@
|
|||||||
"total": "مبلغ قابل پرداخت",
|
"total": "مبلغ قابل پرداخت",
|
||||||
"secureNote": "پرداخت از طریق درگاه امن بانکی انجام میشود.",
|
"secureNote": "پرداخت از طریق درگاه امن بانکی انجام میشود.",
|
||||||
"payTotal": "پرداخت {total}",
|
"payTotal": "پرداخت {total}",
|
||||||
"redirecting": "در حال انتقال به درگاه..."
|
"redirecting": "در حال انتقال به درگاه...",
|
||||||
|
"paymentFailed": "پرداخت ناموفق بود. لطفاً دوباره امتحان کنید."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -1210,7 +1237,13 @@
|
|||||||
"uploadLogo": "بارگذاری لوگو",
|
"uploadLogo": "بارگذاری لوگو",
|
||||||
"uploadCover": "بارگذاری کاور",
|
"uploadCover": "بارگذاری کاور",
|
||||||
"saved": "پروفایل ذخیره شد.",
|
"saved": "پروفایل ذخیره شد.",
|
||||||
"reloginHint": "پلن بهروز شد؛ در صورت نیاز یکبار خارج و وارد شوید."
|
"reloginHint": "پلن بهروز شد؛ در صورت نیاز یکبار خارج و وارد شوید.",
|
||||||
|
"slug": "آدرس پروفایل کوجا",
|
||||||
|
"slugHint": "آدرس صفحه کافه شما در کوجا — فقط حروف انگلیسی، اعداد و خط تیره",
|
||||||
|
"slugPlaceholder": "cafe-roya",
|
||||||
|
"slugTaken": "این آدرس قبلاً گرفته شده. آدرس دیگری انتخاب کنید.",
|
||||||
|
"slugInvalid": "آدرس نامعتبر است. فقط حروف انگلیسی کوچک، اعداد و خط تیره مجاز است.",
|
||||||
|
"kojaUrl": "آدرس کوجا"
|
||||||
},
|
},
|
||||||
"plans": {
|
"plans": {
|
||||||
"compareLabel": "مقایسه پلنها",
|
"compareLabel": "مقایسه پلنها",
|
||||||
|
|||||||
Generated
+2583
-159
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/model-viewer": "^4.2.0",
|
"@google/model-viewer": "^4.2.0",
|
||||||
"three": "^0.163.0",
|
"three": "^0.182.0",
|
||||||
"@microsoft/signalr": "^8.0.7",
|
"@microsoft/signalr": "^8.0.7",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
|
|||||||
@@ -18,13 +18,17 @@ export default function DashboardLayout({
|
|||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const hasHydrated = useAuthStore((s) => s._hasHydrated);
|
||||||
useOfflineSync(); // register online/offline listeners + load queue count
|
useOfflineSync(); // register online/offline listeners + load queue count
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.accessToken) {
|
// Wait for Zustand to finish reading localStorage before deciding to redirect.
|
||||||
|
// Without this guard, the effect fires while user is still null on first render,
|
||||||
|
// causing a spurious redirect to /login even when the token exists in storage.
|
||||||
|
if (hasHydrated && !user?.accessToken) {
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
}
|
}
|
||||||
}, [user, router]);
|
}, [user, hasHydrated, router]);
|
||||||
|
|
||||||
const isRtl = locale !== "en";
|
const isRtl = locale !== "en";
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,24 @@ import { LabeledField } from "@/components/ui/labeled-field";
|
|||||||
import { OtpInput } from "@/components/ui/otp-input";
|
import { OtpInput } from "@/components/ui/otp-input";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
type LoginTab = "otp" | "password";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const t = useTranslations("auth");
|
const t = useTranslations("auth");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const setAuth = useAuthStore((s) => s.setAuth);
|
const setAuth = useAuthStore((s) => s.setAuth);
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<LoginTab>("otp");
|
||||||
|
|
||||||
|
// OTP state
|
||||||
const [phone, setPhone] = useState("09121234567");
|
const [phone, setPhone] = useState("09121234567");
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [step, setStep] = useState<"phone" | "otp">("phone");
|
const [otpStep, setOtpStep] = useState<"phone" | "otp">("phone");
|
||||||
|
|
||||||
|
// Password state
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -32,6 +42,9 @@ export default function LoginPage() {
|
|||||||
return t("smsFailed");
|
return t("smsFailed");
|
||||||
case "INVALID_OTP":
|
case "INVALID_OTP":
|
||||||
return t("invalidOtp");
|
return t("invalidOtp");
|
||||||
|
case "INVALID_TOKEN":
|
||||||
|
case "NOT_FOUND":
|
||||||
|
return tab === "password" ? t("invalidCredentials") : t("notFound");
|
||||||
default:
|
default:
|
||||||
return err.message;
|
return err.message;
|
||||||
}
|
}
|
||||||
@@ -44,7 +57,7 @@ export default function LoginPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await apiPost("/api/auth/send-otp", { phone });
|
await apiPost("/api/auth/send-otp", { phone });
|
||||||
setStep("otp");
|
setOtpStep("otp");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiClientError && e.code === "NOT_FOUND") {
|
if (e instanceof ApiClientError && e.code === "NOT_FOUND") {
|
||||||
// No account → take them to register with phone pre-filled
|
// No account → take them to register with phone pre-filled
|
||||||
@@ -74,6 +87,34 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loginWithPassword = async () => {
|
||||||
|
if (!username.trim() || !password) {
|
||||||
|
setError(t("invalidCredentials"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await apiPost<AuthTokenResponse>("/api/auth/login", {
|
||||||
|
username: username.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
setAuth(data);
|
||||||
|
router.push("/pos");
|
||||||
|
} catch (e) {
|
||||||
|
setError(authErrorMessage(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchTab = (next: LoginTab) => {
|
||||||
|
setTab(next);
|
||||||
|
setError(null);
|
||||||
|
setOtpStep("phone");
|
||||||
|
setCode("");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
@@ -81,8 +122,36 @@ export default function LoginPage() {
|
|||||||
<CardTitle className="text-center text-primary">{t("title")}</CardTitle>
|
<CardTitle className="text-center text-primary">{t("title")}</CardTitle>
|
||||||
<p className="text-center text-sm text-muted-foreground">{t("subtitle")}</p>
|
<p className="text-center text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{step === "phone" ? (
|
{/* Tab switcher */}
|
||||||
|
<div className="flex border-b px-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
|
||||||
|
tab === "otp"
|
||||||
|
? "border-b-2 border-primary text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() => switchTab("otp")}
|
||||||
|
>
|
||||||
|
{t("tabOtp")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
|
||||||
|
tab === "password"
|
||||||
|
? "border-b-2 border-primary text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() => switchTab("password")}
|
||||||
|
>
|
||||||
|
{t("tabPassword")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4 pt-4">
|
||||||
|
{/* ───── OTP tab ───── */}
|
||||||
|
{tab === "otp" && otpStep === "phone" && (
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -105,7 +174,9 @@ export default function LoginPage() {
|
|||||||
{loading ? "..." : t("sendOtp")}
|
{loading ? "..." : t("sendOtp")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{tab === "otp" && otpStep === "otp" && (
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -128,12 +199,60 @@ export default function LoginPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => setStep("phone")}
|
onClick={() => {
|
||||||
|
setOtpStep("phone");
|
||||||
|
setCode("");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("resend")}
|
{t("resend")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ───── Password tab ───── */}
|
||||||
|
{tab === "password" && (
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!loading) void loginWithPassword();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LabeledField label={t("username")} htmlFor="login-username">
|
||||||
|
<Input
|
||||||
|
id="login-username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder={t("usernamePlaceholder")}
|
||||||
|
dir="ltr"
|
||||||
|
className="text-start"
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</LabeledField>
|
||||||
|
<LabeledField label={t("password")} htmlFor="login-password">
|
||||||
|
<Input
|
||||||
|
id="login-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={t("passwordPlaceholder")}
|
||||||
|
dir="ltr"
|
||||||
|
className="text-start"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</LabeledField>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={loading || !username.trim() || !password}
|
||||||
|
>
|
||||||
|
{loading ? "..." : t("verify")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-center text-sm text-destructive">{error}</p>
|
<p className="text-center text-sm text-destructive">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter, Link } from "@/i18n/routing";
|
import { useRouter, Link } from "@/i18n/routing";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
@@ -14,6 +14,46 @@ import { LabeledField } from "@/components/ui/labeled-field";
|
|||||||
import { OtpInput } from "@/components/ui/otp-input";
|
import { OtpInput } from "@/components/ui/otp-input";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
/** Client-side Persian-to-Latin slugifier — mirrors SlugHelper.Slugify on the backend */
|
||||||
|
const PERSIAN_MAP: Record<string, string> = {
|
||||||
|
آ: "a", ا: "a", أ: "a", إ: "a",
|
||||||
|
ب: "b", پ: "p", ت: "t", ث: "s",
|
||||||
|
ج: "j", چ: "ch", ح: "h", خ: "kh",
|
||||||
|
د: "d", ذ: "z", ر: "r", ز: "z", ژ: "zh",
|
||||||
|
س: "s", ش: "sh", ص: "s", ض: "z",
|
||||||
|
ط: "t", ظ: "z", ع: "a", غ: "gh",
|
||||||
|
ف: "f", ق: "gh", ک: "k", ك: "k", گ: "g",
|
||||||
|
ل: "l", م: "m", ن: "n", و: "v",
|
||||||
|
ه: "h", ی: "i", ي: "i",
|
||||||
|
ئ: "y", ء: "", ة: "t", ى: "a", ؤ: "o",
|
||||||
|
"۰": "0", "۱": "1", "۲": "2", "۳": "3", "۴": "4",
|
||||||
|
"۵": "5", "۶": "6", "۷": "7", "۸": "8", "۹": "9",
|
||||||
|
};
|
||||||
|
|
||||||
|
function slugify(input: string): string {
|
||||||
|
let s = "";
|
||||||
|
for (const ch of input) {
|
||||||
|
if (ch in PERSIAN_MAP) {
|
||||||
|
s += PERSIAN_MAP[ch];
|
||||||
|
} else if (/[a-zA-Z0-9]/.test(ch)) {
|
||||||
|
s += ch.toLowerCase();
|
||||||
|
} else if (/[\s\-_]/.test(ch)) {
|
||||||
|
s += "-";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidSlug(slug: string): boolean {
|
||||||
|
if (!slug || slug.length < 2 || slug.length > 80) return false;
|
||||||
|
return /^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KOJA_BASE =
|
||||||
|
typeof window !== "undefined" && window.location.hostname.includes("meezi.ir")
|
||||||
|
? "koja.meezi.ir"
|
||||||
|
: "koja.meezi.ir";
|
||||||
|
|
||||||
function RegisterForm() {
|
function RegisterForm() {
|
||||||
const t = useTranslations("auth");
|
const t = useTranslations("auth");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -22,20 +62,31 @@ function RegisterForm() {
|
|||||||
|
|
||||||
const [phone, setPhone] = useState(searchParams.get("phone") ?? "");
|
const [phone, setPhone] = useState(searchParams.get("phone") ?? "");
|
||||||
const [cafeName, setCafeName] = useState("");
|
const [cafeName, setCafeName] = useState("");
|
||||||
|
const [slug, setSlug] = useState("");
|
||||||
|
const [slugEdited, setSlugEdited] = useState(false);
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [step, setStep] = useState<"info" | "otp">("info");
|
const [step, setStep] = useState<"info" | "otp">("info");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Auto-derive slug from café name unless the user has manually edited it
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slugEdited) {
|
||||||
|
setSlug(slugify(cafeName));
|
||||||
|
}
|
||||||
|
}, [cafeName, slugEdited]);
|
||||||
|
|
||||||
|
const slugValid = isValidSlug(slug);
|
||||||
|
|
||||||
const errorMessage = (err: unknown) => {
|
const errorMessage = (err: unknown) => {
|
||||||
if (err instanceof ApiClientError) {
|
if (err instanceof ApiClientError) {
|
||||||
switch (err.code) {
|
switch (err.code) {
|
||||||
case "RATE_LIMITED": return t("rateLimited");
|
case "RATE_LIMITED": return t("rateLimited");
|
||||||
case "ALREADY_REGISTERED": return t("alreadyRegistered");
|
case "ALREADY_REGISTERED": return t("alreadyRegistered");
|
||||||
case "SMS_FAILED": return t("smsFailed");
|
case "SMS_FAILED": return t("smsFailed");
|
||||||
case "INVALID_OTP": return t("invalidOtp");
|
case "INVALID_OTP": return t("invalidOtp");
|
||||||
case "REGISTRATION_EXPIRED": return t("registrationExpired");
|
case "REGISTRATION_EXPIRED": return t("registrationExpired");
|
||||||
default: return err.message;
|
default: return err.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return err instanceof Error ? err.message : String(err);
|
return err instanceof Error ? err.message : String(err);
|
||||||
@@ -45,7 +96,11 @@ function RegisterForm() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await apiPost("/api/auth/register", { phone, cafeName });
|
await apiPost("/api/auth/register", {
|
||||||
|
phone,
|
||||||
|
cafeName,
|
||||||
|
slug: slugValid ? slug : undefined,
|
||||||
|
});
|
||||||
setStep("otp");
|
setStep("otp");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(errorMessage(e));
|
setError(errorMessage(e));
|
||||||
@@ -94,6 +149,31 @@ function RegisterForm() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</LabeledField>
|
</LabeledField>
|
||||||
|
|
||||||
|
{/* Koja slug / profile URL */}
|
||||||
|
<LabeledField
|
||||||
|
label={t("kojaSlug")}
|
||||||
|
htmlFor="reg-slug"
|
||||||
|
hint={t("kojaSlugHint")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="reg-slug"
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSlugEdited(true);
|
||||||
|
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""));
|
||||||
|
}}
|
||||||
|
placeholder={t("kojaSlugPlaceholder")}
|
||||||
|
dir="ltr"
|
||||||
|
className="text-start font-mono text-sm"
|
||||||
|
/>
|
||||||
|
{slug && (
|
||||||
|
<p className={`mt-1 text-xs font-mono ${slugValid ? "text-muted-foreground" : "text-destructive"}`}>
|
||||||
|
{KOJA_BASE}/{slug}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</LabeledField>
|
||||||
|
|
||||||
<LabeledField label={t("phone")} htmlFor="reg-phone">
|
<LabeledField label={t("phone")} htmlFor="reg-phone">
|
||||||
<Input
|
<Input
|
||||||
id="reg-phone"
|
id="reg-phone"
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Sparkles, Loader2 } from "lucide-react";
|
||||||
|
import { apiPost } from "@/lib/api/client";
|
||||||
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface DemoSeedResult {
|
||||||
|
categoriesAdded: number;
|
||||||
|
itemsAdded: number;
|
||||||
|
tablesAdded: number;
|
||||||
|
ingredientsAdded: number;
|
||||||
|
taxCreated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Which queries to invalidate after seeding. */
|
||||||
|
invalidateKeys: string[][];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DemoDataBanner({ invalidateKeys, className }: Props) {
|
||||||
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||||
|
const role = useAuthStore((s) => s.user?.role);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [summary, setSummary] = useState<DemoSeedResult | null>(null);
|
||||||
|
|
||||||
|
const seed = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiPost<DemoSeedResult>(`/api/cafes/${cafeId}/demo/seed`, {}),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setSummary(result);
|
||||||
|
setDone(true);
|
||||||
|
for (const key of invalidateKeys) {
|
||||||
|
qc.invalidateQueries({ queryKey: key });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cafeId || (role !== "Owner" && role !== "Manager")) return null;
|
||||||
|
if (done && summary) {
|
||||||
|
const nothingAdded =
|
||||||
|
summary.categoriesAdded === 0 &&
|
||||||
|
summary.itemsAdded === 0 &&
|
||||||
|
summary.tablesAdded === 0 &&
|
||||||
|
summary.ingredientsAdded === 0 &&
|
||||||
|
!summary.taxCreated;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-xl border border-[#0F6E56]/30 bg-[#E1F5EE] px-4 py-3 text-sm text-[#0F6E56]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sparkles className="size-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{nothingAdded ? (
|
||||||
|
"همه دادههای نمونه از قبل موجود بودند — موردی اضافه نشد."
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
دادههای نمونه اضافه شد — {summary.categoriesAdded} دسته،{" "}
|
||||||
|
{summary.itemsAdded} آیتم، {summary.tablesAdded} میز،{" "}
|
||||||
|
{summary.ingredientsAdded} ماده اولیه
|
||||||
|
{summary.taxCreated ? "، مالیات ۹٪" : ""}.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-3 rounded-xl border border-dashed border-[#0F6E56]/40 bg-[#E1F5EE]/40 px-5 py-4 sm:flex-row sm:items-center sm:justify-between",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Sparkles className="size-5 shrink-0 text-[#0F6E56]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-[#0F6E56]">شروع سریع با دادههای نمونه</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
۷ دسته، ۵۹+ آیتم منو، ۱۰ میز، ۱۵ ماده اولیه و مالیات ۹٪ بهصورت خودکار اضافه میشود.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0 bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||||
|
disabled={seed.isPending}
|
||||||
|
onClick={() => seed.mutate()}
|
||||||
|
>
|
||||||
|
{seed.isPending ? (
|
||||||
|
<Loader2 className="me-1.5 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="me-1.5 size-4" />
|
||||||
|
)}
|
||||||
|
افزودن دادههای نمونه
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { apiPut, apiDelete, ApiClientError } from "@/lib/api/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { LabeledField } from "@/components/ui/labeled-field";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface Employee {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cafeId: string;
|
||||||
|
employees: Employee[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmployeeCredentialsPanel({ cafeId, employees }: Props) {
|
||||||
|
const t = useTranslations("hr.credentials");
|
||||||
|
|
||||||
|
const [selectedId, setSelectedId] = useState<string>("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [feedback, setFeedback] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
|
const setMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiPut(`/api/cafes/${cafeId}/employees/${selectedId}/credentials`, {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setFeedback({ ok: true, msg: t("saved") });
|
||||||
|
setPassword("");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
if (err instanceof ApiClientError && err.code === "USERNAME_TAKEN") {
|
||||||
|
setFeedback({ ok: false, msg: t("usernameTaken") });
|
||||||
|
} else {
|
||||||
|
setFeedback({ ok: false, msg: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiDelete(`/api/cafes/${cafeId}/employees/${selectedId}/credentials`),
|
||||||
|
onSuccess: () => {
|
||||||
|
setFeedback({ ok: true, msg: t("removed") });
|
||||||
|
setUsername("");
|
||||||
|
setPassword("");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setFeedback({ ok: false, msg: err instanceof Error ? err.message : String(err) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
if (!window.confirm(t("removeConfirm"))) return;
|
||||||
|
setFeedback(null);
|
||||||
|
removeMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPending = setMutation.isPending || removeMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">{t("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Employee selector */}
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{employees.map((emp) => (
|
||||||
|
<button
|
||||||
|
key={emp.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(emp.id);
|
||||||
|
setUsername("");
|
||||||
|
setPassword("");
|
||||||
|
setFeedback(null);
|
||||||
|
}}
|
||||||
|
className={`rounded-lg border p-3 text-start transition-colors cursor-pointer ${
|
||||||
|
selectedId === emp.id
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="font-medium text-sm">{emp.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground" dir="ltr">{emp.phone}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
{selectedId && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{employees.find((e) => e.id === selectedId)?.name}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<LabeledField label={t("username")} htmlFor="cred-username">
|
||||||
|
<Input
|
||||||
|
id="cred-username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder={t("usernamePlaceholder")}
|
||||||
|
dir="ltr"
|
||||||
|
className="text-start"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</LabeledField>
|
||||||
|
<LabeledField label={t("password")} htmlFor="cred-password">
|
||||||
|
<Input
|
||||||
|
id="cred-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={t("passwordPlaceholder")}
|
||||||
|
dir="ltr"
|
||||||
|
className="text-start"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</LabeledField>
|
||||||
|
|
||||||
|
{feedback && (
|
||||||
|
<p className={`text-sm ${feedback.ok ? "text-green-600" : "text-destructive"}`}>
|
||||||
|
{feedback.msg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setFeedback(null);
|
||||||
|
setMutation.mutate();
|
||||||
|
}}
|
||||||
|
disabled={isPending || !username.trim() || password.length < 8}
|
||||||
|
>
|
||||||
|
{isPending ? "..." : t("set")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{t("remove")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedId && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("selectEmployee")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { LabeledField } from "@/components/ui/labeled-field";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { BranchAccessPanel } from "@/components/hr/branch-access-panel";
|
import { BranchAccessPanel } from "@/components/hr/branch-access-panel";
|
||||||
|
import { EmployeeCredentialsPanel } from "@/components/hr/employee-credentials-panel";
|
||||||
|
|
||||||
interface Employee {
|
interface Employee {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -47,7 +48,7 @@ interface Salary {
|
|||||||
isPaid: boolean;
|
isPaid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = "attendance" | "leave" | "payroll" | "access";
|
type Tab = "attendance" | "leave" | "payroll" | "access" | "credentials";
|
||||||
|
|
||||||
export function HrScreen() {
|
export function HrScreen() {
|
||||||
const t = useTranslations("hr");
|
const t = useTranslations("hr");
|
||||||
@@ -122,8 +123,8 @@ export function HrScreen() {
|
|||||||
<h2 className="text-xl font-bold">{t("title")}</h2>
|
<h2 className="text-xl font-bold">{t("title")}</h2>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{((["attendance", "leave", "payroll", "access"] as Tab[]).filter(
|
{((["attendance", "leave", "payroll", "access", "credentials"] as Tab[]).filter(
|
||||||
(key) => key !== "access" || canManageAccess
|
(key) => (key !== "access" && key !== "credentials") || canManageAccess
|
||||||
)).map((key) => (
|
)).map((key) => (
|
||||||
<Button
|
<Button
|
||||||
key={key}
|
key={key}
|
||||||
@@ -230,6 +231,10 @@ export function HrScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "access" && canManageAccess && <BranchAccessPanel cafeId={cafeId} />}
|
{tab === "access" && canManageAccess && <BranchAccessPanel cafeId={cafeId} />}
|
||||||
|
|
||||||
|
{tab === "credentials" && canManageAccess && (
|
||||||
|
<EmployeeCredentialsPanel cafeId={cafeId} employees={employees} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useTranslations, useLocale } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { Link } from "@/i18n/routing";
|
import { Link } from "@/i18n/routing";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
|
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
|
||||||
import { apiGet, apiPatch, apiPost, apiPut } from "@/lib/api/client";
|
import { apiGet, apiPatch, apiPost, apiPut } from "@/lib/api/client";
|
||||||
import { InventoryUnitField } from "@/components/inventory/inventory-unit-field";
|
import { InventoryUnitField } from "@/components/inventory/inventory-unit-field";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
@@ -366,7 +367,17 @@ export function InventoryScreen() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
||||||
) : ingredients.length === 0 ? (
|
) : ingredients.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
<div className="space-y-3">
|
||||||
|
<DemoDataBanner
|
||||||
|
invalidateKeys={[
|
||||||
|
["inventory", cafeId!],
|
||||||
|
["menu-categories", cafeId!],
|
||||||
|
["menu-items-all", cafeId!],
|
||||||
|
["tables-board", cafeId!],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{ingredients.map((ing) => {
|
{ingredients.map((ing) => {
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ function buildDefaultOpenGroups(): OpenGroupsState {
|
|||||||
const stored = readStoredOpenGroups();
|
const stored = readStoredOpenGroups();
|
||||||
const defaults: OpenGroupsState = {};
|
const defaults: OpenGroupsState = {};
|
||||||
for (const g of NAV_GROUPS) {
|
for (const g of NAV_GROUPS) {
|
||||||
defaults[g.id] = stored[g.id] ?? g.defaultOpen;
|
// Default ALL groups closed on first visit; only restore if user explicitly saved state.
|
||||||
|
defaults[g.id] = stored[g.id] ?? false;
|
||||||
}
|
}
|
||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
@@ -238,20 +239,31 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
|
|||||||
[role, branchId, permissions]
|
[role, branchId, permissions]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Accordion: opening a group collapses all others. */
|
||||||
const setGroupOpen = useCallback((groupId: NavGroupId, open: boolean) => {
|
const setGroupOpen = useCallback((groupId: NavGroupId, open: boolean) => {
|
||||||
setOpenGroups((prev) => {
|
setOpenGroups((_prev) => {
|
||||||
const next = { ...prev, [groupId]: open };
|
const next: OpenGroupsState = {};
|
||||||
|
for (const g of NAV_GROUPS) {
|
||||||
|
// If opening: only the clicked group becomes true; everything else closes.
|
||||||
|
// If closing: just close the clicked group, leave others as-is.
|
||||||
|
next[g.id] = open ? g.id === groupId : g.id === groupId ? false : (_prev[g.id] ?? false);
|
||||||
|
}
|
||||||
persistOpenGroups(next);
|
persistOpenGroups(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// When navigating to a new path, open only the group that contains that path (accordion).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeGroup = findNavGroupForPath(pathname);
|
const activeGroup = findNavGroupForPath(pathname);
|
||||||
if (!activeGroup) return;
|
if (!activeGroup) return;
|
||||||
setOpenGroups((prev) => {
|
setOpenGroups((prev) => {
|
||||||
if (prev[activeGroup]) return prev;
|
if (prev[activeGroup]) return prev; // already open, nothing to do
|
||||||
const next = { ...prev, [activeGroup]: true };
|
// Accordion: open active group, close all others
|
||||||
|
const next: OpenGroupsState = {};
|
||||||
|
for (const g of NAV_GROUPS) {
|
||||||
|
next[g.id] = g.id === activeGroup;
|
||||||
|
}
|
||||||
persistOpenGroups(next);
|
persistOpenGroups(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useLocale } from "next-intl";
|
|||||||
import { useRouter } from "@/i18n/routing";
|
import { useRouter } from "@/i18n/routing";
|
||||||
import { Clock, X, Zap } from "lucide-react";
|
import { Clock, X, Zap } from "lucide-react";
|
||||||
|
|
||||||
// 14 Khordad 1405 = June 4, 2026 (Tehran UTC+3:30)
|
// 1 Tir 1405 = June 22, 2026 (Tehran IRDT UTC+4:30)
|
||||||
const DEADLINE = new Date("2026-06-04T00:00:00+03:30");
|
const DEADLINE = new Date("2026-06-22T00:00:00+04:30");
|
||||||
const STORAGE_KEY = "meezi_trial_banner_v1";
|
const STORAGE_KEY = "meezi_trial_banner_v1";
|
||||||
|
|
||||||
interface TimeLeft {
|
interface TimeLeft {
|
||||||
@@ -78,11 +78,11 @@ export function TrialCountdownBanner() {
|
|||||||
|
|
||||||
const textFa = expired
|
const textFa = expired
|
||||||
? "دوره آزمایشی میزی به پایان رسید. برای ادامه پلن انتخاب کنید."
|
? "دوره آزمایشی میزی به پایان رسید. برای ادامه پلن انتخاب کنید."
|
||||||
: "دوره آزمایشی رایگان تا ۱۴ خرداد ۱۴۰۵";
|
: "دوره آزمایشی رایگان تا ۱ تیر ۱۴۰۵";
|
||||||
|
|
||||||
const textEn = expired
|
const textEn = expired
|
||||||
? "Your Meezi trial has ended. Choose a plan to continue."
|
? "Your Meezi trial has ended. Choose a plan to continue."
|
||||||
: "Free trial ends 14 Khordad 1405 (Jun 4)";
|
: "Free trial ends 1 Tir 1405 (Jun 22)";
|
||||||
|
|
||||||
const Digit = ({ value, label }: { value: number; label: string }) => (
|
const Digit = ({ value, label }: { value: number; label: string }) => (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { useIsRtl } from "@/lib/use-is-rtl";
|
import { useIsRtl } from "@/lib/use-is-rtl";
|
||||||
import { Box, Pencil, Plus, Search, Video, X } from "lucide-react";
|
import { Box, Pencil, Plus, Search, Video, X } from "lucide-react";
|
||||||
|
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
|
||||||
import { Menu3dUpload } from "@/components/media/menu-3d-upload";
|
import { Menu3dUpload } from "@/components/media/menu-3d-upload";
|
||||||
import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate";
|
import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate";
|
||||||
import { CategoryVisual } from "@/components/menu/category-visual";
|
import { CategoryVisual } from "@/components/menu/category-visual";
|
||||||
import { CategoryMediaFields } from "@/components/menu/category-media-fields";
|
import { CategoryMediaFields } from "@/components/menu/category-media-fields";
|
||||||
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
|
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
|
||||||
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
|
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
|
||||||
import { apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
import { ApiClientError, apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
||||||
|
import { notify } from "@/lib/notify";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
import { useBranchStore } from "@/lib/stores/branch.store";
|
import { useBranchStore } from "@/lib/stores/branch.store";
|
||||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||||
@@ -182,6 +184,11 @@ function Modal({
|
|||||||
export function MenuAdminScreen() {
|
export function MenuAdminScreen() {
|
||||||
const t = useTranslations("menuAdmin");
|
const t = useTranslations("menuAdmin");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
|
const tNotify = useTranslations("notify");
|
||||||
|
const showError = (err: unknown) =>
|
||||||
|
notify.error(
|
||||||
|
err instanceof ApiClientError ? err.message : tNotify("errorGeneric")
|
||||||
|
);
|
||||||
const isRtl = useIsRtl();
|
const isRtl = useIsRtl();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||||
@@ -266,6 +273,7 @@ export function MenuAdminScreen() {
|
|||||||
setItemModalOpen(false);
|
setItemModalOpen(false);
|
||||||
invalidateMenu();
|
invalidateMenu();
|
||||||
},
|
},
|
||||||
|
onError: showError,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateItemMutation = useMutation({
|
const updateItemMutation = useMutation({
|
||||||
@@ -283,12 +291,14 @@ export function MenuAdminScreen() {
|
|||||||
setItemModalOpen(false);
|
setItemModalOpen(false);
|
||||||
invalidateMenu();
|
invalidateMenu();
|
||||||
},
|
},
|
||||||
|
onError: showError,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleItemMutation = useMutation({
|
const toggleItemMutation = useMutation({
|
||||||
mutationFn: ({ id, isAvailable }: { id: string; isAvailable: boolean }) =>
|
mutationFn: ({ id, isAvailable }: { id: string; isAvailable: boolean }) =>
|
||||||
apiPatch(`/api/cafes/${cafeId}/menu/items/${id}/availability`, { isAvailable }),
|
apiPatch(`/api/cafes/${cafeId}/menu/items/${id}/availability`, { isAvailable }),
|
||||||
onSuccess: invalidateMenu,
|
onSuccess: invalidateMenu,
|
||||||
|
onError: showError,
|
||||||
});
|
});
|
||||||
|
|
||||||
const addCategoryMutation = useMutation({
|
const addCategoryMutation = useMutation({
|
||||||
@@ -306,6 +316,7 @@ export function MenuAdminScreen() {
|
|||||||
setCatModalOpen(false);
|
setCatModalOpen(false);
|
||||||
invalidateMenu();
|
invalidateMenu();
|
||||||
},
|
},
|
||||||
|
onError: showError,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateCategoryMutation = useMutation({
|
const updateCategoryMutation = useMutation({
|
||||||
@@ -321,6 +332,7 @@ export function MenuAdminScreen() {
|
|||||||
setCatModalOpen(false);
|
setCatModalOpen(false);
|
||||||
invalidateMenu();
|
invalidateMenu();
|
||||||
},
|
},
|
||||||
|
onError: showError,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Modal openers ──────────────────────────────────────────────────────────
|
// ── Modal openers ──────────────────────────────────────────────────────────
|
||||||
@@ -449,7 +461,19 @@ export function MenuAdminScreen() {
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
/* ── Catalog tab ─────────────────────────────────────────────────── */
|
/* ── Catalog tab ─────────────────────────────────────────────────── */
|
||||||
<div className="flex min-h-0 gap-4">
|
<div className="flex min-h-0 flex-col gap-4">
|
||||||
|
{categories.length < 5 && items.length < 10 && (
|
||||||
|
<DemoDataBanner
|
||||||
|
invalidateKeys={[
|
||||||
|
["menu-categories", cafeId],
|
||||||
|
["menu-items-all", cafeId],
|
||||||
|
["menu-items", cafeId],
|
||||||
|
["tables-board", cafeId],
|
||||||
|
["inventory", cafeId],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex min-h-0 gap-4">
|
||||||
|
|
||||||
{/* ── Category Sidebar (desktop) ─────────────────────────────── */}
|
{/* ── Category Sidebar (desktop) ─────────────────────────────── */}
|
||||||
<aside className="hidden w-52 shrink-0 lg:block">
|
<aside className="hidden w-52 shrink-0 lg:block">
|
||||||
@@ -752,6 +776,7 @@ export function MenuAdminScreen() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,23 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { LabeledField } from "@/components/ui/labeled-field";
|
import { LabeledField } from "@/components/ui/labeled-field";
|
||||||
import { notify } from "@/lib/notify";
|
import { notify } from "@/lib/notify";
|
||||||
|
|
||||||
|
// ── Location map preview ──────────────────────────────────────────────────────
|
||||||
|
function LocationMapPreview({ lat, lng }: { lat: number; lng: number }) {
|
||||||
|
const zoom = 15;
|
||||||
|
const src = `https://www.openstreetmap.org/export/embed.html?bbox=${lng - 0.01},${lat - 0.01},${lng + 0.01},${lat + 0.01}&layer=mapnik&marker=${lat},${lng}`;
|
||||||
|
return (
|
||||||
|
<div className="relative w-full overflow-hidden rounded-lg border" style={{ height: 220 }}>
|
||||||
|
<iframe
|
||||||
|
src={src}
|
||||||
|
title="location preview"
|
||||||
|
className="h-full w-full border-0"
|
||||||
|
loading="lazy"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type SettingsShopPanelProps = {
|
type SettingsShopPanelProps = {
|
||||||
cafeId: string;
|
cafeId: string;
|
||||||
};
|
};
|
||||||
@@ -24,6 +41,8 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
const [slug, setSlug] = useState("");
|
||||||
|
const [slugError, setSlugError] = useState<string | null>(null);
|
||||||
const [city, setCity] = useState("");
|
const [city, setCity] = useState("");
|
||||||
const [phone, setPhone] = useState("");
|
const [phone, setPhone] = useState("");
|
||||||
const [address, setAddress] = useState("");
|
const [address, setAddress] = useState("");
|
||||||
@@ -31,12 +50,24 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
|
|||||||
const [logoUrl, setLogoUrl] = useState("");
|
const [logoUrl, setLogoUrl] = useState("");
|
||||||
const [coverImageUrl, setCoverImageUrl] = useState("");
|
const [coverImageUrl, setCoverImageUrl] = useState("");
|
||||||
const [snappfoodVendorId, setSnappfoodVendorId] = useState("");
|
const [snappfoodVendorId, setSnappfoodVendorId] = useState("");
|
||||||
|
const [latInput, setLatInput] = useState("");
|
||||||
|
const [lngInput, setLngInput] = useState("");
|
||||||
|
const [locationError, setLocationError] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: cafeSettings } = useCafeSettings(cafeId);
|
const { data: cafeSettings } = useCafeSettings(cafeId);
|
||||||
|
|
||||||
|
const parsedLat = parseFloat(latInput);
|
||||||
|
const parsedLng = parseFloat(lngInput);
|
||||||
|
const hasValidLocation =
|
||||||
|
!isNaN(parsedLat) &&
|
||||||
|
!isNaN(parsedLng) &&
|
||||||
|
parsedLat >= 24 && parsedLat <= 40 &&
|
||||||
|
parsedLng >= 44 && parsedLng <= 64;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cafeSettings) return;
|
if (!cafeSettings) return;
|
||||||
setName(cafeSettings.name ?? "");
|
setName(cafeSettings.name ?? "");
|
||||||
|
setSlug(cafeSettings.slug ?? "");
|
||||||
setCity(cafeSettings.city ?? "");
|
setCity(cafeSettings.city ?? "");
|
||||||
setPhone(cafeSettings.phone ?? "");
|
setPhone(cafeSettings.phone ?? "");
|
||||||
setAddress(cafeSettings.address ?? "");
|
setAddress(cafeSettings.address ?? "");
|
||||||
@@ -44,12 +75,21 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
|
|||||||
setLogoUrl(cafeSettings.logoUrl ?? "");
|
setLogoUrl(cafeSettings.logoUrl ?? "");
|
||||||
setCoverImageUrl(cafeSettings.coverImageUrl ?? "");
|
setCoverImageUrl(cafeSettings.coverImageUrl ?? "");
|
||||||
setSnappfoodVendorId(cafeSettings.snappfoodVendorId ?? "");
|
setSnappfoodVendorId(cafeSettings.snappfoodVendorId ?? "");
|
||||||
|
setLatInput(cafeSettings.latitude != null ? String(cafeSettings.latitude) : "");
|
||||||
|
setLngInput(cafeSettings.longitude != null ? String(cafeSettings.longitude) : "");
|
||||||
}, [cafeSettings]);
|
}, [cafeSettings]);
|
||||||
|
|
||||||
const saveProfile = useMutation({
|
const saveProfile = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () => {
|
||||||
apiPatch<CafeSettings>(`/api/cafes/${cafeId}/settings`, {
|
setSlugError(null);
|
||||||
|
const slugTrimmed = slug.trim();
|
||||||
|
const isValidSlug = !slugTrimmed || /^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(slugTrimmed);
|
||||||
|
if (slugTrimmed && !isValidSlug) {
|
||||||
|
throw new Error("INVALID_SLUG");
|
||||||
|
}
|
||||||
|
return apiPatch<CafeSettings>(`/api/cafes/${cafeId}/settings`, {
|
||||||
name,
|
name,
|
||||||
|
slug: slugTrimmed || undefined,
|
||||||
city,
|
city,
|
||||||
phone,
|
phone,
|
||||||
address,
|
address,
|
||||||
@@ -57,11 +97,45 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
|
|||||||
logoUrl: logoUrl || null,
|
logoUrl: logoUrl || null,
|
||||||
coverImageUrl: coverImageUrl || null,
|
coverImageUrl: coverImageUrl || null,
|
||||||
snappfoodVendorId,
|
snappfoodVendorId,
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.setQueryData(cafeSettingsQueryKey(cafeId), data);
|
queryClient.setQueryData(cafeSettingsQueryKey(cafeId), data);
|
||||||
notify.success(t("profile.saved"));
|
notify.success(t("profile.saved"));
|
||||||
},
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg === "INVALID_SLUG") {
|
||||||
|
setSlugError(t("profile.slugInvalid"));
|
||||||
|
} else if (msg.includes("SLUG_TAKEN")) {
|
||||||
|
setSlugError(t("profile.slugTaken"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveLocation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
setLocationError(null);
|
||||||
|
if (!hasValidLocation && (latInput || lngInput)) {
|
||||||
|
throw new Error("INVALID_LOCATION");
|
||||||
|
}
|
||||||
|
const body = latInput && lngInput && hasValidLocation
|
||||||
|
? { latitude: parsedLat, longitude: parsedLng }
|
||||||
|
: { clearLocation: true };
|
||||||
|
return apiPatch<CafeSettings>(`/api/cafes/${cafeId}/settings`, body);
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(cafeSettingsQueryKey(cafeId), data);
|
||||||
|
notify.success("موقعیت ذخیره شد");
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg === "INVALID_LOCATION" || msg.includes("INVALID_LOCATION")) {
|
||||||
|
setLocationError("مختصات نامعتبر است. مثال: عرض جغرافیایی ۳۵.۶۸۹، طول جغرافیایی ۵۱.۳۸۹");
|
||||||
|
} else {
|
||||||
|
notify.error("خطا در ذخیره موقعیت");
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadLogo = useMutation({
|
const uploadLogo = useMutation({
|
||||||
@@ -129,6 +203,33 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Koja slug */}
|
||||||
|
<LabeledField
|
||||||
|
label={t("profile.slug")}
|
||||||
|
htmlFor="cafe-slug"
|
||||||
|
hint={t("profile.slugHint")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="cafe-slug"
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSlugError(null);
|
||||||
|
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""));
|
||||||
|
}}
|
||||||
|
placeholder={t("profile.slugPlaceholder")}
|
||||||
|
dir="ltr"
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
{slug && (
|
||||||
|
<p className={`text-xs font-mono ${slugError ? "text-destructive" : "text-muted-foreground"}`}>
|
||||||
|
koja.meezi.ir/{slug}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{slugError && (
|
||||||
|
<p className="text-xs text-destructive">{slugError}</p>
|
||||||
|
)}
|
||||||
|
</LabeledField>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<LabeledField label={t("profile.name")} htmlFor="cafe-name">
|
<LabeledField label={t("profile.name")} htmlFor="cafe-name">
|
||||||
<Input id="cafe-name" value={name} onChange={(e) => setName(e.target.value)} />
|
<Input id="cafe-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
@@ -203,6 +304,80 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Location card */}
|
||||||
|
<Card className="rounded-xl border border-border/80 shadow-sm">
|
||||||
|
<CardHeader className="px-6 pb-4 pt-6">
|
||||||
|
<CardTitle className="text-base font-medium">موقعیت روی نقشه</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 px-6 pb-6 pt-0">
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
موقعیت دقیق کافه/رستوران خود را وارد کنید تا مشتریان بتوانند آن را پیدا کنند.
|
||||||
|
برای دریافت مختصات دقیق میتوانید از{" "}
|
||||||
|
<a
|
||||||
|
href={`https://neshan.org/maps/@${parsedLat || 35.6892},${parsedLng || 51.389},15z`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary underline"
|
||||||
|
>
|
||||||
|
نقشه نشان
|
||||||
|
</a>{" "}
|
||||||
|
استفاده کنید.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<LabeledField label="عرض جغرافیایی (Latitude)" htmlFor="cafe-lat">
|
||||||
|
<Input
|
||||||
|
id="cafe-lat"
|
||||||
|
value={latInput}
|
||||||
|
onChange={(e) => { setLatInput(e.target.value); setLocationError(null); }}
|
||||||
|
placeholder="مثال: ۳۵.۶۸۹۲"
|
||||||
|
dir="ltr"
|
||||||
|
className="text-end"
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
</LabeledField>
|
||||||
|
<LabeledField label="طول جغرافیایی (Longitude)" htmlFor="cafe-lng">
|
||||||
|
<Input
|
||||||
|
id="cafe-lng"
|
||||||
|
value={lngInput}
|
||||||
|
onChange={(e) => { setLngInput(e.target.value); setLocationError(null); }}
|
||||||
|
placeholder="مثال: ۵۱.۳۸۹"
|
||||||
|
dir="ltr"
|
||||||
|
className="text-end"
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
</LabeledField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{locationError && (
|
||||||
|
<p className="text-xs text-destructive">{locationError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasValidLocation && (
|
||||||
|
<LocationMapPreview lat={parsedLat} lng={parsedLng} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
className="bg-[#0F6E56] hover:bg-[#0c5e46]"
|
||||||
|
disabled={saveLocation.isPending}
|
||||||
|
onClick={() => saveLocation.mutate()}
|
||||||
|
>
|
||||||
|
ذخیره موقعیت
|
||||||
|
</Button>
|
||||||
|
{(latInput || lngInput) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setLatInput(""); setLngInput(""); setLocationError(null); }}
|
||||||
|
>
|
||||||
|
پاک کردن موقعیت
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function CheckoutScreen() {
|
|||||||
|
|
||||||
const [months, setMonths] = useState(1);
|
const [months, setMonths] = useState(1);
|
||||||
const [paymentMethod, setPaymentMethod] = useState("");
|
const [paymentMethod, setPaymentMethod] = useState("");
|
||||||
|
const [payError, setPayError] = useState<string | null>(null);
|
||||||
|
|
||||||
const numberLocale =
|
const numberLocale =
|
||||||
typeof document !== "undefined" && document.documentElement.lang === "en"
|
typeof document !== "undefined" && document.documentElement.lang === "en"
|
||||||
@@ -76,8 +77,13 @@ export function CheckoutScreen() {
|
|||||||
mutationFn: (body: { planTier: string; months: number; paymentMethod: string }) =>
|
mutationFn: (body: { planTier: string; months: number; paymentMethod: string }) =>
|
||||||
apiPost<SubscribeResponse>("/api/billing/subscribe", body),
|
apiPost<SubscribeResponse>("/api/billing/subscribe", body),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
|
setPayError(null);
|
||||||
window.location.href = data.paymentUrl;
|
window.location.href = data.paymentUrl;
|
||||||
},
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setPayError(msg || tc("paymentFailed"));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!cafeId) return null;
|
if (!cafeId) return null;
|
||||||
@@ -255,10 +261,15 @@ export function CheckoutScreen() {
|
|||||||
|
|
||||||
{/* Pay action */}
|
{/* Pay action */}
|
||||||
<div className="flex flex-col gap-3 border-t border-border/80 bg-muted/20 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 border-t border-border/80 bg-muted/20 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="flex flex-col gap-1">
|
||||||
<ShieldCheck className="h-4 w-4 text-[#0F6E56]" aria-hidden />
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
{tc("secureNote")}
|
<ShieldCheck className="h-4 w-4 text-[#0F6E56]" aria-hidden />
|
||||||
</p>
|
{tc("secureNote")}
|
||||||
|
</p>
|
||||||
|
{payError && (
|
||||||
|
<p className="text-xs text-destructive">{payError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||||
disabled={subscribe.isPending}
|
disabled={subscribe.isPending}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import * as signalR from "@microsoft/signalr";
|
import * as signalR from "@microsoft/signalr";
|
||||||
import { Plus, QrCode, Pencil, Video, Trash2, Copy, ExternalLink } from "lucide-react";
|
import { Plus, QrCode, Pencil, Video, Trash2, Copy, ExternalLink } from "lucide-react";
|
||||||
|
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
|
||||||
import { notify } from "@/lib/notify";
|
import { notify } from "@/lib/notify";
|
||||||
import { MediaPairUpload } from "@/components/media/media-pair-upload";
|
import { MediaPairUpload } from "@/components/media/media-pair-upload";
|
||||||
import { PageHeader } from "@/components/layout/page-header";
|
import { PageHeader } from "@/components/layout/page-header";
|
||||||
@@ -122,7 +123,9 @@ export function TablesScreen() {
|
|||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setActionMessage(err instanceof ApiClientError ? err.message : t("createError"));
|
const msg = err instanceof ApiClientError ? err.message : t("createError");
|
||||||
|
setActionMessage(msg);
|
||||||
|
notify.error(msg);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,6 +187,11 @@ export function TablesScreen() {
|
|||||||
setActionMessage(null);
|
setActionMessage(null);
|
||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const msg = err instanceof ApiClientError ? err.message : t("createError");
|
||||||
|
setActionMessage(msg);
|
||||||
|
notify.error(msg);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const startEdit = (table: TableBoardItem) => {
|
const startEdit = (table: TableBoardItem) => {
|
||||||
@@ -346,9 +354,19 @@ export function TablesScreen() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
||||||
) : tables.length === 0 ? (
|
) : tables.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="space-y-3">
|
||||||
{branchId ? t("emptyBranch") : t("empty")}
|
<DemoDataBanner
|
||||||
</p>
|
invalidateKeys={[
|
||||||
|
["tables-board", cafeId],
|
||||||
|
["menu-categories", cafeId],
|
||||||
|
["menu-items-all", cafeId],
|
||||||
|
["inventory", cafeId],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{branchId ? t("emptyBranch") : t("empty")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{actionMessage ? (
|
{actionMessage ? (
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import axios, { type AxiosError } from "axios";
|
import axios, {
|
||||||
import type { ApiResponse } from "./types";
|
type AxiosError,
|
||||||
|
type InternalAxiosRequestConfig,
|
||||||
|
} from "axios";
|
||||||
|
import type { ApiResponse, AuthTokenResponse } from "./types";
|
||||||
import { getOrCreateTerminalId } from "@/lib/terminal";
|
import { getOrCreateTerminalId } from "@/lib/terminal";
|
||||||
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
|
|
||||||
const baseURL =
|
const baseURL =
|
||||||
process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
|
process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
|
||||||
@@ -21,14 +25,63 @@ api.interceptors.request.use((config) => {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared in-flight refresh promise so that a burst of concurrent 401s triggers
|
||||||
|
* exactly one POST /api/auth/refresh instead of one per failed request.
|
||||||
|
*/
|
||||||
|
let refreshPromise: Promise<string | null> | null = null;
|
||||||
|
|
||||||
|
async function refreshAccessToken(): Promise<string | null> {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const refreshToken = localStorage.getItem("meezi_refresh_token");
|
||||||
|
if (!refreshToken) return null;
|
||||||
|
try {
|
||||||
|
// Bare axios call (not `api`) to avoid recursing through this interceptor.
|
||||||
|
const { data } = await axios.post<ApiResponse<AuthTokenResponse>>(
|
||||||
|
`${baseURL}/api/auth/refresh`,
|
||||||
|
{ refreshToken },
|
||||||
|
{ headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
if (!data.success || !data.data) return null;
|
||||||
|
useAuthStore.getState().setAuth(data.data);
|
||||||
|
return data.data.accessToken;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error: AxiosError<ApiResponse<unknown>>) => {
|
async (error: AxiosError<ApiResponse<unknown>>) => {
|
||||||
|
const status = error.response?.status;
|
||||||
|
const original = error.config as
|
||||||
|
| (InternalAxiosRequestConfig & { _retry?: boolean })
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// Expired access token → try a one-time refresh, then replay the request.
|
||||||
|
if (
|
||||||
|
status === 401 &&
|
||||||
|
original &&
|
||||||
|
!original._retry &&
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
!original.url?.includes("/api/auth/")
|
||||||
|
) {
|
||||||
|
original._retry = true;
|
||||||
|
refreshPromise ??= refreshAccessToken().finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
const newToken = await refreshPromise;
|
||||||
|
if (newToken) {
|
||||||
|
original.headers.Authorization = `Bearer ${newToken}`;
|
||||||
|
return api(original);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const apiError = error.response?.data?.error;
|
const apiError = error.response?.data?.error;
|
||||||
if (apiError?.code) {
|
if (apiError?.code) {
|
||||||
return Promise.reject(new ApiClientError(apiError.code, apiError.message));
|
return Promise.reject(new ApiClientError(apiError.code, apiError.message));
|
||||||
}
|
}
|
||||||
if (error.response?.status === 401 && typeof window !== "undefined") {
|
if (status === 401 && typeof window !== "undefined") {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
const isPublicGuest = path.startsWith("/q/") || path.startsWith("/q");
|
const isPublicGuest = path.startsWith("/q/") || path.startsWith("/q");
|
||||||
const isAdmin = path.includes("/admin");
|
const isAdmin = path.includes("/admin");
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export type CafeSettings = {
|
|||||||
theme: CafeTheme;
|
theme: CafeTheme;
|
||||||
defaultTaxRate?: number;
|
defaultTaxRate?: number;
|
||||||
allowBranchTaxOverride?: boolean;
|
allowBranchTaxOverride?: boolean;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function cafeSettingsQueryKey(cafeId: string) {
|
export function cafeSettingsQueryKey(cafeId: string) {
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export const NAV_GROUPS: NavGroupDef[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const NAV_GROUPS_STORAGE_KEY = "meezi:nav-groups:v3";
|
export const NAV_GROUPS_STORAGE_KEY = "meezi:nav-groups:v4";
|
||||||
|
|
||||||
/** Branch-scoped staff only see daily operations. */
|
/** Branch-scoped staff only see daily operations. */
|
||||||
export const BRANCH_ONLY_NAV_GROUP: NavGroupId = "operations";
|
export const BRANCH_ONLY_NAV_GROUP: NavGroupId = "operations";
|
||||||
|
|||||||
@@ -50,6 +50,16 @@ const nextConfig: NextConfig = {
|
|||||||
{ protocol: "http", hostname: "**" },
|
{ protocol: "http", hostname: "**" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
async redirects() {
|
||||||
|
return [
|
||||||
|
// Short URL: koja.meezi.ir/my-cafe → koja.meezi.ir/fa/cafe/my-cafe
|
||||||
|
{
|
||||||
|
source: "/:slug([a-z0-9][a-z0-9-]*[a-z0-9])",
|
||||||
|
destination: "/fa/cafe/:slug",
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withPWA(withNextIntl(nextConfig));
|
export default withPWA(withNextIntl(nextConfig));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Hero } from "@/components/sections/hero";
|
|||||||
import { Stats } from "@/components/sections/stats";
|
import { Stats } from "@/components/sections/stats";
|
||||||
import { TrustBar } from "@/components/sections/trust-bar";
|
import { TrustBar } from "@/components/sections/trust-bar";
|
||||||
import { Features } from "@/components/sections/features";
|
import { Features } from "@/components/sections/features";
|
||||||
|
import { IranMapSection } from "@/components/sections/iran-map-section";
|
||||||
import { HowItWorks } from "@/components/sections/how-it-works";
|
import { HowItWorks } from "@/components/sections/how-it-works";
|
||||||
import { AppPromo } from "@/components/sections/app-promo";
|
import { AppPromo } from "@/components/sections/app-promo";
|
||||||
import { Testimonials } from "@/components/sections/testimonials";
|
import { Testimonials } from "@/components/sections/testimonials";
|
||||||
@@ -41,6 +42,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
|
|||||||
<Hero />
|
<Hero />
|
||||||
<TrustBar />
|
<TrustBar />
|
||||||
<Stats />
|
<Stats />
|
||||||
|
<IranMapSection />
|
||||||
<Features />
|
<Features />
|
||||||
<HowItWorks />
|
<HowItWorks />
|
||||||
<AppPromo />
|
<AppPromo />
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* IranMapSection — server component
|
||||||
|
* Fetches real café locations from the API and overlays them as blinking dots
|
||||||
|
* on a stylised SVG silhouette of Iran.
|
||||||
|
*/
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type MapMarker = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
city: string | null;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MarkersApiResponse = {
|
||||||
|
success: boolean;
|
||||||
|
data: MapMarker[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Coordinate transform ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Iran bounding box (degrees) — fitted to the real border extent
|
||||||
|
// (lng 44.11–63.32, lat 25.08–39.71) with a small margin so the
|
||||||
|
// silhouette fills the viewBox. Markers reproject with the same box,
|
||||||
|
// so they stay aligned with the outline.
|
||||||
|
const MIN_LNG = 43.6;
|
||||||
|
const MAX_LNG = 63.8;
|
||||||
|
const MIN_LAT = 24.6;
|
||||||
|
const MAX_LAT = 40.2;
|
||||||
|
const SVG_W = 600;
|
||||||
|
const SVG_H = 500;
|
||||||
|
|
||||||
|
const toX = (lng: number) =>
|
||||||
|
((lng - MIN_LNG) / (MAX_LNG - MIN_LNG)) * SVG_W;
|
||||||
|
|
||||||
|
const toY = (lat: number) =>
|
||||||
|
((MAX_LAT - lat) / (MAX_LAT - MIN_LAT)) * SVG_H;
|
||||||
|
|
||||||
|
function toPt([lng, lat]: [number, number]) {
|
||||||
|
return `${toX(lng).toFixed(1)},${toY(lat).toFixed(1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Iran silhouette ────────────────────────────────────────────────────────────
|
||||||
|
// Real national border, simplified to 74 vertices (source: Natural Earth via
|
||||||
|
// world.geo.json). Coordinates are [longitude, latitude]; the ring starts on
|
||||||
|
// the Caspian (NE) and runs clockwise. Projected through toX/toY below, the
|
||||||
|
// same transform used for the café markers, so dots land in the right place.
|
||||||
|
const IRAN_OUTLINE: [number, number][] = [
|
||||||
|
[53.92, 37.20], [54.80, 37.39], [55.51, 37.96], [56.18, 37.94], [56.62, 38.12], [57.33, 38.03],
|
||||||
|
[58.44, 37.52], [59.23, 37.41], [60.38, 36.53], [61.12, 36.49], [61.21, 35.65], [60.80, 34.40],
|
||||||
|
[60.53, 33.68], [60.96, 33.53], [60.54, 32.98], [60.86, 32.18], [60.94, 31.55], [61.70, 31.38],
|
||||||
|
[61.78, 30.74], [60.87, 29.83], [61.37, 29.30], [61.77, 28.70], [62.73, 28.26], [62.76, 27.38],
|
||||||
|
[63.23, 27.22], [63.32, 26.76], [61.87, 26.24], [61.50, 25.08], [59.62, 25.38], [58.53, 25.61],
|
||||||
|
[57.40, 25.74], [56.97, 26.97], [56.49, 27.14], [55.72, 26.96], [54.72, 26.48], [53.49, 26.81],
|
||||||
|
[52.48, 27.58], [51.52, 27.87], [50.85, 28.81], [50.12, 30.15], [49.58, 29.99], [48.94, 30.32],
|
||||||
|
[48.57, 29.93], [48.01, 30.45], [48.00, 30.99], [47.69, 30.98], [47.85, 31.71], [47.33, 32.47],
|
||||||
|
[46.11, 33.02], [45.42, 33.97], [45.65, 34.75], [46.15, 35.09], [46.08, 35.68], [45.42, 35.98],
|
||||||
|
[44.77, 37.17], [44.23, 37.97], [44.42, 38.28], [44.11, 39.43], [44.79, 39.71], [44.95, 39.34],
|
||||||
|
[45.46, 38.87], [46.14, 38.74], [46.51, 38.77], [47.69, 39.51], [48.06, 39.58], [48.36, 39.29],
|
||||||
|
[48.01, 38.79], [48.63, 38.27], [48.88, 38.32], [49.20, 37.58], [50.15, 37.37], [50.84, 36.87],
|
||||||
|
[52.26, 36.70], [53.83, 36.97],
|
||||||
|
];
|
||||||
|
|
||||||
|
const IRAN_PATH =
|
||||||
|
"M " +
|
||||||
|
IRAN_OUTLINE.map(toPt).join(" L ") +
|
||||||
|
" Z";
|
||||||
|
|
||||||
|
// A handful of major cities shown as faint reference dots
|
||||||
|
const MAJOR_CITIES: { name: string; lng: number; lat: number }[] = [
|
||||||
|
{ name: "تهران", lng: 51.389, lat: 35.689 },
|
||||||
|
{ name: "مشهد", lng: 59.608, lat: 36.297 },
|
||||||
|
{ name: "اصفهان", lng: 51.668, lat: 32.661 },
|
||||||
|
{ name: "شیراز", lng: 52.531, lat: 29.594 },
|
||||||
|
{ name: "تبریز", lng: 46.291, lat: 38.08 },
|
||||||
|
{ name: "اهواز", lng: 48.683, lat: 31.318 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Data fetcher ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchMarkers(): Promise<MapMarker[]> {
|
||||||
|
try {
|
||||||
|
const apiBase =
|
||||||
|
process.env.MEEZI_API_URL ?? "https://api.meezi.ir";
|
||||||
|
const res = await fetch(`${apiBase}/api/public/map-markers`, {
|
||||||
|
next: { revalidate: 3600 },
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const json = (await res.json()) as MarkersApiResponse;
|
||||||
|
return json.data ?? [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function IranMapSvg() {
|
||||||
|
const markers = await fetchMarkers();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mx-auto w-full max-w-lg select-none">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
|
||||||
|
aria-label="نقشه ایران با موقعیت کافهها"
|
||||||
|
className="w-full drop-shadow-lg"
|
||||||
|
>
|
||||||
|
{/* Glow filter */}
|
||||||
|
<defs>
|
||||||
|
<filter id="glow">
|
||||||
|
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<radialGradient id="mapGrad" cx="50%" cy="50%" r="60%">
|
||||||
|
<stop offset="0%" stopColor="#e8f5f1" />
|
||||||
|
<stop offset="100%" stopColor="#d1ece5" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Iran silhouette */}
|
||||||
|
<path
|
||||||
|
d={IRAN_PATH}
|
||||||
|
fill="url(#mapGrad)"
|
||||||
|
stroke="#0F6E56"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Major city reference dots (faint) */}
|
||||||
|
{MAJOR_CITIES.map((city) => (
|
||||||
|
<g key={city.name}>
|
||||||
|
<circle
|
||||||
|
cx={toX(city.lng)}
|
||||||
|
cy={toY(city.lat)}
|
||||||
|
r={3}
|
||||||
|
fill="#0F6E56"
|
||||||
|
opacity={0.25}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Café markers — each glows slowly on and off like a small lamp.
|
||||||
|
Halo and core brighten/dim together (ease-in-out), staggered so the
|
||||||
|
map twinkles organically rather than pulsing in unison. */}
|
||||||
|
{markers.map((m, idx) => {
|
||||||
|
const cx = toX(m.longitude);
|
||||||
|
const cy = toY(m.latitude);
|
||||||
|
const delay = `${((idx * 0.7) % 3.6).toFixed(2)}s`;
|
||||||
|
const dur = "3.6s";
|
||||||
|
// ease-in-out for a smooth lamp-like fade
|
||||||
|
const ease = "0.4 0 0.6 1; 0.4 0 0.6 1";
|
||||||
|
return (
|
||||||
|
<g key={m.id} filter="url(#glow)">
|
||||||
|
{/* Soft halo */}
|
||||||
|
<circle cx={cx} cy={cy} r={9} fill="#0F6E56">
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
values="0.45;0.04;0.45"
|
||||||
|
keyTimes="0;0.5;1"
|
||||||
|
calcMode="spline"
|
||||||
|
keySplines={ease}
|
||||||
|
dur={dur}
|
||||||
|
begin={delay}
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
{/* Core dot — turns on (bright, slightly larger) and off (dim) */}
|
||||||
|
<circle cx={cx} cy={cy} r={4.5} fill="#0F6E56">
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
values="1;0.2;1"
|
||||||
|
keyTimes="0;0.5;1"
|
||||||
|
calcMode="spline"
|
||||||
|
keySplines={ease}
|
||||||
|
dur={dur}
|
||||||
|
begin={delay}
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="r"
|
||||||
|
values="4.5;5.6;4.5"
|
||||||
|
keyTimes="0;0.5;1"
|
||||||
|
calcMode="spline"
|
||||||
|
keySplines={ease}
|
||||||
|
dur={dur}
|
||||||
|
begin={delay}
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Floating legend */}
|
||||||
|
{markers.length > 0 && (
|
||||||
|
<div className="absolute bottom-3 start-3 flex items-center gap-2 rounded-full bg-white/90 px-3 py-1.5 text-xs shadow-md backdrop-blur-sm">
|
||||||
|
<span className="flex h-2 w-2 rounded-full bg-brand-600 ring-2 ring-brand-200" />
|
||||||
|
<span className="font-medium text-brand-700">{markers.length} کافه و رستوران</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function IranMapSection() {
|
||||||
|
return (
|
||||||
|
<section className="relative overflow-hidden bg-gradient-to-b from-white to-brand-50/40 py-20 sm:py-28">
|
||||||
|
{/* Subtle background pattern */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-[0.04]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"radial-gradient(circle, #0f6e56 1px, transparent 1px)",
|
||||||
|
backgroundSize: "32px 32px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Heading */}
|
||||||
|
<div className="mb-12 text-center">
|
||||||
|
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-brand-200 bg-brand-50 px-3 py-1.5 text-xs font-semibold text-brand-700">
|
||||||
|
<span className="flex h-1.5 w-1.5 rounded-full bg-brand-500" />
|
||||||
|
پراکنش جغرافیایی
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-extrabold tracking-tight text-gray-900 sm:text-4xl">
|
||||||
|
میزی در سراسر ایران
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-gray-500">
|
||||||
|
از تهران تا مشهد، از تبریز تا شیراز — کافهها و رستورانهای بیشتری هر روز به میزی میپیوندند.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg animate-pulse rounded-2xl bg-brand-50"
|
||||||
|
style={{ aspectRatio: "6/5" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IranMapSvg />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ import { X, Rocket } from "lucide-react";
|
|||||||
import { useLocale } from "next-intl";
|
import { useLocale } from "next-intl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// 14 Khordad 1405 = June 4, 2026 (Tehran, UTC+3:30)
|
// 1 Tir 1405 = June 22, 2026 (Tehran, UTC+3:30)
|
||||||
const LAUNCH_DATE = new Date("2026-06-04T00:00:00+03:30");
|
const LAUNCH_DATE = new Date("2026-06-22T00:00:00+03:30");
|
||||||
const DISMISS_KEY = "meezi_launch_banner_v2";
|
const DISMISS_KEY = "meezi_launch_banner_v3";
|
||||||
|
|
||||||
interface TimeLeft {
|
interface TimeLeft {
|
||||||
days: number;
|
days: number;
|
||||||
@@ -88,8 +88,8 @@ export function LaunchCountdownSection() {
|
|||||||
</span>
|
</span>
|
||||||
<p className="max-w-2xl text-base font-medium text-gray-900 sm:text-lg">
|
<p className="max-w-2xl text-base font-medium text-gray-900 sm:text-lg">
|
||||||
{isFa
|
{isFa
|
||||||
? "میزی رسماً ۱۴ خرداد ۱۴۰۵ برای همه کاربران راهاندازی میشود"
|
? "میزی رسماً ۱ تیر ۱۴۰۵ برای همه کاربران راهاندازی میشود"
|
||||||
: "Meezi officially launches for all users on June 4, 2026"}
|
: "Meezi officially launches for all users on June 22, 2026"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user