# Премиум Водитель 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).