Files
mechanic-pwa/docs/superpowers/plans/2026-06-18-premium-driver-pwa.md
T

57 KiB
Raw Blame History

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

# 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):
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
git add app/modules/payments/api_driver.py tests/test_payments_driver_history.py
git commit -m "feat(payments): driver payments history + single-status endpoints

Co-Authored-By: claude-flow <ruv@ruv.net>"

Working dir for this task: c:\NewProject\crm2\backend.


Task A2: CORS allow app.pptaxi.ru

Files:

  • Modify: app/main.py (CORS middleware)

  • Test: tests/test_cors_driver_app.py

  • Step 1: Write the failing test

# 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:
      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

git add app/main.py tests/test_cors_driver_app.py
git commit -m "feat(payments): allow app.pptaxi.ru CORS origin for driver PWA

Co-Authored-By: claude-flow <ruv@ruv.net>"

Working dir: c:\NewProject\crm2\backend.


PART B — PWA scaffold & foundation

All Part B tasks run from c:\NewProject\PremiumDriverApp\driver-pwa\frontend (branch feat/driver-pwa).

Task B1: Scaffold project (config + deps)

Files (create): driver-pwa/frontend/{package.json, vite.config.ts, tsconfig.json, tsconfig.app.json, tsconfig.node.json, tailwind.config.js, postcss.config.js, vitest.config.ts, index.html, .env.example, .gitignore}

  • Step 1: Create package.json
{
  "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
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:

{
  "files": [],
  "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

tsconfig.app.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:

{
  "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:

/** @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:

export default { plugins: { tailwindcss: {}, autoprefixer: {} } };

vitest.config.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:

<!doctype html>
<html lang="ru">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
    <meta name="theme-color" content="#F3EEE3" />
    <link rel="manifest" href="/manifest.webmanifest" />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet" />
    <title>Премиум Водитель</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

.env.example:

VITE_API_BASE=https://crm.pptaxi.ru/api

.gitignore:

node_modules
dist
.env
.env.local
  • Step 5: Install + commit
npm install
git add package.json package-lock.json vite.config.ts tsconfig*.json tailwind.config.js postcss.config.js vitest.config.ts index.html .env.example .gitignore
git commit -m "chore(driver-pwa): scaffold Vite+PWA+Tailwind project

Co-Authored-By: claude-flow <ruv@ruv.net>"

Expected: npm install completes; no app code yet.


Task B2: Design tokens + entry + test setup

Files (create): src/index.css, src/main.tsx, src/test/setup.ts, src/App.tsx (stub)

  • Step 1: Create src/index.css
@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
import "@testing-library/jest-dom/vitest";
  • Step 3: Create src/App.tsx (temporary stub — replaced in B5)
export default function App() {
  return <div className="p-4">Премиум Водитель</div>;
}
  • Step 4: Create src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
import App from "./App";
import "./index.css";

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: 1, staleTime: 15_000 } },
});

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <App />
        <Toaster richColors position="top-center" />
      </BrowserRouter>
    </QueryClientProvider>
  </React.StrictMode>
);
  • Step 5: Verify build + commit

Run: npm run build Expected: build succeeds (TS + Vite).

git add src/index.css src/main.tsx src/App.tsx src/test/setup.ts
git commit -m "feat(driver-pwa): design tokens, entry, test setup

Co-Authored-By: claude-flow <ruv@ruv.net>"

Task B3: Pure libs — money/phone format + amount/commission

Files (create): src/lib/format.ts, src/lib/format.test.ts, src/lib/amount.ts, src/lib/amount.test.ts

  • Step 1: Write failing tests

src/lib/format.test.ts:

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:

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:

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:

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
git add src/lib/format.ts src/lib/format.test.ts src/lib/amount.ts src/lib/amount.test.ts
git commit -m "feat(driver-pwa): pure money/phone/amount helpers + tests

Co-Authored-By: claude-flow <ruv@ruv.net>"

Task B4: Auth store + API client + typed driver API (zod)

Files (create): src/store/auth.ts, src/api/client.ts, src/api/driver.ts, src/api/driver.test.ts

  • Step 1: Write the failing test (zod schemas parse backend shapes)

src/api/driver.test.ts:

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:

import { create } from "zustand";
import { persist } from "zustand/middleware";

interface AuthState {
  token: string | null;
  driverId: number | null;
  setSession: (token: string, driverId: number) => void;
  clear: () => void;
}

export const useAuth = create<AuthState>()(
  persist(
    (set) => ({
      token: null,
      driverId: null,
      setSession: (token, driverId) => set({ token, driverId }),
      clear: () => set({ token: null, driverId: null }),
    }),
    { name: "pp-driver-auth" }
  )
);

src/api/client.ts:

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:

import { z } from "zod";
import { api } from "./client";

export const AuthRequestSchema = z.union([
  z.object({ status: z.literal("code_sent"), channel: z.string() }),
  z.object({ status: z.literal("onboarding"), deep_links: z.object({ tg: z.string(), max: z.string() }) }),
]);
export type AuthRequest = z.infer<typeof AuthRequestSchema>;

export const VerifySchema = z.object({ token: z.string(), driver_id: z.number() });
export const BalanceSchema = z.object({
  accounts: z.array(z.object({ bucket: z.string(), balance: z.number() })),
});
export const TopupSchema = z.object({
  order_id: z.string(), pay_url: z.string(), commission: z.number(), total: z.number(),
});
export const PaymentStateSchema = z.object({ order_id: z.string(), status: z.string() });
export const PaymentsSchema = z.object({
  payments: z.array(z.object({
    order_id: z.string(), bucket: z.string(), amount: z.number(), commission: z.number(),
    method: z.string(), status: z.string(),
    created_at: z.string().nullable(), paid_at: z.string().nullable(),
  })),
});

export const authRequest = async (phone: string) =>
  AuthRequestSchema.parse(await api.post("driver/auth/request", { json: { phone } }).json());
export const authVerify = async (phone: string, code: string) =>
  VerifySchema.parse(await api.post("driver/auth/verify", { json: { phone, code } }).json());
export const getBalance = async () =>
  BalanceSchema.parse(await api.get("driver/balance").json());
export const topup = async (bucket: string, amount: number, method: "qr" | "card") =>
  TopupSchema.parse(await api.post("driver/topup", { json: { bucket, amount, method } }).json());
export const getPaymentState = async (orderId: string) =>
  PaymentStateSchema.parse(await api.get(`driver/payment/${orderId}`).json());
export const getPayments = async () =>
  PaymentsSchema.parse(await api.get("driver/payments").json());
  • Step 4: Run → PASS npm run test -- src/api.
  • Step 5: Commit
git add src/store/auth.ts src/api/client.ts src/api/driver.ts src/api/driver.test.ts
git commit -m "feat(driver-pwa): auth store + ky client + typed driver API (zod)

Co-Authored-By: claude-flow <ruv@ruv.net>"

Task B5: Shared UI components + router/guard (App.tsx)

Files (create): src/components/AppHeader.tsx, src/components/Segment.tsx, src/components/Spinner.tsx; Modify: src/App.tsx; Test: src/App.test.tsx

  • Step 1: Write the failing test (unauthenticated → Login; authenticated → not Login)

src/App.test.tsx:

import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import { useAuth } from "@/store/auth";

function renderAt(path: string) {
  const qc = new QueryClient();
  return render(
    <QueryClientProvider client={qc}>
      <MemoryRouter initialEntries={[path]}>
        <App />
      </MemoryRouter>
    </QueryClientProvider>
  );
}

describe("routing guard", () => {
  beforeEach(() => useAuth.getState().clear());
  it("redirects to login when no token", () => {
    renderAt("/");
    expect(screen.getByText(/Вход в приложение/i)).toBeInTheDocument();
  });
});
  • Step 2: Run → FAIL npm run test -- src/App (App stub has no login text / routes).

  • Step 3: Implement components + App

src/components/Spinner.tsx:

export function Spinner() {
  return <span className="inline-block w-3.5 h-3.5 border-2 border-line border-t-ink rounded-full animate-spin" />;
}

src/components/AppHeader.tsx:

export function AppHeader({ initials }: { initials?: string }) {
  return (
    <header className="flex items-center justify-between mb-4">
      <span className="text-sm font-bold">Премиум Водитель</span>
      {initials && (
        <span className="w-7 h-7 rounded-full bg-ink text-cream text-[10px] font-bold flex items-center justify-center">
          {initials}
        </span>
      )}
    </header>
  );
}

src/components/Segment.tsx:

interface Props { value: string; options: { value: string; label: string }[]; onChange: (v: string) => void; }
export function Segment({ value, options, onChange }: Props) {
  return (
    <div className="flex gap-1.5">
      {options.map((o) => (
        <button
          key={o.value}
          type="button"
          onClick={() => onChange(o.value)}
          className={`flex-1 py-2.5 rounded-xl2 text-xs border ${
            value === o.value ? "bg-ink text-cream border-ink font-bold" : "bg-surface border-line text-muted"
          }`}
        >
          {o.label}
        </button>
      ))}
    </div>
  );
}

src/App.tsx (replace stub):

import { Navigate, Route, Routes } from "react-router-dom";
import { useAuth } from "@/store/auth";
import { LoginPage } from "@/pages/LoginPage";
import { OnboardingPage } from "@/pages/OnboardingPage";
import { BalancePage } from "@/pages/BalancePage";
import { TopupPage } from "@/pages/TopupPage";
import { PaymentPage } from "@/pages/PaymentPage";
import { HistoryPage } from "@/pages/HistoryPage";

function RequireAuth({ children }: { children: React.ReactNode }) {
  const token = useAuth((s) => s.token);
  return token ? <>{children}</> : <Navigate to="/login" replace />;
}

export default function App() {
  return (
    <div className="mx-auto max-w-md min-h-full px-4 pt-3 pb-8">
      <Routes>
        <Route path="/login" element={<LoginPage />} />
        <Route path="/onboarding" element={<OnboardingPage />} />
        <Route path="/" element={<RequireAuth><BalancePage /></RequireAuth>} />
        <Route path="/topup" element={<RequireAuth><TopupPage /></RequireAuth>} />
        <Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></RequireAuth>} />
        <Route path="/history" element={<RequireAuth><HistoryPage /></RequireAuth>} />
        <Route path="*" element={<Navigate to="/" replace />} />
      </Routes>
    </div>
  );
}

Note: Pages are created in B6B10. To keep this task building/testing in isolation, create minimal stub files for the not-yet-built pages now (each exporting a named component returning null), then flesh them out in their tasks. The LoginPage stub must render the text "Вход в приложение" so this task's test passes; it is fully implemented in B6.

Create stubs: src/pages/LoginPage.tsx (export function LoginPage(){ return <div>Вход в приложение</div>; }), and OnboardingPage, BalancePage, TopupPage, PaymentPage, HistoryPage each export function X(){ return null; }.

  • Step 4: Run → PASS npm run test -- src/App and npm run build.
  • Step 5: Commit
git add src/components src/App.tsx src/App.test.tsx src/pages
git commit -m "feat(driver-pwa): shared UI + router with auth guard

Co-Authored-By: claude-flow <ruv@ruv.net>"

PART C — Screens

Task B6: Login + code entry (phone → TG/MAX code)

Files: Create src/pages/LoginPage.tsx (replace stub); Test src/pages/LoginPage.test.tsx

  • Step 1: Write the failing test
// src/pages/LoginPage.test.tsx
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";

vi.mock("@/api/driver", () => ({
  authRequest: vi.fn(async () => ({ status: "code_sent", channel: "tg" })),
  authVerify: vi.fn(async () => ({ token: "T", driver_id: 7 })),
}));

import { LoginPage } from "./LoginPage";
import * as driver from "@/api/driver";
import { useAuth } from "@/store/auth";

const wrap = () => render(<MemoryRouter><LoginPage /></MemoryRouter>);

describe("LoginPage", () => {
  beforeEach(() => useAuth.getState().clear());
  it("requests code then verifies and stores session", async () => {
    wrap();
    expect(screen.getByText(/Вход в приложение/i)).toBeInTheDocument();
    await userEvent.type(screen.getByPlaceholderText(/телефон/i), "9143054400");
    await userEvent.click(screen.getByRole("button", { name: /Получить код/i }));
    expect(driver.authRequest).toHaveBeenCalledWith("79143054400");
    // code step appears
    const codeInput = await screen.findByPlaceholderText(/код/i);
    await userEvent.type(codeInput, "123456");
    await userEvent.click(screen.getByRole("button", { name: /Войти/i }));
    expect(driver.authVerify).toHaveBeenCalledWith("79143054400", "123456");
  });
});
  • Step 2: Run → FAIL npm run test -- src/pages/LoginPage.

  • Step 3: Implement src/pages/LoginPage.tsx

import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { authRequest, authVerify } from "@/api/driver";
import { digitsOnly, formatPhone } from "@/lib/format";
import { useAuth } from "@/store/auth";
import { Spinner } from "@/components/Spinner";

export function LoginPage() {
  const nav = useNavigate();
  const setSession = useAuth((s) => s.setSession);
  const [phone, setPhone] = useState("");          // national 10 digits
  const [code, setCode] = useState("");
  const [step, setStep] = useState<"phone" | "code">("phone");
  const [channel, setChannel] = useState<string>("");
  const [busy, setBusy] = useState(false);
  const e164 = "7" + phone;

  async function requestCode() {
    if (phone.length < 10) return;
    setBusy(true);
    try {
      const r = await authRequest(e164);
      if (r.status === "onboarding") {
        sessionStorage.setItem("pp-onboarding", JSON.stringify({ ...r.deep_links, phone }));
        nav("/onboarding");
        return;
      }
      setChannel(r.channel);
      setStep("code");
    } catch (err: any) {
      toast.error(err?.response?.status === 404 ? "Номер не найден. Обратитесь в парк." : "Не удалось отправить код");
    } finally {
      setBusy(false);
    }
  }

  async function verify() {
    if (code.length < 4) return;
    setBusy(true);
    try {
      const r = await authVerify(e164, code);
      setSession(r.token, r.driver_id);
      nav("/");
    } catch {
      toast.error("Неверный или просроченный код");
    } finally {
      setBusy(false);
    }
  }

  return (
    <div className="pt-16">
      <h1 className="text-xl font-extrabold mb-1">Премиум Водитель</h1>
      <p className="text-muted text-sm mb-8">Вход в приложение</p>

      {step === "phone" ? (
        <>
          <input
            inputMode="numeric"
            placeholder="Номер телефона"
            value={phone ? formatPhone(phone) : ""}
            onChange={(e) => setPhone(digitsOnly(e.target.value).replace(/^7/, "").slice(0, 10))}
            className="card w-full px-4 py-3 mb-3 text-base bg-white"
          />
          <button className="btn-primary" disabled={busy || phone.length < 10} onClick={requestCode}>
            {busy ? <Spinner /> : "Получить код"}
          </button>
          <p className="text-muted text-xs mt-3">Код придёт в Telegram или MAX.</p>
        </>
      ) : (
        <>
          <p className="text-sm mb-2">Код отправлен в {channel === "max" ? "MAX" : "Telegram"}.</p>
          <input
            inputMode="numeric"
            placeholder="Код из сообщения"
            value={code}
            onChange={(e) => setCode(digitsOnly(e.target.value).slice(0, 6))}
            className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-white"
          />
          <button className="btn-primary" disabled={busy || code.length < 4} onClick={verify}>
            {busy ? <Spinner /> : "Войти"}
          </button>
          <button className="btn-ghost mt-2" onClick={() => setStep("phone")}>Изменить номер</button>
        </>
      )}
    </div>
  );
}
  • Step 4: Run → PASS npm run test -- src/pages/LoginPage.
  • Step 5: Commit
git add src/pages/LoginPage.tsx src/pages/LoginPage.test.tsx
git commit -m "feat(driver-pwa): login (phone -> TG/MAX code)

Co-Authored-By: claude-flow <ruv@ruv.net>"

Task B7: Onboarding (connect TG/MAX bot)

Files: Create src/pages/OnboardingPage.tsx (replace stub); Test src/pages/OnboardingPage.test.tsx

  • Step 1: Write the failing test
// src/pages/OnboardingPage.test.tsx
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { OnboardingPage } from "./OnboardingPage";

describe("OnboardingPage", () => {
  beforeEach(() =>
    sessionStorage.setItem("pp-onboarding", JSON.stringify({ tg: "https://t.me/b?start=x", max: "https://max.ru/b?start=x", phone: "9143054400" }))
  );
  it("shows both bot links + back-to-code action", () => {
    render(<MemoryRouter><OnboardingPage /></MemoryRouter>);
    expect(screen.getByText(/Подключите бот/i)).toBeInTheDocument();
    expect(screen.getByRole("link", { name: /Telegram/i })).toHaveAttribute("href", "https://t.me/b?start=x");
    expect(screen.getByRole("link", { name: /MAX/i })).toHaveAttribute("href", "https://max.ru/b?start=x");
    expect(screen.getByRole("button", { name: /запросить код/i })).toBeInTheDocument();
  });
});
  • Step 2: Run → FAIL.

  • Step 3: Implement src/pages/OnboardingPage.tsx

import { useNavigate } from "react-router-dom";

export function OnboardingPage() {
  const nav = useNavigate();
  const raw = sessionStorage.getItem("pp-onboarding");
  const data = raw ? (JSON.parse(raw) as { tg: string; max: string; phone: string }) : null;
  if (!data) return null;

  return (
    <div className="pt-16">
      <h1 className="text-xl font-extrabold mb-1">Подключите бот</h1>
      <p className="text-muted text-sm mb-8">
        Чтобы получать код входа, откройте бот и нажмите «Старт». Затем вернитесь и запросите код снова.
      </p>
      <a href={data.tg} target="_blank" rel="noopener" className="btn-primary mb-3 no-underline block">
        Подключить Telegram
      </a>
      <a href={data.max} target="_blank" rel="noopener" className="btn-ghost mb-6 no-underline block">
        Подключить MAX
      </a>
      <button className="btn-ghost" onClick={() => nav("/login")}>
        Я подключил  запросить код
      </button>
    </div>
  );
}
  • Step 4: Run → PASS.
  • Step 5: Commit
git add src/pages/OnboardingPage.tsx src/pages/OnboardingPage.test.tsx
git commit -m "feat(driver-pwa): onboarding (connect TG/MAX bot)

Co-Authored-By: claude-flow <ruv@ruv.net>"

Task B8: Balance (hero total + list)

Files: Create src/pages/BalancePage.tsx (replace stub); Test src/pages/BalancePage.test.tsx

  • Step 1: Write the failing test
// src/pages/BalancePage.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

vi.mock("@/api/driver", () => ({
  getBalance: vi.fn(async () => ({
    accounts: [
      { bucket: "Долг аренда", balance: -3200 },
      { bucket: "Депозит", balance: 5000 },
    ],
  })),
}));

import { BalancePage } from "./BalancePage";

function wrap() {
  const qc = new QueryClient();
  return render(
    <QueryClientProvider client={qc}>
      <MemoryRouter><BalancePage /></MemoryRouter>
    </QueryClientProvider>
  );
}

describe("BalancePage", () => {
  it("shows hero total (sum of negatives) and account rows", async () => {
    wrap();
    expect(await screen.findByText("Долг аренда")).toBeInTheDocument();
    expect(screen.getByText("Депозит")).toBeInTheDocument();
    // hero total = sum of negative balances = 3 200 ₽
    expect(screen.getByText("3 200 ₽")).toBeInTheDocument();
    expect(screen.getByRole("button", { name: /Пополнить/i })).toBeInTheDocument();
  });
});
  • Step 2: Run → FAIL.

  • Step 3: Implement src/pages/BalancePage.tsx

import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { History, LogOut } from "lucide-react";
import { getBalance } from "@/api/driver";
import { formatMoney } from "@/lib/format";
import { useAuth } from "@/store/auth";
import { AppHeader } from "@/components/AppHeader";
import { Spinner } from "@/components/Spinner";

export function BalancePage() {
  const nav = useNavigate();
  const clear = useAuth((s) => s.clear);
  const { data, isLoading } = useQuery({ queryKey: ["balance"], queryFn: getBalance });

  const accounts = data?.accounts ?? [];
  const totalDebt = accounts.filter((a) => a.balance < 0).reduce((s, a) => s + a.balance, 0);

  return (
    <div>
      <AppHeader initials="" />
      <div className="card p-4 mb-4 text-center">
        <p className="text-muted text-xs mb-1">Общий долг</p>
        <p className={`text-2xl font-extrabold ${totalDebt < 0 ? "text-neg" : ""}`}>{formatMoney(totalDebt)}</p>
      </div>

      {isLoading ? (
        <div className="flex justify-center py-10"><Spinner /></div>
      ) : (
        <div className="mb-4">
          {accounts.map((a) => (
            <div key={a.bucket} className="flex justify-between items-center py-3 border-b border-line last:border-0">
              <span className="text-sm">{a.bucket}</span>
              <b className={a.balance < 0 ? "text-neg" : "text-pos"}>{formatMoney(a.balance)}</b>
            </div>
          ))}
        </div>
      )}

      <button className="btn-primary" onClick={() => nav("/topup")}>Пополнить</button>

      <div className="flex justify-center gap-6 mt-6 text-muted text-xs">
        <button className="flex items-center gap-1.5" onClick={() => nav("/history")}>
          <History size={15} /> История
        </button>
        <button className="flex items-center gap-1.5" onClick={() => { clear(); nav("/login"); }}>
          <LogOut size={15} /> Выйти
        </button>
      </div>
    </div>
  );
}
  • Step 4: Run → PASS.
  • Step 5: Commit
git add src/pages/BalancePage.tsx src/pages/BalancePage.test.tsx
git commit -m "feat(driver-pwa): balance screen (hero total + accounts)

Co-Authored-By: claude-flow <ruv@ruv.net>"

Task B9: Top-up (custom numeric keypad)

Files: Create src/pages/TopupPage.tsx (replace stub); Test src/pages/TopupPage.test.tsx

The selected account bucket is passed via router state (nav("/topup", { state: { bucket } })); default to the first debt account label "Долг аренда" when absent. Commission rate default 0.052 until /topup returns exact numbers; on pay we navigate to /pay/:orderId with pay_url + amounts in router state.

  • Step 1: Write the failing test
// src/pages/TopupPage.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";

vi.mock("@/api/driver", () => ({
  topup: vi.fn(async () => ({ order_id: "o9", pay_url: "https://qr.nspk.ru/x", commission: 52, total: 1052 })),
}));
import { TopupPage } from "./TopupPage";
import * as driver from "@/api/driver";

const wrap = () => render(<MemoryRouter><TopupPage /></MemoryRouter>);

describe("TopupPage", () => {
  it("keypad builds amount, shows total, and submits topup", async () => {
    wrap();
    await userEvent.click(screen.getByRole("button", { name: "1" }));
    await userEvent.click(screen.getByRole("button", { name: "0" }));
    await userEvent.click(screen.getByRole("button", { name: "0" }));
    await userEvent.click(screen.getByRole("button", { name: "0" }));
    expect(screen.getByTestId("amount").textContent).toBe("1 000 ₽");
    // к оплате 1 052 ₽ (commission 52)
    expect(screen.getByTestId("total").textContent).toContain("1 052");
    await userEvent.click(screen.getByRole("button", { name: /Оплатить/i }));
    expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 1000, "qr");
  });
});
  • Step 2: Run → FAIL.

  • Step 3: Implement src/pages/TopupPage.tsx

import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Delete } from "lucide-react";
import { toast } from "sonner";
import { topup } from "@/api/driver";
import { keypadReduce, withCommission } from "@/lib/amount";
import { formatMoney } from "@/lib/format";
import { Segment } from "@/components/Segment";
import { Spinner } from "@/components/Spinner";

const RATE = 0.052;
const QUICK = ["500", "1000", "2000"];
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "·", "0", "back"];

export function TopupPage() {
  const nav = useNavigate();
  const loc = useLocation();
  const bucket = (loc.state as { bucket?: string } | null)?.bucket ?? "Долг аренда";
  const [raw, setRaw] = useState("");
  const [method, setMethod] = useState("qr");
  const [busy, setBusy] = useState(false);

  const base = raw ? parseInt(raw, 10) : 0;
  const { commission, total } = withCommission(base, RATE);

  function press(k: string) {
    if (k === "·") return;
    setRaw((r) => keypadReduce(r, k));
  }

  async function pay() {
    if (base <= 0) return;
    setBusy(true);
    try {
      const r = await topup(bucket, base, method as "qr" | "card");
      nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base } });
    } catch {
      toast.error("Не удалось создать платёж");
    } finally {
      setBusy(false);
    }
  }

  return (
    <div className="pt-2">
      <button className="text-muted text-xs mb-3" onClick={() => nav(-1)}> {bucket}</button>
      <p data-testid="amount" className="text-3xl font-extrabold text-center mt-2">{formatMoney(base)}</p>
      <p data-testid="total" className="text-muted text-xs text-center mb-3">
        к оплате {formatMoney(total)} (комиссия {formatMoney(commission)})
      </p>

      <div className="flex gap-1.5 mb-3">
        {QUICK.map((q) => (
          <button key={q} className="flex-1 py-2 rounded-xl2 text-xs bg-surface border border-line" onClick={() => setRaw(q)}>
            {q}
          </button>
        ))}
      </div>

      <div className="grid grid-cols-3 gap-2 mb-4">
        {KEYS.map((k) => (
          <button
            key={k}
            aria-label={k}
            className="bg-surface border border-line rounded-xl2 py-3 text-lg font-semibold flex items-center justify-center"
            onClick={() => press(k)}
          >
            {k === "back" ? <Delete size={18} /> : k}
          </button>
        ))}
      </div>

      <div className="mb-4">
        <Segment value={method} onChange={setMethod} options={[{ value: "qr", label: "СБП" }, { value: "card", label: "Картой" }]} />
      </div>

      <button className="btn-primary" disabled={busy || base <= 0} onClick={pay}>
        {busy ? <Spinner /> : "Оплатить"}
      </button>
    </div>
  );
}
  • Step 4: Run → PASS.
  • Step 5: Commit
git add src/pages/TopupPage.tsx src/pages/TopupPage.test.tsx
git commit -m "feat(driver-pwa): top-up with custom keypad

Co-Authored-By: claude-flow <ruv@ruv.net>"

Task B10: Payment + auto-status (poll)

Files: Create src/hooks/usePaymentStatus.ts, src/pages/PaymentPage.tsx (replace stub); Test src/pages/PaymentPage.test.tsx

  • Step 1: Write the failing test
// src/pages/PaymentPage.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Routes, Route } from "react-router-dom";

const states = ["FORM", "PAID"];
vi.mock("@/api/driver", () => ({
  getPaymentState: vi.fn(async () => ({ order_id: "o9", status: states.shift() ?? "PAID" })),
}));
import { PaymentPage } from "./PaymentPage";

function wrap() {
  const qc = new QueryClient();
  return render(
    <QueryClientProvider client={qc}>
      <MemoryRouter initialEntries={[{ pathname: "/pay/o9", state: { payUrl: "https://qr.nspk.ru/x", total: 1052, bucket: "Долг аренда", base: 1000 } }]}>
        <Routes><Route path="/pay/:orderId" element={<PaymentPage />} /></Routes>
      </MemoryRouter>
    </QueryClientProvider>
  );
}

describe("PaymentPage", () => {
  it("shows pay button and flips to success on PAID", async () => {
    wrap();
    expect(screen.getByRole("link", { name: /Оплатить/i })).toHaveAttribute("href", "https://qr.nspk.ru/x");
    await waitFor(() => expect(screen.getByText(/Оплачено/i)).toBeInTheDocument(), { timeout: 3000 });
  });
});
  • Step 2: Run → FAIL.

  • Step 3: Implement

src/hooks/usePaymentStatus.ts:

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:

import { useEffect } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Check } from "lucide-react";
import { formatMoney } from "@/lib/format";
import { Spinner } from "@/components/Spinner";
import { usePaymentStatus } from "@/hooks/usePaymentStatus";

export function PaymentPage() {
  const { orderId = "" } = useParams();
  const nav = useNavigate();
  const qc = useQueryClient();
  const st = (useLocation().state as { payUrl?: string; total?: number; bucket?: string; base?: number } | null) ?? {};
  const { data } = usePaymentStatus(orderId);
  const paid = data?.status === "PAID";

  useEffect(() => {
    if (paid) qc.invalidateQueries({ queryKey: ["balance"] });
  }, [paid, qc]);

  if (paid) {
    return (
      <div className="pt-24 flex flex-col items-center text-center">
        <span className="w-14 h-14 rounded-full bg-pos text-white flex items-center justify-center mb-3"><Check size={28} /></span>
        <p className="text-lg font-extrabold">Оплачено</p>
        <p className="text-muted text-xs mt-1">{st.bucket} пополнен на {formatMoney(st.base ?? 0)}</p>
        <button className="btn-primary mt-8" onClick={() => nav("/")}>На главную</button>
      </div>
    );
  }

  return (
    <div className="pt-10 flex flex-col items-center text-center">
      <span className="w-12 h-12 rounded-xl2 bg-ink text-cream flex items-center justify-center font-extrabold mb-4">СБП</span>
      <p className="text-lg font-extrabold">{formatMoney(st.total ?? 0)}</p>
      <p className="text-muted text-xs mb-6">{st.bucket}</p>
      <a className="btn-primary no-underline" href={st.payUrl} target="_blank" rel="noopener">Оплатить</a>
      <p className="text-muted text-xs mt-2">Откроется ваше приложение банка</p>
      <div className="flex items-center gap-2 mt-6 text-muted text-xs"><Spinner /> Ожидаем оплату…</div>
    </div>
  );
}
  • Step 4: Run → PASS.
  • Step 5: Commit
git add src/hooks/usePaymentStatus.ts src/pages/PaymentPage.tsx src/pages/PaymentPage.test.tsx
git commit -m "feat(driver-pwa): payment screen + auto status polling

Co-Authored-By: claude-flow <ruv@ruv.net>"

Task B11: History

Files: Create src/pages/HistoryPage.tsx (replace stub); Test src/pages/HistoryPage.test.tsx

  • Step 1: Write the failing test
// src/pages/HistoryPage.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

vi.mock("@/api/driver", () => ({
  getPayments: vi.fn(async () => ({ payments: [
    { order_id: "o1", bucket: "Долг аренда", amount: 1000, commission: 52, method: "qr", status: "PAID", created_at: "2026-06-18T09:00:00Z", paid_at: "2026-06-18T09:01:00Z" },
  ] })),
}));
import { HistoryPage } from "./HistoryPage";

function wrap() {
  const qc = new QueryClient();
  return render(<QueryClientProvider client={qc}><MemoryRouter><HistoryPage /></MemoryRouter></QueryClientProvider>);
}

describe("HistoryPage", () => {
  it("lists payments with amount + status", async () => {
    wrap();
    expect(await screen.findByText("Долг аренда")).toBeInTheDocument();
    expect(screen.getByText("1 000 ₽")).toBeInTheDocument();
    expect(screen.getByText(/Оплачено/i)).toBeInTheDocument();
  });
});
  • Step 2: Run → FAIL.

  • Step 3: Implement src/pages/HistoryPage.tsx

import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { getPayments } from "@/api/driver";
import { formatMoney } from "@/lib/format";
import { Spinner } from "@/components/Spinner";

const STATUS_RU: Record<string, string> = {
  PAID: "Оплачено", NEW: "Ожидает", FORM: "Ожидает", CONFIRMED: "Оплачено", REJECTED: "Отклонён",
};

export function HistoryPage() {
  const nav = useNavigate();
  const { data, isLoading } = useQuery({ queryKey: ["payments"], queryFn: getPayments });
  const rows = data?.payments ?? [];

  return (
    <div className="pt-2">
      <button className="text-muted text-xs mb-3" onClick={() => nav("/")}> Назад</button>
      <h1 className="text-lg font-extrabold mb-4">История пополнений</h1>
      {isLoading ? (
        <div className="flex justify-center py-10"><Spinner /></div>
      ) : rows.length === 0 ? (
        <p className="text-muted text-sm">Пока нет пополнений</p>
      ) : (
        rows.map((p) => (
          <div key={p.order_id} className="flex justify-between items-center py-3 border-b border-line">
            <div>
              <p className="text-sm">{p.bucket}</p>
              <p className="text-muted text-xs">
                {p.created_at ? new Date(p.created_at).toLocaleDateString("ru-RU") : ""} · {STATUS_RU[p.status] ?? p.status}
              </p>
            </div>
            <b>{formatMoney(p.amount)}</b>
          </div>
        ))
      )}
    </div>
  );
}
  • Step 4: Run → PASS.
  • Step 5: Commit
git add src/pages/HistoryPage.tsx src/pages/HistoryPage.test.tsx
git commit -m "feat(driver-pwa): payments history screen

Co-Authored-By: claude-flow <ruv@ruv.net>"

Wire the balance account row to preselect the bucket on top-up: in BalancePage.tsx make each account row a button calling nav("/topup", { state: { bucket: a.bucket } }). Add this small change in this task's Step 3 edit and keep BalancePage test green.


Task B12: PWA manifest + icons + final suite

Files: Create public/manifest.webmanifest, public/icons/icon-192.png, public/icons/icon-512.png

  • Step 1: Create public/manifest.webmanifest
{
  "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):

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
git add public/manifest.webmanifest public/icons
git commit -m "feat(driver-pwa): PWA manifest + placeholder icons

Co-Authored-By: claude-flow <ruv@ruv.net>"

Deployment notes (not TDD tasks)

  • Frontend: npm run build → ship dist/ to app.pptaxi.ru static root; add a Caddy site block for app.pptaxi.ru (TLS via LE) serving the SPA with fallback to index.html. Set VITE_API_BASE=https://crm.pptaxi.ru/api at build time (or .env).
  • Backend (Part A) deploys with the normal crm2 blue/green; apply nothing extra (no new tables). CORS change takes effect on restart.
  • The driver-auth n8n workflows + DRIVER_BOT_SEND_URL/DRIVER_BOT_CALLBACK_SECRET + T-Bank terminal/ATOL creds are separate prerequisites for a live end-to-end flow (the PWA itself builds/tests without them).

Self-Review

Spec coverage: Cream tokens (B2 tailwind/index.css) ✓; app name «Премиум Водитель» (index.html, manifest, AppHeader) ✓; Login phone→code (B6) ✓; onboarding TG/MAX (B7) ✓; Balance hero+list (B8) ✓; Top-up custom keypad + quick amounts + СБП/Card segment + commission line (B9) ✓; Payment redirect (SBP deep-link / card form) + auto-poll + success, no QR (B10) ✓; History (B11) ✓; logout (B8) ✓; session JWT in localStorage + ky Bearer + 401 logout (B4) ✓; react-query/zod/zustand/sonner (B2,B4) ✓; PWA manifest/SW (B1 vite-plugin-pwa, B12 manifest) ✓; backend GET /driver/payments + GET /driver/payment/{order_id} (A1) + CORS (A2) ✓; deploy app.pptaxi.ru (notes) ✓. Placeholder scan: No TBD. Page stubs in B5 are explicit and each replaced in its own task (B6B11). Icons are documented placeholder PNGs (swap before launch) — explicit, not a silent gap. Type consistency: API fns (authRequest(e164), authVerify(phone,code), getBalance, topup(bucket,amount,method), getPaymentState(orderId), getPayments) defined in B4 and used identically in B6B11. useAuth {token,driverId,setSession,clear} consistent (B4,B5,B6,B8). Router state keys (bucket for /topup; payUrl,total,bucket,base for /pay) consistent (B9→B10). zod schema field names match backend serializer in A1 (order_id,bucket,amount,commission,method,status,created_at,paid_at). keypad keypadReduce/withCommission consistent (B3,B9).