Files
mechanic-pwa/docs/superpowers/plans/2026-06-18-premium-driver-pwa.md
T
2026-06-18 22:23:32 +10:00

1584 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Премиум Водитель 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 B6B10. 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 (B6B11). 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 B6B11. `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).