diff --git a/docs/superpowers/plans/2026-06-18-premium-driver-pwa.md b/docs/superpowers/plans/2026-06-18-premium-driver-pwa.md new file mode 100644 index 0000000..d19d6f1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-premium-driver-pwa.md @@ -0,0 +1,1582 @@ +# Премиум Водитель 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 " +``` + +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 " +``` + +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 + + + + + + + + + + Премиум Водитель + + +
+ + + +``` +`.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 " +``` +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
Премиум Водитель
; +} +``` + +- [ ] **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( + + + + + + + + +); +``` + +- [ ] **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 " +``` + +--- + +### 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 === "" && key === "0") return ""; // no leading zero + 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 " +``` + +--- + +### 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()( + 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; + +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 " +``` + +--- + +### 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( + + + + + + ); +} + +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 ; +} +``` +`src/components/AppHeader.tsx`: +```tsx +export function AppHeader({ initials }: { initials?: string }) { + return ( +
+ Премиум Водитель + {initials && ( + + {initials} + + )} +
+ ); +} +``` +`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 ( +
+ {options.map((o) => ( + + ))} +
+ ); +} +``` +`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} : ; +} + +export default function App() { + return ( +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+ ); +} +``` + +> 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
Вход в приложение
; }`), 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 " +``` + +--- + +## 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(); + +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(""); + 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 ( +
+

Премиум Водитель

+

Вход в приложение

+ + {step === "phone" ? ( + <> + setPhone(digitsOnly(e.target.value).replace(/^7/, "").slice(0, 10))} + className="card w-full px-4 py-3 mb-3 text-base bg-white" + /> + +

Код придёт в Telegram или MAX.

+ + ) : ( + <> +

Код отправлен в {channel === "max" ? "MAX" : "Telegram"}.

+ setCode(digitsOnly(e.target.value).slice(0, 6))} + className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-white" + /> + + + + )} +
+ ); +} +``` + +- [ ] **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 " +``` + +--- + +### 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(); + 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 ( +
+

Подключите бот

+

+ Чтобы получать код входа, откройте бот и нажмите «Старт». Затем вернитесь и запросите код снова. +

+ + Подключить Telegram + + + Подключить MAX + + +
+ ); +} +``` + +- [ ] **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 " +``` + +--- + +### 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( + + + + ); +} + +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 ( +
+ +
+

Общий долг

+

{formatMoney(totalDebt)}

+
+ + {isLoading ? ( +
+ ) : ( +
+ {accounts.map((a) => ( +
+ {a.bucket} + {formatMoney(a.balance)} +
+ ))} +
+ )} + + + +
+ + +
+
+ ); +} +``` + +- [ ] **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 " +``` + +--- + +### 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(); + +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 ( +
+ +

{formatMoney(base)}

+

+ к оплате {formatMoney(total)} (комиссия {formatMoney(commission)}) +

+ +
+ {QUICK.map((q) => ( + + ))} +
+ +
+ {KEYS.map((k) => ( + + ))} +
+ +
+ +
+ + +
+ ); +} +``` + +- [ ] **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 " +``` + +--- + +### 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( + + + } /> + + + ); +} + +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 ( +
+ +

Оплачено

+

{st.bucket} пополнен на {formatMoney(st.base ?? 0)}

+ +
+ ); + } + + return ( +
+ СБП +

{formatMoney(st.total ?? 0)}

+

{st.bucket}

+ Оплатить +

Откроется ваше приложение банка

+
Ожидаем оплату…
+
+ ); +} +``` + +- [ ] **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 " +``` + +--- + +### 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(); +} + +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 = { + PAID: "Оплачено", NEW: "Ожидает", FORM: "Ожидает", CONFIRMED: "Оплачено", REJECTED: "Отклонён", +}; + +export function HistoryPage() { + const nav = useNavigate(); + const { data, isLoading } = useQuery({ queryKey: ["payments"], queryFn: getPayments }); + const rows = data?.payments ?? []; + + return ( +
+ +

История пополнений

+ {isLoading ? ( +
+ ) : rows.length === 0 ? ( +

Пока нет пополнений

+ ) : ( + rows.map((p) => ( +
+
+

{p.bucket}

+

+ {p.created_at ? new Date(p.created_at).toLocaleDateString("ru-RU") : ""} · {STATUS_RU[p.status] ?? p.status} +

+
+ {formatMoney(p.amount)} +
+ )) + )} +
+ ); +} +``` + +- [ ] **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 " +``` + +> 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 " +``` + +--- + +## 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).