57 KiB
Премиум Водитель 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— addGET /driver/payments+GET /driver/payment/{order_id}(driver-gated, read owndriver_payments).app/main.py— addhttps://app.pptaxi.ruto 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 importsselect,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 existingCORSMiddlewaresetup. If origins are an inline list, extract them to a module-levelCORS_ORIGINSlist 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 addCORS_ORIGINS = ["*"](preserve behavior) — the test still passes only if the literal string is present, so in that case setCORS_ORIGINS = ["*", "https://app.pptaxi.ru"]and keepallow_origins=CORS_ORIGINS. Read the file first and preserve existing origins verbatim.
- Locate
-
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 === "0") return key === "0" ? "0" : key; // replace leading zero
if (current === "" && key === "0") return ""; // no leading zero from empty
if (current.length >= MAX_DIGITS) return current;
return current + key;
}
export function withCommission(base: number, rate: number): { commission: number; total: number } {
const commission = Math.round(base * rate * 100) / 100;
const total = Math.round((base + commission) * 100) / 100;
return { commission, total };
}
- Step 4: Run → PASS
npm run test -- src/lib. - Step 5: Commit
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 B6–B10. To keep this task building/testing in isolation, create minimal stub files for the not-yet-built pages now (each exporting a named component returning
null), then flesh them out in their tasks. The LoginPage stub must render the text "Вход в приложение" so this task's test passes; it is fully implemented in B6.
Create stubs: src/pages/LoginPage.tsx (export function LoginPage(){ return <div>Вход в приложение</div>; }), and OnboardingPage, BalancePage, TopupPage, PaymentPage, HistoryPage each export function X(){ return null; }.
- Step 4: Run → PASS
npm run test -- src/Appandnpm 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.tsxmake each account row a button callingnav("/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→ shipdist/toapp.pptaxi.rustatic root; add a Caddy site block forapp.pptaxi.ru(TLS via LE) serving the SPA with fallback toindex.html. SetVITE_API_BASE=https://crm.pptaxi.ru/apiat 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).