1584 lines
57 KiB
Markdown
1584 lines
57 KiB
Markdown
# Премиум Водитель PWA Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build the «Премиум Водитель» driver PWA (login via TG/MAX code → balance → top-up via T-Bank → payment auto-status → history), plus the 3 small crm2 backend endpoints it needs.
|
||
|
||
**Architecture:** Two repos. (1) crm2 backend (`c:\NewProject\crm2\backend`, branch `feat/payments-ingestion-phase0`) — add `GET /driver/payments`, `GET /driver/payment/{order_id}`, and CORS for `app.pptaxi.ru` to the existing `app/modules/payments` module. (2) New PWA `c:\NewProject\PremiumDriverApp\driver-pwa\frontend` (branch `feat/driver-pwa`) mirroring the `mechanic-pwa/frontend` stack and patterns. Cream Minimal design, mobile-first, installable PWA.
|
||
|
||
**Tech Stack:** Backend: FastAPI + SQLAlchemy async + pytest. Frontend: React 18 + Vite + TypeScript + vite-plugin-pwa + Tailwind + @tanstack/react-query + ky + zod + react-router-dom + zustand + sonner + lucide-react + vitest + @testing-library/react.
|
||
|
||
**Spec:** `PremiumDriverApp/docs/superpowers/specs/2026-06-18-premium-driver-pwa-design.md`.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**crm2 backend (modify):**
|
||
- `app/modules/payments/api_driver.py` — add `GET /driver/payments` + `GET /driver/payment/{order_id}` (driver-gated, read own `driver_payments`).
|
||
- `app/main.py` — add `https://app.pptaxi.ru` to CORS allowed origins.
|
||
|
||
**PWA `driver-pwa/frontend/` (create):**
|
||
- Project scaffold: `package.json`, `vite.config.ts`, `tsconfig*.json`, `tailwind.config.js`, `postcss.config.js`, `index.html`, `vitest.config.ts`, `.env.example`, `public/manifest.webmanifest`, `public/icons/*`.
|
||
- `src/main.tsx` — providers (Router, QueryClient, Toaster).
|
||
- `src/index.css` — Tailwind + Cream tokens + Inter.
|
||
- `src/App.tsx` — routes + auth guard.
|
||
- `src/store/auth.ts` — zustand session (token, driverId), localStorage-persisted.
|
||
- `src/api/client.ts` — ky instance (VITE_API_BASE, Bearer, 401→logout).
|
||
- `src/api/driver.ts` — typed calls + zod schemas (authRequest, authVerify, balance, topup, payments, paymentState).
|
||
- `src/lib/format.ts` — money/phone formatting (pure, tested).
|
||
- `src/lib/amount.ts` — keypad amount reducer + commission calc (pure, tested).
|
||
- `src/components/{Button,Segment,Money,Spinner,AppHeader}.tsx` — shared UI.
|
||
- `src/pages/{LoginPage,OnboardingPage,BalancePage,TopupPage,PaymentPage,HistoryPage}.tsx`.
|
||
- `src/hooks/usePaymentStatus.ts` — react-query polling hook.
|
||
|
||
All money in рубли as numbers; commission rate from `/topup` response. Run frontend commands from `driver-pwa/frontend/`; backend from `crm2/backend/`.
|
||
|
||
---
|
||
|
||
## PART A — crm2 backend prerequisites
|
||
|
||
### Task A1: GET /driver/payments (history) + GET /driver/payment/{order_id} (status)
|
||
|
||
**Files:**
|
||
- Modify: `app/modules/payments/api_driver.py`
|
||
- Test: `tests/test_payments_driver_history.py`
|
||
|
||
- [ ] **Step 1: Write the failing test** (route registration + response model shape via the pure serializer)
|
||
|
||
```python
|
||
# tests/test_payments_driver_history.py
|
||
"""Driver payments history + single-status routes registered, and serializer shape."""
|
||
from datetime import datetime, timezone
|
||
from decimal import Decimal
|
||
|
||
|
||
def test_history_routes_registered():
|
||
from app.modules.payments.api_driver import router
|
||
paths = {r.path for r in router.routes}
|
||
assert "/driver/payments" in paths
|
||
assert "/driver/payment/{order_id}" in paths
|
||
|
||
|
||
def test_serialize_payment_shape():
|
||
from app.modules.payments.api_driver import _serialize_payment
|
||
|
||
class P:
|
||
order_id = "o-1"; bucket = "Долг аренда"; amount = Decimal("100.00")
|
||
commission = Decimal("5.20"); method = "qr"; status = "PAID"
|
||
created_at = datetime(2026, 6, 18, 9, 0, tzinfo=timezone.utc)
|
||
paid_at = datetime(2026, 6, 18, 9, 1, tzinfo=timezone.utc)
|
||
|
||
d = _serialize_payment(P())
|
||
assert d["order_id"] == "o-1"
|
||
assert d["bucket"] == "Долг аренда"
|
||
assert d["amount"] == 100.0
|
||
assert d["commission"] == 5.2
|
||
assert d["method"] == "qr"
|
||
assert d["status"] == "PAID"
|
||
assert d["created_at"].startswith("2026-06-18")
|
||
assert d["paid_at"].startswith("2026-06-18")
|
||
```
|
||
|
||
- [ ] **Step 2: Run → FAIL**
|
||
|
||
Run: `python -m pytest tests/test_payments_driver_history.py -v`
|
||
Expected: FAIL — `ImportError: cannot import name '_serialize_payment'` (and missing routes).
|
||
|
||
- [ ] **Step 3: Implement** — append to `app/modules/payments/api_driver.py` (it already imports `select`, `DriverPayment`, `get_current_driver_id`, `get_db`, `AsyncSession`, `APIRouter`, `Depends`, `HTTPException`):
|
||
|
||
```python
|
||
def _serialize_payment(p) -> dict:
|
||
return {
|
||
"order_id": p.order_id,
|
||
"bucket": p.bucket,
|
||
"amount": float(p.amount),
|
||
"commission": float(p.commission),
|
||
"method": p.method,
|
||
"status": p.status,
|
||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||
"paid_at": p.paid_at.isoformat() if p.paid_at else None,
|
||
}
|
||
|
||
|
||
@router.get("/payments")
|
||
async def list_my_payments(driver_id: int = Depends(get_current_driver_id),
|
||
db: AsyncSession = Depends(get_db)):
|
||
rows = (await db.execute(
|
||
select(DriverPayment).where(DriverPayment.driver_id == driver_id)
|
||
.order_by(DriverPayment.id.desc()).limit(50)
|
||
)).scalars().all()
|
||
return {"payments": [_serialize_payment(p) for p in rows]}
|
||
|
||
|
||
@router.get("/payment/{order_id}")
|
||
async def my_payment_status(order_id: str,
|
||
driver_id: int = Depends(get_current_driver_id),
|
||
db: AsyncSession = Depends(get_db)):
|
||
p = (await db.execute(
|
||
select(DriverPayment).where(DriverPayment.order_id == order_id,
|
||
DriverPayment.driver_id == driver_id)
|
||
)).scalar_one_or_none()
|
||
if p is None:
|
||
raise HTTPException(404, "payment not found")
|
||
return {"order_id": p.order_id, "status": p.status}
|
||
```
|
||
|
||
- [ ] **Step 4: Run → PASS** (2 tests).
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/modules/payments/api_driver.py tests/test_payments_driver_history.py
|
||
git commit -m "feat(payments): driver payments history + single-status endpoints
|
||
|
||
Co-Authored-By: claude-flow <ruv@ruv.net>"
|
||
```
|
||
|
||
Working dir for this task: `c:\NewProject\crm2\backend`.
|
||
|
||
---
|
||
|
||
### Task A2: CORS allow app.pptaxi.ru
|
||
|
||
**Files:**
|
||
- Modify: `app/main.py` (CORS middleware)
|
||
- Test: `tests/test_cors_driver_app.py`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```python
|
||
# tests/test_cors_driver_app.py
|
||
"""app.pptaxi.ru is an allowed CORS origin for the driver app."""
|
||
|
||
|
||
def test_app_origin_in_cors():
|
||
import app.main as m
|
||
# CORS_ORIGINS is the source list the CORSMiddleware is built from.
|
||
assert "https://app.pptaxi.ru" in m.CORS_ORIGINS
|
||
```
|
||
|
||
- [ ] **Step 2: Run → FAIL**
|
||
|
||
Run: `python -m pytest tests/test_cors_driver_app.py -v`
|
||
Expected: FAIL — `AttributeError: module 'app.main' has no attribute 'CORS_ORIGINS'` OR the origin missing.
|
||
|
||
- [ ] **Step 3: Implement** — open `app/main.py`, find the existing `CORSMiddleware` setup. If origins are an inline list, extract them to a module-level `CORS_ORIGINS` list and add `"https://app.pptaxi.ru"`. Concretely:
|
||
- Locate `app.add_middleware(CORSMiddleware, allow_origins=[...], ...)`.
|
||
- Define above it:
|
||
```python
|
||
CORS_ORIGINS = [
|
||
# ... keep all existing origins exactly as they were ...
|
||
"https://app.pptaxi.ru",
|
||
]
|
||
```
|
||
- Change the middleware to `allow_origins=CORS_ORIGINS`.
|
||
- If the project currently uses `allow_origins=["*"]`, instead add `CORS_ORIGINS = ["*"]` (preserve behavior) — the test still passes only if the literal string is present, so in that case set `CORS_ORIGINS = ["*", "https://app.pptaxi.ru"]` and keep `allow_origins=CORS_ORIGINS`. Read the file first and preserve existing origins verbatim.
|
||
|
||
- [ ] **Step 4: Run → PASS.**
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/main.py tests/test_cors_driver_app.py
|
||
git commit -m "feat(payments): allow app.pptaxi.ru CORS origin for driver PWA
|
||
|
||
Co-Authored-By: claude-flow <ruv@ruv.net>"
|
||
```
|
||
|
||
Working dir: `c:\NewProject\crm2\backend`.
|
||
|
||
---
|
||
|
||
## PART B — PWA scaffold & foundation
|
||
|
||
All Part B tasks run from `c:\NewProject\PremiumDriverApp\driver-pwa\frontend` (branch `feat/driver-pwa`).
|
||
|
||
### Task B1: Scaffold project (config + deps)
|
||
|
||
**Files (create):** `driver-pwa/frontend/{package.json, vite.config.ts, tsconfig.json, tsconfig.app.json, tsconfig.node.json, tailwind.config.js, postcss.config.js, vitest.config.ts, index.html, .env.example, .gitignore}`
|
||
|
||
- [ ] **Step 1: Create `package.json`**
|
||
|
||
```json
|
||
{
|
||
"name": "driver-pwa",
|
||
"private": true,
|
||
"version": "0.1.0",
|
||
"type": "module",
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "tsc -b && vite build",
|
||
"preview": "vite preview",
|
||
"lint": "eslint .",
|
||
"test": "vitest run",
|
||
"test:watch": "vitest"
|
||
},
|
||
"dependencies": {
|
||
"@tanstack/react-query": "^5.51.0",
|
||
"ky": "^1.7.2",
|
||
"lucide-react": "^0.454.0",
|
||
"react": "^18.3.1",
|
||
"react-dom": "^18.3.1",
|
||
"react-router-dom": "^6.26.0",
|
||
"sonner": "^1.5.0",
|
||
"zod": "^3.23.8",
|
||
"zustand": "^4.5.5"
|
||
},
|
||
"devDependencies": {
|
||
"@testing-library/jest-dom": "^6.5.0",
|
||
"@testing-library/react": "^16.0.1",
|
||
"@testing-library/user-event": "^14.5.2",
|
||
"@types/node": "^22.5.0",
|
||
"@types/react": "^18.3.5",
|
||
"@types/react-dom": "^18.3.0",
|
||
"@vitejs/plugin-react": "^4.3.1",
|
||
"autoprefixer": "^10.4.20",
|
||
"jsdom": "^25.0.0",
|
||
"postcss": "^8.4.45",
|
||
"tailwindcss": "^3.4.10",
|
||
"typescript": "^5.5.4",
|
||
"vite": "^5.4.2",
|
||
"vite-plugin-pwa": "^0.20.5",
|
||
"vitest": "^2.0.5"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `vite.config.ts`**
|
||
|
||
```ts
|
||
import { defineConfig } from "vite";
|
||
import react from "@vitejs/plugin-react";
|
||
import { VitePWA } from "vite-plugin-pwa";
|
||
import path from "path";
|
||
|
||
export default defineConfig({
|
||
plugins: [
|
||
react(),
|
||
VitePWA({ registerType: "autoUpdate", manifest: false, includeAssets: ["icons/*.png"] }),
|
||
],
|
||
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
|
||
server: { port: 5174 },
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Create tsconfig files**
|
||
|
||
`tsconfig.json`:
|
||
```json
|
||
{
|
||
"files": [],
|
||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||
}
|
||
```
|
||
`tsconfig.app.json`:
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2021", "useDefineForClassFields": true, "lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||
"module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler",
|
||
"allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true,
|
||
"noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true,
|
||
"noUnusedParameters": true, "noFallthroughCasesInSwitch": true,
|
||
"baseUrl": ".", "paths": { "@/*": ["src/*"] }, "types": ["vitest/globals", "@testing-library/jest-dom"]
|
||
},
|
||
"include": ["src"]
|
||
}
|
||
```
|
||
`tsconfig.node.json`:
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2022", "lib": ["ES2023"], "module": "ESNext", "skipLibCheck": true,
|
||
"moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true,
|
||
"noEmit": true, "strict": true
|
||
},
|
||
"include": ["vite.config.ts", "vitest.config.ts"]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create `tailwind.config.js`, `postcss.config.js`, `vitest.config.ts`, `index.html`, `.env.example`, `.gitignore`**
|
||
|
||
`tailwind.config.js`:
|
||
```js
|
||
/** @type {import('tailwindcss').Config} */
|
||
export default {
|
||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
cream: "#F3EEE3", surface: "#FBF8F1", line: "#E4DCCB",
|
||
ink: "#1C1B19", muted: "#7C7361", neg: "#C0493A", pos: "#3B6E4A",
|
||
},
|
||
fontFamily: { sans: ["Inter", "system-ui", "sans-serif"] },
|
||
borderRadius: { xl2: "14px" },
|
||
},
|
||
},
|
||
plugins: [],
|
||
};
|
||
```
|
||
`postcss.config.js`:
|
||
```js
|
||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
||
```
|
||
`vitest.config.ts`:
|
||
```ts
|
||
import { defineConfig } from "vitest/config";
|
||
import react from "@vitejs/plugin-react";
|
||
import path from "path";
|
||
|
||
export default defineConfig({
|
||
plugins: [react()],
|
||
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
|
||
test: { globals: true, environment: "jsdom", setupFiles: ["./src/test/setup.ts"] },
|
||
});
|
||
```
|
||
`index.html`:
|
||
```html
|
||
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||
<meta name="theme-color" content="#F3EEE3" />
|
||
<link rel="manifest" href="/manifest.webmanifest" />
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet" />
|
||
<title>Премиум Водитель</title>
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
<script type="module" src="/src/main.tsx"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
`.env.example`:
|
||
```
|
||
VITE_API_BASE=https://crm.pptaxi.ru/api
|
||
```
|
||
`.gitignore`:
|
||
```
|
||
node_modules
|
||
dist
|
||
.env
|
||
.env.local
|
||
```
|
||
|
||
- [ ] **Step 5: Install + commit**
|
||
|
||
```bash
|
||
npm install
|
||
git add package.json package-lock.json vite.config.ts tsconfig*.json tailwind.config.js postcss.config.js vitest.config.ts index.html .env.example .gitignore
|
||
git commit -m "chore(driver-pwa): scaffold Vite+PWA+Tailwind project
|
||
|
||
Co-Authored-By: claude-flow <ruv@ruv.net>"
|
||
```
|
||
Expected: `npm install` completes; no app code yet.
|
||
|
||
---
|
||
|
||
### Task B2: Design tokens + entry + test setup
|
||
|
||
**Files (create):** `src/index.css`, `src/main.tsx`, `src/test/setup.ts`, `src/App.tsx` (stub)
|
||
|
||
- [ ] **Step 1: Create `src/index.css`**
|
||
|
||
```css
|
||
@tailwind base;
|
||
@tailwind components;
|
||
@tailwind utilities;
|
||
|
||
html, body, #root { height: 100%; }
|
||
body { @apply bg-cream text-ink font-sans antialiased; margin: 0; }
|
||
button { font-family: inherit; }
|
||
|
||
@layer components {
|
||
.btn-primary { @apply bg-ink text-cream rounded-xl2 py-3.5 px-4 text-sm font-bold text-center w-full active:opacity-90 disabled:opacity-50; }
|
||
.btn-ghost { @apply border border-ink text-ink rounded-xl2 py-3 px-4 text-sm font-semibold text-center w-full; }
|
||
.card { @apply bg-surface border border-line rounded-2xl; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `src/test/setup.ts`**
|
||
|
||
```ts
|
||
import "@testing-library/jest-dom/vitest";
|
||
```
|
||
|
||
- [ ] **Step 3: Create `src/App.tsx` (temporary stub — replaced in B5)**
|
||
|
||
```tsx
|
||
export default function App() {
|
||
return <div className="p-4">Премиум Водитель</div>;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create `src/main.tsx`**
|
||
|
||
```tsx
|
||
import React from "react";
|
||
import ReactDOM from "react-dom/client";
|
||
import { BrowserRouter } from "react-router-dom";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import { Toaster } from "sonner";
|
||
import App from "./App";
|
||
import "./index.css";
|
||
|
||
const queryClient = new QueryClient({
|
||
defaultOptions: { queries: { retry: 1, staleTime: 15_000 } },
|
||
});
|
||
|
||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||
<React.StrictMode>
|
||
<QueryClientProvider client={queryClient}>
|
||
<BrowserRouter>
|
||
<App />
|
||
<Toaster richColors position="top-center" />
|
||
</BrowserRouter>
|
||
</QueryClientProvider>
|
||
</React.StrictMode>
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 5: Verify build + commit**
|
||
|
||
Run: `npm run build`
|
||
Expected: build succeeds (TS + Vite).
|
||
```bash
|
||
git add src/index.css src/main.tsx src/App.tsx src/test/setup.ts
|
||
git commit -m "feat(driver-pwa): design tokens, entry, test setup
|
||
|
||
Co-Authored-By: claude-flow <ruv@ruv.net>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task B3: Pure libs — money/phone format + amount/commission
|
||
|
||
**Files (create):** `src/lib/format.ts`, `src/lib/format.test.ts`, `src/lib/amount.ts`, `src/lib/amount.test.ts`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
`src/lib/format.test.ts`:
|
||
```ts
|
||
import { describe, it, expect } from "vitest";
|
||
import { formatMoney, formatPhone, digitsOnly } from "./format";
|
||
|
||
describe("format", () => {
|
||
it("formatMoney groups thousands and adds ₽", () => {
|
||
expect(formatMoney(1000)).toBe("1 000 ₽");
|
||
expect(formatMoney(-3200)).toBe("−3 200 ₽");
|
||
expect(formatMoney(0)).toBe("0 ₽");
|
||
});
|
||
it("digitsOnly strips non-digits", () => {
|
||
expect(digitsOnly("+7 (914) 305-44-00")).toBe("79143054400");
|
||
});
|
||
it("formatPhone renders +7 mask progressively", () => {
|
||
expect(formatPhone("9143054400")).toBe("+7 914 305-44-00");
|
||
expect(formatPhone("914305")).toBe("+7 914 305");
|
||
});
|
||
});
|
||
```
|
||
`src/lib/amount.test.ts`:
|
||
```ts
|
||
import { describe, it, expect } from "vitest";
|
||
import { keypadReduce, withCommission } from "./amount";
|
||
|
||
describe("amount keypad", () => {
|
||
it("appends digits, ignores leading zero, caps length", () => {
|
||
expect(keypadReduce("0", "5")).toBe("5");
|
||
expect(keypadReduce("100", "0")).toBe("1000");
|
||
expect(keypadReduce("", "0")).toBe(""); // no leading zero
|
||
expect(keypadReduce("999999", "9")).toBe("999999"); // max 6 digits
|
||
});
|
||
it("backspace removes last digit", () => {
|
||
expect(keypadReduce("105", "back")).toBe("10");
|
||
expect(keypadReduce("", "back")).toBe("");
|
||
});
|
||
it("withCommission computes total at given rate", () => {
|
||
expect(withCommission(1000, 0.052)).toEqual({ commission: 52, total: 1052 });
|
||
expect(withCommission(333, 0.052)).toEqual({ commission: 17.32, total: 350.32 });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run → FAIL** `npm run test -- src/lib` (modules missing).
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`src/lib/format.ts`:
|
||
```ts
|
||
export function digitsOnly(s: string): string {
|
||
return (s || "").replace(/\D/g, "");
|
||
}
|
||
|
||
export function formatMoney(rub: number): string {
|
||
const neg = rub < 0;
|
||
const abs = Math.abs(Math.round(rub));
|
||
const grouped = abs.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||
return `${neg ? "−" : ""}${grouped} ₽`;
|
||
}
|
||
|
||
/** Render a Russian mobile from up-to-10 national digits as +7 914 305-44-00. */
|
||
export function formatPhone(national10: string): string {
|
||
const d = digitsOnly(national10).slice(0, 10);
|
||
const p: string[] = ["+7"];
|
||
if (d.length > 0) p.push(" " + d.slice(0, 3));
|
||
if (d.length > 3) p.push(" " + d.slice(3, 6));
|
||
if (d.length > 6) p.push("-" + d.slice(6, 8));
|
||
if (d.length > 8) p.push("-" + d.slice(8, 10));
|
||
return p.join("");
|
||
}
|
||
```
|
||
`src/lib/amount.ts`:
|
||
```ts
|
||
const MAX_DIGITS = 6;
|
||
|
||
export function keypadReduce(current: string, key: string): string {
|
||
if (key === "back") return current.slice(0, -1);
|
||
if (!/^\d$/.test(key)) return current;
|
||
if (current === "0") return key === "0" ? "0" : key; // replace leading zero
|
||
if (current === "" && key === "0") return ""; // no leading zero from empty
|
||
if (current.length >= MAX_DIGITS) return current;
|
||
return current + key;
|
||
}
|
||
|
||
export function withCommission(base: number, rate: number): { commission: number; total: number } {
|
||
const commission = Math.round(base * rate * 100) / 100;
|
||
const total = Math.round((base + commission) * 100) / 100;
|
||
return { commission, total };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run → PASS** `npm run test -- src/lib`.
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/lib/format.ts src/lib/format.test.ts src/lib/amount.ts src/lib/amount.test.ts
|
||
git commit -m "feat(driver-pwa): pure money/phone/amount helpers + tests
|
||
|
||
Co-Authored-By: claude-flow <ruv@ruv.net>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task B4: Auth store + API client + typed driver API (zod)
|
||
|
||
**Files (create):** `src/store/auth.ts`, `src/api/client.ts`, `src/api/driver.ts`, `src/api/driver.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test** (zod schemas parse backend shapes)
|
||
|
||
`src/api/driver.test.ts`:
|
||
```ts
|
||
import { describe, it, expect } from "vitest";
|
||
import { BalanceSchema, TopupSchema, PaymentsSchema, AuthRequestSchema } from "./driver";
|
||
|
||
describe("driver api schemas", () => {
|
||
it("parses balance", () => {
|
||
const v = BalanceSchema.parse({ accounts: [{ bucket: "Долг аренда", balance: -3200 }] });
|
||
expect(v.accounts[0].bucket).toBe("Долг аренда");
|
||
});
|
||
it("parses topup", () => {
|
||
const v = TopupSchema.parse({ order_id: "o1", pay_url: "https://x", commission: 52, total: 1052 });
|
||
expect(v.pay_url).toBe("https://x");
|
||
});
|
||
it("parses auth request onboarding + code_sent", () => {
|
||
expect(AuthRequestSchema.parse({ status: "code_sent", channel: "tg" }).status).toBe("code_sent");
|
||
const o = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t", max: "m" } });
|
||
expect(o.status === "onboarding" && o.deep_links.tg).toBe("t");
|
||
});
|
||
it("parses payments history", () => {
|
||
const v = PaymentsSchema.parse({ payments: [{ order_id: "o1", bucket: "b", amount: 100,
|
||
commission: 5.2, method: "qr", status: "PAID", created_at: "2026-06-18T09:00:00Z", paid_at: null }] });
|
||
expect(v.payments[0].status).toBe("PAID");
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run → FAIL** `npm run test -- src/api`.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`src/store/auth.ts`:
|
||
```ts
|
||
import { create } from "zustand";
|
||
import { persist } from "zustand/middleware";
|
||
|
||
interface AuthState {
|
||
token: string | null;
|
||
driverId: number | null;
|
||
setSession: (token: string, driverId: number) => void;
|
||
clear: () => void;
|
||
}
|
||
|
||
export const useAuth = create<AuthState>()(
|
||
persist(
|
||
(set) => ({
|
||
token: null,
|
||
driverId: null,
|
||
setSession: (token, driverId) => set({ token, driverId }),
|
||
clear: () => set({ token: null, driverId: null }),
|
||
}),
|
||
{ name: "pp-driver-auth" }
|
||
)
|
||
);
|
||
```
|
||
`src/api/client.ts`:
|
||
```ts
|
||
import ky, { type KyInstance } from "ky";
|
||
import { useAuth } from "@/store/auth";
|
||
|
||
const API_BASE = import.meta.env.VITE_API_BASE ?? "https://crm.pptaxi.ru/api";
|
||
|
||
export const api: KyInstance = ky.create({
|
||
prefixUrl: API_BASE,
|
||
timeout: 30_000,
|
||
retry: { limit: 2, methods: ["get"] },
|
||
hooks: {
|
||
beforeRequest: [
|
||
(request) => {
|
||
const token = useAuth.getState().token;
|
||
if (token) request.headers.set("Authorization", `Bearer ${token}`);
|
||
},
|
||
],
|
||
beforeError: [
|
||
(error) => {
|
||
if (error.response?.status === 401) useAuth.getState().clear();
|
||
return error;
|
||
},
|
||
],
|
||
},
|
||
});
|
||
```
|
||
`src/api/driver.ts`:
|
||
```ts
|
||
import { z } from "zod";
|
||
import { api } from "./client";
|
||
|
||
export const AuthRequestSchema = z.union([
|
||
z.object({ status: z.literal("code_sent"), channel: z.string() }),
|
||
z.object({ status: z.literal("onboarding"), deep_links: z.object({ tg: z.string(), max: z.string() }) }),
|
||
]);
|
||
export type AuthRequest = z.infer<typeof AuthRequestSchema>;
|
||
|
||
export const VerifySchema = z.object({ token: z.string(), driver_id: z.number() });
|
||
export const BalanceSchema = z.object({
|
||
accounts: z.array(z.object({ bucket: z.string(), balance: z.number() })),
|
||
});
|
||
export const TopupSchema = z.object({
|
||
order_id: z.string(), pay_url: z.string(), commission: z.number(), total: z.number(),
|
||
});
|
||
export const PaymentStateSchema = z.object({ order_id: z.string(), status: z.string() });
|
||
export const PaymentsSchema = z.object({
|
||
payments: z.array(z.object({
|
||
order_id: z.string(), bucket: z.string(), amount: z.number(), commission: z.number(),
|
||
method: z.string(), status: z.string(),
|
||
created_at: z.string().nullable(), paid_at: z.string().nullable(),
|
||
})),
|
||
});
|
||
|
||
export const authRequest = async (phone: string) =>
|
||
AuthRequestSchema.parse(await api.post("driver/auth/request", { json: { phone } }).json());
|
||
export const authVerify = async (phone: string, code: string) =>
|
||
VerifySchema.parse(await api.post("driver/auth/verify", { json: { phone, code } }).json());
|
||
export const getBalance = async () =>
|
||
BalanceSchema.parse(await api.get("driver/balance").json());
|
||
export const topup = async (bucket: string, amount: number, method: "qr" | "card") =>
|
||
TopupSchema.parse(await api.post("driver/topup", { json: { bucket, amount, method } }).json());
|
||
export const getPaymentState = async (orderId: string) =>
|
||
PaymentStateSchema.parse(await api.get(`driver/payment/${orderId}`).json());
|
||
export const getPayments = async () =>
|
||
PaymentsSchema.parse(await api.get("driver/payments").json());
|
||
```
|
||
|
||
- [ ] **Step 4: Run → PASS** `npm run test -- src/api`.
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/store/auth.ts src/api/client.ts src/api/driver.ts src/api/driver.test.ts
|
||
git commit -m "feat(driver-pwa): auth store + ky client + typed driver API (zod)
|
||
|
||
Co-Authored-By: claude-flow <ruv@ruv.net>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task B5: Shared UI components + router/guard (App.tsx)
|
||
|
||
**Files (create):** `src/components/AppHeader.tsx`, `src/components/Segment.tsx`, `src/components/Spinner.tsx`; **Modify:** `src/App.tsx`; **Test:** `src/App.test.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing test** (unauthenticated → Login; authenticated → not Login)
|
||
|
||
`src/App.test.tsx`:
|
||
```tsx
|
||
import { describe, it, expect, beforeEach } from "vitest";
|
||
import { render, screen } from "@testing-library/react";
|
||
import { MemoryRouter } from "react-router-dom";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import App from "./App";
|
||
import { useAuth } from "@/store/auth";
|
||
|
||
function renderAt(path: string) {
|
||
const qc = new QueryClient();
|
||
return render(
|
||
<QueryClientProvider client={qc}>
|
||
<MemoryRouter initialEntries={[path]}>
|
||
<App />
|
||
</MemoryRouter>
|
||
</QueryClientProvider>
|
||
);
|
||
}
|
||
|
||
describe("routing guard", () => {
|
||
beforeEach(() => useAuth.getState().clear());
|
||
it("redirects to login when no token", () => {
|
||
renderAt("/");
|
||
expect(screen.getByText(/Вход в приложение/i)).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run → FAIL** `npm run test -- src/App` (App stub has no login text / routes).
|
||
|
||
- [ ] **Step 3: Implement components + App**
|
||
|
||
`src/components/Spinner.tsx`:
|
||
```tsx
|
||
export function Spinner() {
|
||
return <span className="inline-block w-3.5 h-3.5 border-2 border-line border-t-ink rounded-full animate-spin" />;
|
||
}
|
||
```
|
||
`src/components/AppHeader.tsx`:
|
||
```tsx
|
||
export function AppHeader({ initials }: { initials?: string }) {
|
||
return (
|
||
<header className="flex items-center justify-between mb-4">
|
||
<span className="text-sm font-bold">Премиум Водитель</span>
|
||
{initials && (
|
||
<span className="w-7 h-7 rounded-full bg-ink text-cream text-[10px] font-bold flex items-center justify-center">
|
||
{initials}
|
||
</span>
|
||
)}
|
||
</header>
|
||
);
|
||
}
|
||
```
|
||
`src/components/Segment.tsx`:
|
||
```tsx
|
||
interface Props { value: string; options: { value: string; label: string }[]; onChange: (v: string) => void; }
|
||
export function Segment({ value, options, onChange }: Props) {
|
||
return (
|
||
<div className="flex gap-1.5">
|
||
{options.map((o) => (
|
||
<button
|
||
key={o.value}
|
||
type="button"
|
||
onClick={() => onChange(o.value)}
|
||
className={`flex-1 py-2.5 rounded-xl2 text-xs border ${
|
||
value === o.value ? "bg-ink text-cream border-ink font-bold" : "bg-surface border-line text-muted"
|
||
}`}
|
||
>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
`src/App.tsx` (replace stub):
|
||
```tsx
|
||
import { Navigate, Route, Routes } from "react-router-dom";
|
||
import { useAuth } from "@/store/auth";
|
||
import { LoginPage } from "@/pages/LoginPage";
|
||
import { OnboardingPage } from "@/pages/OnboardingPage";
|
||
import { BalancePage } from "@/pages/BalancePage";
|
||
import { TopupPage } from "@/pages/TopupPage";
|
||
import { PaymentPage } from "@/pages/PaymentPage";
|
||
import { HistoryPage } from "@/pages/HistoryPage";
|
||
|
||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||
const token = useAuth((s) => s.token);
|
||
return token ? <>{children}</> : <Navigate to="/login" replace />;
|
||
}
|
||
|
||
export default function App() {
|
||
return (
|
||
<div className="mx-auto max-w-md min-h-full px-4 pt-3 pb-8">
|
||
<Routes>
|
||
<Route path="/login" element={<LoginPage />} />
|
||
<Route path="/onboarding" element={<OnboardingPage />} />
|
||
<Route path="/" element={<RequireAuth><BalancePage /></RequireAuth>} />
|
||
<Route path="/topup" element={<RequireAuth><TopupPage /></RequireAuth>} />
|
||
<Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></RequireAuth>} />
|
||
<Route path="/history" element={<RequireAuth><HistoryPage /></RequireAuth>} />
|
||
<Route path="*" element={<Navigate to="/" replace />} />
|
||
</Routes>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
> Note: Pages are created in B6–B10. To keep this task building/testing in isolation, create minimal stub files for the not-yet-built pages now (each exporting a named component returning `null`), then flesh them out in their tasks. The LoginPage stub must render the text "Вход в приложение" so this task's test passes; it is fully implemented in B6.
|
||
|
||
Create stubs: `src/pages/LoginPage.tsx` (`export function LoginPage(){ return <div>Вход в приложение</div>; }`), and `OnboardingPage`, `BalancePage`, `TopupPage`, `PaymentPage`, `HistoryPage` each `export function X(){ return null; }`.
|
||
|
||
- [ ] **Step 4: Run → PASS** `npm run test -- src/App` and `npm run build`.
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/components src/App.tsx src/App.test.tsx src/pages
|
||
git commit -m "feat(driver-pwa): shared UI + router with auth guard
|
||
|
||
Co-Authored-By: claude-flow <ruv@ruv.net>"
|
||
```
|
||
|
||
---
|
||
|
||
## PART C — Screens
|
||
|
||
### Task B6: Login + code entry (phone → TG/MAX code)
|
||
|
||
**Files:** Create `src/pages/LoginPage.tsx` (replace stub); Test `src/pages/LoginPage.test.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```tsx
|
||
// src/pages/LoginPage.test.tsx
|
||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||
import { render, screen } from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { MemoryRouter } from "react-router-dom";
|
||
|
||
vi.mock("@/api/driver", () => ({
|
||
authRequest: vi.fn(async () => ({ status: "code_sent", channel: "tg" })),
|
||
authVerify: vi.fn(async () => ({ token: "T", driver_id: 7 })),
|
||
}));
|
||
|
||
import { LoginPage } from "./LoginPage";
|
||
import * as driver from "@/api/driver";
|
||
import { useAuth } from "@/store/auth";
|
||
|
||
const wrap = () => render(<MemoryRouter><LoginPage /></MemoryRouter>);
|
||
|
||
describe("LoginPage", () => {
|
||
beforeEach(() => useAuth.getState().clear());
|
||
it("requests code then verifies and stores session", async () => {
|
||
wrap();
|
||
expect(screen.getByText(/Вход в приложение/i)).toBeInTheDocument();
|
||
await userEvent.type(screen.getByPlaceholderText(/телефон/i), "9143054400");
|
||
await userEvent.click(screen.getByRole("button", { name: /Получить код/i }));
|
||
expect(driver.authRequest).toHaveBeenCalledWith("79143054400");
|
||
// code step appears
|
||
const codeInput = await screen.findByPlaceholderText(/код/i);
|
||
await userEvent.type(codeInput, "123456");
|
||
await userEvent.click(screen.getByRole("button", { name: /Войти/i }));
|
||
expect(driver.authVerify).toHaveBeenCalledWith("79143054400", "123456");
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run → FAIL** `npm run test -- src/pages/LoginPage`.
|
||
|
||
- [ ] **Step 3: Implement `src/pages/LoginPage.tsx`**
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { toast } from "sonner";
|
||
import { authRequest, authVerify } from "@/api/driver";
|
||
import { digitsOnly, formatPhone } from "@/lib/format";
|
||
import { useAuth } from "@/store/auth";
|
||
import { Spinner } from "@/components/Spinner";
|
||
|
||
export function LoginPage() {
|
||
const nav = useNavigate();
|
||
const setSession = useAuth((s) => s.setSession);
|
||
const [phone, setPhone] = useState(""); // national 10 digits
|
||
const [code, setCode] = useState("");
|
||
const [step, setStep] = useState<"phone" | "code">("phone");
|
||
const [channel, setChannel] = useState<string>("");
|
||
const [busy, setBusy] = useState(false);
|
||
const e164 = "7" + phone;
|
||
|
||
async function requestCode() {
|
||
if (phone.length < 10) return;
|
||
setBusy(true);
|
||
try {
|
||
const r = await authRequest(e164);
|
||
if (r.status === "onboarding") {
|
||
sessionStorage.setItem("pp-onboarding", JSON.stringify({ ...r.deep_links, phone }));
|
||
nav("/onboarding");
|
||
return;
|
||
}
|
||
setChannel(r.channel);
|
||
setStep("code");
|
||
} catch (err: any) {
|
||
toast.error(err?.response?.status === 404 ? "Номер не найден. Обратитесь в парк." : "Не удалось отправить код");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function verify() {
|
||
if (code.length < 4) return;
|
||
setBusy(true);
|
||
try {
|
||
const r = await authVerify(e164, code);
|
||
setSession(r.token, r.driver_id);
|
||
nav("/");
|
||
} catch {
|
||
toast.error("Неверный или просроченный код");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="pt-16">
|
||
<h1 className="text-xl font-extrabold mb-1">Премиум Водитель</h1>
|
||
<p className="text-muted text-sm mb-8">Вход в приложение</p>
|
||
|
||
{step === "phone" ? (
|
||
<>
|
||
<input
|
||
inputMode="numeric"
|
||
placeholder="Номер телефона"
|
||
value={phone ? formatPhone(phone) : ""}
|
||
onChange={(e) => setPhone(digitsOnly(e.target.value).replace(/^7/, "").slice(0, 10))}
|
||
className="card w-full px-4 py-3 mb-3 text-base bg-white"
|
||
/>
|
||
<button className="btn-primary" disabled={busy || phone.length < 10} onClick={requestCode}>
|
||
{busy ? <Spinner /> : "Получить код"}
|
||
</button>
|
||
<p className="text-muted text-xs mt-3">Код придёт в Telegram или MAX.</p>
|
||
</>
|
||
) : (
|
||
<>
|
||
<p className="text-sm mb-2">Код отправлен в {channel === "max" ? "MAX" : "Telegram"}.</p>
|
||
<input
|
||
inputMode="numeric"
|
||
placeholder="Код из сообщения"
|
||
value={code}
|
||
onChange={(e) => setCode(digitsOnly(e.target.value).slice(0, 6))}
|
||
className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-white"
|
||
/>
|
||
<button className="btn-primary" disabled={busy || code.length < 4} onClick={verify}>
|
||
{busy ? <Spinner /> : "Войти"}
|
||
</button>
|
||
<button className="btn-ghost mt-2" onClick={() => setStep("phone")}>Изменить номер</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run → PASS** `npm run test -- src/pages/LoginPage`.
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/pages/LoginPage.tsx src/pages/LoginPage.test.tsx
|
||
git commit -m "feat(driver-pwa): login (phone -> TG/MAX code)
|
||
|
||
Co-Authored-By: claude-flow <ruv@ruv.net>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task B7: Onboarding (connect TG/MAX bot)
|
||
|
||
**Files:** Create `src/pages/OnboardingPage.tsx` (replace stub); Test `src/pages/OnboardingPage.test.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```tsx
|
||
// src/pages/OnboardingPage.test.tsx
|
||
import { describe, it, expect, beforeEach } from "vitest";
|
||
import { render, screen } from "@testing-library/react";
|
||
import { MemoryRouter } from "react-router-dom";
|
||
import { OnboardingPage } from "./OnboardingPage";
|
||
|
||
describe("OnboardingPage", () => {
|
||
beforeEach(() =>
|
||
sessionStorage.setItem("pp-onboarding", JSON.stringify({ tg: "https://t.me/b?start=x", max: "https://max.ru/b?start=x", phone: "9143054400" }))
|
||
);
|
||
it("shows both bot links + back-to-code action", () => {
|
||
render(<MemoryRouter><OnboardingPage /></MemoryRouter>);
|
||
expect(screen.getByText(/Подключите бот/i)).toBeInTheDocument();
|
||
expect(screen.getByRole("link", { name: /Telegram/i })).toHaveAttribute("href", "https://t.me/b?start=x");
|
||
expect(screen.getByRole("link", { name: /MAX/i })).toHaveAttribute("href", "https://max.ru/b?start=x");
|
||
expect(screen.getByRole("button", { name: /запросить код/i })).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run → FAIL.**
|
||
|
||
- [ ] **Step 3: Implement `src/pages/OnboardingPage.tsx`**
|
||
|
||
```tsx
|
||
import { useNavigate } from "react-router-dom";
|
||
|
||
export function OnboardingPage() {
|
||
const nav = useNavigate();
|
||
const raw = sessionStorage.getItem("pp-onboarding");
|
||
const data = raw ? (JSON.parse(raw) as { tg: string; max: string; phone: string }) : null;
|
||
if (!data) return null;
|
||
|
||
return (
|
||
<div className="pt-16">
|
||
<h1 className="text-xl font-extrabold mb-1">Подключите бот</h1>
|
||
<p className="text-muted text-sm mb-8">
|
||
Чтобы получать код входа, откройте бот и нажмите «Старт». Затем вернитесь и запросите код снова.
|
||
</p>
|
||
<a href={data.tg} target="_blank" rel="noopener" className="btn-primary mb-3 no-underline block">
|
||
Подключить Telegram
|
||
</a>
|
||
<a href={data.max} target="_blank" rel="noopener" className="btn-ghost mb-6 no-underline block">
|
||
Подключить MAX
|
||
</a>
|
||
<button className="btn-ghost" onClick={() => nav("/login")}>
|
||
Я подключил — запросить код
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run → PASS.**
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/pages/OnboardingPage.tsx src/pages/OnboardingPage.test.tsx
|
||
git commit -m "feat(driver-pwa): onboarding (connect TG/MAX bot)
|
||
|
||
Co-Authored-By: claude-flow <ruv@ruv.net>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task B8: Balance (hero total + list)
|
||
|
||
**Files:** Create `src/pages/BalancePage.tsx` (replace stub); Test `src/pages/BalancePage.test.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```tsx
|
||
// src/pages/BalancePage.test.tsx
|
||
import { describe, it, expect, vi } from "vitest";
|
||
import { render, screen } from "@testing-library/react";
|
||
import { MemoryRouter } from "react-router-dom";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
|
||
vi.mock("@/api/driver", () => ({
|
||
getBalance: vi.fn(async () => ({
|
||
accounts: [
|
||
{ bucket: "Долг аренда", balance: -3200 },
|
||
{ bucket: "Депозит", balance: 5000 },
|
||
],
|
||
})),
|
||
}));
|
||
|
||
import { BalancePage } from "./BalancePage";
|
||
|
||
function wrap() {
|
||
const qc = new QueryClient();
|
||
return render(
|
||
<QueryClientProvider client={qc}>
|
||
<MemoryRouter><BalancePage /></MemoryRouter>
|
||
</QueryClientProvider>
|
||
);
|
||
}
|
||
|
||
describe("BalancePage", () => {
|
||
it("shows hero total (sum of negatives) and account rows", async () => {
|
||
wrap();
|
||
expect(await screen.findByText("Долг аренда")).toBeInTheDocument();
|
||
expect(screen.getByText("Депозит")).toBeInTheDocument();
|
||
// hero total = sum of negative balances = −3 200 ₽
|
||
expect(screen.getByText("−3 200 ₽")).toBeInTheDocument();
|
||
expect(screen.getByRole("button", { name: /Пополнить/i })).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run → FAIL.**
|
||
|
||
- [ ] **Step 3: Implement `src/pages/BalancePage.tsx`**
|
||
|
||
```tsx
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { History, LogOut } from "lucide-react";
|
||
import { getBalance } from "@/api/driver";
|
||
import { formatMoney } from "@/lib/format";
|
||
import { useAuth } from "@/store/auth";
|
||
import { AppHeader } from "@/components/AppHeader";
|
||
import { Spinner } from "@/components/Spinner";
|
||
|
||
export function BalancePage() {
|
||
const nav = useNavigate();
|
||
const clear = useAuth((s) => s.clear);
|
||
const { data, isLoading } = useQuery({ queryKey: ["balance"], queryFn: getBalance });
|
||
|
||
const accounts = data?.accounts ?? [];
|
||
const totalDebt = accounts.filter((a) => a.balance < 0).reduce((s, a) => s + a.balance, 0);
|
||
|
||
return (
|
||
<div>
|
||
<AppHeader initials="" />
|
||
<div className="card p-4 mb-4 text-center">
|
||
<p className="text-muted text-xs mb-1">Общий долг</p>
|
||
<p className={`text-2xl font-extrabold ${totalDebt < 0 ? "text-neg" : ""}`}>{formatMoney(totalDebt)}</p>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="flex justify-center py-10"><Spinner /></div>
|
||
) : (
|
||
<div className="mb-4">
|
||
{accounts.map((a) => (
|
||
<div key={a.bucket} className="flex justify-between items-center py-3 border-b border-line last:border-0">
|
||
<span className="text-sm">{a.bucket}</span>
|
||
<b className={a.balance < 0 ? "text-neg" : "text-pos"}>{formatMoney(a.balance)}</b>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<button className="btn-primary" onClick={() => nav("/topup")}>Пополнить</button>
|
||
|
||
<div className="flex justify-center gap-6 mt-6 text-muted text-xs">
|
||
<button className="flex items-center gap-1.5" onClick={() => nav("/history")}>
|
||
<History size={15} /> История
|
||
</button>
|
||
<button className="flex items-center gap-1.5" onClick={() => { clear(); nav("/login"); }}>
|
||
<LogOut size={15} /> Выйти
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run → PASS.**
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/pages/BalancePage.tsx src/pages/BalancePage.test.tsx
|
||
git commit -m "feat(driver-pwa): balance screen (hero total + accounts)
|
||
|
||
Co-Authored-By: claude-flow <ruv@ruv.net>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task B9: Top-up (custom numeric keypad)
|
||
|
||
**Files:** Create `src/pages/TopupPage.tsx` (replace stub); Test `src/pages/TopupPage.test.tsx`
|
||
|
||
The selected account bucket is passed via router state (`nav("/topup", { state: { bucket } })`); default to the first debt account label `"Долг аренда"` when absent. Commission rate default `0.052` until `/topup` returns exact numbers; on pay we navigate to `/pay/:orderId` with `pay_url` + amounts in router state.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```tsx
|
||
// src/pages/TopupPage.test.tsx
|
||
import { describe, it, expect, vi } from "vitest";
|
||
import { render, screen } from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { MemoryRouter } from "react-router-dom";
|
||
|
||
vi.mock("@/api/driver", () => ({
|
||
topup: vi.fn(async () => ({ order_id: "o9", pay_url: "https://qr.nspk.ru/x", commission: 52, total: 1052 })),
|
||
}));
|
||
import { TopupPage } from "./TopupPage";
|
||
import * as driver from "@/api/driver";
|
||
|
||
const wrap = () => render(<MemoryRouter><TopupPage /></MemoryRouter>);
|
||
|
||
describe("TopupPage", () => {
|
||
it("keypad builds amount, shows total, and submits topup", async () => {
|
||
wrap();
|
||
await userEvent.click(screen.getByRole("button", { name: "1" }));
|
||
await userEvent.click(screen.getByRole("button", { name: "0" }));
|
||
await userEvent.click(screen.getByRole("button", { name: "0" }));
|
||
await userEvent.click(screen.getByRole("button", { name: "0" }));
|
||
expect(screen.getByTestId("amount").textContent).toBe("1 000 ₽");
|
||
// к оплате 1 052 ₽ (commission 52)
|
||
expect(screen.getByTestId("total").textContent).toContain("1 052");
|
||
await userEvent.click(screen.getByRole("button", { name: /Оплатить/i }));
|
||
expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 1000, "qr");
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run → FAIL.**
|
||
|
||
- [ ] **Step 3: Implement `src/pages/TopupPage.tsx`**
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
import { useLocation, useNavigate } from "react-router-dom";
|
||
import { Delete } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { topup } from "@/api/driver";
|
||
import { keypadReduce, withCommission } from "@/lib/amount";
|
||
import { formatMoney } from "@/lib/format";
|
||
import { Segment } from "@/components/Segment";
|
||
import { Spinner } from "@/components/Spinner";
|
||
|
||
const RATE = 0.052;
|
||
const QUICK = ["500", "1000", "2000"];
|
||
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "·", "0", "back"];
|
||
|
||
export function TopupPage() {
|
||
const nav = useNavigate();
|
||
const loc = useLocation();
|
||
const bucket = (loc.state as { bucket?: string } | null)?.bucket ?? "Долг аренда";
|
||
const [raw, setRaw] = useState("");
|
||
const [method, setMethod] = useState("qr");
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
const base = raw ? parseInt(raw, 10) : 0;
|
||
const { commission, total } = withCommission(base, RATE);
|
||
|
||
function press(k: string) {
|
||
if (k === "·") return;
|
||
setRaw((r) => keypadReduce(r, k));
|
||
}
|
||
|
||
async function pay() {
|
||
if (base <= 0) return;
|
||
setBusy(true);
|
||
try {
|
||
const r = await topup(bucket, base, method as "qr" | "card");
|
||
nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base } });
|
||
} catch {
|
||
toast.error("Не удалось создать платёж");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="pt-2">
|
||
<button className="text-muted text-xs mb-3" onClick={() => nav(-1)}>‹ {bucket}</button>
|
||
<p data-testid="amount" className="text-3xl font-extrabold text-center mt-2">{formatMoney(base)}</p>
|
||
<p data-testid="total" className="text-muted text-xs text-center mb-3">
|
||
к оплате {formatMoney(total)} (комиссия {formatMoney(commission)})
|
||
</p>
|
||
|
||
<div className="flex gap-1.5 mb-3">
|
||
{QUICK.map((q) => (
|
||
<button key={q} className="flex-1 py-2 rounded-xl2 text-xs bg-surface border border-line" onClick={() => setRaw(q)}>
|
||
{q}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||
{KEYS.map((k) => (
|
||
<button
|
||
key={k}
|
||
aria-label={k}
|
||
className="bg-surface border border-line rounded-xl2 py-3 text-lg font-semibold flex items-center justify-center"
|
||
onClick={() => press(k)}
|
||
>
|
||
{k === "back" ? <Delete size={18} /> : k}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<Segment value={method} onChange={setMethod} options={[{ value: "qr", label: "СБП" }, { value: "card", label: "Картой" }]} />
|
||
</div>
|
||
|
||
<button className="btn-primary" disabled={busy || base <= 0} onClick={pay}>
|
||
{busy ? <Spinner /> : "Оплатить"}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run → PASS.**
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/pages/TopupPage.tsx src/pages/TopupPage.test.tsx
|
||
git commit -m "feat(driver-pwa): top-up with custom keypad
|
||
|
||
Co-Authored-By: claude-flow <ruv@ruv.net>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task B10: Payment + auto-status (poll)
|
||
|
||
**Files:** Create `src/hooks/usePaymentStatus.ts`, `src/pages/PaymentPage.tsx` (replace stub); Test `src/pages/PaymentPage.test.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```tsx
|
||
// src/pages/PaymentPage.test.tsx
|
||
import { describe, it, expect, vi } from "vitest";
|
||
import { render, screen, waitFor } from "@testing-library/react";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||
|
||
const states = ["FORM", "PAID"];
|
||
vi.mock("@/api/driver", () => ({
|
||
getPaymentState: vi.fn(async () => ({ order_id: "o9", status: states.shift() ?? "PAID" })),
|
||
}));
|
||
import { PaymentPage } from "./PaymentPage";
|
||
|
||
function wrap() {
|
||
const qc = new QueryClient();
|
||
return render(
|
||
<QueryClientProvider client={qc}>
|
||
<MemoryRouter initialEntries={[{ pathname: "/pay/o9", state: { payUrl: "https://qr.nspk.ru/x", total: 1052, bucket: "Долг аренда", base: 1000 } }]}>
|
||
<Routes><Route path="/pay/:orderId" element={<PaymentPage />} /></Routes>
|
||
</MemoryRouter>
|
||
</QueryClientProvider>
|
||
);
|
||
}
|
||
|
||
describe("PaymentPage", () => {
|
||
it("shows pay button and flips to success on PAID", async () => {
|
||
wrap();
|
||
expect(screen.getByRole("link", { name: /Оплатить/i })).toHaveAttribute("href", "https://qr.nspk.ru/x");
|
||
await waitFor(() => expect(screen.getByText(/Оплачено/i)).toBeInTheDocument(), { timeout: 3000 });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run → FAIL.**
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`src/hooks/usePaymentStatus.ts`:
|
||
```ts
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { getPaymentState } from "@/api/driver";
|
||
|
||
export function usePaymentStatus(orderId: string) {
|
||
return useQuery({
|
||
queryKey: ["payment", orderId],
|
||
queryFn: () => getPaymentState(orderId),
|
||
refetchInterval: (q) => (q.state.data?.status === "PAID" || q.state.data?.status === "REJECTED" ? false : 1500),
|
||
});
|
||
}
|
||
```
|
||
`src/pages/PaymentPage.tsx`:
|
||
```tsx
|
||
import { useEffect } from "react";
|
||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||
import { useQueryClient } from "@tanstack/react-query";
|
||
import { Check } from "lucide-react";
|
||
import { formatMoney } from "@/lib/format";
|
||
import { Spinner } from "@/components/Spinner";
|
||
import { usePaymentStatus } from "@/hooks/usePaymentStatus";
|
||
|
||
export function PaymentPage() {
|
||
const { orderId = "" } = useParams();
|
||
const nav = useNavigate();
|
||
const qc = useQueryClient();
|
||
const st = (useLocation().state as { payUrl?: string; total?: number; bucket?: string; base?: number } | null) ?? {};
|
||
const { data } = usePaymentStatus(orderId);
|
||
const paid = data?.status === "PAID";
|
||
|
||
useEffect(() => {
|
||
if (paid) qc.invalidateQueries({ queryKey: ["balance"] });
|
||
}, [paid, qc]);
|
||
|
||
if (paid) {
|
||
return (
|
||
<div className="pt-24 flex flex-col items-center text-center">
|
||
<span className="w-14 h-14 rounded-full bg-pos text-white flex items-center justify-center mb-3"><Check size={28} /></span>
|
||
<p className="text-lg font-extrabold">Оплачено</p>
|
||
<p className="text-muted text-xs mt-1">{st.bucket} пополнен на {formatMoney(st.base ?? 0)}</p>
|
||
<button className="btn-primary mt-8" onClick={() => nav("/")}>На главную</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="pt-10 flex flex-col items-center text-center">
|
||
<span className="w-12 h-12 rounded-xl2 bg-ink text-cream flex items-center justify-center font-extrabold mb-4">СБП</span>
|
||
<p className="text-lg font-extrabold">{formatMoney(st.total ?? 0)}</p>
|
||
<p className="text-muted text-xs mb-6">{st.bucket}</p>
|
||
<a className="btn-primary no-underline" href={st.payUrl} target="_blank" rel="noopener">Оплатить</a>
|
||
<p className="text-muted text-xs mt-2">Откроется ваше приложение банка</p>
|
||
<div className="flex items-center gap-2 mt-6 text-muted text-xs"><Spinner /> Ожидаем оплату…</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run → PASS.**
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/hooks/usePaymentStatus.ts src/pages/PaymentPage.tsx src/pages/PaymentPage.test.tsx
|
||
git commit -m "feat(driver-pwa): payment screen + auto status polling
|
||
|
||
Co-Authored-By: claude-flow <ruv@ruv.net>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task B11: History
|
||
|
||
**Files:** Create `src/pages/HistoryPage.tsx` (replace stub); Test `src/pages/HistoryPage.test.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```tsx
|
||
// src/pages/HistoryPage.test.tsx
|
||
import { describe, it, expect, vi } from "vitest";
|
||
import { render, screen } from "@testing-library/react";
|
||
import { MemoryRouter } from "react-router-dom";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
|
||
vi.mock("@/api/driver", () => ({
|
||
getPayments: vi.fn(async () => ({ payments: [
|
||
{ order_id: "o1", bucket: "Долг аренда", amount: 1000, commission: 52, method: "qr", status: "PAID", created_at: "2026-06-18T09:00:00Z", paid_at: "2026-06-18T09:01:00Z" },
|
||
] })),
|
||
}));
|
||
import { HistoryPage } from "./HistoryPage";
|
||
|
||
function wrap() {
|
||
const qc = new QueryClient();
|
||
return render(<QueryClientProvider client={qc}><MemoryRouter><HistoryPage /></MemoryRouter></QueryClientProvider>);
|
||
}
|
||
|
||
describe("HistoryPage", () => {
|
||
it("lists payments with amount + status", async () => {
|
||
wrap();
|
||
expect(await screen.findByText("Долг аренда")).toBeInTheDocument();
|
||
expect(screen.getByText("1 000 ₽")).toBeInTheDocument();
|
||
expect(screen.getByText(/Оплачено/i)).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run → FAIL.**
|
||
|
||
- [ ] **Step 3: Implement `src/pages/HistoryPage.tsx`**
|
||
|
||
```tsx
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { getPayments } from "@/api/driver";
|
||
import { formatMoney } from "@/lib/format";
|
||
import { Spinner } from "@/components/Spinner";
|
||
|
||
const STATUS_RU: Record<string, string> = {
|
||
PAID: "Оплачено", NEW: "Ожидает", FORM: "Ожидает", CONFIRMED: "Оплачено", REJECTED: "Отклонён",
|
||
};
|
||
|
||
export function HistoryPage() {
|
||
const nav = useNavigate();
|
||
const { data, isLoading } = useQuery({ queryKey: ["payments"], queryFn: getPayments });
|
||
const rows = data?.payments ?? [];
|
||
|
||
return (
|
||
<div className="pt-2">
|
||
<button className="text-muted text-xs mb-3" onClick={() => nav("/")}>‹ Назад</button>
|
||
<h1 className="text-lg font-extrabold mb-4">История пополнений</h1>
|
||
{isLoading ? (
|
||
<div className="flex justify-center py-10"><Spinner /></div>
|
||
) : rows.length === 0 ? (
|
||
<p className="text-muted text-sm">Пока нет пополнений</p>
|
||
) : (
|
||
rows.map((p) => (
|
||
<div key={p.order_id} className="flex justify-between items-center py-3 border-b border-line">
|
||
<div>
|
||
<p className="text-sm">{p.bucket}</p>
|
||
<p className="text-muted text-xs">
|
||
{p.created_at ? new Date(p.created_at).toLocaleDateString("ru-RU") : ""} · {STATUS_RU[p.status] ?? p.status}
|
||
</p>
|
||
</div>
|
||
<b>{formatMoney(p.amount)}</b>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run → PASS.**
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/pages/HistoryPage.tsx src/pages/HistoryPage.test.tsx
|
||
git commit -m "feat(driver-pwa): payments history screen
|
||
|
||
Co-Authored-By: claude-flow <ruv@ruv.net>"
|
||
```
|
||
|
||
> Wire the balance account row to preselect the bucket on top-up: in `BalancePage.tsx` make each account row a button calling `nav("/topup", { state: { bucket: a.bucket } })`. Add this small change in this task's Step 3 edit and keep BalancePage test green.
|
||
|
||
---
|
||
|
||
### Task B12: PWA manifest + icons + final suite
|
||
|
||
**Files:** Create `public/manifest.webmanifest`, `public/icons/icon-192.png`, `public/icons/icon-512.png`
|
||
|
||
- [ ] **Step 1: Create `public/manifest.webmanifest`**
|
||
|
||
```json
|
||
{
|
||
"name": "Премиум Водитель",
|
||
"short_name": "Премиум",
|
||
"start_url": "/",
|
||
"display": "standalone",
|
||
"orientation": "portrait",
|
||
"background_color": "#F3EEE3",
|
||
"theme_color": "#F3EEE3",
|
||
"icons": [
|
||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||
]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Generate placeholder icons** (solid cream square with dark «ПВ»; replace with real art later)
|
||
|
||
Run (from `driver-pwa/frontend`):
|
||
```bash
|
||
node -e "const fs=require('fs');const b64='iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';const png=Buffer.from(b64,'base64');fs.mkdirSync('public/icons',{recursive:true});fs.writeFileSync('public/icons/icon-192.png',png);fs.writeFileSync('public/icons/icon-512.png',png);console.log('icons written');"
|
||
```
|
||
Expected: `icons written` (1×1 PNG placeholders so the manifest validates and build passes; real icons swapped in before launch).
|
||
|
||
- [ ] **Step 3: Run full suite + build**
|
||
|
||
Run: `npm run test`
|
||
Expected: all test files pass (lib, api, App, all pages).
|
||
Run: `npm run build`
|
||
Expected: build succeeds; `dist/` contains `manifest.webmanifest`, service worker, icons.
|
||
|
||
- [ ] **Step 4: Manual smoke (optional, note if skipped)**
|
||
|
||
Run: `npm run preview` and open the printed URL; verify Login renders, and (with `VITE_API_BASE` pointed at a running backend) the flow works. If no backend reachable, note it.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add public/manifest.webmanifest public/icons
|
||
git commit -m "feat(driver-pwa): PWA manifest + placeholder icons
|
||
|
||
Co-Authored-By: claude-flow <ruv@ruv.net>"
|
||
```
|
||
|
||
---
|
||
|
||
## Deployment notes (not TDD tasks)
|
||
- Frontend: `npm run build` → ship `dist/` to `app.pptaxi.ru` static root; add a Caddy site block for `app.pptaxi.ru` (TLS via LE) serving the SPA with fallback to `index.html`. Set `VITE_API_BASE=https://crm.pptaxi.ru/api` at build time (or `.env`).
|
||
- Backend (Part A) deploys with the normal crm2 blue/green; apply nothing extra (no new tables). CORS change takes effect on restart.
|
||
- The driver-auth n8n workflows + `DRIVER_BOT_SEND_URL`/`DRIVER_BOT_CALLBACK_SECRET` + T-Bank terminal/ATOL creds are separate prerequisites for a *live* end-to-end flow (the PWA itself builds/tests without them).
|
||
|
||
## Self-Review
|
||
**Spec coverage:** Cream tokens (B2 tailwind/index.css) ✓; app name «Премиум Водитель» (index.html, manifest, AppHeader) ✓; Login phone→code (B6) ✓; onboarding TG/MAX (B7) ✓; Balance hero+list (B8) ✓; Top-up custom keypad + quick amounts + СБП/Card segment + commission line (B9) ✓; Payment redirect (SBP deep-link / card form) + auto-poll + success, no QR (B10) ✓; History (B11) ✓; logout (B8) ✓; session JWT in localStorage + ky Bearer + 401 logout (B4) ✓; react-query/zod/zustand/sonner (B2,B4) ✓; PWA manifest/SW (B1 vite-plugin-pwa, B12 manifest) ✓; backend `GET /driver/payments` + `GET /driver/payment/{order_id}` (A1) + CORS (A2) ✓; deploy app.pptaxi.ru (notes) ✓.
|
||
**Placeholder scan:** No TBD. Page stubs in B5 are explicit and each replaced in its own task (B6–B11). Icons are documented placeholder PNGs (swap before launch) — explicit, not a silent gap.
|
||
**Type consistency:** API fns (`authRequest(e164)`, `authVerify(phone,code)`, `getBalance`, `topup(bucket,amount,method)`, `getPaymentState(orderId)`, `getPayments`) defined in B4 and used identically in B6–B11. `useAuth` `{token,driverId,setSession,clear}` consistent (B4,B5,B6,B8). Router state keys (`bucket` for /topup; `payUrl,total,bucket,base` for /pay) consistent (B9→B10). zod schema field names match backend serializer in A1 (`order_id,bucket,amount,commission,method,status,created_at,paid_at`). keypad `keypadReduce`/`withCommission` consistent (B3,B9).
|