feat: complete server-backed six-lesson app and trial tooling
Frontend CI / unit (test, type-check, build) (push) Successful in 1m11s
Frontend CI / browser E2E (frontend + pinned backend) (push) Failing after 2m48s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-12 14:40:37 +03:30
co-authored by Cursor
parent 653f1b0c5a
commit e3f0457dc9
73 changed files with 3792 additions and 395 deletions
+8 -4
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
# Local development (optional). Default: Vite proxy sends /api to http://127.0.0.1:3000
# VITE_API_URL=/api
+98
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1 @@
22.18.0
+1 -1
View File
@@ -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
+130 -11
View File
@@ -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
View File
@@ -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
+142
View File
@@ -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 `خطای سرور`)
+6
View File
@@ -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."
}
+76
View File
@@ -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)
})
})
+8
View File
@@ -0,0 +1,8 @@
/** Valid UI choices for level 1 (what-is-money) — matches backend grader keys. */
export const level1ChoiceLabels = [
'مبادله آسان‌تر کالاها',
'درست',
'چون همه همیشه همان چیز را می‌خواهند',
'درست',
'پول وسیلهٔ مبادله و نگه‌داری ارزش است',
] as const
+17
View File
@@ -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
}
}
+60
View File
@@ -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 }
}>
}
+47
View File
@@ -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)
}
+82
View File
@@ -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()
})
})
+67
View File
@@ -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)
})
})
+66
View File
@@ -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',
},
},
],
})
+93
View File
@@ -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)
})
})
+31
View File
@@ -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
View File
@@ -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 / {
+550
View File
@@ -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
View File
@@ -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"
+125
View File
@@ -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
+60
View File
@@ -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
}
+71
View File
@@ -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)
})
+19
View File
@@ -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 })
}
+28
View File
@@ -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
View File
@@ -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" />
+38
View File
@@ -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
}
+15
View File
@@ -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
}
+46
View File
@@ -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
View File
@@ -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'
+31
View File
@@ -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
}
}
+58
View File
@@ -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
View File
@@ -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,
+22
View File
@@ -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>
-130
View File
@@ -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
View File
@@ -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
}
+86 -37
View File
@@ -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,26 +82,33 @@ 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 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>
امروز ۲۰ هزار تومان داری کجا سرمایه‌گذاری می‌کنی که در آینده سود بیشتری داشته باشه؟
</p>
<p>{{ challenge.question }}</p>
</div>
<img
:src="illustrations.professor"
alt="استاد"
class="question-panel__prof"
/>
<img :src="illustrations.professor" alt="استاد" class="question-panel__prof" />
</div>
<div class="options">
<button
v-for="(option, i) in CHALLENGE_OPTIONS"
v-for="(option, i) in challenge.options"
:key="option.id"
type="button"
class="option-btn card-enter"
@@ -90,46 +118,46 @@ async function claimReward() {
}"
:style="{
animationDelay: `${0.08 + i * 0.04}s`,
'--option-border': optionBorders[option.id],
'--option-bg': optionBackgrounds[option.id],
'--option-border': optionBorders[option.id] ?? option.color,
'--option-bg': optionBackgrounds[option.id] ?? '#0c1628',
}"
:disabled="rewardClaimed"
: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>
</div>
<Transition name="page">
<div v-if="showFeedback && selectedOption" class="feedback">
<p>{{ selectedOption.feedback }}</p>
<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">پاداش: ۵۰ سکه</span>
<img
:src="illustrations.coinLarge"
alt=""
class="reward-row__coin"
aria-hidden="true"
/>
<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
View File
@@ -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>
+73 -11
View File
@@ -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;
+3 -3
View File
@@ -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')">
شروع
+2 -1
View File
@@ -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>
+7
View File
@@ -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
+48 -5
View File
@@ -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);
+3 -1
View File
@@ -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`,
)
+2 -3
View File
@@ -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) {
+56 -16
View File
@@ -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,6 +78,17 @@ async function confirmPurchase() {
<div class="page-content page-content--no-nav store-page">
<AppHeader title="فروشگاه جوایز" show-back :coins="userStore.coins" @back="router.back()" />
<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>
<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"
@@ -100,6 +116,8 @@ async function confirmPurchase() {
@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
View File
@@ -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
View File
@@ -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()
},
},
+68
View File
@@ -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
}
},
},
})
+59 -20
View File
@@ -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)
if (this.claimPending) return false
this.claimPending = true
try {
await runMeMutation(() => api.completeGame(gameId))
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)
} finally {
this.claimPending = false
}
},
},
})
+6 -8
View File
@@ -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)),
])
},
},
+73
View File
@@ -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
View File
@@ -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
View File
@@ -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 : 'خطا در فروش',
}
}
},
},
+20
View File
@@ -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
}
+24
View File
@@ -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))
}
+197
View File
@@ -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>>
}
+56
View File
@@ -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,
}
}
+49
View File
@@ -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>>
}
+50
View File
@@ -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)
}
+109
View File
@@ -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>>
}
+26
View File
@@ -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)
+15
View File
@@ -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)
+290
View File
@@ -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)
})
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.app.json",
"include": ["src/**/*", "test/**/*"],
"compilerOptions": {
"noEmit": true
}
}
+5 -6
View File
@@ -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,
},
},