feat: complete server-backed six-lesson app and trial tooling
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+8
-4
@@ -3,10 +3,14 @@
|
||||
# Build-time: API base URL for the Vue app (/api = nginx proxies to backend).
|
||||
VITE_API_URL=/api
|
||||
|
||||
# Host port for the frontend container
|
||||
# Host port published for the frontend container
|
||||
FRONTEND_PORT=8080
|
||||
|
||||
# Backend env (used by ../backend-nest service in compose)
|
||||
JWT_SECRET=replace-with-a-long-random-secret
|
||||
# Backend env (compose service `backend`)
|
||||
JWT_SECRET=<replace-with-48+-character-random-secret>
|
||||
# Must match the public URL users open in the browser (scheme + host + port).
|
||||
CORS_ORIGIN=http://your-server:8080
|
||||
CORS_ORIGIN=http://<your-trial-host>:8080
|
||||
|
||||
# Backend (set in compose for trial stack — override only if needed)
|
||||
# TRUST_PROXY=true
|
||||
# TRUST_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Local development (optional). Default: Vite proxy sends /api to http://127.0.0.1:3000
|
||||
# VITE_API_URL=/api
|
||||
@@ -0,0 +1,98 @@
|
||||
name: Frontend CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
NODE_VERSION: '22.18.0'
|
||||
E2E_BACKEND_PORT: '3010'
|
||||
E2E_FRONTEND_PORT: '5190'
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: unit (test, type-check, build)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout frontend
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies (npm ci)
|
||||
run: npm ci
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test
|
||||
|
||||
- name: Type check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Production build
|
||||
run: npm run build
|
||||
|
||||
e2e:
|
||||
name: browser E2E (frontend + pinned backend)
|
||||
runs-on: ubuntu-latest
|
||||
needs: unit
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CI: 'true'
|
||||
E2E_BACKEND_ROOT: backend-nest
|
||||
E2E_BACKEND_REF: ${{ vars.E2E_BACKEND_REF }}
|
||||
E2E_BACKEND_CHECKOUT_TOKEN: ${{ secrets.E2E_BACKEND_CHECKOUT_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout frontend
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Require pinned backend ref (SHA or tag)
|
||||
run: |
|
||||
if [ -z "${E2E_BACKEND_REF}" ]; then
|
||||
echo "Set repository variable E2E_BACKEND_REF to a backend commit SHA. No fallback to main."
|
||||
exit 1
|
||||
fi
|
||||
echo "E2E_BACKEND_REF=${E2E_BACKEND_REF}"
|
||||
|
||||
- name: Checkout backend (pinned ref)
|
||||
run: node scripts/ci-checkout-backend.mjs
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install frontend dependencies (npm ci)
|
||||
run: npm ci
|
||||
|
||||
- name: Install backend dependencies (npm ci)
|
||||
working-directory: backend-nest
|
||||
run: npm ci
|
||||
|
||||
- name: Build backend for E2E
|
||||
run: npm run e2e:prepare
|
||||
|
||||
- name: Install Playwright Chromium (OS deps)
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: Run Playwright E2E
|
||||
run: npm run test:e2e:ci
|
||||
|
||||
- name: Upload failure artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-failure-${{ gitea.run_id }}
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: E2E cleanup
|
||||
if: always()
|
||||
run: npm run e2e:cleanup
|
||||
+11
@@ -35,3 +35,14 @@ coverage
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.docker.example
|
||||
|
||||
# Playwright E2E
|
||||
test-results
|
||||
playwright-report
|
||||
e2e/playwright-report
|
||||
e2e/.runtime
|
||||
blob-report
|
||||
e2e-full-run.txt
|
||||
e2e-last-run.txt
|
||||
# CI clone of backend (see e2e/backend.contract.json)
|
||||
/backend-nest
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22.12.0-bookworm-slim AS builder
|
||||
FROM node:22.18.0-bookworm-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -1,19 +1,138 @@
|
||||
# Child Gold Land — Frontend
|
||||
|
||||
رابط کاربری Vue 3 + Vite + Pinia.
|
||||
Vue 3 + Vite + Pinia + TypeScript.
|
||||
|
||||
## راهاندازی
|
||||
## Requirements
|
||||
|
||||
```sh
|
||||
npm install
|
||||
- **Node.js:** `^22.18.0` or `>=24.12.0` (`package.json` → `engines`)
|
||||
- **Pinned for local dev / CI:** `22.18.0` (see `.nvmrc`)
|
||||
|
||||
```powershell
|
||||
nvm install 22.18.0
|
||||
nvm use 22.18.0
|
||||
```
|
||||
|
||||
## Backend (sibling repo)
|
||||
|
||||
API is **required** for login and gameplay. Run Nest backend from `../backend-nest` on port **3000** (separate Git repo: `gold_land_server` on Gitea).
|
||||
|
||||
## Local development
|
||||
|
||||
```powershell
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## اسکریپتها
|
||||
- App: http://localhost:5174 (`vite.config.ts` — `strictPort: true`)
|
||||
- API: requests to `/api/*` are proxied to http://127.0.0.1:3000
|
||||
|
||||
| دستور | توضیح |
|
||||
|--------|--------|
|
||||
| `npm run dev` | سرور توسعه |
|
||||
| `npm run build` | بیلد production |
|
||||
| `npm run preview` | پیشنمایش بیلد |
|
||||
| `npm run type-check` | بررسی TypeScript |
|
||||
Optional env: copy `.env.example` to `.env.local` if you need overrides (`VITE_API_URL`).
|
||||
|
||||
Demo account (when backend `NODE_ENV=development`): username `demo`, password `123456`.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run dev` | Vite dev server (port 5174) |
|
||||
| `npm run build` | `vue-tsc` + production build |
|
||||
| `npm run preview` | Preview production build |
|
||||
| `npm run type-check` | TypeScript check only |
|
||||
| `npm run test` | Node unit tests (`test/`) |
|
||||
| `npm run test:e2e` | Build backend + Playwright E2E (30 tests) |
|
||||
| `npm run test:e2e:ci` | Playwright only (backend must already be built) |
|
||||
| `npm run test:e2e:install` | `playwright install chromium` (+ OS deps on Linux: `--with-deps`) |
|
||||
| `npm run e2e:prepare` | `npm run build` in backend (`E2E_BACKEND_ROOT`) |
|
||||
| `npm run ci:checkout-backend` | Clone backend for CI layout (see E2E contract) |
|
||||
|
||||
## CI (Gitea Actions)
|
||||
|
||||
Workflow: [`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) (runs on **push** and **pull_request** when a Gitea **act_runner** is registered — **not verified** on `get.feep.ir` from this repo alone).
|
||||
|
||||
### Jobs (this repository)
|
||||
|
||||
| Job | Steps (same as local) |
|
||||
|-----|------------------------|
|
||||
| **unit** | `npm ci` → `npm run test` → `npm run type-check` → `npm run build` |
|
||||
| **e2e** | checkout frontend → clone **pinned** backend → `npm ci` (both) → `e2e:prepare` → `playwright install chromium --with-deps` → `npm run test:e2e:ci` |
|
||||
|
||||
- **Node:** `22.18.0` (explicit in workflow)
|
||||
- **Cache:** npm cache via `actions/setup-node` (optional; job still passes without cache)
|
||||
- **Timeouts:** unit 25 min, e2e 45 min
|
||||
- **Failure artifacts:** `playwright-report/`, `test-results/` (no tokens, no production DB)
|
||||
- **Cleanup:** `npm run e2e:cleanup` runs `always()` after E2E
|
||||
|
||||
### Local equivalent (unit checks)
|
||||
|
||||
```powershell
|
||||
npm ci
|
||||
npm run test
|
||||
npm run type-check
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Two-repo E2E contract
|
||||
|
||||
| Item | Value |
|
||||
|------|--------|
|
||||
| Contract file | [`e2e/backend.contract.json`](e2e/backend.contract.json) |
|
||||
| Default backend URL | `https://get.feep.ir/gold_land/gold_land_server.git` |
|
||||
| Default ref | `main` (override for reproducibility) |
|
||||
| Checkout path (CI) | `backend-nest/` under frontend workspace |
|
||||
| Backend build | `npm run build` in that tree (`e2e:prepare`) |
|
||||
| E2E backend port | `3010` (`E2E_BACKEND_PORT`) |
|
||||
| E2E frontend port | `5190` (`E2E_FRONTEND_PORT`) |
|
||||
| Readiness | `GET http://127.0.0.1:3010/health` |
|
||||
| Temp DB | SQLite under OS temp (`scripts/e2e-backend.mjs`) — never `data/app.db` |
|
||||
|
||||
**Pin backend version in CI (required for e2e job):** set repository variable `E2E_BACKEND_REF` to a **commit SHA** (or immutable tag). There is **no** silent fallback to `main`.
|
||||
|
||||
**Local sibling layout (no clone):**
|
||||
|
||||
```powershell
|
||||
$env:E2E_BACKEND_ROOT = "E:\path\to\backend-nest" # optional; default is ../backend-nest
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
**Local fallback when Playwright Chromium CDN fails:** `$env:PLAYWRIGHT_CHROME_CHANNEL = "chrome"` (system Chrome). **CI must not rely on this** — use `npx playwright install chromium --with-deps`.
|
||||
|
||||
### Secrets (names only — never commit values)
|
||||
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| `E2E_BACKEND_CHECKOUT_TOKEN` | Read access to private `gold_land_server` for `git clone` in E2E job |
|
||||
|
||||
If the backend repo is public, clone may work without a token. If clone fails, the **e2e** job fails; **unit** still runs.
|
||||
|
||||
### Runner prerequisites (Linux, e.g. `ubuntu-latest` label)
|
||||
|
||||
- Node **22.18.0**
|
||||
- Git
|
||||
- Build toolchain for **better-sqlite3** (backend dependency): `build-essential`, `python3` (Playwright `--with-deps` installs browser OS libs; backend `npm ci` still needs native compile on Linux)
|
||||
- Sufficient disk for `node_modules` × 2 + Chromium
|
||||
|
||||
### Not yet verified on a real runner
|
||||
|
||||
- Gitea Actions enabled and **act_runner** online for `gold_land/gold_land_user`
|
||||
- E2E job with private backend clone + `E2E_BACKEND_CHECKOUT_TOKEN`
|
||||
- `vars.E2E_BACKEND_REF` / `gitea.run_id` in workflow expressions (Gitea ≥ 1.21; `github.*` aliases may apply)
|
||||
|
||||
## Optional: start backend + frontend (Windows)
|
||||
|
||||
```powershell
|
||||
pwsh -File ..\backend-nest\scripts\dev-local-stack.ps1
|
||||
```
|
||||
|
||||
Sibling folder layout: `frontend` + `backend-nest`.
|
||||
|
||||
## Docker / trial deploy
|
||||
|
||||
`docker-compose.yml` builds frontend + backend for a **trial** stack (nginx + Nest + SQLite volume).
|
||||
|
||||
See [docs/DEPLOY-TRIAL.md](docs/DEPLOY-TRIAL.md) for architecture, placeholders, HTTPS notes, backup, health, and rollback.
|
||||
|
||||
Local day-to-day dev usually uses `npm run dev` + backend `npm run dev` or `npm run start`.
|
||||
|
||||
## Project status doc
|
||||
|
||||
A workspace-level overview may exist at `../docs/project-status.md` when both repos share a parent folder. That file is **not** part of this Git repo.
|
||||
|
||||
+5
-1
@@ -14,6 +14,10 @@ services:
|
||||
DB_PATH: /app/data/app.db
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
CORS_ORIGIN: ${CORS_ORIGIN}
|
||||
TRUST_PROXY: "true"
|
||||
TRUST_PROXY_CIDRS: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||
expose:
|
||||
- "3000"
|
||||
volumes:
|
||||
- backend_sqlite:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -23,7 +27,7 @@ services:
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
"fetch('http://127.0.0.1:3000/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# Trial deploy — 6 active lessons (no public launch checklist)
|
||||
|
||||
This document prepares a **trial** stack using the existing Docker Compose layout. It does **not** deploy to a real server from this repo.
|
||||
|
||||
## Architecture (default)
|
||||
|
||||
```
|
||||
Browser → [TLS terminator — your choice] → frontend nginx :80
|
||||
├─ / → Vue SPA (try_files → index.html)
|
||||
├─ /api/* → proxy → backend Nest :3000
|
||||
└─ /health → proxy → backend /health
|
||||
Backend → SQLite file on named volume (single instance)
|
||||
```
|
||||
|
||||
| Component | Image / build | User |
|
||||
|-----------|---------------|------|
|
||||
| Frontend | `frontend/Dockerfile` → nginx:alpine | nginx (non-root worker) |
|
||||
| Backend | `backend-nest/Dockerfile` → node:22.18.0-slim | `node` |
|
||||
| Data | Docker volume `child-gold-land-sqlite` → `/app/data/app.db` | writable by `node` |
|
||||
|
||||
**Not supported without proof:** SQLite on NFS/network shares, horizontal multi-instance backend with this rate limiter.
|
||||
|
||||
## Placeholders (set on server — never commit)
|
||||
|
||||
Copy `frontend/.env.docker.example` → `frontend/.env.docker`:
|
||||
|
||||
| Variable | Example placeholder |
|
||||
|----------|---------------------|
|
||||
| `JWT_SECRET` | `<generate-48+-char-random>` |
|
||||
| `CORS_ORIGIN` | `https://<your-trial-host>` or `http://<host>:8080` |
|
||||
| `VITE_API_URL` | `/api` (keep for same-origin nginx proxy) |
|
||||
| `FRONTEND_PORT` | `8080` |
|
||||
|
||||
### Proxy trust boundary (compose default)
|
||||
|
||||
| Variable | Compose default | Meaning |
|
||||
|----------|-----------------|--------|
|
||||
| `TRUST_PROXY` | `true` | Use `X-Real-IP` / `X-Forwarded-For` **only** when TCP peer is in `TRUST_PROXY_CIDRS` |
|
||||
| `TRUST_PROXY_CIDRS` | Explicit list in compose (trial) | **Production:** set the CIDR of the nginx→backend hop (e.g. `docker network inspect child-gold-land`); backend **refuses** `TRUST_PROXY=true` without `TRUST_PROXY_CIDRS` when `NODE_ENV=production` |
|
||||
|
||||
- **Fastify `trustProxy` stays off** — client IP for rate limits comes from `getClientIp()` (trusted peer + single-hop headers).
|
||||
- nginx sets `X-Forwarded-For $remote_addr` (replaces forged client chains).
|
||||
- **Backend is not published on the host** — only frontend port is exposed; direct access to Nest bypasses nginx and should not be exposed in trial deploy.
|
||||
|
||||
**Rate limiting:** in-memory, **per client IP**, **single backend instance**. Many users behind one NAT share one bucket. For stricter limits use an edge proxy or account-based throttling (out of scope).
|
||||
|
||||
**Do not** expose backend port `3000` on the host in production trial.
|
||||
|
||||
## Build and start (local trial)
|
||||
|
||||
From sibling clones (`frontend/`, `backend-nest/`):
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
copy .env.docker.example .env.docker
|
||||
# Edit .env.docker — set JWT_SECRET and CORS_ORIGIN
|
||||
|
||||
docker compose config # validate compose file
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Checks:
|
||||
|
||||
```powershell
|
||||
curl http://localhost:8080/health
|
||||
curl http://localhost:8080/health/ready
|
||||
curl http://localhost:8080/api/... # via app UI login
|
||||
```
|
||||
|
||||
Vue routes: refresh on `/map`, `/market`, etc. must return `index.html` (configured in `nginx.conf`).
|
||||
|
||||
## HTTPS
|
||||
|
||||
- Terminate TLS at **your** reverse proxy (Caddy, Traefik, nginx on host) or cloud LB.
|
||||
- Redirect HTTP→HTTPS at the terminator.
|
||||
- Set `CORS_ORIGIN` to the **exact** browser origin (`https://host`, no trailing slash).
|
||||
- Do not generate real certificates in this repo.
|
||||
|
||||
## Persistent data
|
||||
|
||||
- **Path in container:** `/app/data/app.db` (+ `-wal`/`-shm` when WAL mode active)
|
||||
- **Volume:** `child-gold-land-sqlite` (compose)
|
||||
- **Host permissions:** ensure volume UID/GID allows `node` (uid 1000 in image) to read/write; if bind-mounting a host dir: `chown 1000:1000 <dir>`
|
||||
|
||||
Startup **never** replaces an existing DB with seed data. Migrations run forward-only on boot; failure aborts startup.
|
||||
|
||||
## Backup and restore
|
||||
|
||||
On backend repo (host or one-off container with volume mounted):
|
||||
|
||||
```powershell
|
||||
cd backend-nest
|
||||
$env:DB_PATH = "/path/to/app.db" # or container path
|
||||
$env:BACKUP_DIR = "/path/to/backups"
|
||||
$env:BACKUP_RETENTION_DAYS = "14"
|
||||
npm run backup:sqlite
|
||||
|
||||
npm run restore:sqlite -- backups/app-....db /tmp/restored.db
|
||||
# Stop backend first. Restore to a sibling path (not live DB_PATH), then swap files — see backend-nest/docs/restore-production-db.md.
|
||||
# RESTORE_ACK_STOPPED=YES = operator ack only (not automatic stop detection).
|
||||
```
|
||||
|
||||
Uses SQLite **backup API** (WAL-safe), then `integrity_check`.
|
||||
|
||||
## Health vs readiness
|
||||
|
||||
| Endpoint | Meaning |
|
||||
|----------|---------|
|
||||
| `GET /health` | Liveness — process up |
|
||||
| `GET /health/ready` | Readiness — DB migrated |
|
||||
|
||||
Compose `depends_on: service_healthy` uses **ready**.
|
||||
|
||||
## Rollback
|
||||
|
||||
| Action | Effect |
|
||||
|--------|--------|
|
||||
| `docker compose pull` + run previous image tags | Rolls back **code** only |
|
||||
| DB | **Not** rolled back with image — restore from backup if needed |
|
||||
| Forward migrations | Newer code may require newer schema; rolling back code without DB restore can break — test rollback pairs in staging |
|
||||
|
||||
## Safe shutdown
|
||||
|
||||
```powershell
|
||||
docker compose down
|
||||
# Data remains in volume child-gold-land-sqlite
|
||||
```
|
||||
|
||||
## CI backend pin (E2E)
|
||||
|
||||
No silent `main` fallback: set Gitea variable **`E2E_BACKEND_REF`** to backend **commit SHA**. See `e2e/backend.contract.json` and frontend README.
|
||||
|
||||
Secrets (names only): `E2E_BACKEND_CHECKOUT_TOKEN`.
|
||||
|
||||
## Still required for real deploy
|
||||
|
||||
- [ ] Registered Gitea **act_runner** (CI not verified here)
|
||||
- [ ] Trial host OS, domain, TLS certificates
|
||||
- [ ] Strong `JWT_SECRET` on server
|
||||
- [ ] Off-site backup storage
|
||||
- [ ] Monitoring / log aggregation (no stack traces to clients — API returns generic `خطای سرور`)
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"repositoryUrl": "https://get.feep.ir/gold_land/gold_land_server.git",
|
||||
"checkoutPath": "backend-nest",
|
||||
"localRef": "",
|
||||
"notes": "CI: set variable E2E_BACKEND_REF to backend commit SHA (required). Local clone: set E2E_BACKEND_REF or localRef. Local sibling: set E2E_BACKEND_ROOT to ../backend-nest and skip ci:checkout-backend."
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth'
|
||||
|
||||
test.describe('store, game, educational challenge', () => {
|
||||
test('store purchase and duplicate guard', async ({ page }) => {
|
||||
const user = uniqueUsername('store')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
const me0 = await fetchMe(page)
|
||||
const coins0 = me0.user.coins
|
||||
|
||||
await page.goto('/store')
|
||||
await page.getByText('در حال بارگذاری').waitFor({ state: 'detached', timeout: 30_000 }).catch(() => {})
|
||||
await page.getByRole('button', { name: 'تقویت XP' }).click()
|
||||
await page.getByRole('button', { name: 'خرید' }).click()
|
||||
|
||||
await expect(page.getByText(/تقویت XP.*خریده شد/)).toBeVisible({ timeout: 15_000 })
|
||||
const me1 = await fetchMe(page)
|
||||
expect(me1.user.coins).toBeLessThan(coins0)
|
||||
expect(me1.progress.ownedItems).toContain('boost-xp')
|
||||
|
||||
await page.reload()
|
||||
const meRefresh = await fetchMe(page)
|
||||
expect(meRefresh.progress.ownedItems).toContain('boost-xp')
|
||||
|
||||
await expect(page.locator('.product-card').filter({ hasText: 'تقویت XP' })).toContainText('مال توئه')
|
||||
const me2 = await fetchMe(page)
|
||||
expect(me2.user.coins).toBe(me1.user.coins)
|
||||
})
|
||||
|
||||
test('gold guess first completion only', async ({ page }) => {
|
||||
const user = uniqueUsername('game')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
const me0 = await fetchMe(page)
|
||||
const coins0 = me0.user.coins
|
||||
const xp0 = me0.user.xp
|
||||
|
||||
await page.goto('/games/gold-guess')
|
||||
await page.getByText('در حال بارگذاری پاداش').waitFor({ state: 'detached', timeout: 30_000 }).catch(() => {})
|
||||
await page.getByRole('button', { name: 'افزایش' }).click()
|
||||
await expect(page.locator('.toast--success')).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
const me1 = await fetchMe(page)
|
||||
expect(me1.user.coins).toBeGreaterThan(coins0)
|
||||
expect(me1.progress.claimedRewards).toContain('gold-guess')
|
||||
|
||||
await page.reload()
|
||||
await page.getByRole('button', { name: 'افزایش' }).click({ timeout: 5000 }).catch(() => {})
|
||||
const me2 = await fetchMe(page)
|
||||
expect(me2.user.coins).toBe(me1.user.coins)
|
||||
expect(me2.user.xp).toBeGreaterThanOrEqual(xp0)
|
||||
})
|
||||
|
||||
test('educational challenge once', async ({ page }) => {
|
||||
const user = uniqueUsername('challenge')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
const me0 = await fetchMe(page)
|
||||
const coins0 = me0.user.coins
|
||||
|
||||
await page.goto('/challenge')
|
||||
await page.getByRole('heading', { name: 'چالش آموزشی' }).waitFor()
|
||||
await page.getByText('در حال بارگذاری').waitFor({ state: 'detached', timeout: 30_000 }).catch(() => {})
|
||||
|
||||
await page.getByRole('button', { name: 'طلا' }).click()
|
||||
await page.getByRole('button', { name: 'دریافت پاداش' }).click()
|
||||
|
||||
await expect(page.getByText('پاداش دریافت شد')).toBeVisible({ timeout: 15_000 })
|
||||
const me1 = await fetchMe(page)
|
||||
expect(me1.user.coins).toBeGreaterThan(coins0)
|
||||
expect(me1.progress.completedChallenges).toContain('daily-challenge')
|
||||
|
||||
await page.reload()
|
||||
await page.getByRole('heading', { name: 'چالش آموزشی' }).waitFor()
|
||||
const me2 = await fetchMe(page)
|
||||
expect(me2.user.coins).toBe(me1.user.coins)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Valid UI choices for level 1 (what-is-money) — matches backend grader keys. */
|
||||
export const level1ChoiceLabels = [
|
||||
'مبادله آسانتر کالاها',
|
||||
'درست',
|
||||
'چون همه همیشه همان چیز را میخواهند',
|
||||
'درست',
|
||||
'پول وسیلهٔ مبادله و نگهداری ارزش است',
|
||||
] as const
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export default function globalTeardown() {
|
||||
const statePath = join(process.cwd(), 'e2e', '.runtime', 'state.json')
|
||||
try {
|
||||
const { tempDir } = JSON.parse(readFileSync(statePath, 'utf8')) as { tempDir: string }
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
rmSync(join(process.cwd(), 'e2e', '.runtime'), { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
export function uniqueUsername(prefix = 'e2e'): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
export async function registerViaUi(
|
||||
page: Page,
|
||||
username: string,
|
||||
password: string,
|
||||
displayName?: string,
|
||||
) {
|
||||
await page.goto('/login')
|
||||
await page.getByRole('button', { name: 'ثبتنام کن' }).click()
|
||||
if (displayName) {
|
||||
await page.getByLabel('نام نمایشی').fill(displayName)
|
||||
}
|
||||
await page.getByLabel('نام کاربری').fill(username)
|
||||
await page.getByLabel('رمز عبور').fill(password)
|
||||
await page.getByRole('button', { name: 'ثبتنام' }).click()
|
||||
await page.waitForURL('/')
|
||||
}
|
||||
|
||||
export async function loginViaUi(page: Page, username: string, password: string) {
|
||||
await page.goto('/login')
|
||||
await page.getByLabel('نام کاربری').fill(username)
|
||||
await page.getByLabel('رمز عبور').fill(password)
|
||||
await page.getByRole('button', { name: 'ورود', exact: true }).click()
|
||||
await page.waitForURL('/')
|
||||
}
|
||||
|
||||
export async function logoutViaProfile(page: Page) {
|
||||
await page.goto('/profile')
|
||||
await page.getByRole('button', { name: 'خروج' }).click()
|
||||
await page.waitForURL('/login')
|
||||
}
|
||||
|
||||
export async function getAuthToken(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => localStorage.getItem('gold-land-auth-token'))
|
||||
}
|
||||
|
||||
export async function fetchMe(page: Page) {
|
||||
const token = await getAuthToken(page)
|
||||
if (!token) throw new Error('no auth token')
|
||||
const res = await page.request.get('/api/me', {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!res.ok()) throw new Error(`GET /api/me failed: ${res.status()}`)
|
||||
return res.json() as Promise<{
|
||||
user: { xp: number; coins: number; name: string }
|
||||
progress: {
|
||||
mapLevels: Array<{ id: number; stars: number; status: string }>
|
||||
totalStars: number
|
||||
completedChallenges: string[]
|
||||
claimedRewards: string[]
|
||||
ownedItems: string[]
|
||||
}
|
||||
wallet: { cash: number; goldGrams: number }
|
||||
}>
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { level1ChoiceLabels } from '../fixtures/level1-answers'
|
||||
|
||||
export async function openLevel1FromMap(page: Page) {
|
||||
await page.goto('/map')
|
||||
await page.getByRole('heading', { name: 'سرزمین طلا' }).waitFor()
|
||||
await page.getByRole('button', { name: /مرحله 1:/ }).click()
|
||||
await page.getByRole('button', { name: 'شروع درس' }).click()
|
||||
await page.waitForURL(/\/gold-land\/levels\/1/)
|
||||
await page.getByText('در حال بارگذاری').waitFor({ state: 'detached', timeout: 30_000 })
|
||||
}
|
||||
|
||||
export async function playCurrentLessonSteps(page: Page) {
|
||||
const pending = [...level1ChoiceLabels]
|
||||
|
||||
for (let step = 0; step < 50; step++) {
|
||||
if (await page.getByText('امتیاز').isVisible().catch(() => false)) return
|
||||
|
||||
const finish = page.getByRole('button', { name: 'اتمام درس' })
|
||||
if ((await finish.isVisible().catch(() => false)) && (await finish.isEnabled())) {
|
||||
await finish.click()
|
||||
await page.getByText('امتیاز').waitFor({ timeout: 30_000 })
|
||||
return
|
||||
}
|
||||
|
||||
if (pending.length > 0) {
|
||||
const label = pending[0]!
|
||||
const chip = page.getByRole('button', { name: label, exact: true })
|
||||
if (await chip.isVisible().catch(() => false)) {
|
||||
await chip.click()
|
||||
pending.shift()
|
||||
}
|
||||
}
|
||||
|
||||
const next = page.getByRole('button', { name: 'بعدی' })
|
||||
if (await next.isEnabled().catch(() => false)) {
|
||||
await next.click()
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('lesson steps did not complete')
|
||||
}
|
||||
|
||||
export async function completeLevel1Lesson(page: Page) {
|
||||
await openLevel1FromMap(page)
|
||||
await playCurrentLessonSteps(page)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
fetchMe,
|
||||
loginViaUi,
|
||||
logoutViaProfile,
|
||||
registerViaUi,
|
||||
uniqueUsername,
|
||||
} from './helpers/auth'
|
||||
import { completeLevel1Lesson, playCurrentLessonSteps } from './helpers/lesson'
|
||||
|
||||
test.describe('main user journey', () => {
|
||||
test('register → lesson 1 → map → refresh → logout → login', async ({ page }) => {
|
||||
const user = uniqueUsername('journey')
|
||||
const password = 'pass1234'
|
||||
|
||||
await registerViaUi(page, user, password, 'کاربر E2E')
|
||||
|
||||
const meBefore = await fetchMe(page)
|
||||
const xp0 = meBefore.user.xp
|
||||
const coins0 = meBefore.user.coins
|
||||
|
||||
await completeLevel1Lesson(page)
|
||||
|
||||
await expect(page.getByText('مرحله 2 باز شد')).toBeVisible()
|
||||
const rewardXp = page.locator('.stat-row--reward').filter({ hasText: 'XP' })
|
||||
if (await rewardXp.count()) {
|
||||
await expect(rewardXp).toBeVisible()
|
||||
}
|
||||
|
||||
const meAfterLesson = await fetchMe(page)
|
||||
expect(meAfterLesson.progress.mapLevels.find((l) => l.id === 1)?.stars).toBeGreaterThan(0)
|
||||
expect(meAfterLesson.progress.mapLevels.find((l) => l.id === 2)?.status).not.toBe('locked')
|
||||
expect(meAfterLesson.user.xp).toBeGreaterThan(xp0)
|
||||
expect(meAfterLesson.user.coins).toBeGreaterThan(coins0)
|
||||
|
||||
await page.getByRole('button', { name: 'بازگشت به نقشه' }).click()
|
||||
await page.waitForURL(/\/map/)
|
||||
|
||||
await page.reload()
|
||||
await page.waitForURL(/\/map/)
|
||||
const meAfterRefresh = await fetchMe(page)
|
||||
expect(meAfterRefresh.progress.mapLevels.find((l) => l.id === 1)?.stars).toBe(
|
||||
meAfterLesson.progress.mapLevels.find((l) => l.id === 1)?.stars,
|
||||
)
|
||||
|
||||
await logoutViaProfile(page)
|
||||
await loginViaUi(page, user, password)
|
||||
const meRelogin = await fetchMe(page)
|
||||
expect(meRelogin.progress.mapLevels.find((l) => l.id === 1)?.stars).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('lesson replay without improvement grants no extra reward in UI', async ({ page }) => {
|
||||
const user = uniqueUsername('replay')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
await completeLevel1Lesson(page)
|
||||
|
||||
const me1 = await fetchMe(page)
|
||||
const xp1 = me1.user.xp
|
||||
const coins1 = me1.user.coins
|
||||
|
||||
await page.getByRole('button', { name: 'مرور دوباره' }).click()
|
||||
await page.getByText('در حال بارگذاری').waitFor({ state: 'detached', timeout: 30_000 })
|
||||
await playCurrentLessonSteps(page)
|
||||
|
||||
const me2 = await fetchMe(page)
|
||||
expect(me2.user.xp).toBe(xp1)
|
||||
expect(me2.user.coins).toBe(coins1)
|
||||
})
|
||||
|
||||
test('locked and coming_soon levels behave correctly', async ({ page }) => {
|
||||
const user = uniqueUsername('maplock')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
await page.goto('/map')
|
||||
await page.getByRole('heading', { name: 'سرزمین طلا' }).waitFor()
|
||||
|
||||
const level2 = page.getByRole('button', { name: /مرحله 2:/ })
|
||||
await expect(level2).toBeDisabled()
|
||||
|
||||
await page.goto('/gold-land/levels/7')
|
||||
await expect(page.getByText('هنوز قفل است')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { tomansForGoldPurchase, tomansForGoldSale } from '../src/utils/goldTrade'
|
||||
import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth'
|
||||
|
||||
test.describe('wallet rounding UI vs server', () => {
|
||||
test('micro trade and standard buy/sell match server policy', async ({ page }) => {
|
||||
const user = uniqueUsername('market')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
const me0 = await fetchMe(page)
|
||||
const cash0 = me0.wallet.cash
|
||||
const gold0 = me0.wallet.goldGrams
|
||||
|
||||
await page.goto('/market')
|
||||
await page.locator('button.trade-btn--buy').first().click()
|
||||
const buyModal = page.getByRole('dialog')
|
||||
await buyModal.locator('.trade-form__input').fill('0.000003')
|
||||
await expect(page.getByText('برآورد هزینه (خرید به بالا)')).toBeVisible()
|
||||
await buyModal.getByRole('button', { name: 'تأیید خرید' }).click()
|
||||
await expect(page.getByText('خرید با موفقیت انجام شد!').last()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(buyModal).toBeHidden({ timeout: 10_000 })
|
||||
|
||||
let me = await fetchMe(page)
|
||||
expect(me.wallet.goldGrams).toBeCloseTo(gold0 + 0.000_003, 6)
|
||||
expect(me.wallet.cash).toBe(cash0 - tomansForGoldPurchase(0.000_003))
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.locator('button.trade-btn--sell').first().click()
|
||||
const sellModal = page.getByRole('dialog')
|
||||
await sellModal.locator('.trade-form__input').fill('0.000001')
|
||||
await sellModal.getByRole('button', { name: 'تأیید فروش' }).click()
|
||||
await expect(page.getByText('فروش با موفقیت انجام شد!').last()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
await expect(sellModal).toBeHidden({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
me = await fetchMe(page)
|
||||
expect(me.wallet.goldGrams).toBeCloseTo(gold0, 6)
|
||||
expect(me.wallet.cash).toBeLessThanOrEqual(cash0)
|
||||
|
||||
await page.locator('button.trade-btn--buy').first().click()
|
||||
const buyModal2 = page.getByRole('dialog')
|
||||
await buyModal2.locator('.trade-form__input').fill('0.05')
|
||||
await expect(buyModal2.getByText('برآورد هزینه (خرید به بالا)')).toBeVisible()
|
||||
await buyModal2.getByRole('button', { name: 'تأیید خرید' }).click()
|
||||
await expect(page.getByText('خرید با موفقیت انجام شد!').last()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
me = await fetchMe(page)
|
||||
expect(me.wallet.goldGrams).toBeGreaterThan(gold0 + 0.04)
|
||||
})
|
||||
|
||||
test('invalid grams shows server error without changing balance', async ({ page }) => {
|
||||
const user = uniqueUsername('marketbad')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
const before = await fetchMe(page)
|
||||
const snap = JSON.stringify(before.wallet)
|
||||
|
||||
await page.goto('/market')
|
||||
await page.locator('button.trade-btn--buy').first().click()
|
||||
await page.locator('.trade-form__input').fill('0.0000004')
|
||||
await page.getByRole('button', { name: 'تأیید خرید' }).click()
|
||||
await expect(page.getByText(/نامعتبر|کمتر از یک تومان|دقت/)).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
const after = await fetchMe(page)
|
||||
expect(JSON.stringify(after.wallet)).toBe(snap)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
const frontendRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:5190'
|
||||
const backendPort = Number(process.env.E2E_BACKEND_PORT ?? 3010)
|
||||
const frontendPort = Number(process.env.E2E_FRONTEND_PORT ?? 5190)
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: '**/*.spec.ts',
|
||||
timeout: 120_000,
|
||||
fullyParallel: false,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]],
|
||||
globalTeardown: './global-teardown.ts',
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'off',
|
||||
locale: 'fa-IR',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'mobile',
|
||||
use: {
|
||||
...devices['Pixel 7'],
|
||||
...(process.env.PLAYWRIGHT_CHROME_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHROME_CHANNEL as 'chrome' } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'desktop',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1280, height: 800 },
|
||||
...(process.env.PLAYWRIGHT_CHROME_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHROME_CHANNEL as 'chrome' } : {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: [
|
||||
{
|
||||
command: 'node scripts/e2e-backend.mjs',
|
||||
cwd: frontendRoot,
|
||||
url: `http://127.0.0.1:${backendPort}/health`,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
{
|
||||
command: `npx vite --port ${frontendPort} --strictPort`,
|
||||
cwd: frontendRoot,
|
||||
port: frontendPort,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: false,
|
||||
env: {
|
||||
...process.env,
|
||||
VITE_DEV_PORT: String(frontendPort),
|
||||
VITE_DEV_API_TARGET: `http://127.0.0.1:${backendPort}`,
|
||||
VITE_E2E: '1',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { fetchMe, loginViaUi, logoutViaProfile, registerViaUi, uniqueUsername } from './helpers/auth'
|
||||
|
||||
test.describe('session and errors', () => {
|
||||
test('wrong password shows login failure', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.getByLabel('نام کاربری').fill('no_such_user_e2e')
|
||||
await page.getByLabel('رمز عبور').fill('wrongpass')
|
||||
await page.getByRole('button', { name: 'ورود', exact: true }).click()
|
||||
await expect(page.locator('.toast--error')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page).toHaveURL(/login/)
|
||||
})
|
||||
|
||||
test('second account does not show first account data', async ({ page }) => {
|
||||
const u1 = uniqueUsername('acct1')
|
||||
const u2 = uniqueUsername('acct2')
|
||||
await registerViaUi(page, u1, 'pass1234', 'کاربر یک')
|
||||
const me1 = await fetchMe(page)
|
||||
await page.goto('/profile')
|
||||
await expect(page.locator('.user-section__name')).toContainText(me1.user.name ?? 'کاربر یک')
|
||||
|
||||
await logoutViaProfile(page)
|
||||
await page.getByRole('button', { name: 'ثبتنام کن' }).click()
|
||||
await page.getByLabel('نام نمایشی').fill('کاربر دو')
|
||||
await page.getByLabel('نام کاربری').fill(u2)
|
||||
await page.getByLabel('رمز عبور').fill('pass1234')
|
||||
await page.getByRole('button', { name: 'ثبتنام' }).click()
|
||||
await page.waitForURL('/')
|
||||
const me2 = await fetchMe(page)
|
||||
await page.goto('/profile')
|
||||
await expect(page.locator('.user-section__name')).toContainText(me2.user.name)
|
||||
await expect(page.locator('.user-section__name')).not.toContainText('کاربر یک')
|
||||
})
|
||||
|
||||
test('401 clears session to login', async ({ page }) => {
|
||||
const user = uniqueUsername('401')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
await page.goto('/wallet')
|
||||
await page.evaluate(() => localStorage.setItem('gold-land-auth-token', 'invalid-token'))
|
||||
await page.goto('/market')
|
||||
await page.waitForURL('/login', { timeout: 15_000 })
|
||||
})
|
||||
|
||||
test('catalog load retry after network failure', async ({ page }) => {
|
||||
const user = uniqueUsername('net')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
|
||||
let storeCalls = 0
|
||||
await page.route('**/api/store/products', async (route) => {
|
||||
storeCalls += 1
|
||||
if (storeCalls === 1) {
|
||||
await route.abort('failed')
|
||||
return
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
await page.goto('/store')
|
||||
await expect(page.locator('.store-status--error')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText(/اتصال به سرور|خطا در دریافت/)).toBeVisible()
|
||||
await page.getByRole('button', { name: 'تلاش مجدد' }).click()
|
||||
await page.getByRole('button', { name: 'تقویت XP' }).waitFor({ timeout: 20_000 })
|
||||
expect(storeCalls).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test('bootstrap network error keeps token and retry loads server data', async ({ page }) => {
|
||||
const user = uniqueUsername('boot')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
const meOk = await fetchMe(page)
|
||||
|
||||
let meCalls = 0
|
||||
await page.route('**/api/me', async (route) => {
|
||||
meCalls += 1
|
||||
if (meCalls === 1) {
|
||||
await route.abort('failed')
|
||||
return
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
await page.reload()
|
||||
await expect(page.getByRole('alert')).toContainText('اتصال به سرور', { timeout: 15_000 })
|
||||
expect(await page.evaluate(() => localStorage.getItem('gold-land-auth-token'))).toBeTruthy()
|
||||
|
||||
await page.getByRole('button', { name: 'تلاش مجدد' }).click()
|
||||
await expect(page.getByRole('alert')).toBeHidden({ timeout: 20_000 })
|
||||
|
||||
const meAfter = await fetchMe(page)
|
||||
expect(meAfter.user.xp).toBe(meOk.user.xp)
|
||||
expect(meAfter.user.coins).toBe(meOk.user.coins)
|
||||
expect(meCalls).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { registerViaUi, uniqueUsername } from './helpers/auth'
|
||||
|
||||
test.describe('UI quality', () => {
|
||||
test('no unexpected console errors on home and RTL layout', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => errors.push(err.message))
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text())
|
||||
})
|
||||
|
||||
const user = uniqueUsername('ui')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
const bodyDir = await page.evaluate(() => getComputedStyle(document.body).direction)
|
||||
expect(bodyDir).toBe('rtl')
|
||||
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 2)
|
||||
expect(overflow).toBe(false)
|
||||
|
||||
const filtered = errors.filter(
|
||||
(e) => !e.includes('favicon') && !e.includes('DevTools') && !e.includes('404'),
|
||||
)
|
||||
expect(filtered).toEqual([])
|
||||
})
|
||||
|
||||
test('keyboard focus reaches login username field', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.getByLabel('نام کاربری').focus()
|
||||
await expect(page.getByLabel('نام کاربری')).toBeFocused()
|
||||
})
|
||||
})
|
||||
+12
-1
@@ -11,7 +11,8 @@ server {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# Single hop: client IP as seen by nginx (do not append client-supplied XFF).
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
@@ -19,6 +20,16 @@ server {
|
||||
proxy_pass http://backend:3000/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
}
|
||||
|
||||
location /health/ready {
|
||||
proxy_pass http://backend:3000/health/ready;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
Generated
+550
@@ -17,11 +17,13 @@
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"npm-run-all2": "^9.0.2",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.1.5",
|
||||
"vite-plugin-vue-devtools": "^8.1.5",
|
||||
@@ -520,6 +522,448 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/vazirmatn": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/vazirmatn/-/vazirmatn-5.3.0.tgz",
|
||||
@@ -613,6 +1057,22 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz",
|
||||
"integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.64.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -1518,6 +1978,48 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.2",
|
||||
"@esbuild/android-arm": "0.28.2",
|
||||
"@esbuild/android-arm64": "0.28.2",
|
||||
"@esbuild/android-x64": "0.28.2",
|
||||
"@esbuild/darwin-arm64": "0.28.2",
|
||||
"@esbuild/darwin-x64": "0.28.2",
|
||||
"@esbuild/freebsd-arm64": "0.28.2",
|
||||
"@esbuild/freebsd-x64": "0.28.2",
|
||||
"@esbuild/linux-arm": "0.28.2",
|
||||
"@esbuild/linux-arm64": "0.28.2",
|
||||
"@esbuild/linux-ia32": "0.28.2",
|
||||
"@esbuild/linux-loong64": "0.28.2",
|
||||
"@esbuild/linux-mips64el": "0.28.2",
|
||||
"@esbuild/linux-ppc64": "0.28.2",
|
||||
"@esbuild/linux-riscv64": "0.28.2",
|
||||
"@esbuild/linux-s390x": "0.28.2",
|
||||
"@esbuild/linux-x64": "0.28.2",
|
||||
"@esbuild/netbsd-arm64": "0.28.2",
|
||||
"@esbuild/netbsd-x64": "0.28.2",
|
||||
"@esbuild/openbsd-arm64": "0.28.2",
|
||||
"@esbuild/openbsd-x64": "0.28.2",
|
||||
"@esbuild/openharmony-arm64": "0.28.2",
|
||||
"@esbuild/sunos-x64": "0.28.2",
|
||||
"@esbuild/win32-arm64": "0.28.2",
|
||||
"@esbuild/win32-ia32": "0.28.2",
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -2239,6 +2741,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
|
||||
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.64.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
|
||||
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.21",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz",
|
||||
@@ -2473,6 +3004,25 @@
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.15",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
|
||||
"integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
|
||||
+13
-2
@@ -8,7 +8,16 @@
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build"
|
||||
"type-check": "vue-tsc --build",
|
||||
"test": "node test/run-all-tests.mjs",
|
||||
"test:session": "node test/run-session-tests.mjs",
|
||||
"test:catalog": "node --import tsx --test --test-reporter=spec test/catalog-api.test.ts",
|
||||
"test:e2e": "npm run e2e:prepare && npm run test:e2e:ci",
|
||||
"test:e2e:ci": "playwright test -c e2e/playwright.config.ts",
|
||||
"test:e2e:install": "playwright install chromium",
|
||||
"e2e:prepare": "node scripts/e2e-prepare.mjs",
|
||||
"e2e:cleanup": "node scripts/e2e-cleanup.mjs",
|
||||
"ci:checkout-backend": "node scripts/ci-checkout-backend.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/vazirmatn": "^5.2.5",
|
||||
@@ -25,10 +34,12 @@
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"npm-run-all2": "^9.0.2",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.1.5",
|
||||
"vite-plugin-vue-devtools": "^8.1.5",
|
||||
"vue-tsc": "^3.3.7"
|
||||
"vue-tsc": "^3.3.7",
|
||||
"@playwright/test": "^1.55.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.12.0"
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* CI: clone backend repo at pinned ref (see e2e/backend.contract.json).
|
||||
* In CI (CI=true): E2E_BACKEND_REF is required — no fallback to main.
|
||||
*/
|
||||
import { readFileSync, existsSync, rmSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
||||
const frontendRoot = join(scriptDir, '..')
|
||||
const contractPath = join(frontendRoot, 'e2e', 'backend.contract.json')
|
||||
|
||||
const isCi = process.env.CI === 'true' || process.env.GITEA_ACTIONS === 'true'
|
||||
|
||||
if (!existsSync(contractPath)) {
|
||||
console.error('Missing e2e/backend.contract.json')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
/** @type {{ repositoryUrl: string; checkoutPath: string; localRef?: string }} */
|
||||
const contract = JSON.parse(readFileSync(contractPath, 'utf8'))
|
||||
|
||||
const envRef = process.env.E2E_BACKEND_REF?.trim()
|
||||
let ref = envRef
|
||||
|
||||
if (isCi) {
|
||||
if (!ref) {
|
||||
console.error(
|
||||
'E2E backend ref is required in CI. Set repository variable E2E_BACKEND_REF to a full commit SHA (or immutable tag). Silent fallback to main is disabled.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
} else if (!ref) {
|
||||
ref = contract.localRef?.trim()
|
||||
if (!ref) {
|
||||
console.error(
|
||||
'Set E2E_BACKEND_REF to a backend commit SHA/tag, or localRef in e2e/backend.contract.json for clone-based local runs.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const repoUrl = process.env.E2E_BACKEND_REPOSITORY_URL?.trim() || contract.repositoryUrl
|
||||
const checkoutPath = resolve(
|
||||
frontendRoot,
|
||||
process.env.E2E_BACKEND_ROOT?.trim() || contract.checkoutPath,
|
||||
)
|
||||
|
||||
const token = process.env.E2E_BACKEND_CHECKOUT_TOKEN?.trim()
|
||||
let cloneUrl = repoUrl
|
||||
if (token) {
|
||||
const u = new URL(repoUrl.replace(/^git\+/, ''))
|
||||
u.username = token
|
||||
u.password = 'x-oauth-basic'
|
||||
cloneUrl = u.toString()
|
||||
}
|
||||
|
||||
if (existsSync(checkoutPath)) {
|
||||
rmSync(checkoutPath, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const git = 'git'
|
||||
const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
||||
const gitOpts = { stdio: 'inherit', env: gitEnv, shell: process.platform === 'win32' }
|
||||
|
||||
const isFullSha = /^[0-9a-f]{40}$/i.test(ref)
|
||||
|
||||
console.log(`Cloning backend ref=${ref} into ${checkoutPath}`)
|
||||
|
||||
let clone
|
||||
if (isFullSha) {
|
||||
clone = spawnSync(git, ['clone', '--depth', '1', '--no-checkout', cloneUrl, checkoutPath], gitOpts)
|
||||
if (clone.status !== 0) {
|
||||
console.error('Backend clone failed (init).')
|
||||
process.exit(clone.status ?? 1)
|
||||
}
|
||||
const fetch = spawnSync(
|
||||
git,
|
||||
['fetch', '--depth', '1', 'origin', ref],
|
||||
{ ...gitOpts, cwd: checkoutPath },
|
||||
)
|
||||
if (fetch.status !== 0) {
|
||||
console.error(`Backend fetch failed for SHA ${ref}.`)
|
||||
process.exit(fetch.status ?? 1)
|
||||
}
|
||||
const co = spawnSync(git, ['checkout', 'FETCH_HEAD'], { ...gitOpts, cwd: checkoutPath })
|
||||
if (co.status !== 0) {
|
||||
console.error('Backend checkout failed.')
|
||||
process.exit(co.status ?? 1)
|
||||
}
|
||||
} else {
|
||||
clone = spawnSync(
|
||||
git,
|
||||
['clone', '--depth', '1', '--branch', ref, cloneUrl, checkoutPath],
|
||||
gitOpts,
|
||||
)
|
||||
if (clone.status !== 0) {
|
||||
console.error(
|
||||
'Backend clone failed. For private repos set secret E2E_BACKEND_CHECKOUT_TOKEN (repo read).',
|
||||
)
|
||||
process.exit(clone.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
const head = spawnSync(git, ['rev-parse', 'HEAD'], {
|
||||
cwd: checkoutPath,
|
||||
encoding: 'utf8',
|
||||
shell: process.platform === 'win32',
|
||||
})
|
||||
const checkedOutSha = head.stdout?.trim()
|
||||
if (checkedOutSha) {
|
||||
console.log(`Backend checkout commit: ${checkedOutSha}`)
|
||||
if (isFullSha && checkedOutSha.toLowerCase() !== ref.toLowerCase()) {
|
||||
console.error(
|
||||
`E2E_BACKEND_REF (${ref}) does not match cloned HEAD (${checkedOutSha}).`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
console.error('Could not read cloned backend commit (git rev-parse HEAD failed).')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.env.E2E_BACKEND_ROOT = checkoutPath
|
||||
@@ -0,0 +1,60 @@
|
||||
# Ephemeral trial stack smoke test — does not use production volumes or .env.docker.
|
||||
param(
|
||||
[int]$Port = 18080
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$frontendRoot = Split-Path -Parent $PSScriptRoot
|
||||
$project = "cgltrial$(Get-Random)"
|
||||
$secret = ('trial-{0}' -f ('x' * 40))
|
||||
$cors = "http://127.0.0.1:$Port"
|
||||
$envFile = Join-Path $env:TEMP "cgl-trial-$project.env"
|
||||
@"
|
||||
JWT_SECRET=$secret
|
||||
CORS_ORIGIN=$cors
|
||||
VITE_API_URL=/api
|
||||
FRONTEND_PORT=$Port
|
||||
"@ | Set-Content -Path $envFile -Encoding utf8
|
||||
|
||||
Push-Location $frontendRoot
|
||||
try {
|
||||
docker compose -p $project --env-file $envFile config | Out-Null
|
||||
docker compose -p $project --env-file $envFile build
|
||||
docker compose -p $project --env-file $envFile up -d
|
||||
|
||||
$deadline = (Get-Date).AddMinutes(3)
|
||||
do {
|
||||
Start-Sleep -Seconds 3
|
||||
try {
|
||||
$h = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/health/ready" -UseBasicParsing
|
||||
if ($h.StatusCode -eq 200) { break }
|
||||
} catch { }
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
|
||||
$ready = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/health/ready" -UseBasicParsing
|
||||
if ($ready.StatusCode -ne 200) { throw 'readiness failed' }
|
||||
|
||||
$user = "trial_$([guid]::NewGuid().ToString('N').Substring(0,8))"
|
||||
$body = @{ username = $user; password = 'pass1234'; name = 'Trial' } | ConvertTo-Json
|
||||
$reg = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/api/auth/register" -Method POST -Body $body -ContentType 'application/json; charset=utf-8' -UseBasicParsing
|
||||
$token = ($reg.Content | ConvertFrom-Json).token
|
||||
$me = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/api/me" -Headers @{ Authorization = "Bearer $token" } -UseBasicParsing
|
||||
if ($me.StatusCode -ne 200) { throw 'GET /api/me failed' }
|
||||
|
||||
$spa = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/map" -UseBasicParsing
|
||||
if ($spa.Content -notmatch 'html') { throw 'SPA route failed' }
|
||||
|
||||
$backendPorts = docker compose -p $project ps --format json | ConvertFrom-Json
|
||||
foreach ($svc in $backendPorts) {
|
||||
if ($svc.Service -eq 'backend' -and $svc.Publishers) {
|
||||
throw 'backend must not publish host ports'
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host 'docker-trial-smoke: OK'
|
||||
}
|
||||
finally {
|
||||
docker compose -p $project --env-file $envFile down -v --remove-orphans 2>$null
|
||||
Remove-Item $envFile -Force -ErrorAction SilentlyContinue
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* E2E-only backend: temp SQLite, configurable port/root. Used by Playwright webServer.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
||||
const frontendRoot = join(scriptDir, '..')
|
||||
const defaultBackend = join(frontendRoot, '..', 'backend-nest')
|
||||
const backendRoot = resolve(process.env.E2E_BACKEND_ROOT ?? defaultBackend)
|
||||
const backendPort = String(process.env.E2E_BACKEND_PORT ?? '3010')
|
||||
const frontendPort = String(process.env.E2E_FRONTEND_PORT ?? '5190')
|
||||
const corsOrigin = process.env.E2E_CORS_ORIGIN ?? `http://127.0.0.1:${frontendPort}`
|
||||
|
||||
const distMain = join(backendRoot, 'dist', 'main.js')
|
||||
if (!existsSync(distMain)) {
|
||||
console.error(`Missing ${distMain}. Run: npm run e2e:prepare (or npm run build in backend).`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const runtimeDir = join(frontendRoot, 'e2e', '.runtime')
|
||||
mkdirSync(runtimeDir, { recursive: true })
|
||||
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'cgl-e2e-'))
|
||||
const dbPath = join(tempDir, 'app.db')
|
||||
writeFileSync(
|
||||
join(runtimeDir, 'state.json'),
|
||||
JSON.stringify({ tempDir, dbPath, backendPort: Number(backendPort) }),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const backend = spawn(process.execPath, ['dist/main.js'], {
|
||||
cwd: backendRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: backendPort,
|
||||
DB_PATH: dbPath,
|
||||
CORS_ORIGIN: corsOrigin,
|
||||
JWT_SECRET: process.env.E2E_JWT_SECRET ?? 'e2e-ci-jwt-not-for-production',
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
function cleanupTemp() {
|
||||
try {
|
||||
backend.kill()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function shutdown(code = 0) {
|
||||
cleanupTemp()
|
||||
process.exit(code)
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown(0))
|
||||
process.on('SIGTERM', () => shutdown(0))
|
||||
backend.on('exit', (code, signal) => {
|
||||
if (signal) shutdown(0)
|
||||
else process.exit(code ?? 1)
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync, rmSync, existsSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const frontendRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const runtimeDir = join(frontendRoot, 'e2e', '.runtime')
|
||||
const statePath = join(runtimeDir, 'state.json')
|
||||
|
||||
if (existsSync(statePath)) {
|
||||
try {
|
||||
const { tempDir } = JSON.parse(readFileSync(statePath, 'utf8'))
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (existsSync(runtimeDir)) {
|
||||
rmSync(runtimeDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Build backend for E2E. Path from E2E_BACKEND_ROOT or sibling ../backend-nest.
|
||||
*/
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
||||
const frontendRoot = join(scriptDir, '..')
|
||||
const defaultBackend = join(frontendRoot, '..', 'backend-nest')
|
||||
const backendRoot = resolve(process.env.E2E_BACKEND_ROOT ?? defaultBackend)
|
||||
|
||||
if (!existsSync(join(backendRoot, 'package.json'))) {
|
||||
console.error(
|
||||
`E2E backend not found at ${backendRoot}. Set E2E_BACKEND_ROOT or clone per e2e/backend.contract.json.`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const result = spawnSync('npm', ['run', 'build'], {
|
||||
cwd: backendRoot,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
shell: process.platform === 'win32',
|
||||
})
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
+10
@@ -1,9 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import ToastContainer from '@/components/common/ToastContainer.vue'
|
||||
import BootstrapRecoveryBanner from '@/components/common/BootstrapRecoveryBanner.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { bootstrapFailedRef } from '@/api/bootstrapState'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const showBootstrapRecovery = computed(
|
||||
() => authStore.isAuthenticated && bootstrapFailedRef.value,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<BootstrapRecoveryBanner v-if="showBootstrapRecovery" />
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition name="page" mode="out-in">
|
||||
<component :is="Component" :key="route.path" />
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { setAuthToken } from '@/api/token'
|
||||
import { resetAppBootstrap } from '@/api/bootstrapState'
|
||||
|
||||
/** Bumped on logout and before login/register to drop in-flight responses from a prior account. */
|
||||
let sessionGeneration = 0
|
||||
|
||||
export function captureSessionGeneration(): number {
|
||||
return sessionGeneration
|
||||
}
|
||||
|
||||
export function invalidateSession(): void {
|
||||
sessionGeneration += 1
|
||||
}
|
||||
|
||||
export function isSessionCurrent(generation: number): boolean {
|
||||
return generation === sessionGeneration
|
||||
}
|
||||
|
||||
type UnauthorizedHandler = () => void
|
||||
let unauthorizedHandler: UnauthorizedHandler | null = null
|
||||
|
||||
export function setUnauthorizedHandler(handler: UnauthorizedHandler): void {
|
||||
unauthorizedHandler = handler
|
||||
}
|
||||
|
||||
/** Protected-route 401: clear session and redirect (not used for wrong login password). */
|
||||
export function handleUnauthorized(): void {
|
||||
invalidateSession()
|
||||
setAuthToken(null)
|
||||
resetAppBootstrap()
|
||||
unauthorizedHandler?.()
|
||||
}
|
||||
|
||||
/** Test-only reset of session generation and handler slot. */
|
||||
export function resetSessionTrackingForTests(): void {
|
||||
sessionGeneration = 0
|
||||
unauthorizedHandler = null
|
||||
}
|
||||
@@ -1,13 +1,28 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
let bootstrapped = false
|
||||
const bootstrapFailed = ref(false)
|
||||
|
||||
export function isAppBootstrapped() {
|
||||
return bootstrapped
|
||||
}
|
||||
|
||||
export function isBootstrapFailed() {
|
||||
return bootstrapFailed.value
|
||||
}
|
||||
|
||||
export const bootstrapFailedRef = bootstrapFailed
|
||||
|
||||
export function setBootstrapFailed(failed: boolean) {
|
||||
bootstrapFailed.value = failed
|
||||
}
|
||||
|
||||
export function markAppBootstrapped() {
|
||||
bootstrapped = true
|
||||
bootstrapFailed.value = false
|
||||
}
|
||||
|
||||
export function resetAppBootstrap() {
|
||||
bootstrapped = false
|
||||
bootstrapFailed.value = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { StaleSessionError } from '@/api/errors'
|
||||
|
||||
export type CatalogFetchKey = 'store-products' | 'games-catalog' | 'daily-challenge'
|
||||
|
||||
type Slot = {
|
||||
epoch: number
|
||||
requestSeq: number
|
||||
lastCommittedSeq: number
|
||||
}
|
||||
|
||||
const slots: Record<CatalogFetchKey, Slot> = {
|
||||
'store-products': { epoch: 0, requestSeq: 0, lastCommittedSeq: 0 },
|
||||
'games-catalog': { epoch: 0, requestSeq: 0, lastCommittedSeq: 0 },
|
||||
'daily-challenge': { epoch: 0, requestSeq: 0, lastCommittedSeq: 0 },
|
||||
}
|
||||
|
||||
export function resetCatalogFetchSync(): void {
|
||||
for (const key of Object.keys(slots) as CatalogFetchKey[]) {
|
||||
slots[key].epoch += 1
|
||||
slots[key].requestSeq = 0
|
||||
slots[key].lastCommittedSeq = 0
|
||||
}
|
||||
}
|
||||
|
||||
export function resetCatalogFetchSyncForTests(): void {
|
||||
resetCatalogFetchSync()
|
||||
}
|
||||
|
||||
/** Drop stale or out-of-order catalog GET responses (same session or after logout). */
|
||||
export async function fetchCatalogOrdered<T>(
|
||||
key: CatalogFetchKey,
|
||||
fetcher: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const slot = slots[key]
|
||||
const epochAtStart = slot.epoch
|
||||
const seq = ++slot.requestSeq
|
||||
const data = await fetcher()
|
||||
if (epochAtStart !== slot.epoch) {
|
||||
throw new StaleSessionError()
|
||||
}
|
||||
if (seq < slot.lastCommittedSeq) {
|
||||
throw new StaleSessionError()
|
||||
}
|
||||
slot.lastCommittedSeq = seq
|
||||
return data
|
||||
}
|
||||
+46
-31
@@ -1,25 +1,18 @@
|
||||
const TOKEN_KEY = 'gold-land-auth-token'
|
||||
import {
|
||||
captureSessionGeneration,
|
||||
handleUnauthorized,
|
||||
isSessionCurrent,
|
||||
} from '@/api/authSession'
|
||||
import {
|
||||
ApiClientError,
|
||||
ApiNetworkError,
|
||||
ApiUnauthorizedError,
|
||||
StaleSessionError,
|
||||
} from '@/api/errors'
|
||||
|
||||
let authToken: string | null = null
|
||||
import { getAuthToken } from '@/api/token'
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
if (authToken) return authToken
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string | null) {
|
||||
authToken = token
|
||||
try {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
export { getAuthToken, setAuthToken } from '@/api/token'
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string
|
||||
@@ -107,9 +100,14 @@ export interface DailyChallengeCompleteResponse extends MeResponse {
|
||||
alreadyCompleted: boolean
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api'
|
||||
const API_BASE = import.meta.env?.VITE_API_URL ?? '/api'
|
||||
|
||||
function isPublicAuthPath(path: string): boolean {
|
||||
return path === '/auth/login' || path === '/auth/register'
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const sessionGeneration = captureSessionGeneration()
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers as Record<string, string> | undefined),
|
||||
@@ -125,12 +123,28 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
headers,
|
||||
})
|
||||
} catch {
|
||||
throw new Error('اتصال به سرور برقرار نشد. لطفاً backend را اجرا کنید.')
|
||||
throw new ApiNetworkError()
|
||||
}
|
||||
|
||||
if (!isSessionCurrent(sessionGeneration)) {
|
||||
throw new StaleSessionError()
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({ error: 'خطای سرور' }))
|
||||
throw new Error(body.error ?? 'خطای سرور')
|
||||
const message = typeof body.error === 'string' ? body.error : 'خطای سرور'
|
||||
|
||||
if (response.status === 401) {
|
||||
if (isPublicAuthPath(path)) {
|
||||
throw new ApiUnauthorizedError(message)
|
||||
}
|
||||
if (isSessionCurrent(sessionGeneration)) {
|
||||
handleUnauthorized()
|
||||
}
|
||||
throw new ApiUnauthorizedError(message)
|
||||
}
|
||||
|
||||
throw new ApiClientError(message, response.status)
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
@@ -170,16 +184,15 @@ export const api = {
|
||||
}),
|
||||
completeGame: (gameId: string) =>
|
||||
request<MeResponse>(`/games/${gameId}/complete`, { method: 'POST', body: '{}' }),
|
||||
getStoreProducts: () =>
|
||||
request<import('@/types/catalog.types').StoreProduct[]>('/store/products'),
|
||||
getGames: () => request<import('@/types/catalog.types').GameCatalogItem[]>('/games'),
|
||||
purchaseProduct: (productId: string) =>
|
||||
request<MeResponse>('/store/purchases', { method: 'POST', body: JSON.stringify({ productId }) }),
|
||||
completeLesson: (lessonId: string) =>
|
||||
request<MeResponse>(`/lessons/${lessonId}/complete`, { method: 'POST', body: '{}' }),
|
||||
completeMapLevel: (levelId: number, stars: number) =>
|
||||
request<MeResponse>(`/map/levels/${levelId}/complete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ stars }),
|
||||
}),
|
||||
getGoldLandProgress: () => request<import('@/modules/gold-land/types/goldLand.types').GoldLandProgressResponse>('/gold-land/progress'),
|
||||
getGoldLandProgress: () =>
|
||||
request<import('@/modules/gold-land/types/goldLand.types').GoldLandProgressResponse>(
|
||||
'/gold-land/progress',
|
||||
),
|
||||
getGoldLandLevel: (levelId: number) =>
|
||||
request<import('@/modules/gold-land/types/goldLand.types').LevelDetailResponse>(
|
||||
`/gold-land/levels/${levelId}`,
|
||||
@@ -202,3 +215,5 @@ export const api = {
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
export { ApiNetworkError, ApiUnauthorizedError, StaleSessionError, ApiClientError } from '@/api/errors'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export class ApiNetworkError extends Error {
|
||||
readonly name = 'ApiNetworkError'
|
||||
constructor(message = 'اتصال به سرور برقرار نشد. لطفاً backend را اجرا کنید.') {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiUnauthorizedError extends Error {
|
||||
readonly name = 'ApiUnauthorizedError'
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Response arrived after logout or account switch — ignore, do not mutate Pinia. */
|
||||
export class StaleSessionError extends Error {
|
||||
readonly name = 'StaleSessionError'
|
||||
constructor() {
|
||||
super('Stale session')
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
readonly name = 'ApiClientError'
|
||||
readonly status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { MeResponse } from '@/api/client'
|
||||
import { captureSessionGeneration, isSessionCurrent } from '@/api/authSession'
|
||||
import { StaleSessionError } from '@/api/errors'
|
||||
|
||||
type ApplyMe = (data: MeResponse) => boolean
|
||||
|
||||
let queueEpoch = 0
|
||||
let queueTail: Promise<void> = Promise.resolve()
|
||||
let applyMe: ApplyMe = () => false
|
||||
|
||||
export function bindMeMutationApply(fn: ApplyMe): void {
|
||||
applyMe = fn
|
||||
}
|
||||
|
||||
export function resetMeMutationQueue(): void {
|
||||
queueEpoch += 1
|
||||
queueTail = Promise.resolve()
|
||||
}
|
||||
|
||||
export function resetMeMutationQueueForTests(): void {
|
||||
resetMeMutationQueue()
|
||||
}
|
||||
|
||||
function assertMutationSession(epochAtEnqueue: number, sessionGen: number): void {
|
||||
if (epochAtEnqueue !== queueEpoch) {
|
||||
throw new StaleSessionError()
|
||||
}
|
||||
if (!isSessionCurrent(sessionGen)) {
|
||||
throw new StaleSessionError()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes state-changing API calls in this tab so a slower response cannot apply before
|
||||
* a later mutation finishes. Does not replace server revision — see docs in sync.ts.
|
||||
*/
|
||||
export async function runMeMutation<R>(
|
||||
fetcher: () => Promise<R>,
|
||||
pickMe: (result: R) => MeResponse = (result) => result as unknown as MeResponse,
|
||||
): Promise<R> {
|
||||
const sessionGen = captureSessionGeneration()
|
||||
const epochAtEnqueue = queueEpoch
|
||||
|
||||
const execute = async (): Promise<R> => {
|
||||
assertMutationSession(epochAtEnqueue, sessionGen)
|
||||
const result = await fetcher()
|
||||
assertMutationSession(epochAtEnqueue, sessionGen)
|
||||
applyMe(pickMe(result))
|
||||
return result
|
||||
}
|
||||
|
||||
const resultPromise = queueTail.then(execute)
|
||||
queueTail = resultPromise.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
)
|
||||
return resultPromise
|
||||
}
|
||||
+69
-2
@@ -1,10 +1,54 @@
|
||||
import { api, type MeResponse } from '@/api/client'
|
||||
import { captureSessionGeneration, isSessionCurrent } from '@/api/authSession'
|
||||
import { StaleSessionError } from '@/api/errors'
|
||||
import { resetCatalogFetchSync } from '@/api/catalogFetchSync'
|
||||
import {
|
||||
bindMeMutationApply,
|
||||
resetMeMutationQueue,
|
||||
runMeMutation,
|
||||
} from '@/api/meMutationQueue'
|
||||
import { resetGoldLandProgressSync } from '@/modules/gold-land/services/goldLandProgressSync'
|
||||
import { useDailyChallengeStore } from '@/stores/dailyChallenge'
|
||||
import { useGamesStore } from '@/stores/games'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { useStoreCatalogStore } from '@/stores/storeCatalog'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useWalletStore } from '@/stores/wallet'
|
||||
|
||||
export function applyMeResponse(data: MeResponse) {
|
||||
/** Incremented on every successful `applyMeResponse`; stale reads capture revision at start and skip if superseded. */
|
||||
let userStateRevision = 0
|
||||
|
||||
export function resetMeMutationSync(): void {
|
||||
resetMeMutationQueue()
|
||||
}
|
||||
|
||||
export function resetMeMutationSyncForTests(): void {
|
||||
resetMeMutationQueue()
|
||||
}
|
||||
|
||||
export { runMeMutation }
|
||||
|
||||
export function captureUserStateRevision(): number {
|
||||
return userStateRevision
|
||||
}
|
||||
|
||||
export function resetUserStateRevisionForTests(): void {
|
||||
userStateRevision = 0
|
||||
}
|
||||
|
||||
export type ApplyMeOptions = {
|
||||
/** When set, skip apply if a newer mutation/bootstrap already updated stores (same session). */
|
||||
ifNotSuperseded?: number
|
||||
}
|
||||
|
||||
/** Single writer for server-backed user state (from `/api/me` or endpoints that embed `me`). */
|
||||
export function applyMeResponse(data: MeResponse, options?: ApplyMeOptions): boolean {
|
||||
if (
|
||||
options?.ifNotSuperseded !== undefined &&
|
||||
userStateRevision > options.ifNotSuperseded
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const userStore = useUserStore()
|
||||
const walletStore = useWalletStore()
|
||||
const gamesStore = useGamesStore()
|
||||
@@ -41,17 +85,40 @@ export function applyMeResponse(data: MeResponse) {
|
||||
marketStore.$patch({
|
||||
selectedRange: data.market.selectedRange,
|
||||
})
|
||||
|
||||
userStateRevision += 1
|
||||
return true
|
||||
}
|
||||
|
||||
bindMeMutationApply(applyMeResponse)
|
||||
|
||||
export function resetUserStores() {
|
||||
useUserStore().$reset()
|
||||
useWalletStore().$reset()
|
||||
useGamesStore().$reset()
|
||||
useMarketStore().$reset()
|
||||
useStoreCatalogStore().$reset()
|
||||
useDailyChallengeStore().$reset()
|
||||
userStateRevision = 0
|
||||
resetGoldLandProgressSync()
|
||||
resetCatalogFetchSync()
|
||||
resetMeMutationSync()
|
||||
}
|
||||
|
||||
export async function bootstrapFromServer() {
|
||||
const revisionAtStart = captureUserStateRevision()
|
||||
const data = await api.getMe()
|
||||
applyMeResponse(data)
|
||||
applyMeResponse(data, { ifNotSuperseded: revisionAtStart })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function syncMarketQuote(range: string) {
|
||||
const sessionGeneration = captureSessionGeneration()
|
||||
const marketStore = useMarketStore()
|
||||
const quote = await api.getMarketGold(range)
|
||||
if (!isSessionCurrent(sessionGeneration)) {
|
||||
throw new StaleSessionError()
|
||||
}
|
||||
marketStore.$patch({
|
||||
currentPrice: quote.currentPrice,
|
||||
dailyChange: quote.dailyChangePercent,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const TOKEN_KEY = 'gold-land-auth-token'
|
||||
|
||||
let authToken: string | null = null
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
if (authToken) return authToken
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string | null) {
|
||||
authToken = token
|
||||
try {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import PrimaryButton from '@/components/common/PrimaryButton.vue'
|
||||
import {
|
||||
bootstrapFailedRef,
|
||||
markAppBootstrapped,
|
||||
setBootstrapFailed,
|
||||
} from '@/api/bootstrapState'
|
||||
import { bootstrapFromServer, syncMarketQuote } from '@/api/sync'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { ApiNetworkError } from '@/api/errors'
|
||||
|
||||
const retrying = ref(false)
|
||||
|
||||
async function retryBootstrap() {
|
||||
if (retrying.value || !bootstrapFailedRef.value) return
|
||||
retrying.value = true
|
||||
try {
|
||||
await bootstrapFromServer()
|
||||
await syncMarketQuote(useMarketStore().selectedRange)
|
||||
markAppBootstrapped()
|
||||
setBootstrapFailed(false)
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiNetworkError)) {
|
||||
setBootstrapFailed(false)
|
||||
}
|
||||
} finally {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="bootstrapFailedRef" class="bootstrap-recovery" role="alert">
|
||||
<p class="bootstrap-recovery__text">اتصال به سرور برقرار نشد. دادههای حساب ممکن است بهروز نباشند.</p>
|
||||
<PrimaryButton variant="gold" size="sm" :disabled="retrying" @click="retryBootstrap">
|
||||
{{ retrying ? 'در حال تلاش…' : 'تلاش مجدد' }}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bootstrap-recovery {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fff3cd;
|
||||
color: #664d03;
|
||||
border-bottom: 1px solid #ffecb5;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.bootstrap-recovery__text {
|
||||
margin: 0;
|
||||
flex: 1 1 12rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -1,130 +0,0 @@
|
||||
export interface GameItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
route?: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface StoreProduct {
|
||||
id: string
|
||||
title: string
|
||||
price: number
|
||||
category: 'all' | 'special' | 'visual' | 'boost'
|
||||
emoji: string
|
||||
}
|
||||
|
||||
export interface MapLevel {
|
||||
id: number
|
||||
title: string
|
||||
stars: number
|
||||
maxStars: number
|
||||
status: 'completed' | 'current' | 'unlocked' | 'locked'
|
||||
lessonRoute?: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Achievement {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
unlocked: boolean
|
||||
}
|
||||
|
||||
export interface ChallengeOption {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
color: string
|
||||
isCorrect: boolean
|
||||
feedback: string
|
||||
}
|
||||
|
||||
export const INITIAL_TRANSACTIONS = [
|
||||
{ id: '1', type: 'expense' as const, title: 'خرید طلا', amount: 1600000, unit: 'تومان' as const, date: '۱۴۰۴/۰۴/۱۲' },
|
||||
{ id: '2', type: 'income' as const, title: 'ماموریت روزانه', amount: 50, unit: 'سکه' as const, date: '۱۴۰۴/۰۴/۱۱' },
|
||||
{ id: '3', type: 'income' as const, title: 'پاداش بازی', amount: 20, unit: 'سکه' as const, date: '۱۴۰۴/۰۴/۱۰' },
|
||||
]
|
||||
|
||||
export const GAMES: GameItem[] = [
|
||||
{ id: 'gold-guess', title: 'حدس قیمت طلا', description: 'قیمت فردا رو حدس بزن', icon: '📈', route: '/games/gold-guess', color: '#7438c9' },
|
||||
{ id: 'inflation', title: 'تورم رو شکست بده', description: 'ارزش پولت رو حفظ کن', icon: '👾', route: '/lessons/inflation', color: '#e73343' },
|
||||
{ id: 'portfolio', title: 'ساخت سبد سرمایهگذاری', description: 'بهترین ترکیب رو انتخاب کن', icon: '📊', color: '#1987d4' },
|
||||
{ id: 'smart-shop', title: 'خرید هوشمند', description: 'بهترین خرید رو انجام بده', icon: '🛒', color: '#20b94b' },
|
||||
{ id: 'savings', title: 'چالش پسانداز', description: 'پسانداز کن و پیشرفت کن', icon: '🐷', color: '#f5b62d' },
|
||||
{ id: 'investor', title: 'شاگرد سرمایهگذار', description: 'تصمیم بگیر و سود ببر', icon: '🎓', color: '#f07a2a' },
|
||||
]
|
||||
|
||||
export const STORE_PRODUCTS: StoreProduct[] = [
|
||||
{ id: 'castle', title: 'قلعه طلایی', price: 5000, category: 'special', emoji: '🏰' },
|
||||
{ id: 'suit', title: 'لباس ماجراجو', price: 2500, category: 'visual', emoji: '🧥' },
|
||||
{ id: 'pet', title: 'حیوان همراه', price: 3000, category: 'special', emoji: '🦊' },
|
||||
{ id: 'sea-bg', title: 'پسزمینه دریایی', price: 1500, category: 'visual', emoji: '🌊' },
|
||||
{ id: 'crown', title: 'تاج طلایی', price: 4000, category: 'special', emoji: '👑' },
|
||||
{ id: 'boost-xp', title: 'تقویت XP', price: 800, category: 'boost', emoji: '⚡' },
|
||||
]
|
||||
|
||||
export const CHALLENGE_OPTIONS: ChallengeOption[] = [
|
||||
{ id: 'gold', label: 'طلا', icon: '🥇', color: '#f5b62d', isCorrect: true, feedback: 'عالی! طلا در بلندمدت میتونه ارزش پولت رو حفظ کنه.' },
|
||||
{ id: 'dollar', label: 'دلار', icon: '💵', color: '#20b94b', isCorrect: false, feedback: 'دلار گزینهایه، ولی طلا برای حفظ ارزش در تورم بهتره.' },
|
||||
{ id: 'bank', label: 'بانک', icon: '🏦', color: '#1987d4', isCorrect: false, feedback: 'پسانداز بانکی امنه، ولی سودش معمولاً از تورم کمتره.' },
|
||||
{ id: 'phone', label: 'خرید گوشی', icon: '📱', color: '#7438c9', isCorrect: false, feedback: 'گوشی ارزش مالی نگه نمیداره. بهتره سرمایهگذاری کنی!' },
|
||||
]
|
||||
|
||||
export const ACHIEVEMENTS: Achievement[] = [
|
||||
{ id: 'first-gold', title: 'اولین خرید طلا', description: 'اولین خرید طلای خودت رو انجام دادی', icon: '🥇', unlocked: true },
|
||||
{ id: 'saver', title: 'پساندازگر کوچک', description: '۱۰۰ هزار تومان پسانداز کردی', icon: '💰', unlocked: true },
|
||||
{ id: 'market', title: 'شاگرد بازار', description: '۵ بار در بازار معامله کردی', icon: '📊', unlocked: false },
|
||||
{ id: 'inflation', title: 'شکستدهنده تورم', description: 'درس تورم رو کامل کردی', icon: '👾', unlocked: false },
|
||||
]
|
||||
|
||||
function generatePriceHistory(basePrice: number, points: number): number[] {
|
||||
const history: number[] = []
|
||||
let price = basePrice * 0.92
|
||||
for (let i = 0; i < points; i++) {
|
||||
const change = (Math.sin(i * 0.4) * 0.015 + Math.random() * 0.008) * price
|
||||
price += change
|
||||
history.push(Math.round(price))
|
||||
}
|
||||
history[history.length - 1] = basePrice
|
||||
return history
|
||||
}
|
||||
|
||||
export const GOLD_PRICE = 1830000
|
||||
export const PRICE_HISTORY = {
|
||||
'1d': generatePriceHistory(GOLD_PRICE, 24),
|
||||
'7d': generatePriceHistory(GOLD_PRICE, 28),
|
||||
'30d': generatePriceHistory(GOLD_PRICE, 30),
|
||||
'90d': generatePriceHistory(GOLD_PRICE, 30),
|
||||
'1y': generatePriceHistory(GOLD_PRICE, 52),
|
||||
}
|
||||
|
||||
export const MAP_LEVELS: MapLevel[] = [
|
||||
{ id: 1, title: 'پول چیه؟', stars: 0, maxStars: 3, status: 'current', x: 24, y: 79 },
|
||||
{ id: 2, title: 'پسانداز', stars: 0, maxStars: 3, status: 'locked', x: 59, y: 63 },
|
||||
{ id: 3, title: 'نیاز و خواسته', stars: 0, maxStars: 3, status: 'locked', x: 32, y: 47 },
|
||||
{ id: 4, title: 'طلا چیه؟', stars: 0, maxStars: 3, status: 'locked', x: 22, y: 36 },
|
||||
{ id: 5, title: 'بازار طلا', stars: 0, maxStars: 3, status: 'locked', x: 42, y: 34 },
|
||||
{ id: 6, title: 'تورم', stars: 0, maxStars: 3, status: 'locked', lessonRoute: '/lessons/inflation', x: 54, y: 66 },
|
||||
{ id: 7, title: 'سرمایهگذاری', stars: 0, maxStars: 3, status: 'locked', x: 40, y: 61 },
|
||||
{ id: 8, title: 'سبد دارایی', stars: 0, maxStars: 3, status: 'locked', x: 28, y: 77 },
|
||||
{ id: 9, title: 'ریسک و پاداش', stars: 0, maxStars: 3, status: 'locked', x: 47, y: 63 },
|
||||
{ id: 10, title: 'پسانداز هوشمند', stars: 0, maxStars: 3, status: 'locked', x: 33, y: 52 },
|
||||
{ id: 11, title: 'اقتصاد خانواده', stars: 0, maxStars: 3, status: 'locked', x: 47, y: 41 },
|
||||
{ id: 12, title: 'کارآفرینی', stars: 0, maxStars: 3, status: 'locked', x: 48, y: 30 },
|
||||
{ id: 13, title: 'سرزمین طلا', stars: 0, maxStars: 3, status: 'locked', x: 54, y: 18 },
|
||||
]
|
||||
|
||||
export const ILLUSTRATIONS = {
|
||||
heroExplorer: '/src/assets/illustrations/hero-explorer.svg',
|
||||
treasureChest: '/src/assets/illustrations/treasure-chest.svg',
|
||||
professor: '/src/assets/illustrations/professor.svg',
|
||||
shoppingCart: '/src/assets/illustrations/shopping-cart.svg',
|
||||
goldBar: '/src/assets/illustrations/gold-bar.svg',
|
||||
coin: '/src/assets/illustrations/coin.svg',
|
||||
castle: '/src/assets/illustrations/castle.svg',
|
||||
mapBackground: '/src/assets/illustrations/map-scene.svg',
|
||||
} as const
|
||||
+17
-1
@@ -6,9 +6,25 @@ import '@fontsource/vazirmatn/700.css'
|
||||
import '@fontsource/vazirmatn/800.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { setUnauthorizedHandler } from '@/api/authSession'
|
||||
import { resetUserStores } from '@/api/sync'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import './styles/global.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
setUnauthorizedHandler(() => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.$patch({
|
||||
token: null,
|
||||
username: null,
|
||||
displayName: null,
|
||||
})
|
||||
resetUserStores()
|
||||
void router.replace({ name: 'login' })
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import PrimaryButton from '@/components/common/PrimaryButton.vue'
|
||||
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
|
||||
import { applyMeResponse } from '@/api/sync'
|
||||
import { runMeMutation } from '@/api/sync'
|
||||
import { StaleSessionError } from '@/api/errors'
|
||||
import LessonHeader from './LessonHeader.vue'
|
||||
import LessonProgress from './LessonProgress.vue'
|
||||
import LessonStepRenderer from './LessonStepRenderer.vue'
|
||||
@@ -183,18 +184,17 @@ async function submitLesson() {
|
||||
|
||||
try {
|
||||
const durationMs = Date.now() - startedAt.value
|
||||
const response = await goldLandApi.completeLevel(
|
||||
props.levelId,
|
||||
buildSubmission(),
|
||||
durationMs,
|
||||
const response = await runMeMutation(
|
||||
() => goldLandApi.completeLevel(props.levelId, buildSubmission(), durationMs),
|
||||
(r) => r.me,
|
||||
)
|
||||
applyMeResponse(response.me)
|
||||
result.value = response
|
||||
emit('completed', response)
|
||||
|
||||
const progress = await goldLandApi.getProgress()
|
||||
levelsProgress.value = progress.levels
|
||||
} catch (err) {
|
||||
if (err instanceof StaleSessionError) return
|
||||
error.value = err instanceof Error ? err.message : 'خطا در ارسال پاسخها'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from '@/api/client'
|
||||
import { fetchGoldLandProgress } from './goldLandProgressSync'
|
||||
import type {
|
||||
CompleteLevelResponse,
|
||||
GoldLandProgressResponse,
|
||||
@@ -8,7 +9,7 @@ import type {
|
||||
} from '../types/goldLand.types'
|
||||
|
||||
export const goldLandApi = {
|
||||
getProgress: () => api.getGoldLandProgress(),
|
||||
getProgress: () => fetchGoldLandProgress(),
|
||||
getLevel: (levelId: number) => api.getGoldLandLevel(levelId),
|
||||
startLevel: (levelId: number) => api.startGoldLandLevel(levelId),
|
||||
completeLevel: (levelId: number, answers: LessonSubmissionAnswer[], durationMs?: number) =>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { api } from '@/api/client'
|
||||
import { StaleSessionError } from '@/api/errors'
|
||||
import type { GoldLandProgressResponse } from '../types/goldLand.types'
|
||||
|
||||
/** Monotonic seq so an older in-flight progress response cannot overwrite a newer one (same session). */
|
||||
let progressRequestSeq = 0
|
||||
let lastCommittedProgressSeq = 0
|
||||
let progressEpoch = 0
|
||||
|
||||
export function resetGoldLandProgressSync(): void {
|
||||
progressRequestSeq = 0
|
||||
lastCommittedProgressSeq = 0
|
||||
progressEpoch += 1
|
||||
}
|
||||
|
||||
export async function fetchGoldLandProgress(): Promise<GoldLandProgressResponse> {
|
||||
const epochAtStart = progressEpoch
|
||||
const seq = ++progressRequestSeq
|
||||
const data = await api.getGoldLandProgress()
|
||||
if (epochAtStart !== progressEpoch) {
|
||||
throw new StaleSessionError()
|
||||
}
|
||||
if (seq < lastCommittedProgressSeq) {
|
||||
throw new StaleSessionError()
|
||||
}
|
||||
lastCommittedProgressSeq = seq
|
||||
return data
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageBackButton from '@/components/common/PageBackButton.vue'
|
||||
import { CHALLENGE_OPTIONS } from '@/data/mockData'
|
||||
import PrimaryButton from '@/components/common/PrimaryButton.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useDailyChallengeStore } from '@/stores/dailyChallenge'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { api } from '@/api/client'
|
||||
import { applyMeResponse } from '@/api/sync'
|
||||
import { StaleSessionError } from '@/api/client'
|
||||
import { illustrations } from '@/assets/illustrations'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const challengeStore = useDailyChallengeStore()
|
||||
const toast = useToast()
|
||||
|
||||
const optionImages: Record<string, string> = {
|
||||
@@ -36,22 +37,42 @@ const optionBackgrounds: Record<string, string> = {
|
||||
|
||||
const selectedId = ref<string | null>(null)
|
||||
const showFeedback = ref(false)
|
||||
const rewardClaimed = computed(() => userStore.completedChallenges.includes('daily-challenge'))
|
||||
const selectedOption = computed(() => CHALLENGE_OPTIONS.find((o) => o.id === selectedId.value))
|
||||
|
||||
const challenge = computed(() => challengeStore.challenge)
|
||||
const rewardClaimed = computed(
|
||||
() => challengeStore.isCompleted || userStore.completedChallenges.includes('daily-challenge'),
|
||||
)
|
||||
const rewardText = computed(() => {
|
||||
const c = challenge.value
|
||||
if (!c) return ''
|
||||
return `${c.coinReward} سکه`
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void challengeStore.loadChallenge()
|
||||
})
|
||||
|
||||
function retryLoad() {
|
||||
void challengeStore.loadChallenge({ force: true })
|
||||
}
|
||||
|
||||
function selectOption(id: string) {
|
||||
if (rewardClaimed.value) return
|
||||
if (rewardClaimed.value || challengeStore.submitPending) return
|
||||
selectedId.value = id
|
||||
showFeedback.value = true
|
||||
}
|
||||
|
||||
async function claimReward() {
|
||||
if (!selectedId.value || rewardClaimed.value) return
|
||||
if (!selectedId.value || rewardClaimed.value || challengeStore.submitPending) return
|
||||
try {
|
||||
const data = await api.completeDailyChallenge(selectedId.value)
|
||||
applyMeResponse(data)
|
||||
if (!data.alreadyCompleted) toast.success('۵۰ سکه و ۳۰ XP دریافت کردی!')
|
||||
const data = await challengeStore.completeOption(selectedId.value)
|
||||
if (!data.alreadyCompleted && challenge.value) {
|
||||
toast.success(
|
||||
`${challenge.value.coinReward} سکه و ${challenge.value.xpReward} XP دریافت کردی!`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof StaleSessionError) return
|
||||
toast.error(error instanceof Error ? error.message : 'خطا در دریافت پاداش')
|
||||
}
|
||||
}
|
||||
@@ -61,75 +82,82 @@ async function claimReward() {
|
||||
<div class="challenge-page">
|
||||
<header class="challenge-header">
|
||||
<PageBackButton @click="router.back()" />
|
||||
<h1 class="challenge-header__title">چالش روزانه</h1>
|
||||
<h1 class="challenge-header__title">چالش آموزشی</h1>
|
||||
<span class="challenge-header__spacer" aria-hidden="true" />
|
||||
</header>
|
||||
|
||||
<div class="question-panel card-enter">
|
||||
<div class="question-panel__bubble">
|
||||
<p>
|
||||
امروز ۲۰ هزار تومان داری کجا سرمایهگذاری میکنی که در آینده سود بیشتری داشته باشه؟
|
||||
</p>
|
||||
<div v-if="challengeStore.loadStatus === 'loading'" class="challenge-status card-enter">
|
||||
در حال بارگذاری چالش آموزشی...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="challengeStore.loadStatus === 'error'"
|
||||
class="challenge-status challenge-status--error card-enter"
|
||||
>
|
||||
<p>{{ challengeStore.loadError ?? 'خطا در دریافت چالش آموزشی' }}</p>
|
||||
<PrimaryButton variant="gold" size="sm" @click="retryLoad">تلاش مجدد</PrimaryButton>
|
||||
</div>
|
||||
|
||||
<template v-else-if="challenge">
|
||||
<div class="question-panel card-enter">
|
||||
<div class="question-panel__bubble">
|
||||
<p>{{ challenge.question }}</p>
|
||||
</div>
|
||||
<img :src="illustrations.professor" alt="استاد" class="question-panel__prof" />
|
||||
</div>
|
||||
<img
|
||||
:src="illustrations.professor"
|
||||
alt="استاد"
|
||||
class="question-panel__prof"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="options">
|
||||
<button
|
||||
v-for="(option, i) in CHALLENGE_OPTIONS"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="option-btn card-enter"
|
||||
:class="{
|
||||
'option-btn--selected': selectedId === option.id,
|
||||
'option-btn--disabled': rewardClaimed,
|
||||
}"
|
||||
:style="{
|
||||
animationDelay: `${0.08 + i * 0.04}s`,
|
||||
'--option-border': optionBorders[option.id],
|
||||
'--option-bg': optionBackgrounds[option.id],
|
||||
}"
|
||||
:disabled="rewardClaimed"
|
||||
@click="selectOption(option.id)"
|
||||
>
|
||||
<span class="option-btn__label">{{ option.label }}</span>
|
||||
<img
|
||||
:src="optionImages[option.id]"
|
||||
alt=""
|
||||
class="option-btn__icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Transition name="page">
|
||||
<div v-if="showFeedback && selectedOption" class="feedback">
|
||||
<p>{{ selectedOption.feedback }}</p>
|
||||
<div class="options">
|
||||
<button
|
||||
v-if="!rewardClaimed"
|
||||
v-for="(option, i) in challenge.options"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="feedback__claim"
|
||||
@click="claimReward"
|
||||
class="option-btn card-enter"
|
||||
:class="{
|
||||
'option-btn--selected': selectedId === option.id,
|
||||
'option-btn--disabled': rewardClaimed,
|
||||
}"
|
||||
:style="{
|
||||
animationDelay: `${0.08 + i * 0.04}s`,
|
||||
'--option-border': optionBorders[option.id] ?? option.color,
|
||||
'--option-bg': optionBackgrounds[option.id] ?? '#0c1628',
|
||||
}"
|
||||
:disabled="rewardClaimed || challengeStore.submitPending"
|
||||
@click="selectOption(option.id)"
|
||||
>
|
||||
دریافت پاداش
|
||||
<span class="option-btn__label">{{ option.label }}</span>
|
||||
<img
|
||||
v-if="optionImages[option.id]"
|
||||
:src="optionImages[option.id]"
|
||||
alt=""
|
||||
class="option-btn__icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-else class="option-btn__emoji" aria-hidden="true">{{ option.icon }}</span>
|
||||
</button>
|
||||
<p v-else class="feedback__done">پاداش دریافت شد ✓</p>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="reward-row card-enter" style="animation-delay:0.28s">
|
||||
<span class="reward-row__text">پاداش: ۵۰ سکه</span>
|
||||
<img
|
||||
:src="illustrations.coinLarge"
|
||||
alt=""
|
||||
class="reward-row__coin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<Transition name="page">
|
||||
<div v-if="showFeedback && selectedId" class="feedback">
|
||||
<p v-if="challengeStore.lastFeedback">{{ challengeStore.lastFeedback }}</p>
|
||||
<p v-else-if="!rewardClaimed">گزینه انتخاب شد. برای دیدن بازخورد و دریافت پاداش دکمه زیر را بزن.</p>
|
||||
<button
|
||||
v-if="!rewardClaimed"
|
||||
type="button"
|
||||
class="feedback__claim"
|
||||
:disabled="challengeStore.submitPending"
|
||||
@click="claimReward"
|
||||
>
|
||||
{{ challengeStore.submitPending ? 'در حال ارسال...' : 'دریافت پاداش' }}
|
||||
</button>
|
||||
<p v-else class="feedback__done">پاداش دریافت شد ✓</p>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="reward-row card-enter" style="animation-delay: 0.28s">
|
||||
<span class="reward-row__text">پاداش: {{ rewardText }}</span>
|
||||
<img :src="illustrations.coinLarge" alt="" class="reward-row__coin" aria-hidden="true" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -140,6 +168,18 @@ async function claimReward() {
|
||||
background: #07101f;
|
||||
}
|
||||
|
||||
.challenge-status {
|
||||
text-align: center;
|
||||
padding: 40px 12px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.challenge-status--error p {
|
||||
margin-bottom: 14px;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.challenge-header {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
@@ -210,7 +250,6 @@ async function claimReward() {
|
||||
margin-bottom: -8px;
|
||||
margin-right: -4px;
|
||||
object-fit: contain;
|
||||
/* hide solid black plate behind the character */
|
||||
mix-blend-mode: lighten;
|
||||
filter: drop-shadow(0 8px 18px rgba(0, 0, 0, 0.45));
|
||||
pointer-events: none;
|
||||
@@ -275,6 +314,11 @@ async function claimReward() {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.option-btn__emoji {
|
||||
font-size: 2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 16px;
|
||||
@@ -303,7 +347,12 @@ async function claimReward() {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.feedback__claim:active {
|
||||
.feedback__claim:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.feedback__claim:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
|
||||
+48
-4
@@ -1,14 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AppHeader from '@/components/common/AppHeader.vue'
|
||||
import GameCard from '@/components/common/GameCard.vue'
|
||||
import { GAMES } from '@/data/mockData'
|
||||
import PrimaryButton from '@/components/common/PrimaryButton.vue'
|
||||
import { useGamesStore } from '@/stores/games'
|
||||
import type { GameCatalogItem } from '@/types/catalog.types'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const gamesStore = useGamesStore()
|
||||
const toast = useToast()
|
||||
|
||||
function startGame(game: (typeof GAMES)[0]) {
|
||||
onMounted(() => {
|
||||
void gamesStore.loadCatalog()
|
||||
})
|
||||
|
||||
function retryLoad() {
|
||||
void gamesStore.loadCatalog({ force: true })
|
||||
}
|
||||
|
||||
function startGame(game: GameCatalogItem) {
|
||||
game.route ? router.push(game.route) : toast.show('به زودی!', 'info')
|
||||
}
|
||||
</script>
|
||||
@@ -17,9 +29,28 @@ function startGame(game: (typeof GAMES)[0]) {
|
||||
<div class="page-content page-content--no-nav">
|
||||
<AppHeader title="بازیها" show-back @back="router.back()" />
|
||||
|
||||
<div class="games-list">
|
||||
<div v-if="gamesStore.catalogLoadStatus === 'loading'" class="games-status card-enter">
|
||||
در حال بارگذاری بازیها...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="gamesStore.catalogLoadStatus === 'error'"
|
||||
class="games-status games-status--error card-enter"
|
||||
>
|
||||
<p>{{ gamesStore.catalogLoadError ?? 'خطا در دریافت بازیها' }}</p>
|
||||
<PrimaryButton variant="gold" size="sm" @click="retryLoad">تلاش مجدد</PrimaryButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="gamesStore.catalogLoadStatus === 'ready' && gamesStore.catalog.length === 0"
|
||||
class="games-status card-enter"
|
||||
>
|
||||
فعلاً بازیای برای نمایش نیست.
|
||||
</div>
|
||||
|
||||
<div v-else class="games-list">
|
||||
<GameCard
|
||||
v-for="(game, i) in GAMES"
|
||||
v-for="(game, i) in gamesStore.catalog"
|
||||
:key="game.id"
|
||||
:id="game.id"
|
||||
:title="game.title"
|
||||
@@ -40,4 +71,17 @@ function startGame(game: (typeof GAMES)[0]) {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.games-status {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.games-status--error p {
|
||||
margin-bottom: 14px;
|
||||
color: #f87171;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { TrendingUp, TrendingDown } from 'lucide-vue-next'
|
||||
import AppHeader from '@/components/common/AppHeader.vue'
|
||||
@@ -9,8 +9,11 @@ import { useMarketStore } from '@/stores/market'
|
||||
import { useGamesStore } from '@/stores/games'
|
||||
import { formatCurrency } from '@/utils/storage'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { StaleSessionError } from '@/api/client'
|
||||
import { illustrations } from '@/assets/illustrations'
|
||||
|
||||
const GOLD_GUESS_ID = 'gold-guess'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
const gamesStore = useGamesStore()
|
||||
@@ -18,18 +21,48 @@ const toast = useToast()
|
||||
|
||||
const choice = ref<'up' | 'down' | null>(null)
|
||||
const showResult = ref(false)
|
||||
const rewardClaimed = computed(() => gamesStore.isRewardClaimed('gold-guess'))
|
||||
const serverGrantedReward = ref(false)
|
||||
const rewardClaimed = computed(() => gamesStore.isRewardClaimed(GOLD_GUESS_ID))
|
||||
const actualDirection = computed(() => (marketStore.dailyChange >= 0 ? 'up' : 'down'))
|
||||
const isCorrect = computed(() => choice.value === actualDirection.value)
|
||||
|
||||
const catalogGame = computed(() => gamesStore.gameById(GOLD_GUESS_ID))
|
||||
const rewardsLoading = computed(
|
||||
() => gamesStore.catalogLoadStatus === 'loading' || gamesStore.catalogLoadStatus === 'idle',
|
||||
)
|
||||
const rewardsKnown = computed(() => gamesStore.catalogLoadStatus === 'ready' && catalogGame.value != null)
|
||||
|
||||
const rewardCoins = computed(() => catalogGame.value?.rewardCoins)
|
||||
const rewardXp = computed(() => catalogGame.value?.rewardXp)
|
||||
|
||||
onMounted(() => {
|
||||
void gamesStore.loadCatalog()
|
||||
})
|
||||
|
||||
function rewardToastMessage(): string | null {
|
||||
if (!rewardsKnown.value) return null
|
||||
const parts: string[] = []
|
||||
if (rewardCoins.value != null && rewardCoins.value > 0) {
|
||||
parts.push(`${rewardCoins.value} سکه`)
|
||||
}
|
||||
if (rewardXp.value != null && rewardXp.value > 0) {
|
||||
parts.push(`${rewardXp.value} XP`)
|
||||
}
|
||||
return parts.length > 0 ? `${parts.join(' و ')} دریافت کردی!` : null
|
||||
}
|
||||
|
||||
async function makeChoice(dir: 'up' | 'down') {
|
||||
if (rewardClaimed.value) return
|
||||
if (rewardClaimed.value || gamesStore.claimPending) return
|
||||
choice.value = dir
|
||||
showResult.value = true
|
||||
try {
|
||||
const claimed = await gamesStore.claimReward('gold-guess')
|
||||
if (claimed) toast.success('۲۰ سکه و ۵۰ XP دریافت کردی!')
|
||||
const claimed = await gamesStore.claimReward(GOLD_GUESS_ID)
|
||||
showResult.value = true
|
||||
serverGrantedReward.value = claimed
|
||||
const msg = rewardToastMessage()
|
||||
if (claimed && msg) toast.success(`پاداش اولین تکمیل: ${msg}`)
|
||||
} catch (error) {
|
||||
choice.value = null
|
||||
if (error instanceof StaleSessionError) return
|
||||
toast.error(error instanceof Error ? error.message : 'خطا در دریافت پاداش')
|
||||
}
|
||||
}
|
||||
@@ -52,10 +85,10 @@ async function makeChoice(dir: 'up' | 'down') {
|
||||
</SectionCard>
|
||||
|
||||
<div class="choice-buttons card-enter" style="animation-delay:0.12s">
|
||||
<PrimaryButton variant="green" size="lg" full-width :disabled="rewardClaimed" @click="makeChoice('up')">
|
||||
<PrimaryButton variant="green" size="lg" full-width :disabled="rewardClaimed || gamesStore.claimPending" @click="makeChoice('up')">
|
||||
<TrendingUp :size="22" /> افزایش
|
||||
</PrimaryButton>
|
||||
<PrimaryButton variant="red" size="lg" full-width :disabled="rewardClaimed" @click="makeChoice('down')">
|
||||
<PrimaryButton variant="red" size="lg" full-width :disabled="rewardClaimed || gamesStore.claimPending" @click="makeChoice('down')">
|
||||
<TrendingDown :size="22" /> کاهش
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
@@ -65,13 +98,36 @@ async function makeChoice(dir: 'up' | 'down') {
|
||||
<p class="result-card__text">
|
||||
{{ isCorrect ? '🎉 آفرین! درست حدس زدی!' : '💡 قیمت امروز ' + (actualDirection === 'up' ? 'افزایش' : 'کاهش') + ' داشته.' }}
|
||||
</p>
|
||||
<p class="result-card__tip">سرمایهگذارهای حرفهای قبل از خرید، روند قیمت رو بررسی میکنن.</p>
|
||||
<p class="result-card__tip">
|
||||
{{
|
||||
serverGrantedReward
|
||||
? 'پاداش از سرور با اولین تکمیل ثبت شد.'
|
||||
: rewardClaimed
|
||||
? 'پاداش این بازی قبلاً دریافت شده است.'
|
||||
: 'پاداش به درستی حدس وابسته نیست؛ سرور پاسخ را ارزیابی نمیکند.'
|
||||
}}
|
||||
</p>
|
||||
</SectionCard>
|
||||
</Transition>
|
||||
|
||||
<div class="reward-footer card-enter">
|
||||
<span>🪙 ۲۰ سکه</span>
|
||||
<span>⭐ ۵۰ XP</span>
|
||||
<template v-if="rewardsLoading">
|
||||
<span class="reward-footer__muted">در حال بارگذاری پاداش...</span>
|
||||
</template>
|
||||
<template v-else-if="rewardsKnown">
|
||||
<span class="reward-footer__muted">پاداش اولین تکمیل (بدون ارزیابی حدس در سرور)</span>
|
||||
<span v-if="rewardCoins != null && rewardCoins > 0">🪙 {{ rewardCoins }} سکه</span>
|
||||
<span v-if="rewardXp != null && rewardXp > 0">⭐ {{ rewardXp }} XP</span>
|
||||
<span
|
||||
v-if="(rewardCoins == null || rewardCoins === 0) && (rewardXp == null || rewardXp === 0)"
|
||||
class="reward-footer__muted"
|
||||
>
|
||||
پاداشی برای این بازی ثبت نشده
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="reward-footer__muted">اطلاعات پاداش این بازی در دسترس نیست</span>
|
||||
</template>
|
||||
<span v-if="rewardClaimed" class="reward-footer__done">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,6 +214,12 @@ async function makeChoice(dir: 'up' | 'down') {
|
||||
color: var(--gold-light);
|
||||
}
|
||||
|
||||
.reward-footer__muted {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.reward-footer__done {
|
||||
color: var(--green);
|
||||
font-size: 1.125rem;
|
||||
|
||||
@@ -11,7 +11,7 @@ const userStore = useUserStore()
|
||||
const walletStore = useWalletStore()
|
||||
|
||||
const quickActions = [
|
||||
{ label: 'چالش روزانه', icon: null, image: illustrations.targetIcon, route: '/challenge', color: '#f4ae21' },
|
||||
{ label: 'چالش آموزشی', icon: null, image: illustrations.targetIcon, route: '/challenge', color: '#f4ae21' },
|
||||
{ label: 'بازیها', icon: null, image: illustrations.gamepadIcon, route: '/games', color: '#9c50e9' },
|
||||
{ label: 'فروشگاه', icon: null, image: illustrations.cartIcon, route: '/store', color: '#e85aad' },
|
||||
{ label: 'دوستان', icon: null, image: illustrations.friendsIcon, route: '/friends', color: '#3aa8e8' },
|
||||
@@ -81,9 +81,9 @@ const quickActions = [
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="challenge__content">
|
||||
<h2 class="challenge__title">چالش امروز</h2>
|
||||
<h2 class="challenge__title">چالش آموزشی</h2>
|
||||
<p class="challenge__text">
|
||||
امروز ۲۰ هزار تومان داری کجا سرمایهگذاری میکنی؟
|
||||
۲۰ هزار تومان داری؛ کجا سرمایهگذاری میکنی؟ (یکبار پاداش)
|
||||
</p>
|
||||
<button type="button" class="challenge__btn" @click="router.push('/challenge')">
|
||||
شروع
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onActivated, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import MapNode from '@/components/map/MapNode.vue'
|
||||
import AppModal from '@/components/common/AppModal.vue'
|
||||
@@ -95,6 +95,7 @@ watch(focusLevelId, () => {
|
||||
})
|
||||
|
||||
onMounted(loadProgress)
|
||||
onActivated(loadProgress)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PrimaryButton from '@/components/common/PrimaryButton.vue'
|
||||
import { StaleSessionError } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
@@ -36,6 +37,12 @@ async function submit() {
|
||||
await marketStore.loadQuote('1d')
|
||||
router.replace('/')
|
||||
} catch (error) {
|
||||
if (error instanceof StaleSessionError) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
toast.error('درخواست ورود منقضی شد. دوباره تلاش کنید.')
|
||||
}
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof Error ? error.message : 'خطا در ورود')
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useUserStore } from '@/stores/user'
|
||||
import { useWalletStore } from '@/stores/wallet'
|
||||
import { useMarketStore, type TimeRange } from '@/stores/market'
|
||||
import { formatNumber, formatDecimal, formatCurrency, formatPercent } from '@/utils/storage'
|
||||
import { tomansForGoldPurchase, tomansForGoldSale } from '@/utils/goldTrade'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { illustrations } from '@/assets/illustrations'
|
||||
|
||||
@@ -29,7 +30,16 @@ const timeRanges: { key: TimeRange; label: string }[] = [
|
||||
const showBuyModal = ref(false)
|
||||
const showSellModal = ref(false)
|
||||
const tradeGrams = ref(1)
|
||||
const tradeCost = computed(() => tradeGrams.value * marketStore.currentPrice)
|
||||
const tradeBuyEstimate = computed(() => {
|
||||
const g = Number(tradeGrams.value)
|
||||
if (!Number.isFinite(g) || g <= 0) return null
|
||||
return tomansForGoldPurchase(g)
|
||||
})
|
||||
const tradeSellEstimate = computed(() => {
|
||||
const g = Number(tradeGrams.value)
|
||||
if (!Number.isFinite(g) || g <= 0) return null
|
||||
return tomansForGoldSale(g)
|
||||
})
|
||||
const assetValue = computed(() => walletStore.goldGrams * marketStore.currentPrice)
|
||||
|
||||
function openBuy() { tradeGrams.value = 1; showBuyModal.value = true }
|
||||
@@ -122,8 +132,21 @@ async function confirmSell() {
|
||||
<AppModal :open="showBuyModal" title="خرید طلا" @close="showBuyModal = false">
|
||||
<div class="trade-form">
|
||||
<label class="trade-form__label">مقدار (گرم)</label>
|
||||
<input v-model.number="tradeGrams" type="number" min="0.1" step="0.1" class="trade-form__input" />
|
||||
<p class="trade-form__cost">هزینه: {{ formatCurrency(tradeCost) }}</p>
|
||||
<input
|
||||
v-model.number="tradeGrams"
|
||||
type="number"
|
||||
min="0.000001"
|
||||
step="0.000001"
|
||||
class="trade-form__input"
|
||||
dir="ltr"
|
||||
/>
|
||||
<p class="trade-form__cost">
|
||||
<template v-if="tradeBuyEstimate != null">
|
||||
برآورد هزینه (خرید به بالا): {{ formatCurrency(tradeBuyEstimate) }}
|
||||
</template>
|
||||
<template v-else>مقدار نامعتبر</template>
|
||||
</p>
|
||||
<p class="trade-form__hint">مبلغ نهایی پس از تأیید از سرور ثبت میشود.</p>
|
||||
<p class="trade-form__balance">موجودی: {{ formatCurrency(walletStore.cash) }}</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -136,8 +159,21 @@ async function confirmSell() {
|
||||
<AppModal :open="showSellModal" title="فروش طلا" @close="showSellModal = false">
|
||||
<div class="trade-form">
|
||||
<label class="trade-form__label">مقدار (گرم)</label>
|
||||
<input v-model.number="tradeGrams" type="number" min="0.1" step="0.1" class="trade-form__input" />
|
||||
<p class="trade-form__cost">دریافتی: {{ formatCurrency(tradeCost) }}</p>
|
||||
<input
|
||||
v-model.number="tradeGrams"
|
||||
type="number"
|
||||
min="0.000001"
|
||||
step="0.000001"
|
||||
class="trade-form__input"
|
||||
dir="ltr"
|
||||
/>
|
||||
<p class="trade-form__cost">
|
||||
<template v-if="tradeSellEstimate != null">
|
||||
برآورد دریافتی (فروش به پایین): {{ formatCurrency(tradeSellEstimate) }}
|
||||
</template>
|
||||
<template v-else>مقدار نامعتبر</template>
|
||||
</p>
|
||||
<p class="trade-form__hint">مبلغ نهایی پس از تأیید از سرور ثبت میشود.</p>
|
||||
<p class="trade-form__balance">طلای موجود: {{ formatDecimal(walletStore.goldGrams) }} گرم</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -479,6 +515,13 @@ async function confirmSell() {
|
||||
color: var(--gold-light);
|
||||
}
|
||||
|
||||
.trade-form__hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.trade-form__balance {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -17,7 +17,9 @@ const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
const showAchievements = ref(false)
|
||||
|
||||
const xpPercent = computed(() => (userStore.xp / userStore.maxXp) * 100)
|
||||
const xpPercent = computed(() =>
|
||||
userStore.maxXp > 0 ? (userStore.xp / userStore.maxXp) * 100 : 0,
|
||||
)
|
||||
const xpLabel = computed(
|
||||
() => `${formatNumber(userStore.xp)} / ${formatNumber(userStore.maxXp)} XP`,
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useUserStore } from '@/stores/user'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { api } from '@/api/client'
|
||||
import { applyMeResponse } from '@/api/sync'
|
||||
import { runMeMutation } from '@/api/sync'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
@@ -51,8 +51,7 @@ async function saveProfile() {
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const data = await api.updateProfile(name)
|
||||
applyMeResponse(data)
|
||||
await runMeMutation(() => api.updateProfile(name))
|
||||
authStore.displayName = name
|
||||
|
||||
if (wantsPasswordChange) {
|
||||
|
||||
+81
-41
@@ -1,19 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AppHeader from '@/components/common/AppHeader.vue'
|
||||
import ProductCard from '@/components/store/ProductCard.vue'
|
||||
import AppModal from '@/components/common/AppModal.vue'
|
||||
import PrimaryButton from '@/components/common/PrimaryButton.vue'
|
||||
import { STORE_PRODUCTS, type StoreProduct } from '@/data/mockData'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useGamesStore } from '@/stores/games'
|
||||
import { useStoreCatalogStore } from '@/stores/storeCatalog'
|
||||
import type { StoreProduct } from '@/types/catalog.types'
|
||||
import { formatNumber } from '@/utils/storage'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { StaleSessionError } from '@/api/client'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const gamesStore = useGamesStore()
|
||||
const catalogStore = useStoreCatalogStore()
|
||||
const toast = useToast()
|
||||
|
||||
type Category = 'all' | 'special' | 'visual' | 'boost'
|
||||
@@ -25,30 +28,31 @@ const tabs: { key: Category; label: string }[] = [
|
||||
{ key: 'boost', label: 'تقویتی' },
|
||||
]
|
||||
|
||||
const STORE_DISPLAY_ORDER = ['suit', 'castle', 'sea-bg', 'pet', 'crown', 'boost-xp']
|
||||
|
||||
const activeTab = ref<Category>('all')
|
||||
const selectedProduct = ref<StoreProduct | null>(null)
|
||||
const showConfirm = ref(false)
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
const list =
|
||||
activeTab.value === 'all'
|
||||
? STORE_PRODUCTS
|
||||
: STORE_PRODUCTS.filter((product) => product.category === activeTab.value)
|
||||
const filteredProducts = computed(() => catalogStore.productsByCategory(activeTab.value))
|
||||
|
||||
return [...list].sort(
|
||||
(a, b) => STORE_DISPLAY_ORDER.indexOf(a.id) - STORE_DISPLAY_ORDER.indexOf(b.id),
|
||||
)
|
||||
const showLoading = computed(() => catalogStore.loadStatus === 'loading')
|
||||
const showError = computed(() => catalogStore.loadStatus === 'error')
|
||||
const showEmpty = computed(() => catalogStore.isEmpty)
|
||||
|
||||
onMounted(() => {
|
||||
void catalogStore.loadProducts()
|
||||
})
|
||||
|
||||
function retryLoad() {
|
||||
void catalogStore.loadProducts({ force: true })
|
||||
}
|
||||
|
||||
function selectProduct(product: StoreProduct) {
|
||||
selectedProduct.value = product
|
||||
showConfirm.value = true
|
||||
}
|
||||
|
||||
async function confirmPurchase() {
|
||||
if (!selectedProduct.value) return
|
||||
if (!selectedProduct.value || catalogStore.purchasePending) return
|
||||
const product = selectedProduct.value
|
||||
if (gamesStore.isOwned(product.id)) {
|
||||
toast.show('قبلاً خریدی!', 'info')
|
||||
@@ -60,10 +64,11 @@ async function confirmPurchase() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await gamesStore.purchaseItem(product.id)
|
||||
await catalogStore.purchaseProduct(product.id)
|
||||
toast.success(`${product.title} خریده شد!`)
|
||||
showConfirm.value = false
|
||||
} catch (error) {
|
||||
if (error instanceof StaleSessionError) return
|
||||
toast.error(error instanceof Error ? error.message : 'خطا در خرید')
|
||||
}
|
||||
}
|
||||
@@ -73,33 +78,46 @@ async function confirmPurchase() {
|
||||
<div class="page-content page-content--no-nav store-page">
|
||||
<AppHeader title="فروشگاه جوایز" show-back :coins="userStore.coins" @back="router.back()" />
|
||||
|
||||
<div class="store-tabs card-enter">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="store-tab"
|
||||
:class="{ 'store-tab--active': activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
<div v-if="showLoading" class="store-status card-enter">در حال بارگذاری محصولات...</div>
|
||||
|
||||
<div v-else-if="showError" class="store-status store-status--error card-enter">
|
||||
<p>{{ catalogStore.loadError ?? 'خطا در دریافت فروشگاه' }}</p>
|
||||
<PrimaryButton variant="gold" size="sm" @click="retryLoad">تلاش مجدد</PrimaryButton>
|
||||
</div>
|
||||
|
||||
<div class="store-grid">
|
||||
<ProductCard
|
||||
v-for="(product, index) in filteredProducts"
|
||||
:key="product.id"
|
||||
:id="product.id"
|
||||
:title="product.title"
|
||||
:price="product.price"
|
||||
:emoji="product.emoji"
|
||||
:owned="gamesStore.isOwned(product.id)"
|
||||
class="card-enter"
|
||||
:style="{ animationDelay: `${index * 0.04}s` }"
|
||||
@select="selectProduct(product)"
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="showEmpty" class="store-status card-enter">فعلاً محصولی برای نمایش نیست.</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="store-tabs card-enter">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="store-tab"
|
||||
:class="{ 'store-tab--active': activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="store-grid">
|
||||
<ProductCard
|
||||
v-for="(product, index) in filteredProducts"
|
||||
:key="product.id"
|
||||
:id="product.id"
|
||||
:title="product.title"
|
||||
:price="product.price"
|
||||
:emoji="product.emoji"
|
||||
:owned="gamesStore.isOwned(product.id)"
|
||||
class="card-enter"
|
||||
:style="{ animationDelay: `${index * 0.04}s` }"
|
||||
@select="selectProduct(product)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<AppModal :open="showConfirm" :title="selectedProduct?.title ?? ''" @close="showConfirm = false">
|
||||
<div v-if="selectedProduct" class="purchase-preview">
|
||||
@@ -117,10 +135,19 @@ async function confirmPurchase() {
|
||||
<PrimaryButton
|
||||
variant="gold"
|
||||
full-width
|
||||
:disabled="selectedProduct ? gamesStore.isOwned(selectedProduct.id) : false"
|
||||
:disabled="
|
||||
catalogStore.purchasePending ||
|
||||
(selectedProduct ? gamesStore.isOwned(selectedProduct.id) : false)
|
||||
"
|
||||
@click="confirmPurchase"
|
||||
>
|
||||
{{ selectedProduct && gamesStore.isOwned(selectedProduct.id) ? 'مال توئه ✓' : 'خرید' }}
|
||||
{{
|
||||
catalogStore.purchasePending
|
||||
? 'در حال خرید...'
|
||||
: selectedProduct && gamesStore.isOwned(selectedProduct.id)
|
||||
? 'مال توئه ✓'
|
||||
: 'خرید'
|
||||
}}
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</AppModal>
|
||||
@@ -132,6 +159,19 @@ async function confirmPurchase() {
|
||||
background: #0f0b22;
|
||||
}
|
||||
|
||||
.store-status {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.store-status--error p {
|
||||
margin-bottom: 14px;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.store-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+18
-5
@@ -1,8 +1,14 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import MobileAppLayout from '@/layouts/MobileAppLayout.vue'
|
||||
import { ApiNetworkError, StaleSessionError } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { bootstrapFromServer } from '@/api/sync'
|
||||
import { isAppBootstrapped, markAppBootstrapped, resetAppBootstrap } from '@/api/bootstrapState'
|
||||
import { bootstrapFromServer, syncMarketQuote } from '@/api/sync'
|
||||
import {
|
||||
isAppBootstrapped,
|
||||
markAppBootstrapped,
|
||||
resetAppBootstrap,
|
||||
setBootstrapFailed,
|
||||
} from '@/api/bootstrapState'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
|
||||
const router = createRouter({
|
||||
@@ -36,7 +42,7 @@ const router = createRouter({
|
||||
path: '/challenge',
|
||||
name: 'challenge',
|
||||
component: () => import('@/pages/DailyChallengePage.vue'),
|
||||
meta: { title: 'چالش روزانه', nav: false, requiresAuth: true },
|
||||
meta: { title: 'چالش آموزشی', nav: false, requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/games',
|
||||
@@ -97,9 +103,16 @@ router.beforeEach(async (to) => {
|
||||
if (!isAppBootstrapped()) {
|
||||
try {
|
||||
await bootstrapFromServer()
|
||||
await useMarketStore().loadQuote('1d')
|
||||
await syncMarketQuote(useMarketStore().selectedRange)
|
||||
markAppBootstrapped()
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof StaleSessionError) {
|
||||
return { name: 'login' }
|
||||
}
|
||||
if (error instanceof ApiNetworkError) {
|
||||
setBootstrapFailed(true)
|
||||
return true
|
||||
}
|
||||
authStore.logout()
|
||||
resetAppBootstrap()
|
||||
return { name: 'login' }
|
||||
|
||||
+17
-3
@@ -1,7 +1,9 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { api, setAuthToken, getAuthToken } from '@/api/client'
|
||||
import { applyMeResponse } from '@/api/sync'
|
||||
import { resetAppBootstrap } from '@/api/bootstrapState'
|
||||
import { api } from '@/api/client'
|
||||
import { getAuthToken, setAuthToken } from '@/api/token'
|
||||
import { invalidateSession } from '@/api/authSession'
|
||||
import { markAppBootstrapped, resetAppBootstrap } from '@/api/bootstrapState'
|
||||
import { applyMeResponse, resetUserStores } from '@/api/sync'
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
@@ -26,30 +28,42 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
|
||||
async login(username: string, password: string) {
|
||||
invalidateSession()
|
||||
resetUserStores()
|
||||
resetAppBootstrap()
|
||||
|
||||
const data = await api.login(username, password)
|
||||
this.token = data.token
|
||||
this.username = data.user.username
|
||||
this.displayName = data.user.name
|
||||
setAuthToken(data.token)
|
||||
applyMeResponse(data.me)
|
||||
markAppBootstrapped()
|
||||
return data
|
||||
},
|
||||
|
||||
async register(username: string, password: string, name?: string) {
|
||||
invalidateSession()
|
||||
resetUserStores()
|
||||
resetAppBootstrap()
|
||||
|
||||
const data = await api.register(username, password, name)
|
||||
this.token = data.token
|
||||
this.username = data.user.username
|
||||
this.displayName = data.user.name
|
||||
setAuthToken(data.token)
|
||||
applyMeResponse(data.me)
|
||||
markAppBootstrapped()
|
||||
return data
|
||||
},
|
||||
|
||||
logout() {
|
||||
invalidateSession()
|
||||
this.token = null
|
||||
this.username = null
|
||||
this.displayName = null
|
||||
setAuthToken(null)
|
||||
resetUserStores()
|
||||
resetAppBootstrap()
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { api, type DailyChallengeCompleteResponse, type DailyChallengeResponse } from '@/api/client'
|
||||
import { fetchCatalogOrdered } from '@/api/catalogFetchSync'
|
||||
import { StaleSessionError } from '@/api/errors'
|
||||
import { runMeMutation } from '@/api/sync'
|
||||
|
||||
type LoadStatus = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
interface DailyChallengeState {
|
||||
challenge: DailyChallengeResponse | null
|
||||
loadStatus: LoadStatus
|
||||
loadError: string | null
|
||||
submitPending: boolean
|
||||
lastFeedback: string | null
|
||||
}
|
||||
|
||||
export const useDailyChallengeStore = defineStore('dailyChallenge', {
|
||||
state: (): DailyChallengeState => ({
|
||||
challenge: null,
|
||||
loadStatus: 'idle',
|
||||
loadError: null,
|
||||
submitPending: false,
|
||||
lastFeedback: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isCompleted: (state) => state.challenge?.completed ?? false,
|
||||
},
|
||||
|
||||
actions: {
|
||||
async loadChallenge(options?: { force?: boolean }) {
|
||||
if (this.loadStatus === 'loading') return
|
||||
if (this.loadStatus === 'ready' && this.challenge && !options?.force) return
|
||||
|
||||
this.loadStatus = 'loading'
|
||||
this.loadError = null
|
||||
try {
|
||||
this.challenge = await fetchCatalogOrdered('daily-challenge', () => api.getDailyChallenge())
|
||||
this.loadStatus = 'ready'
|
||||
} catch (error) {
|
||||
if (error instanceof StaleSessionError) {
|
||||
this.loadStatus = this.challenge ? 'ready' : 'idle'
|
||||
return
|
||||
}
|
||||
this.loadStatus = 'error'
|
||||
this.loadError =
|
||||
error instanceof Error ? error.message : 'خطا در دریافت چالش آموزشی'
|
||||
}
|
||||
},
|
||||
|
||||
async completeOption(optionId: string): Promise<DailyChallengeCompleteResponse> {
|
||||
if (this.submitPending) {
|
||||
throw new Error('در حال ارسال...')
|
||||
}
|
||||
this.submitPending = true
|
||||
try {
|
||||
const data = await runMeMutation(() => api.completeDailyChallenge(optionId))
|
||||
this.lastFeedback = data.feedback
|
||||
if (this.challenge) {
|
||||
this.challenge = { ...this.challenge, completed: true }
|
||||
}
|
||||
return data
|
||||
} finally {
|
||||
this.submitPending = false
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
+60
-21
@@ -1,15 +1,35 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { MAP_LEVELS } from '@/data/mockData'
|
||||
import { api } from '@/api/client'
|
||||
import { applyMeResponse } from '@/api/sync'
|
||||
import { fetchCatalogOrdered } from '@/api/catalogFetchSync'
|
||||
import { StaleSessionError } from '@/api/errors'
|
||||
import { runMeMutation } from '@/api/sync'
|
||||
import type { GameCatalogItem } from '@/types/catalog.types'
|
||||
|
||||
export interface LegacyMapLevelSnapshot {
|
||||
id: number
|
||||
title: string
|
||||
stars: number
|
||||
maxStars: number
|
||||
status: 'completed' | 'current' | 'unlocked' | 'locked'
|
||||
lessonRoute?: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type CatalogLoadStatus = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
interface GamesState {
|
||||
completedGames: string[]
|
||||
claimedRewards: string[]
|
||||
ownedItems: string[]
|
||||
mapLevels: typeof MAP_LEVELS
|
||||
/** Legacy mirror from `/api/me` (home/widgets). Gold Land map uses `/api/gold-land/progress`. */
|
||||
mapLevels: LegacyMapLevelSnapshot[]
|
||||
totalStars: number
|
||||
maxStars: number
|
||||
catalog: GameCatalogItem[]
|
||||
catalogLoadStatus: CatalogLoadStatus
|
||||
catalogLoadError: string | null
|
||||
claimPending: boolean
|
||||
}
|
||||
|
||||
export const useGamesStore = defineStore('games', {
|
||||
@@ -17,13 +37,19 @@ export const useGamesStore = defineStore('games', {
|
||||
completedGames: [],
|
||||
claimedRewards: [],
|
||||
ownedItems: [],
|
||||
mapLevels: MAP_LEVELS.map((l) => ({ ...l })),
|
||||
mapLevels: [],
|
||||
totalStars: 0,
|
||||
maxStars: 39,
|
||||
maxStars: 0,
|
||||
catalog: [],
|
||||
catalogLoadStatus: 'idle',
|
||||
catalogLoadError: null,
|
||||
claimPending: false,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
mapProgress: (state) => (state.totalStars / state.maxStars) * 100,
|
||||
mapProgress: (state) =>
|
||||
state.maxStars > 0 ? (state.totalStars / state.maxStars) * 100 : 0,
|
||||
gameById: (state) => (gameId: string) => state.catalog.find((g) => g.id === gameId) ?? null,
|
||||
},
|
||||
|
||||
actions: {
|
||||
@@ -35,23 +61,36 @@ export const useGamesStore = defineStore('games', {
|
||||
return this.ownedItems.includes(itemId)
|
||||
},
|
||||
|
||||
async loadCatalog(options?: { force?: boolean }) {
|
||||
if (this.catalogLoadStatus === 'loading') return
|
||||
if (this.catalogLoadStatus === 'ready' && this.catalog.length > 0 && !options?.force) return
|
||||
|
||||
this.catalogLoadStatus = 'loading'
|
||||
this.catalogLoadError = null
|
||||
try {
|
||||
this.catalog = await fetchCatalogOrdered('games-catalog', () => api.getGames())
|
||||
this.catalogLoadStatus = 'ready'
|
||||
} catch (error) {
|
||||
if (error instanceof StaleSessionError) {
|
||||
this.catalogLoadStatus = this.catalog.length > 0 ? 'ready' : 'idle'
|
||||
return
|
||||
}
|
||||
this.catalogLoadStatus = 'error'
|
||||
this.catalogLoadError =
|
||||
error instanceof Error ? error.message : 'خطا در دریافت لیست بازیها'
|
||||
}
|
||||
},
|
||||
|
||||
async claimReward(gameId: string): Promise<boolean> {
|
||||
if (this.claimedRewards.includes(gameId)) return false
|
||||
const data = await api.completeGame(gameId)
|
||||
applyMeResponse(data)
|
||||
return true
|
||||
},
|
||||
|
||||
async purchaseItem(itemId: string): Promise<boolean> {
|
||||
if (this.ownedItems.includes(itemId)) return false
|
||||
const data = await api.purchaseProduct(itemId)
|
||||
applyMeResponse(data)
|
||||
return true
|
||||
},
|
||||
|
||||
async completeLevel(levelId: number, stars: number) {
|
||||
const data = await api.completeMapLevel(levelId, stars)
|
||||
applyMeResponse(data)
|
||||
if (this.claimPending) return false
|
||||
this.claimPending = true
|
||||
try {
|
||||
await runMeMutation(() => api.completeGame(gameId))
|
||||
return true
|
||||
} finally {
|
||||
this.claimPending = false
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { GOLD_PRICE, PRICE_HISTORY } from '@/data/mockData'
|
||||
import { api } from '@/api/client'
|
||||
import { applyMeResponse } from '@/api/sync'
|
||||
import { runMeMutation } from '@/api/sync'
|
||||
|
||||
export type TimeRange = '1d' | '7d' | '30d' | '90d' | '1y'
|
||||
|
||||
@@ -14,17 +13,16 @@ interface MarketState {
|
||||
|
||||
export const useMarketStore = defineStore('market', {
|
||||
state: (): MarketState => ({
|
||||
currentPrice: GOLD_PRICE,
|
||||
dailyChange: 2.4,
|
||||
currentPrice: 0,
|
||||
dailyChange: 0,
|
||||
selectedRange: '1d',
|
||||
priceHistoryCache: {},
|
||||
}),
|
||||
|
||||
getters: {
|
||||
priceHistory: (state) =>
|
||||
state.priceHistoryCache[state.selectedRange] ?? PRICE_HISTORY[state.selectedRange],
|
||||
priceHistory: (state) => state.priceHistoryCache[state.selectedRange] ?? [],
|
||||
labels(state): string[] {
|
||||
const history = state.priceHistoryCache[state.selectedRange] ?? PRICE_HISTORY[state.selectedRange]
|
||||
const history = state.priceHistoryCache[state.selectedRange] ?? []
|
||||
return history.map((_, i) => {
|
||||
if (state.selectedRange === '1d') return `${i}:۰۰`
|
||||
return `${i + 1}`
|
||||
@@ -45,7 +43,7 @@ export const useMarketStore = defineStore('market', {
|
||||
this.selectedRange = range
|
||||
await Promise.all([
|
||||
this.loadQuote(range),
|
||||
api.setMarketRange(range).then(applyMeResponse),
|
||||
runMeMutation(() => api.setMarketRange(range)),
|
||||
])
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { api } from '@/api/client'
|
||||
import { fetchCatalogOrdered } from '@/api/catalogFetchSync'
|
||||
import { StaleSessionError } from '@/api/errors'
|
||||
import { runMeMutation } from '@/api/sync'
|
||||
import type { StoreProduct, StoreProductCategory } from '@/types/catalog.types'
|
||||
|
||||
type LoadStatus = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
interface StoreCatalogState {
|
||||
products: StoreProduct[]
|
||||
loadStatus: LoadStatus
|
||||
loadError: string | null
|
||||
purchasePending: boolean
|
||||
}
|
||||
|
||||
export const STORE_DISPLAY_ORDER = ['suit', 'castle', 'sea-bg', 'pet', 'crown', 'boost-xp']
|
||||
|
||||
export const useStoreCatalogStore = defineStore('storeCatalog', {
|
||||
state: (): StoreCatalogState => ({
|
||||
products: [],
|
||||
loadStatus: 'idle',
|
||||
loadError: null,
|
||||
purchasePending: false,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
productsByCategory:
|
||||
(state) =>
|
||||
(category: StoreProductCategory | 'all'): StoreProduct[] => {
|
||||
const list =
|
||||
category === 'all'
|
||||
? state.products
|
||||
: state.products.filter((p) => p.category === category)
|
||||
return [...list].sort(
|
||||
(a, b) => STORE_DISPLAY_ORDER.indexOf(a.id) - STORE_DISPLAY_ORDER.indexOf(b.id),
|
||||
)
|
||||
},
|
||||
isEmpty: (state) => state.loadStatus === 'ready' && state.products.length === 0,
|
||||
},
|
||||
|
||||
actions: {
|
||||
async loadProducts(options?: { force?: boolean }) {
|
||||
if (this.loadStatus === 'loading') return
|
||||
if (this.loadStatus === 'ready' && !options?.force) return
|
||||
|
||||
this.loadStatus = 'loading'
|
||||
this.loadError = null
|
||||
try {
|
||||
this.products = await fetchCatalogOrdered('store-products', () => api.getStoreProducts())
|
||||
this.loadStatus = 'ready'
|
||||
} catch (error) {
|
||||
if (error instanceof StaleSessionError) {
|
||||
this.loadStatus = this.products.length > 0 ? 'ready' : 'idle'
|
||||
return
|
||||
}
|
||||
this.loadStatus = 'error'
|
||||
this.loadError =
|
||||
error instanceof Error ? error.message : 'خطا در دریافت محصولات فروشگاه'
|
||||
}
|
||||
},
|
||||
|
||||
async purchaseProduct(productId: string) {
|
||||
if (this.purchasePending) return
|
||||
this.purchasePending = true
|
||||
try {
|
||||
await runMeMutation(() => api.purchaseProduct(productId))
|
||||
} finally {
|
||||
this.purchasePending = false
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
+20
-12
@@ -1,5 +1,12 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ACHIEVEMENTS } from '@/data/mockData'
|
||||
|
||||
export interface AchievementSnapshot {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
unlocked: boolean
|
||||
}
|
||||
|
||||
interface UserState {
|
||||
name: string
|
||||
@@ -9,27 +16,28 @@ interface UserState {
|
||||
coins: number
|
||||
completedChallenges: string[]
|
||||
completedLessons: string[]
|
||||
achievements: typeof ACHIEVEMENTS
|
||||
achievements: AchievementSnapshot[]
|
||||
missionProgress: number
|
||||
missionTarget: number
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: (): UserState => ({
|
||||
name: 'علی',
|
||||
level: 12,
|
||||
xp: 850,
|
||||
maxXp: 1200,
|
||||
coins: 1350,
|
||||
name: '',
|
||||
level: 1,
|
||||
xp: 0,
|
||||
maxXp: 100,
|
||||
coins: 0,
|
||||
completedChallenges: [],
|
||||
completedLessons: [],
|
||||
achievements: ACHIEVEMENTS.map((a) => ({ ...a })),
|
||||
missionProgress: 12,
|
||||
missionTarget: 20,
|
||||
achievements: [],
|
||||
missionProgress: 0,
|
||||
missionTarget: 1,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
xpProgress: (state) => (state.xp / state.maxXp) * 100,
|
||||
missionPercent: (state) => (state.missionProgress / state.missionTarget) * 100,
|
||||
xpProgress: (state) => (state.maxXp > 0 ? (state.xp / state.maxXp) * 100 : 0),
|
||||
missionPercent: (state) =>
|
||||
state.missionTarget > 0 ? (state.missionProgress / state.missionTarget) * 100 : 0,
|
||||
},
|
||||
})
|
||||
|
||||
+13
-9
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { api } from '@/api/client'
|
||||
import { applyMeResponse } from '@/api/sync'
|
||||
import { runMeMutation } from '@/api/sync'
|
||||
|
||||
export interface Transaction {
|
||||
id: string
|
||||
@@ -19,8 +19,8 @@ interface WalletState {
|
||||
|
||||
export const useWalletStore = defineStore('wallet', {
|
||||
state: (): WalletState => ({
|
||||
cash: 100_000,
|
||||
goldGrams: 42.5,
|
||||
cash: 0,
|
||||
goldGrams: 0,
|
||||
transactions: [],
|
||||
}),
|
||||
|
||||
@@ -32,21 +32,25 @@ export const useWalletStore = defineStore('wallet', {
|
||||
actions: {
|
||||
async buyGold(grams: number, _pricePerGram?: number) {
|
||||
try {
|
||||
const data = await api.buyGold(grams)
|
||||
applyMeResponse(data)
|
||||
await runMeMutation(() => api.buyGold(grams))
|
||||
return { success: true, message: 'خرید با موفقیت انجام شد!' }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : 'خطا در خرید' }
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'خطا در خرید',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async sellGold(grams: number, _pricePerGram?: number) {
|
||||
try {
|
||||
const data = await api.sellGold(grams)
|
||||
applyMeResponse(data)
|
||||
await runMeMutation(() => api.sellGold(grams))
|
||||
return { success: true, message: 'فروش با موفقیت انجام شد!' }
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : 'خطا در فروش' }
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'خطا در فروش',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export type StoreProductCategory = 'special' | 'visual' | 'boost'
|
||||
|
||||
export interface StoreProduct {
|
||||
id: string
|
||||
title: string
|
||||
price: number
|
||||
category: StoreProductCategory
|
||||
emoji: string
|
||||
}
|
||||
|
||||
export interface GameCatalogItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
route?: string
|
||||
color: string
|
||||
rewardCoins?: number
|
||||
rewardXp?: number
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Mirrors backend gold-trade.helpers (buy ceil / sell floor, µg precision). */
|
||||
export const GOLD_PRICE_TOMAN_PER_GRAM = 1_830_000
|
||||
export const MICROGRAMS_PER_GRAM = 1_000_000
|
||||
export const MIN_GOLD_TRADE_GRAMS = 0.000_001
|
||||
|
||||
export function normalizeGoldGrams(grams: number): number {
|
||||
return Math.round(grams * MICROGRAMS_PER_GRAM) / MICROGRAMS_PER_GRAM
|
||||
}
|
||||
|
||||
function tomansNumeratorMicro(microGrams: number): bigint {
|
||||
return BigInt(microGrams) * BigInt(GOLD_PRICE_TOMAN_PER_GRAM)
|
||||
}
|
||||
|
||||
export function tomansForGoldPurchase(grams: number): number {
|
||||
const micro = Math.round(normalizeGoldGrams(grams) * MICROGRAMS_PER_GRAM)
|
||||
const num = tomansNumeratorMicro(micro)
|
||||
return Number((num + BigInt(MICROGRAMS_PER_GRAM - 1)) / BigInt(MICROGRAMS_PER_GRAM))
|
||||
}
|
||||
|
||||
export function tomansForGoldSale(grams: number): number {
|
||||
const micro = Math.round(normalizeGoldGrams(grams) * MICROGRAMS_PER_GRAM)
|
||||
const num = tomansNumeratorMicro(micro)
|
||||
return Number(num / BigInt(MICROGRAMS_PER_GRAM))
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Stage 4 — store / games / daily challenge API wiring and stale-response policy.
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMeFixture } from './fixtures/me.fixture'
|
||||
import { installFetchMock, resetAppTestState, type MockResponse } from './helpers/fetchMock'
|
||||
|
||||
test.beforeEach(async () => {
|
||||
installFetchMock()
|
||||
await resetAppTestState()
|
||||
setActivePinia(createPinia())
|
||||
const { resetUserStores } = await import('../src/api/sync')
|
||||
resetUserStores()
|
||||
})
|
||||
|
||||
test('store catalog loads products from GET /store/products', async () => {
|
||||
const products = [
|
||||
{ id: 'castle', title: 'قلعه', price: 5000, category: 'special', emoji: '🏰' },
|
||||
]
|
||||
globalThis.routeHandlers!.set('/store/products', () => ({ status: 200, body: products }))
|
||||
|
||||
const { useStoreCatalogStore } = await import('../src/stores/storeCatalog')
|
||||
const store = useStoreCatalogStore()
|
||||
await store.loadProducts()
|
||||
assert.equal(store.loadStatus, 'ready')
|
||||
assert.equal(store.products.length, 1)
|
||||
assert.equal(store.products[0]?.id, 'castle')
|
||||
})
|
||||
|
||||
test('store GET error surfaces retryable error state', async () => {
|
||||
globalThis.routeHandlers!.set('/store/products', () => ({
|
||||
status: 500,
|
||||
body: { error: 'خطای سرور' },
|
||||
}))
|
||||
|
||||
const { useStoreCatalogStore } = await import('../src/stores/storeCatalog')
|
||||
const store = useStoreCatalogStore()
|
||||
await store.loadProducts()
|
||||
assert.equal(store.loadStatus, 'error')
|
||||
assert.ok(store.loadError)
|
||||
})
|
||||
|
||||
test('successful purchase applies server me without optimistic coin change', async () => {
|
||||
const meBefore = createMeFixture({ coins: 1000 })
|
||||
const meAfter = createMeFixture({ coins: 500 })
|
||||
meAfter.progress.ownedItems = ['castle']
|
||||
|
||||
const { applyMeResponse } = await import('../src/api/sync')
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
const { useStoreCatalogStore } = await import('../src/stores/storeCatalog')
|
||||
|
||||
applyMeResponse(meBefore)
|
||||
assert.equal(useUserStore().coins, 1000)
|
||||
|
||||
globalThis.routeHandlers!.set('/store/purchases', () => ({
|
||||
status: 200,
|
||||
body: meAfter,
|
||||
delayMs: 15,
|
||||
}))
|
||||
|
||||
const store = useStoreCatalogStore()
|
||||
const purchase = store.purchaseProduct('castle')
|
||||
assert.equal(useUserStore().coins, 1000)
|
||||
await purchase
|
||||
assert.equal(useUserStore().coins, 500)
|
||||
assert.ok(useUserStore().coins !== 1000)
|
||||
})
|
||||
|
||||
test('failed purchase does not change coins', async () => {
|
||||
const me = createMeFixture({ coins: 200 })
|
||||
const { applyMeResponse } = await import('../src/api/sync')
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
const { useStoreCatalogStore } = await import('../src/stores/storeCatalog')
|
||||
|
||||
applyMeResponse(me)
|
||||
globalThis.routeHandlers!.set('/store/purchases', () => ({
|
||||
status: 400,
|
||||
body: { error: 'سکه کافی نداری!' },
|
||||
}))
|
||||
|
||||
const store = useStoreCatalogStore()
|
||||
const { ApiClientError } = await import('../src/api/errors')
|
||||
await assert.rejects(store.purchaseProduct('castle'), ApiClientError)
|
||||
assert.equal(useUserStore().coins, 200)
|
||||
})
|
||||
|
||||
test('stale store catalog GET after logout does not repopulate products', async () => {
|
||||
const { invalidateSession } = await import('../src/api/authSession')
|
||||
const { useStoreCatalogStore } = await import('../src/stores/storeCatalog')
|
||||
const { StaleSessionError } = await import('../src/api/errors')
|
||||
|
||||
globalThis.routeHandlers!.set('/store/products', () => ({
|
||||
status: 200,
|
||||
body: [{ id: 'x', title: 'X', price: 1, category: 'boost', emoji: '⚡' }],
|
||||
delayMs: 30,
|
||||
}))
|
||||
|
||||
const store = useStoreCatalogStore()
|
||||
const load = store.loadProducts()
|
||||
invalidateSession()
|
||||
await load
|
||||
assert.equal(store.products.length, 0)
|
||||
assert.equal(store.loadStatus, 'idle')
|
||||
})
|
||||
|
||||
test('queued purchases apply in server order even when first HTTP response is slower', async () => {
|
||||
const me1 = createMeFixture({ coins: 100 })
|
||||
const me2 = createMeFixture({ coins: 250 })
|
||||
|
||||
const { applyMeResponse, runMeMutation } = await import('../src/api/sync')
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
|
||||
applyMeResponse(createMeFixture({ coins: 50 }))
|
||||
|
||||
let purchaseCall = 0
|
||||
globalThis.routeHandlers!.set('/store/purchases', () => {
|
||||
purchaseCall += 1
|
||||
if (purchaseCall === 1) {
|
||||
return { status: 200, body: me1, delayMs: 35 }
|
||||
}
|
||||
return { status: 200, body: me2 }
|
||||
})
|
||||
|
||||
const { api } = await import('../src/api/client')
|
||||
|
||||
await Promise.all([
|
||||
runMeMutation(() => api.purchaseProduct('castle')),
|
||||
runMeMutation(() => api.purchaseProduct('suit')),
|
||||
])
|
||||
assert.equal(useUserStore().coins, 250)
|
||||
})
|
||||
|
||||
test('games catalog load uses GET /games', async () => {
|
||||
globalThis.routeHandlers!.set('/games', () => ({
|
||||
status: 200,
|
||||
body: [
|
||||
{
|
||||
id: 'gold-guess',
|
||||
title: 'حدس',
|
||||
description: 'd',
|
||||
icon: '📈',
|
||||
color: '#000',
|
||||
route: '/games/gold-guess',
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const { useGamesStore } = await import('../src/stores/games')
|
||||
const games = useGamesStore()
|
||||
await games.loadCatalog()
|
||||
assert.equal(games.catalogLoadStatus, 'ready')
|
||||
assert.equal(games.catalog[0]?.id, 'gold-guess')
|
||||
})
|
||||
|
||||
test('daily challenge complete returns server feedback only after POST', async () => {
|
||||
const me = createMeFixture({ coins: 10 })
|
||||
const { applyMeResponse } = await import('../src/api/sync')
|
||||
applyMeResponse(me)
|
||||
|
||||
globalThis.routeHandlers!.set('/challenges/daily', () => ({
|
||||
status: 200,
|
||||
body: {
|
||||
id: 'daily-challenge',
|
||||
question: 'Q?',
|
||||
coinReward: 50,
|
||||
xpReward: 30,
|
||||
completed: false,
|
||||
options: [{ id: 'gold', label: 'طلا', icon: '🥇', color: '#f5b62d' }],
|
||||
},
|
||||
}))
|
||||
|
||||
globalThis.routeHandlers!.set('/challenges/daily/complete', () => ({
|
||||
status: 200,
|
||||
body: {
|
||||
...me,
|
||||
user: { ...me.user, coins: 60 },
|
||||
feedback: 'بازخورد سرور',
|
||||
alreadyCompleted: false,
|
||||
},
|
||||
}))
|
||||
|
||||
const { useDailyChallengeStore } = await import('../src/stores/dailyChallenge')
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
const challenge = useDailyChallengeStore()
|
||||
await challenge.loadChallenge()
|
||||
const result = await challenge.completeOption('gold')
|
||||
assert.equal(result.feedback, 'بازخورد سرور')
|
||||
assert.equal(challenge.lastFeedback, 'بازخورد سرور')
|
||||
assert.equal(useUserStore().coins, 60)
|
||||
})
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var routeHandlers: Map<string, (init?: RequestInit) => MockResponse | Promise<MockResponse>>
|
||||
}
|
||||
Vendored
+56
@@ -0,0 +1,56 @@
|
||||
import type { MeResponse } from '../../src/api/client'
|
||||
|
||||
export function createMeFixture(overrides?: {
|
||||
xp?: number
|
||||
coins?: number
|
||||
totalStars?: number
|
||||
name?: string
|
||||
}): MeResponse {
|
||||
const xp = overrides?.xp ?? 10
|
||||
const coins = overrides?.coins ?? 100
|
||||
const totalStars = overrides?.totalStars ?? 0
|
||||
return {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
name: overrides?.name ?? 'Test User',
|
||||
level: 1,
|
||||
xp,
|
||||
maxXp: 100,
|
||||
coins,
|
||||
missionProgress: 0,
|
||||
missionTarget: 3,
|
||||
},
|
||||
wallet: { cash: 500, goldGrams: 0 },
|
||||
progress: {
|
||||
completedChallenges: [],
|
||||
completedLessons: [],
|
||||
completedGames: [],
|
||||
claimedRewards: [],
|
||||
ownedItems: [],
|
||||
mapLevels: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'L1',
|
||||
stars: totalStars > 0 ? 3 : 0,
|
||||
maxStars: 3,
|
||||
status: totalStars > 0 ? 'completed' : 'current',
|
||||
x: 50,
|
||||
y: 50,
|
||||
},
|
||||
],
|
||||
totalStars,
|
||||
maxStars: 18,
|
||||
},
|
||||
achievements: [],
|
||||
transactions: [],
|
||||
market: { selectedRange: '1d' },
|
||||
}
|
||||
}
|
||||
|
||||
export function createAuthLoginBody(token: string, me: MeResponse) {
|
||||
return {
|
||||
token,
|
||||
user: { id: me.user.id, name: me.user.name, username: 'testuser' },
|
||||
me,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { installFetchMock, resetAppTestState } from './helpers/fetchMock'
|
||||
|
||||
test.beforeEach(async () => {
|
||||
installFetchMock()
|
||||
await resetAppTestState()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
test('games catalog exposes gold-guess rewards from GET /games', async () => {
|
||||
globalThis.routeHandlers!.set('/games', () => ({
|
||||
status: 200,
|
||||
body: [
|
||||
{
|
||||
id: 'gold-guess',
|
||||
title: 'حدس',
|
||||
description: 'd',
|
||||
icon: '📈',
|
||||
color: '#7438c9',
|
||||
route: '/games/gold-guess',
|
||||
rewardCoins: 20,
|
||||
rewardXp: 50,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const { useGamesStore } = await import('../src/stores/games')
|
||||
const games = useGamesStore()
|
||||
await games.loadCatalog()
|
||||
const item = games.gameById('gold-guess')
|
||||
assert.ok(item)
|
||||
assert.equal(item!.rewardCoins, 20)
|
||||
assert.equal(item!.rewardXp, 50)
|
||||
})
|
||||
|
||||
test('unknown game id returns null without throwing', async () => {
|
||||
globalThis.routeHandlers!.set('/games', () => ({ status: 200, body: [] }))
|
||||
const { useGamesStore } = await import('../src/stores/games')
|
||||
const games = useGamesStore()
|
||||
await games.loadCatalog()
|
||||
assert.equal(games.gameById('missing-game'), null)
|
||||
})
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var routeHandlers: Map<string, (init?: RequestInit) => import('./helpers/fetchMock').MockResponse | Promise<import('./helpers/fetchMock').MockResponse>>
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export type MockResponse = {
|
||||
status: number
|
||||
body?: unknown
|
||||
delayMs?: number
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var routeHandlers: Map<string, (init?: RequestInit) => MockResponse | Promise<MockResponse>>
|
||||
}
|
||||
|
||||
export function installFetchMock() {
|
||||
globalThis.routeHandlers = new Map()
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
const path = url.replace(/^https?:\/\/[^/]+/, '').replace(/^\/api/, '') || url
|
||||
|
||||
const handler = globalThis.routeHandlers.get(path)
|
||||
if (!handler) {
|
||||
return new Response(JSON.stringify({ error: 'not found' }), { status: 404 })
|
||||
}
|
||||
|
||||
const spec = await handler(init)
|
||||
if (spec.delayMs && spec.delayMs > 0) {
|
||||
await new Promise((r) => setTimeout(r, spec.delayMs))
|
||||
}
|
||||
return new Response(JSON.stringify(spec.body ?? {}), {
|
||||
status: spec.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}) as typeof fetch
|
||||
}
|
||||
|
||||
export async function resetAppTestState() {
|
||||
const { resetSessionTrackingForTests } = await import('../../src/api/authSession')
|
||||
const { resetUserStateRevisionForTests, resetMeMutationSyncForTests } = await import(
|
||||
'../../src/api/sync'
|
||||
)
|
||||
const { resetMeMutationQueueForTests } = await import('../../src/api/meMutationQueue')
|
||||
const { resetCatalogFetchSyncForTests } = await import('../../src/api/catalogFetchSync')
|
||||
const { setAuthToken } = await import('../../src/api/token')
|
||||
const { resetAppBootstrap } = await import('../../src/api/bootstrapState')
|
||||
resetSessionTrackingForTests()
|
||||
resetUserStateRevisionForTests()
|
||||
resetMeMutationSyncForTests()
|
||||
resetMeMutationQueueForTests()
|
||||
resetCatalogFetchSyncForTests()
|
||||
resetAppBootstrap()
|
||||
setAuthToken(null)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Mutation queue — server-freshness vs response arrival order (same tab).
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMeFixture } from './fixtures/me.fixture'
|
||||
import { installFetchMock, resetAppTestState } from './helpers/fetchMock'
|
||||
|
||||
test.beforeEach(async () => {
|
||||
installFetchMock()
|
||||
await resetAppTestState()
|
||||
setActivePinia(createPinia())
|
||||
const { resetUserStores } = await import('../src/api/sync')
|
||||
resetUserStores()
|
||||
})
|
||||
|
||||
test('uncoordinated late applyMe simulates stale server overwrite regression', async () => {
|
||||
const { applyMeResponse } = await import('../src/api/sync')
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
|
||||
applyMeResponse(createMeFixture({ coins: 10 }))
|
||||
const newer = createMeFixture({ coins: 200 })
|
||||
const older = createMeFixture({ coins: 50 })
|
||||
|
||||
applyMeResponse(newer)
|
||||
applyMeResponse(older)
|
||||
assert.equal(useUserStore().coins, 50, 'older server snapshot must not win when applied last')
|
||||
})
|
||||
|
||||
test('serial queue: slower older server response then newer mutation keeps final newer state', async () => {
|
||||
const meOlder = createMeFixture({ coins: 50 })
|
||||
const meNewer = createMeFixture({ coins: 200 })
|
||||
|
||||
const { applyMeResponse, runMeMutation } = await import('../src/api/sync')
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
const { api } = await import('../src/api/client')
|
||||
|
||||
applyMeResponse(createMeFixture({ coins: 10 }))
|
||||
|
||||
let call = 0
|
||||
globalThis.routeHandlers!.set('/store/purchases', () => {
|
||||
call += 1
|
||||
if (call === 1) {
|
||||
return { status: 200, body: meOlder, delayMs: 35 }
|
||||
}
|
||||
return { status: 200, body: meNewer }
|
||||
})
|
||||
|
||||
const first = runMeMutation(() => api.purchaseProduct('castle'))
|
||||
const second = runMeMutation(() => api.purchaseProduct('suit'))
|
||||
await Promise.all([first, second])
|
||||
|
||||
assert.equal(useUserStore().coins, 200)
|
||||
})
|
||||
|
||||
test('failed mutation does not block the next mutation in queue', async () => {
|
||||
const meOk = createMeFixture({ coins: 180 })
|
||||
const { applyMeResponse, runMeMutation } = await import('../src/api/sync')
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
const { api } = await import('../src/api/client')
|
||||
const { ApiClientError } = await import('../src/api/errors')
|
||||
|
||||
applyMeResponse(createMeFixture({ coins: 10 }))
|
||||
|
||||
let call = 0
|
||||
globalThis.routeHandlers!.set('/store/purchases', () => {
|
||||
call += 1
|
||||
if (call === 1) {
|
||||
return { status: 400, body: { error: 'سکه کافی نداری!' } }
|
||||
}
|
||||
return { status: 200, body: meOk }
|
||||
})
|
||||
|
||||
await assert.rejects(runMeMutation(() => api.purchaseProduct('castle')), ApiClientError)
|
||||
await runMeMutation(() => api.purchaseProduct('suit'))
|
||||
assert.equal(useUserStore().coins, 180)
|
||||
})
|
||||
|
||||
test('logout prevents queued mutation from applying or sending after session change', async () => {
|
||||
const meStale = createMeFixture({ coins: 999 })
|
||||
const meCurrent = createMeFixture({ coins: 40 })
|
||||
|
||||
const { applyMeResponse, runMeMutation } = await import('../src/api/sync')
|
||||
const { invalidateSession } = await import('../src/api/authSession')
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
const { api } = await import('../src/api/client')
|
||||
const { StaleSessionError } = await import('../src/api/errors')
|
||||
|
||||
applyMeResponse(createMeFixture({ coins: 10 }))
|
||||
|
||||
globalThis.routeHandlers!.set('/store/purchases', () => ({
|
||||
status: 200,
|
||||
body: meStale,
|
||||
delayMs: 40,
|
||||
}))
|
||||
|
||||
const pending = runMeMutation(() => api.purchaseProduct('castle'))
|
||||
invalidateSession()
|
||||
applyMeResponse(meCurrent)
|
||||
|
||||
await assert.rejects(pending, StaleSessionError)
|
||||
assert.equal(useUserStore().coins, 40)
|
||||
})
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var routeHandlers: Map<string, (init?: RequestInit) => import('./helpers/fetchMock').MockResponse | Promise<import('./helpers/fetchMock').MockResponse>>
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const root = join(here, '..')
|
||||
process.env.TSX_TSCONFIG_PATH = join(root, 'tsconfig.test.json')
|
||||
|
||||
const files = [
|
||||
'session-sync.test.ts',
|
||||
'catalog-api.test.ts',
|
||||
'mutation-queue.test.ts',
|
||||
'gold-guess-rewards.test.ts',
|
||||
]
|
||||
let failed = false
|
||||
|
||||
for (const file of files) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['--import', 'tsx', '--test', '--test-reporter=spec', join(here, file)],
|
||||
{ cwd: root, stdio: 'inherit', env: process.env },
|
||||
)
|
||||
if (result.status !== 0) failed = true
|
||||
}
|
||||
|
||||
process.exit(failed ? 1 : 0)
|
||||
@@ -0,0 +1,15 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const root = join(here, '..')
|
||||
process.env.TSX_TSCONFIG_PATH = join(root, 'tsconfig.test.json')
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['--import', 'tsx', '--test', '--test-reporter=spec', join(here, 'session-sync.test.ts')],
|
||||
{ cwd: root, stdio: 'inherit', env: process.env },
|
||||
)
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Stage 3 validation — session races, stale responses, and error policy (client + Pinia).
|
||||
* Run: npm run test:session
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createAuthLoginBody, createMeFixture } from './fixtures/me.fixture'
|
||||
import { installFetchMock, resetAppTestState } from './helpers/fetchMock'
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var routeHandlers: Map<string, (init?: RequestInit) => import('./helpers/fetchMock').MockResponse | Promise<import('./helpers/fetchMock').MockResponse>>
|
||||
}
|
||||
|
||||
async function setupPinia() {
|
||||
setActivePinia(createPinia())
|
||||
const { resetUserStores } = await import('../src/api/sync')
|
||||
resetUserStores()
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
installFetchMock()
|
||||
await resetAppTestState()
|
||||
await setupPinia()
|
||||
})
|
||||
|
||||
test.after(() => {
|
||||
console.log('session-sync tests finished')
|
||||
})
|
||||
|
||||
test('delayed GET /me after logout does not mutate Pinia', async () => {
|
||||
const { api } = await import('../src/api/client')
|
||||
const { applyMeResponse } = await import('../src/api/sync')
|
||||
const { invalidateSession } = await import('../src/api/authSession')
|
||||
const { StaleSessionError } = await import('../src/api/errors')
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
|
||||
const meOld = createMeFixture({ xp: 5, coins: 50, name: 'Account A' })
|
||||
const meNew = createMeFixture({ xp: 99, coins: 999, name: 'Account B' })
|
||||
|
||||
globalThis.routeHandlers.set('/me', () => ({ status: 200, body: meOld, delayMs: 50 }))
|
||||
|
||||
const mePromise = api.getMe()
|
||||
invalidateSession()
|
||||
applyMeResponse(meNew)
|
||||
|
||||
await assert.rejects(mePromise, StaleSessionError)
|
||||
assert.equal(useUserStore().xp, 99)
|
||||
assert.equal(useUserStore().coins, 999)
|
||||
})
|
||||
|
||||
test('delayed GET /me after account switch throws StaleSessionError', async () => {
|
||||
const { api } = await import('../src/api/client')
|
||||
const { applyMeResponse } = await import('../src/api/sync')
|
||||
const { invalidateSession } = await import('../src/api/authSession')
|
||||
const { StaleSessionError } = await import('../src/api/errors')
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
|
||||
const meA = createMeFixture({ xp: 1, name: 'A' })
|
||||
const meB = createMeFixture({ xp: 2, name: 'B' })
|
||||
|
||||
globalThis.routeHandlers.set('/me', () => ({ status: 200, body: meA, delayMs: 30 }))
|
||||
|
||||
const p = api.getMe()
|
||||
invalidateSession()
|
||||
applyMeResponse(meB)
|
||||
|
||||
await assert.rejects(p, StaleSessionError)
|
||||
assert.equal(useUserStore().xp, 2)
|
||||
})
|
||||
|
||||
test('delayed 401 from prior session does not clear current session', async () => {
|
||||
const { api } = await import('../src/api/client')
|
||||
const { applyMeResponse } = await import('../src/api/sync')
|
||||
const { invalidateSession, setUnauthorizedHandler } = await import('../src/api/authSession')
|
||||
const { setAuthToken, getAuthToken } = await import('../src/api/token')
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
|
||||
let unauthorizedCalls = 0
|
||||
setUnauthorizedHandler(() => {
|
||||
unauthorizedCalls += 1
|
||||
})
|
||||
|
||||
globalThis.routeHandlers.set('/me', () => ({ status: 401, body: { error: 'expired' }, delayMs: 25 }))
|
||||
|
||||
setAuthToken('token-a')
|
||||
const stale401 = api.getMe()
|
||||
|
||||
invalidateSession()
|
||||
setAuthToken('token-b')
|
||||
applyMeResponse(createMeFixture({ xp: 77, name: 'B' }))
|
||||
|
||||
await stale401.catch(() => undefined)
|
||||
|
||||
assert.equal(unauthorizedCalls, 0)
|
||||
assert.equal(getAuthToken(), 'token-b')
|
||||
assert.equal(useUserStore().xp, 77)
|
||||
})
|
||||
|
||||
test('stale login response cannot replace newer login token and state', async () => {
|
||||
const { api } = await import('../src/api/client')
|
||||
const { invalidateSession } = await import('../src/api/authSession')
|
||||
const { setAuthToken, getAuthToken } = await import('../src/api/token')
|
||||
const { applyMeResponse } = await import('../src/api/sync')
|
||||
const { StaleSessionError } = await import('../src/api/errors')
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
|
||||
const meSlow = createMeFixture({ xp: 1, name: 'Slow' })
|
||||
const meFast = createMeFixture({ xp: 50, name: 'Fast' })
|
||||
|
||||
globalThis.routeHandlers.set('/auth/login', (init) => {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as { username?: string }
|
||||
if (body.username === 'slow') {
|
||||
return { status: 200, body: createAuthLoginBody('token-slow', meSlow), delayMs: 40 }
|
||||
}
|
||||
return { status: 200, body: createAuthLoginBody('token-fast', meFast) }
|
||||
})
|
||||
|
||||
invalidateSession()
|
||||
const slowLogin = api.login('slow', 'pass')
|
||||
|
||||
invalidateSession()
|
||||
setAuthToken(null)
|
||||
const fast = await api.login('fast', 'pass')
|
||||
applyMeResponse(fast.me)
|
||||
setAuthToken(fast.token)
|
||||
|
||||
await assert.rejects(slowLogin, StaleSessionError)
|
||||
assert.equal(getAuthToken(), 'token-fast')
|
||||
assert.equal(useUserStore().xp, 50)
|
||||
})
|
||||
|
||||
test('stale bootstrap /me cannot roll back XP after lesson complete (same session)', async () => {
|
||||
const { bootstrapFromServer, applyMeResponse, captureUserStateRevision } = await import(
|
||||
'../src/api/sync'
|
||||
)
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
const { useGamesStore } = await import('../src/stores/games')
|
||||
|
||||
const meBootstrap = createMeFixture({ xp: 10, coins: 100, totalStars: 0 })
|
||||
const meAfterLesson = createMeFixture({ xp: 55, coins: 130, totalStars: 3 })
|
||||
|
||||
globalThis.routeHandlers.set('/me', () => ({ status: 200, body: meBootstrap, delayMs: 35 }))
|
||||
|
||||
const revBefore = captureUserStateRevision()
|
||||
const bootstrapPromise = bootstrapFromServer()
|
||||
|
||||
applyMeResponse(meAfterLesson)
|
||||
|
||||
await bootstrapPromise
|
||||
assert.ok(captureUserStateRevision() > revBefore)
|
||||
|
||||
assert.equal(useUserStore().xp, 55)
|
||||
assert.equal(useUserStore().coins, 130)
|
||||
assert.equal(useGamesStore().totalStars, 3)
|
||||
})
|
||||
|
||||
test('out-of-order gold-land progress responses keep newest map state', async () => {
|
||||
const { goldLandApi } = await import('../src/modules/gold-land/services/goldLand.api')
|
||||
const { StaleSessionError } = await import('../src/api/errors')
|
||||
|
||||
let call = 0
|
||||
globalThis.routeHandlers.set('/gold-land/progress', () => {
|
||||
call += 1
|
||||
if (call === 1) {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
levels: [{ id: 1, title: 'Old', status: 'available', stars: 0, maxStars: 3 }],
|
||||
},
|
||||
delayMs: 40,
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
levels: [{ id: 1, title: 'New', status: 'completed', stars: 3, maxStars: 3 }],
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const slow = goldLandApi.getProgress()
|
||||
const fast = await goldLandApi.getProgress()
|
||||
assert.equal(fast.levels[0]?.title, 'New')
|
||||
assert.equal(fast.levels[0]?.stars, 3)
|
||||
|
||||
await assert.rejects(slow, StaleSessionError)
|
||||
})
|
||||
|
||||
test('401 on protected route for current session invokes unauthorized handler', async () => {
|
||||
const { api } = await import('../src/api/client')
|
||||
const { setUnauthorizedHandler } = await import('../src/api/authSession')
|
||||
const { setAuthToken, getAuthToken } = await import('../src/api/token')
|
||||
|
||||
let cleared = false
|
||||
setUnauthorizedHandler(() => {
|
||||
cleared = true
|
||||
})
|
||||
setAuthToken('live-token')
|
||||
|
||||
globalThis.routeHandlers.set('/me', () => ({ status: 401, body: { error: 'Unauthorized' } }))
|
||||
|
||||
await assert.rejects(api.getMe(), (e: Error) => e.name === 'ApiUnauthorizedError')
|
||||
assert.equal(cleared, true)
|
||||
assert.equal(getAuthToken(), null)
|
||||
})
|
||||
|
||||
test('wrong login password does not invoke global unauthorized handler', async () => {
|
||||
const { api } = await import('../src/api/client')
|
||||
const { setUnauthorizedHandler } = await import('../src/api/authSession')
|
||||
const { setAuthToken, getAuthToken } = await import('../src/api/token')
|
||||
|
||||
let cleared = false
|
||||
setUnauthorizedHandler(() => {
|
||||
cleared = true
|
||||
})
|
||||
setAuthToken(null)
|
||||
|
||||
globalThis.routeHandlers.set('/auth/login', () => ({
|
||||
status: 401,
|
||||
body: { error: 'نام کاربری یا رمز عبور اشتباه است' },
|
||||
}))
|
||||
|
||||
await assert.rejects(api.login('demo', 'wrong'), (e: Error) => e.name === 'ApiUnauthorizedError')
|
||||
assert.equal(cleared, false)
|
||||
assert.equal(getAuthToken(), null)
|
||||
})
|
||||
|
||||
test('network error on bootstrap getMe preserves token', async () => {
|
||||
const { api } = await import('../src/api/client')
|
||||
const { setAuthToken, getAuthToken } = await import('../src/api/token')
|
||||
const { ApiNetworkError } = await import('../src/api/errors')
|
||||
|
||||
setAuthToken('persist-me')
|
||||
globalThis.routeHandlers.set('/me', () => {
|
||||
throw new TypeError('fetch failed')
|
||||
})
|
||||
|
||||
await assert.rejects(api.getMe(), ApiNetworkError)
|
||||
assert.equal(getAuthToken(), 'persist-me')
|
||||
})
|
||||
|
||||
test('logout resets user, wallet, games stores and progress sync', async () => {
|
||||
const { applyMeResponse, resetUserStores } = await import('../src/api/sync')
|
||||
const { useUserStore } = await import('../src/stores/user')
|
||||
const { useWalletStore } = await import('../src/stores/wallet')
|
||||
const { useGamesStore } = await import('../src/stores/games')
|
||||
const { goldLandApi } = await import('../src/modules/gold-land/services/goldLand.api')
|
||||
|
||||
applyMeResponse(createMeFixture({ xp: 42, coins: 200, totalStars: 6 }))
|
||||
assert.equal(useUserStore().xp, 42)
|
||||
assert.equal(useWalletStore().cash, 500)
|
||||
|
||||
globalThis.routeHandlers.set('/gold-land/progress', () => ({
|
||||
status: 200,
|
||||
body: { levels: [{ id: 1, title: 'X', status: 'completed', stars: 3, maxStars: 3 }] },
|
||||
}))
|
||||
await goldLandApi.getProgress()
|
||||
|
||||
resetUserStores()
|
||||
|
||||
assert.equal(useUserStore().xp, 0)
|
||||
assert.equal(useUserStore().coins, 0)
|
||||
assert.equal(useWalletStore().cash, 0)
|
||||
assert.equal(useGamesStore().totalStars, 0)
|
||||
|
||||
let call = 0
|
||||
globalThis.routeHandlers.set('/gold-land/progress', () => {
|
||||
call += 1
|
||||
if (call === 1) {
|
||||
return {
|
||||
status: 200,
|
||||
body: { levels: [{ id: 1, title: 'Stale', status: 'available', stars: 0, maxStars: 3 }] },
|
||||
delayMs: 20,
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: { levels: [{ id: 1, title: 'Fresh', status: 'completed', stars: 3, maxStars: 3 }] },
|
||||
}
|
||||
})
|
||||
|
||||
const stale = goldLandApi.getProgress()
|
||||
resetUserStores()
|
||||
const fresh = await goldLandApi.getProgress()
|
||||
assert.equal(fresh.levels[0]?.title, 'Fresh')
|
||||
const { StaleSessionError } = await import('../src/api/errors')
|
||||
await assert.rejects(stale, StaleSessionError)
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"include": ["src/**/*", "test/**/*"],
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
+5
-6
@@ -5,22 +5,21 @@ import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
const enableVueDevTools = process.env.VITE_E2E !== '1'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
plugins: [vue(), ...(enableVueDevTools ? [vueDevTools()] : [])],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
port: Number(process.env.VITE_DEV_PORT) || 5174,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:3000',
|
||||
target: process.env.VITE_DEV_API_TARGET || 'http://127.0.0.1:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user