feat(payments): wallet screen with balances, benefits and storefront
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
Add the "Кошелёк" section to the settings hub: context-visible chip balances, active benefits (no-ads term/forever, hints) and a storefront of chip-priced values and money-priced chip packs. Guests have no wallet; the Google Play build hides the money purchases behind a RuStore stub; a web purchase that would draw VK/Telegram chips warns first. Add the catalog read path the storefront needs — a context-projected GET /api/v1/user/wallet/catalog (payments service + store, gateway op wallet.catalog, FBS Catalog/CatalogProduct/CatalogAtom, client decode) — plus the client leg for the existing wallet.get/buy ops. Value spends reuse the existing spend path; the chip-pack purchase (money order flow) arrives with payment intake, so its action is a disabled placeholder for now. Covered by Go unit (catalog projection) + integration (/wallet/catalog over Postgres), vitest (formatting, spendable selection, web-spend warning, GP flag, codec + gateway encode round-trips) and Playwright mock e2e (render, guest-hidden, GP stub, warning) on Chromium + WebKit.
This commit is contained in:
@@ -40,6 +40,8 @@ import type {
|
||||
Stats,
|
||||
Variant,
|
||||
WordCheckResult,
|
||||
Wallet,
|
||||
Catalog,
|
||||
} from '../model';
|
||||
import { valueForLetter } from '../alphabet';
|
||||
import { seedMockAlphabets } from './alphabet';
|
||||
@@ -112,10 +114,37 @@ export class MockGateway implements GatewayClient {
|
||||
private readonly stats: Stats = { ...MOCK_STATS };
|
||||
private readonly drafts = new Map<string, string>();
|
||||
|
||||
// The mock chip wallet: a web/native (direct) context holding a direct and a vk segment, so the
|
||||
// storefront, the priority draw (direct→vk→tg) and the web-spend warning (a value whose price
|
||||
// reaches into the vk segment) are all exercisable without a backend.
|
||||
private readonly mockWallet: Wallet = {
|
||||
segments: [
|
||||
{ source: 'direct', chips: 120, spendable: true },
|
||||
{ source: 'vk', chips: 400, spendable: true },
|
||||
],
|
||||
adsForever: false,
|
||||
adsPaidUntilMs: 0,
|
||||
hints: 5,
|
||||
};
|
||||
// The mock storefront: two chip-priced values (one cheap enough to draw direct only, one that
|
||||
// reaches into vk → the warning) and one RUB-priced chip pack (the direct-context method).
|
||||
private readonly mockCatalog: Catalog = {
|
||||
products: [
|
||||
{ kind: 'value', productId: 'val-hints-50', title: '50 подсказок', chips: 100, moneyAmount: 0, moneyCurrency: '', atoms: [{ atomType: 'hints', quantity: 50 }] },
|
||||
{ kind: 'value', productId: 'val-noads-30', title: 'Без рекламы: 30 дней', chips: 300, moneyAmount: 0, moneyCurrency: '', atoms: [{ atomType: 'noads_days', quantity: 30 }] },
|
||||
{ kind: 'pack', productId: 'pack-chips-100', title: '100 фишек', chips: 0, moneyAmount: 14900, moneyCurrency: 'RUB', atoms: [{ atomType: 'chips', quantity: 100 }] },
|
||||
],
|
||||
};
|
||||
|
||||
constructor() {
|
||||
// Seed the per-variant alphabet cache the rack, blank chooser and scoring read, so the
|
||||
// mock-driven UI is alphabet-agnostic without a backend.
|
||||
seedMockAlphabets();
|
||||
// e2e seam: `?guest` seeds a guest profile so the durable-only surfaces (Friends, Wallet) can
|
||||
// be asserted hidden. The mock profile is otherwise a durable account.
|
||||
if (typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('guest')) {
|
||||
this.profile.isGuest = true;
|
||||
}
|
||||
}
|
||||
|
||||
setToken(_token: string | null): void {
|
||||
@@ -166,6 +195,44 @@ export class MockGateway implements GatewayClient {
|
||||
// The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam.
|
||||
return { blocked: false, permanent: false, until: '', reason: '' };
|
||||
}
|
||||
|
||||
// --- wallet ---
|
||||
async wallet(): Promise<Wallet> {
|
||||
return this.cloneWallet();
|
||||
}
|
||||
async catalog(): Promise<Catalog> {
|
||||
return { products: this.mockCatalog.products.map((p) => ({ ...p, atoms: p.atoms.map((a) => ({ ...a })) })) };
|
||||
}
|
||||
async walletBuy(productId: string): Promise<Wallet> {
|
||||
const p = this.mockCatalog.products.find((x) => x.productId === productId);
|
||||
if (!p || p.kind !== 'value') throw new GatewayError('product_not_found');
|
||||
// Draw the chip price across the segments by priority direct→vk→tg, mirroring the backend.
|
||||
let remaining = p.chips;
|
||||
for (const src of ['direct', 'vk', 'telegram'] as const) {
|
||||
if (remaining <= 0) break;
|
||||
const seg = this.mockWallet.segments.find((s) => s.source === src);
|
||||
if (!seg) continue;
|
||||
const take = Math.min(seg.chips, remaining);
|
||||
seg.chips -= take;
|
||||
remaining -= take;
|
||||
}
|
||||
if (remaining > 0) throw new GatewayError('insufficient_chips');
|
||||
for (const a of p.atoms) {
|
||||
if (a.atomType === 'hints') this.mockWallet.hints += a.quantity;
|
||||
if (a.atomType === 'noads_days') {
|
||||
this.mockWallet.adsPaidUntilMs = Math.max(Date.now(), this.mockWallet.adsPaidUntilMs) + a.quantity * 86_400_000;
|
||||
}
|
||||
}
|
||||
return this.cloneWallet();
|
||||
}
|
||||
private cloneWallet(): Wallet {
|
||||
return {
|
||||
segments: this.mockWallet.segments.map((s) => ({ ...s })),
|
||||
adsForever: this.mockWallet.adsForever,
|
||||
adsPaidUntilMs: this.mockWallet.adsPaidUntilMs,
|
||||
hints: this.mockWallet.hints,
|
||||
};
|
||||
}
|
||||
async fetchDict(variant: Variant, _version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
|
||||
// The offline e2e serves the real per-variant dawgs from the preview build's /e2edict/ (copied
|
||||
// in by scripts/e2e-dict.mjs from the scrabble-dictionary release, never committed), so a local
|
||||
|
||||
Reference in New Issue
Block a user