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:
@@ -0,0 +1,99 @@
|
||||
import { expect, test, type Page } from './fixtures';
|
||||
|
||||
// The Wallet section against the mock transport (no backend). The mock seeds a web/native (direct)
|
||||
// account holding a direct + vk chip segment and a small catalog (two chip-priced values and one
|
||||
// rouble-priced chip pack — see lib/mock/client.ts), so the storefront, the store-compliance
|
||||
// warning and the Google Play stub are all exercisable without a backend.
|
||||
|
||||
async function loginLobby(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
}
|
||||
|
||||
async function openSettings(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /Settings/ }).click(); // lobby ⚙️ tab
|
||||
await expect(page.locator('.pane')).toHaveCount(1); // let the slide settle
|
||||
}
|
||||
|
||||
async function openWallet(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await page.getByRole('button', { name: 'Wallet', exact: true }).click();
|
||||
await expect(page.getByTestId('wallet')).toBeVisible();
|
||||
}
|
||||
|
||||
test('wallet: balances, benefits and the storefront render for a durable account', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await openWallet(page);
|
||||
|
||||
// Balances: the seeded direct ("Web") and vk segments.
|
||||
await expect(page.getByRole('heading', { name: 'Balance' })).toBeVisible();
|
||||
await expect(page.getByText('Web', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('VK', { exact: true })).toBeVisible();
|
||||
|
||||
// Benefits + the storefront: two values priced in chips, one pack priced in roubles.
|
||||
await expect(page.getByRole('heading', { name: 'Benefits' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Store' })).toBeVisible();
|
||||
await expect(page.getByTestId('product')).toHaveCount(3);
|
||||
const pack = page.locator('[data-kind="pack"]');
|
||||
await expect(pack).toContainText('₽');
|
||||
// The pack purchase (money intake) is not wired yet, so its action is the disabled 'Soon'.
|
||||
await expect(pack.getByRole('button', { name: 'Soon' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('wallet: the tab sits between Friends and About', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await openSettings(page);
|
||||
const labels = await page.locator('.tab .lbl').allInnerTexts();
|
||||
const iFriends = labels.indexOf('Friends');
|
||||
const iWallet = labels.indexOf('Wallet');
|
||||
const iAbout = labels.indexOf('Info');
|
||||
expect(iFriends).toBeGreaterThanOrEqual(0);
|
||||
expect(iWallet).toBe(iFriends + 1);
|
||||
expect(iAbout).toBe(iWallet + 1);
|
||||
});
|
||||
|
||||
test('wallet: a guest has no wallet (nor friends) tab', async ({ page }) => {
|
||||
await page.goto('/?guest');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
await openSettings(page);
|
||||
await expect(page.getByRole('button', { name: 'Wallet', exact: true })).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Friends', exact: true })).toBeHidden();
|
||||
});
|
||||
|
||||
test('wallet: the Google Play build hides the money purchases behind the RuStore stub', async ({ page }) => {
|
||||
await page.goto('/?gp');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
await openWallet(page);
|
||||
|
||||
// The chip pack is gone; the stub points at the RuStore build. Spending earned chips still works.
|
||||
await expect(page.getByTestId('gp-stub')).toBeVisible();
|
||||
await expect(page.locator('[data-kind="pack"]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible();
|
||||
});
|
||||
|
||||
test('wallet: a web spend that would draw store chips warns first, then completes', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await openWallet(page);
|
||||
|
||||
// The 300-chip value drains direct (120) then reaches into vk (180) → a store segment → warn.
|
||||
await page.locator('[data-pid="val-noads-30"]').getByTestId('buy').click();
|
||||
await expect(page.getByTestId('warn')).toBeVisible();
|
||||
await page.getByTestId('warn-confirm').click();
|
||||
await expect(page.getByTestId('warn')).toBeHidden();
|
||||
// The spend applied: the no-ads benefit now shows an end date.
|
||||
await expect(page.getByTestId('wallet')).toContainText('No ads until');
|
||||
});
|
||||
|
||||
test('wallet: a spend the direct segment covers alone does not warn', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await openWallet(page);
|
||||
|
||||
// The 100-chip value is covered by the direct segment (120) alone → no store draw, no warning.
|
||||
await page.locator('[data-pid="val-hints-50"]').getByTestId('buy').click();
|
||||
await expect(page.getByTestId('warn')).toBeHidden();
|
||||
// The hints benefit rose from 5 to 55.
|
||||
await expect(page.getByTestId('wallet')).toContainText('Hints 55');
|
||||
});
|
||||
@@ -26,10 +26,11 @@ const DIST = 'dist';
|
||||
// countdown toast live in the always-loaded game screen (Game.svelte) — and to 120 for the offline
|
||||
// pass-and-play (hotseat) mode: the on-screen PIN pad, the creation roster and the in-game host menu
|
||||
// live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN
|
||||
// hashing stay in lazy chunks / are negligible). The heavy parts — the dict loader, the move
|
||||
// generator and the preload orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS
|
||||
// chunk, not this JS budget.
|
||||
const BUDGET = { app: 120, shared: 30, landing: 5 };
|
||||
// hashing stay in lazy chunks / are negligible), then to 123 for the Wallet section — its screen,
|
||||
// storefront logic and the catalog codec load with the always-mounted settings hub (its i18n lands
|
||||
// in the shared chunk). The heavy parts — the dict loader, the move generator and the preload
|
||||
// orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
|
||||
const BUDGET = { app: 123, shared: 30, landing: 5 };
|
||||
|
||||
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
||||
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||
|
||||
@@ -116,6 +116,8 @@
|
||||
<SettingsHub initialTab="about" />
|
||||
{:else if router.route.name === 'friends'}
|
||||
<SettingsHub initialTab="friends" />
|
||||
{:else if router.route.name === 'wallet'}
|
||||
<SettingsHub initialTab="wallet" />
|
||||
{:else if router.route.name === 'feedback'}
|
||||
<Feedback />
|
||||
{:else if router.route.name === 'stats'}
|
||||
|
||||
@@ -11,6 +11,9 @@ export { BestMoveTile } from './scrabblefb/best-move-tile.js';
|
||||
export { BestMoveView } from './scrabblefb/best-move-view.js';
|
||||
export { BlockList } from './scrabblefb/block-list.js';
|
||||
export { BlockStatus } from './scrabblefb/block-status.js';
|
||||
export { Catalog } from './scrabblefb/catalog.js';
|
||||
export { CatalogAtom } from './scrabblefb/catalog-atom.js';
|
||||
export { CatalogProduct } from './scrabblefb/catalog-product.js';
|
||||
export { ChatList } from './scrabblefb/chat-list.js';
|
||||
export { ChatMessage } from './scrabblefb/chat-message.js';
|
||||
export { ChatPostRequest } from './scrabblefb/chat-post-request.js';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class CatalogAtom {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):CatalogAtom {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsCatalogAtom(bb:flatbuffers.ByteBuffer, obj?:CatalogAtom):CatalogAtom {
|
||||
return (obj || new CatalogAtom()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsCatalogAtom(bb:flatbuffers.ByteBuffer, obj?:CatalogAtom):CatalogAtom {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new CatalogAtom()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
atomType():string|null
|
||||
atomType(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
atomType(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
quantity():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startCatalogAtom(builder:flatbuffers.Builder) {
|
||||
builder.startObject(2);
|
||||
}
|
||||
|
||||
static addAtomType(builder:flatbuffers.Builder, atomTypeOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, atomTypeOffset, 0);
|
||||
}
|
||||
|
||||
static addQuantity(builder:flatbuffers.Builder, quantity:number) {
|
||||
builder.addFieldInt32(1, quantity, 0);
|
||||
}
|
||||
|
||||
static endCatalogAtom(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createCatalogAtom(builder:flatbuffers.Builder, atomTypeOffset:flatbuffers.Offset, quantity:number):flatbuffers.Offset {
|
||||
CatalogAtom.startCatalogAtom(builder);
|
||||
CatalogAtom.addAtomType(builder, atomTypeOffset);
|
||||
CatalogAtom.addQuantity(builder, quantity);
|
||||
return CatalogAtom.endCatalogAtom(builder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { CatalogAtom } from '../scrabblefb/catalog-atom.js';
|
||||
|
||||
|
||||
export class CatalogProduct {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):CatalogProduct {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsCatalogProduct(bb:flatbuffers.ByteBuffer, obj?:CatalogProduct):CatalogProduct {
|
||||
return (obj || new CatalogProduct()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsCatalogProduct(bb:flatbuffers.ByteBuffer, obj?:CatalogProduct):CatalogProduct {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new CatalogProduct()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
kind():string|null
|
||||
kind(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
kind(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
productId():string|null
|
||||
productId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
productId(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
title():string|null
|
||||
title(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
title(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
chips():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
moneyAmount():bigint {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 12);
|
||||
return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0');
|
||||
}
|
||||
|
||||
moneyCurrency():string|null
|
||||
moneyCurrency(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
moneyCurrency(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 14);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
atoms(index: number, obj?:CatalogAtom):CatalogAtom|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 16);
|
||||
return offset ? (obj || new CatalogAtom()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
}
|
||||
|
||||
atomsLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 16);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startCatalogProduct(builder:flatbuffers.Builder) {
|
||||
builder.startObject(7);
|
||||
}
|
||||
|
||||
static addKind(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, kindOffset, 0);
|
||||
}
|
||||
|
||||
static addProductId(builder:flatbuffers.Builder, productIdOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, productIdOffset, 0);
|
||||
}
|
||||
|
||||
static addTitle(builder:flatbuffers.Builder, titleOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, titleOffset, 0);
|
||||
}
|
||||
|
||||
static addChips(builder:flatbuffers.Builder, chips:number) {
|
||||
builder.addFieldInt32(3, chips, 0);
|
||||
}
|
||||
|
||||
static addMoneyAmount(builder:flatbuffers.Builder, moneyAmount:bigint) {
|
||||
builder.addFieldInt64(4, moneyAmount, BigInt('0'));
|
||||
}
|
||||
|
||||
static addMoneyCurrency(builder:flatbuffers.Builder, moneyCurrencyOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(5, moneyCurrencyOffset, 0);
|
||||
}
|
||||
|
||||
static addAtoms(builder:flatbuffers.Builder, atomsOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(6, atomsOffset, 0);
|
||||
}
|
||||
|
||||
static createAtomsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startAtomsVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static endCatalogProduct(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createCatalogProduct(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset, productIdOffset:flatbuffers.Offset, titleOffset:flatbuffers.Offset, chips:number, moneyAmount:bigint, moneyCurrencyOffset:flatbuffers.Offset, atomsOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
CatalogProduct.startCatalogProduct(builder);
|
||||
CatalogProduct.addKind(builder, kindOffset);
|
||||
CatalogProduct.addProductId(builder, productIdOffset);
|
||||
CatalogProduct.addTitle(builder, titleOffset);
|
||||
CatalogProduct.addChips(builder, chips);
|
||||
CatalogProduct.addMoneyAmount(builder, moneyAmount);
|
||||
CatalogProduct.addMoneyCurrency(builder, moneyCurrencyOffset);
|
||||
CatalogProduct.addAtoms(builder, atomsOffset);
|
||||
return CatalogProduct.endCatalogProduct(builder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { CatalogProduct } from '../scrabblefb/catalog-product.js';
|
||||
|
||||
|
||||
export class Catalog {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):Catalog {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsCatalog(bb:flatbuffers.ByteBuffer, obj?:Catalog):Catalog {
|
||||
return (obj || new Catalog()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsCatalog(bb:flatbuffers.ByteBuffer, obj?:Catalog):Catalog {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new Catalog()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
products(index: number, obj?:CatalogProduct):CatalogProduct|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? (obj || new CatalogProduct()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
}
|
||||
|
||||
productsLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startCatalog(builder:flatbuffers.Builder) {
|
||||
builder.startObject(1);
|
||||
}
|
||||
|
||||
static addProducts(builder:flatbuffers.Builder, productsOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, productsOffset, 0);
|
||||
}
|
||||
|
||||
static createProductsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startProductsVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static endCatalog(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createCatalog(builder:flatbuffers.Builder, productsOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
Catalog.startCatalog(builder);
|
||||
Catalog.addProducts(builder, productsOffset);
|
||||
return Catalog.endCatalog(builder);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ import type {
|
||||
Tile,
|
||||
Variant,
|
||||
WordCheckResult,
|
||||
Wallet,
|
||||
Catalog,
|
||||
} from './model';
|
||||
|
||||
/** GatewayError carries a stable code (the gateway result_code, or an edge code). */
|
||||
@@ -135,6 +137,17 @@ export interface GatewayClient {
|
||||
/** feedbackUnread reports whether an operator reply awaits delivery (the badge). */
|
||||
feedbackUnread(): Promise<boolean>;
|
||||
|
||||
// --- wallet ---
|
||||
/** wallet returns the caller's chip segments and active benefits, visible in their current
|
||||
* trusted execution context (view-only when the platform is untrusted or VK-iOS frozen). */
|
||||
wallet(): Promise<Wallet>;
|
||||
/** catalog returns the storefront for the caller's context: chip-priced values and the chip
|
||||
* packs priced in the context's payment method. */
|
||||
catalog(): Promise<Catalog>;
|
||||
/** walletBuy spends chips on a chip-priced value and returns the updated wallet. Gate-checked
|
||||
* server-side: an untrusted/frozen context or an insufficient balance is refused. */
|
||||
walletBuy(productId: string): Promise<Wallet>;
|
||||
|
||||
// --- friends ---
|
||||
friendsList(): Promise<AccountRef[]>;
|
||||
friendsIncoming(): Promise<AccountRef[]>;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
decodeOutgoingList,
|
||||
decodeProfile,
|
||||
decodeWallet,
|
||||
decodeCatalog,
|
||||
encodeWalletBuy,
|
||||
decodeSession,
|
||||
decodeFeedbackState,
|
||||
@@ -72,6 +73,54 @@ describe('codec', () => {
|
||||
expect(w.hints).toBe(5);
|
||||
});
|
||||
|
||||
it('round-trips the catalog view (value + pack)', () => {
|
||||
const b = new Builder(256);
|
||||
|
||||
// a chip-priced value with one atom
|
||||
const vKind = b.createString('value');
|
||||
const vId = b.createString('val-1');
|
||||
const vTitle = b.createString('50 hints');
|
||||
const aType = b.createString('hints');
|
||||
fb.CatalogAtom.startCatalogAtom(b);
|
||||
fb.CatalogAtom.addAtomType(b, aType);
|
||||
fb.CatalogAtom.addQuantity(b, 50);
|
||||
const atom = fb.CatalogAtom.endCatalogAtom(b);
|
||||
const vAtoms = fb.CatalogProduct.createAtomsVector(b, [atom]);
|
||||
fb.CatalogProduct.startCatalogProduct(b);
|
||||
fb.CatalogProduct.addKind(b, vKind);
|
||||
fb.CatalogProduct.addProductId(b, vId);
|
||||
fb.CatalogProduct.addTitle(b, vTitle);
|
||||
fb.CatalogProduct.addChips(b, 100);
|
||||
fb.CatalogProduct.addMoneyAmount(b, BigInt(0));
|
||||
fb.CatalogProduct.addAtoms(b, vAtoms);
|
||||
const value = fb.CatalogProduct.endCatalogProduct(b);
|
||||
|
||||
// a RUB-priced chip pack (no atoms exercised)
|
||||
const pKind = b.createString('pack');
|
||||
const pId = b.createString('pack-1');
|
||||
const pTitle = b.createString('100 chips');
|
||||
const pCur = b.createString('RUB');
|
||||
fb.CatalogProduct.startCatalogProduct(b);
|
||||
fb.CatalogProduct.addKind(b, pKind);
|
||||
fb.CatalogProduct.addProductId(b, pId);
|
||||
fb.CatalogProduct.addTitle(b, pTitle);
|
||||
fb.CatalogProduct.addChips(b, 0);
|
||||
fb.CatalogProduct.addMoneyAmount(b, BigInt(14900));
|
||||
fb.CatalogProduct.addMoneyCurrency(b, pCur);
|
||||
const pack = fb.CatalogProduct.endCatalogProduct(b);
|
||||
|
||||
const prods = fb.Catalog.createProductsVector(b, [value, pack]);
|
||||
fb.Catalog.startCatalog(b);
|
||||
fb.Catalog.addProducts(b, prods);
|
||||
b.finish(fb.Catalog.endCatalog(b));
|
||||
|
||||
const c = decodeCatalog(b.asUint8Array());
|
||||
expect(c.products).toEqual([
|
||||
{ kind: 'value', productId: 'val-1', title: '50 hints', chips: 100, moneyAmount: 0, moneyCurrency: '', atoms: [{ atomType: 'hints', quantity: 50 }] },
|
||||
{ kind: 'pack', productId: 'pack-1', title: '100 chips', chips: 0, moneyAmount: 14900, moneyCurrency: 'RUB', atoms: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('round-trips the export-url request and response', () => {
|
||||
const req = fb.ExportUrlRequest.getRootAsExportUrlRequest(
|
||||
new ByteBuffer(
|
||||
|
||||
@@ -40,6 +40,9 @@ import type {
|
||||
Profile,
|
||||
Wallet,
|
||||
WalletSegment,
|
||||
Catalog,
|
||||
CatalogProduct,
|
||||
CatalogAtom,
|
||||
ProfileUpdate,
|
||||
PushEvent,
|
||||
Seat,
|
||||
@@ -388,6 +391,33 @@ export function decodeWallet(buf: Uint8Array): Wallet {
|
||||
};
|
||||
}
|
||||
|
||||
// decodeCatalog reads the storefront payload: the context-visible products, each a chip-priced
|
||||
// value or a chip pack priced in the context's payment method, with its atom composition.
|
||||
export function decodeCatalog(buf: Uint8Array): Catalog {
|
||||
const c = fb.Catalog.getRootAsCatalog(new ByteBuffer(buf));
|
||||
const products: CatalogProduct[] = [];
|
||||
for (let i = 0; i < c.productsLength(); i++) {
|
||||
const p = c.products(i);
|
||||
if (!p) continue;
|
||||
const atoms: CatalogAtom[] = [];
|
||||
for (let j = 0; j < p.atomsLength(); j++) {
|
||||
const a = p.atoms(j);
|
||||
if (!a) continue;
|
||||
atoms.push({ atomType: s(a.atomType()), quantity: a.quantity() });
|
||||
}
|
||||
products.push({
|
||||
kind: s(p.kind()) === 'pack' ? 'pack' : 'value',
|
||||
productId: s(p.productId()),
|
||||
title: s(p.title()),
|
||||
chips: p.chips(),
|
||||
moneyAmount: Number(p.moneyAmount()),
|
||||
moneyCurrency: s(p.moneyCurrency()),
|
||||
atoms,
|
||||
});
|
||||
}
|
||||
return { products };
|
||||
}
|
||||
|
||||
export function decodeProfile(buf: Uint8Array): Profile {
|
||||
const p = fb.Profile.getRootAsProfile(new ByteBuffer(buf));
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isGooglePlayBuild } from './distribution';
|
||||
|
||||
describe('isGooglePlayBuild', () => {
|
||||
it('is false by default (no VITE_GP_BUILD flag, not the mock ?gp force)', () => {
|
||||
// The unit env is neither the Google Play build nor the mock ?gp force, so the normal purchase
|
||||
// actions show. The Google Play stub and the mock force are covered by the Playwright e2e.
|
||||
expect(isGooglePlayBuild()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
// Distribution channel of the native build. Google Play forbids selling in-app currency through
|
||||
// an external gate, so the Google Play build hides the purchase actions and shows a stub pointing
|
||||
// the user to the RuStore build; every other build (web, VK, Telegram, RuStore native) sells
|
||||
// normally. The channel is a build-time flag the Google Play build sets, not a runtime guess.
|
||||
|
||||
/**
|
||||
* isGooglePlayBuild reports whether this is the Google Play native build, where in-app-currency
|
||||
* purchases are hidden. It reads the build-time flag VITE_GP_BUILD (set to "1" only by the Google
|
||||
* Play build); every other build reads false. Under the mock e2e it can be forced on with a `?gp`
|
||||
* query parameter so the stub path is exercisable without a separate build.
|
||||
*/
|
||||
export function isGooglePlayBuild(): boolean {
|
||||
if (
|
||||
import.meta.env.MODE === 'mock' &&
|
||||
typeof window !== 'undefined' &&
|
||||
new URLSearchParams(window.location.search).has('gp')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (import.meta.env as Record<string, string | undefined>).VITE_GP_BUILD === '1';
|
||||
}
|
||||
@@ -225,6 +225,33 @@ export const en = {
|
||||
'offline.promptYes': 'Enable',
|
||||
'offline.promptNo': 'Keep trying',
|
||||
|
||||
// --- wallet ---
|
||||
'wallet.title': 'Wallet',
|
||||
'wallet.tab': 'Wallet',
|
||||
'wallet.balance': 'Balance',
|
||||
'wallet.noChips': 'No chips yet',
|
||||
'wallet.source.vk': 'VK',
|
||||
'wallet.source.telegram': 'Telegram',
|
||||
'wallet.source.direct': 'Web',
|
||||
'wallet.viewOnly': 'view only',
|
||||
'wallet.benefits': 'Benefits',
|
||||
'wallet.noAdsUntil': 'No ads until {date}',
|
||||
'wallet.noAdsForever': 'No ads forever',
|
||||
'wallet.adsOn': 'Ads are on',
|
||||
'wallet.hints': 'Hints {n}',
|
||||
'wallet.store': 'Store',
|
||||
'wallet.empty': 'The store is empty for now',
|
||||
'wallet.buy': 'Buy',
|
||||
'wallet.soon': 'Soon',
|
||||
'wallet.gpStub': 'To buy chips, install the RuStore build.',
|
||||
'wallet.cur.RUB': '₽',
|
||||
'wallet.cur.VOTE': 'votes',
|
||||
'wallet.cur.XTR': '⭐',
|
||||
'wallet.warnTitle': 'Web-only purchase',
|
||||
'wallet.warnBody':
|
||||
'This spends your VK/Telegram chips, and the benefit will work on the web and in the app only — because of store rules.',
|
||||
'wallet.warnConfirm': 'Continue',
|
||||
'wallet.warnCancel': 'Cancel',
|
||||
'about.title': 'About',
|
||||
'about.tab': 'Info',
|
||||
'about.description': 'A multiplatform Scrabble game.',
|
||||
|
||||
@@ -225,6 +225,33 @@ export const ru: Record<MessageKey, string> = {
|
||||
'offline.promptYes': 'Включить',
|
||||
'offline.promptNo': 'Ждать сеть',
|
||||
|
||||
// --- wallet ---
|
||||
'wallet.title': 'Кошелёк',
|
||||
'wallet.tab': 'Кошелёк',
|
||||
'wallet.balance': 'Баланс',
|
||||
'wallet.noChips': 'Пока нет фишек',
|
||||
'wallet.source.vk': 'VK',
|
||||
'wallet.source.telegram': 'Telegram',
|
||||
'wallet.source.direct': 'Веб',
|
||||
'wallet.viewOnly': 'просмотр',
|
||||
'wallet.benefits': 'Блага',
|
||||
'wallet.noAdsUntil': 'Без рекламы до {date}',
|
||||
'wallet.noAdsForever': 'Без рекламы навсегда',
|
||||
'wallet.adsOn': 'Реклама включена',
|
||||
'wallet.hints': 'Подсказки {n}',
|
||||
'wallet.store': 'Магазин',
|
||||
'wallet.empty': 'Магазин пока пуст',
|
||||
'wallet.buy': 'Купить',
|
||||
'wallet.soon': 'Скоро',
|
||||
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
|
||||
'wallet.cur.RUB': '₽',
|
||||
'wallet.cur.VOTE': 'голосов',
|
||||
'wallet.cur.XTR': '⭐',
|
||||
'wallet.warnTitle': 'Покупка только для веба',
|
||||
'wallet.warnBody':
|
||||
'Оплата спишет фишки VK/Telegram, и благо будет работать только в вебе и приложении — из-за правил магазинов.',
|
||||
'wallet.warnConfirm': 'Продолжить',
|
||||
'wallet.warnCancel': 'Отмена',
|
||||
'about.title': 'О программе',
|
||||
'about.tab': 'Инфо',
|
||||
'about.description': 'Мультиплатформенная игра в «Эрудит».',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -170,6 +170,32 @@ export interface Wallet {
|
||||
hints: number;
|
||||
}
|
||||
|
||||
/** One atom line of a storefront product: the base value type it grants ("chips"/"hints"/
|
||||
* "noads_days"/"tournament") and how many of it the product carries. */
|
||||
export interface CatalogAtom {
|
||||
atomType: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
/** One storefront product for the caller's context. A "value" is bought with chips (chips is its
|
||||
* uniform price, money fields zero); a "pack" funds chips with money (moneyAmount is its price in
|
||||
* the context currency's minor units under moneyCurrency, chips zero). atoms lists what it grants. */
|
||||
export interface CatalogProduct {
|
||||
kind: 'value' | 'pack';
|
||||
productId: string;
|
||||
title: string;
|
||||
chips: number;
|
||||
moneyAmount: number;
|
||||
moneyCurrency: string;
|
||||
atoms: CatalogAtom[];
|
||||
}
|
||||
|
||||
/** The storefront: the products visible and purchasable in the caller's context — the chip-priced
|
||||
* values and the chip packs priced in the context's payment method. */
|
||||
export interface Catalog {
|
||||
products: CatalogProduct[];
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
|
||||
@@ -13,6 +13,7 @@ export type RouteName =
|
||||
| 'settings'
|
||||
| 'about'
|
||||
| 'friends'
|
||||
| 'wallet'
|
||||
| 'feedback'
|
||||
| 'stats'
|
||||
| 'confirm'
|
||||
@@ -55,6 +56,8 @@ export function parse(hash: string): Route {
|
||||
return { name: 'about', params: {} };
|
||||
case 'friends':
|
||||
return { name: 'friends', params: {} };
|
||||
case 'wallet':
|
||||
return { name: 'wallet', params: {} };
|
||||
case 'feedback':
|
||||
return { name: 'feedback', params: {} };
|
||||
case 'stats':
|
||||
|
||||
@@ -228,6 +228,16 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
return codec.decodeFeedbackUnread(await exec('feedback.unread', codec.empty()));
|
||||
},
|
||||
|
||||
async wallet() {
|
||||
return codec.decodeWallet(await exec('wallet.get', codec.empty()));
|
||||
},
|
||||
async catalog() {
|
||||
return codec.decodeCatalog(await exec('wallet.catalog', codec.empty()));
|
||||
},
|
||||
async walletBuy(productId: string) {
|
||||
return codec.decodeWallet(await exec('wallet.buy', codec.encodeWalletBuy(productId)));
|
||||
},
|
||||
|
||||
async friendsList() {
|
||||
return codec.decodeFriendList(await exec('friends.list', codec.empty()));
|
||||
},
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Wallet, WalletSegment } from './model';
|
||||
import { majorAmount, formatAmount, spendableChips, needsWebSpendWarning } from './wallet';
|
||||
|
||||
function seg(source: string, chips: number, spendable = true): WalletSegment {
|
||||
return { source, chips, spendable };
|
||||
}
|
||||
|
||||
function wallet(segments: WalletSegment[]): Wallet {
|
||||
return { segments, adsForever: false, adsPaidUntilMs: 0, hints: 0 };
|
||||
}
|
||||
|
||||
describe('money formatting', () => {
|
||||
it('scales roubles by 100 and leaves whole-unit currencies as-is', () => {
|
||||
expect(majorAmount(14900, 'RUB')).toBe(149);
|
||||
expect(majorAmount(20, 'VOTE')).toBe(20);
|
||||
expect(majorAmount(25, 'XTR')).toBe(25);
|
||||
});
|
||||
|
||||
it('formats roubles with two decimals and whole-unit currencies as integers', () => {
|
||||
expect(formatAmount(14900, 'RUB')).toBe('149.00');
|
||||
expect(formatAmount(15050, 'RUB')).toBe('150.50');
|
||||
expect(formatAmount(20, 'VOTE')).toBe('20');
|
||||
expect(formatAmount(25, 'XTR')).toBe('25');
|
||||
});
|
||||
});
|
||||
|
||||
describe('spendableChips', () => {
|
||||
it('sums only the spendable segments', () => {
|
||||
expect(spendableChips(wallet([seg('direct', 100), seg('vk', 50)]))).toBe(150);
|
||||
expect(spendableChips(wallet([seg('direct', 100, false), seg('vk', 50, false)]))).toBe(0);
|
||||
expect(spendableChips(wallet([seg('direct', 100), seg('vk', 50, false)]))).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsWebSpendWarning', () => {
|
||||
it('does not warn when the direct segment alone covers the price', () => {
|
||||
expect(needsWebSpendWarning('direct', [seg('direct', 200), seg('vk', 400)], 150)).toBe(false);
|
||||
});
|
||||
|
||||
it('warns when the priority draw reaches into a store (vk/tg) segment', () => {
|
||||
// direct 120 + vk covers the rest of a 300 price → touches vk
|
||||
expect(needsWebSpendWarning('direct', [seg('direct', 120), seg('vk', 400)], 300)).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when only a store segment is spendable and it covers the price', () => {
|
||||
expect(needsWebSpendWarning('direct', [seg('vk', 400)], 100)).toBe(true);
|
||||
expect(needsWebSpendWarning('direct', [seg('telegram', 400)], 100)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not warn when the price exceeds all spendable chips (buy is disabled anyway)', () => {
|
||||
expect(needsWebSpendWarning('direct', [seg('direct', 120), seg('vk', 100)], 500)).toBe(false);
|
||||
});
|
||||
|
||||
it('never warns inside a VK or Telegram context (spend stays in the same store segment)', () => {
|
||||
expect(needsWebSpendWarning('vk', [seg('vk', 400)], 100)).toBe(false);
|
||||
expect(needsWebSpendWarning('telegram', [seg('telegram', 400)], 100)).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores non-spendable segments (a frozen or untrusted wallet never warns)', () => {
|
||||
expect(needsWebSpendWarning('direct', [seg('vk', 400, false)], 100)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// Wallet storefront logic, kept pure and out of the .svelte screen so it unit-tests in the node
|
||||
// environment: money/price formatting, the spendable-segment selection, and the web-spend warning
|
||||
// trigger. The server owns the real store-compliance gate; these helpers drive display only.
|
||||
|
||||
import type { Wallet, WalletSegment } from './model';
|
||||
import { insideVK } from './vk';
|
||||
import { insideTelegram } from './telegram';
|
||||
|
||||
/** The client's execution context for the wallet UI, mirroring the server's platform kind. */
|
||||
export type SpendContext = 'vk' | 'telegram' | 'direct';
|
||||
|
||||
/**
|
||||
* executionContext reports the client's best-effort context: a VK Mini App, a Telegram Mini App,
|
||||
* else a web/native (direct) session. It only drives the display-only web-spend warning; the
|
||||
* server enforces the real gate on every spend (fail-closed), never trusting this.
|
||||
*/
|
||||
export function executionContext(): SpendContext {
|
||||
if (insideVK()) return 'vk';
|
||||
if (insideTelegram()) return 'telegram';
|
||||
return 'direct';
|
||||
}
|
||||
|
||||
/**
|
||||
* majorAmount converts a money amount in a currency's minor units to its major unit — the rouble
|
||||
* has 100 kopecks; Votes and Stars are whole units (scale 1).
|
||||
*/
|
||||
export function majorAmount(minor: number, currency: string): number {
|
||||
return minor / (currency === 'RUB' ? 100 : 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* formatAmount renders a money price in major units: two fractional digits for the rouble, a whole
|
||||
* number for the whole-unit currencies (Votes / Stars). The currency label is added by the caller
|
||||
* (it is localized), so this stays a pure number formatter.
|
||||
*/
|
||||
export function formatAmount(minor: number, currency: string): string {
|
||||
return currency === 'RUB' ? (minor / 100).toFixed(2) : String(minor);
|
||||
}
|
||||
|
||||
/**
|
||||
* spendableChips totals the chips the player can actually spend in the current context — the sum of
|
||||
* the segments the server marked spendable (zero for a VK-iOS-frozen or untrusted wallet, where no
|
||||
* segment is spendable).
|
||||
*/
|
||||
export function spendableChips(w: Wallet): number {
|
||||
return w.segments.reduce((sum, s) => sum + (s.spendable ? s.chips : 0), 0);
|
||||
}
|
||||
|
||||
// drawPriority is the segment draw order on the web, mirroring the backend spend: the home direct
|
||||
// segment first, then the store-funded segments.
|
||||
const drawPriority: readonly string[] = ['direct', 'vk', 'telegram'];
|
||||
|
||||
/**
|
||||
* needsWebSpendWarning reports whether buying a chip-priced value in the current context would draw
|
||||
* store-funded (vk / telegram) chips — the case the UI must warn about, because the benefit it buys
|
||||
* (origin=direct) is then usable on the web/native only, by the store-compliance rule. It fires only
|
||||
* in a direct context and only when the priority draw (direct→vk→tg) actually reaches a store
|
||||
* segment to cover the price; a spend that the direct segment covers alone, or a spend in a VK/
|
||||
* Telegram context (which stays within the same store segment), never warns.
|
||||
*/
|
||||
export function needsWebSpendWarning(
|
||||
context: SpendContext,
|
||||
segments: WalletSegment[],
|
||||
chipPrice: number,
|
||||
): boolean {
|
||||
if (context !== 'direct') return false;
|
||||
let remaining = chipPrice;
|
||||
let reachesStore = false;
|
||||
for (const src of drawPriority) {
|
||||
if (remaining <= 0) break;
|
||||
const seg = segments.find((s) => s.source === src && s.spendable);
|
||||
if (!seg || seg.chips <= 0) continue;
|
||||
const take = Math.min(seg.chips, remaining);
|
||||
if (src !== 'direct' && take > 0) reachesStore = true;
|
||||
remaining -= take;
|
||||
}
|
||||
return remaining <= 0 && reachesStore;
|
||||
}
|
||||
@@ -4,15 +4,17 @@
|
||||
import Settings from './Settings.svelte';
|
||||
import Profile from './Profile.svelte';
|
||||
import Friends from './Friends.svelte';
|
||||
import Wallet from './Wallet.svelte';
|
||||
import About from './About.svelte';
|
||||
import { app } from '../lib/app.svelte';
|
||||
import { offlineMode } from '../lib/offline.svelte';
|
||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
|
||||
// The Settings hub: a single nav bar + bottom tab bar hosting the Settings / Profile /
|
||||
// Friends / About bodies. Tabs switch in place (no navigation), so the back control
|
||||
// always returns to the lobby. Guests have no social surface, so the Friends tab hides.
|
||||
type SettingsTab = 'settings' | 'profile' | 'friends' | 'about';
|
||||
// Friends / Wallet / About bodies. Tabs switch in place (no navigation), so the back control
|
||||
// always returns to the lobby. Guests have no social surface and no wallet, so the Friends and
|
||||
// Wallet tabs hide for them.
|
||||
type SettingsTab = 'settings' | 'profile' | 'friends' | 'wallet' | 'about';
|
||||
let { initialTab = 'settings' }: { initialTab?: SettingsTab } = $props();
|
||||
|
||||
const guest = $derived(app.profile?.isGuest ?? true);
|
||||
@@ -20,21 +22,22 @@
|
||||
// the hub is keyed by route in App.svelte, so initialTab is constant for its lifetime.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let tab = $state<SettingsTab>(initialTab);
|
||||
// A guest who deep-links to the Friends tab falls back to Settings.
|
||||
// A guest who deep-links to the Friends or Wallet tab (durable-only surfaces) falls back to Settings.
|
||||
$effect(() => {
|
||||
if (guest && tab === 'friends') tab = 'settings';
|
||||
if (guest && (tab === 'friends' || tab === 'wallet')) tab = 'settings';
|
||||
});
|
||||
// Offline mode has no network, so the Profile and Friends surfaces (which read/save over the
|
||||
// network) are disabled — fall back to Settings if the hub is on one (a deep-link or a live flip).
|
||||
// Settings stays reachable: it holds the online/offline toggle.
|
||||
$effect(() => {
|
||||
if (offlineMode.active && (tab === 'profile' || tab === 'friends')) tab = 'settings';
|
||||
if (offlineMode.active && (tab === 'profile' || tab === 'friends' || tab === 'wallet')) tab = 'settings';
|
||||
});
|
||||
|
||||
const titleKey: Record<SettingsTab, MessageKey> = {
|
||||
settings: 'settings.title',
|
||||
profile: 'profile.title',
|
||||
friends: 'friends.title',
|
||||
wallet: 'wallet.title',
|
||||
about: 'about.title',
|
||||
};
|
||||
</script>
|
||||
@@ -46,6 +49,8 @@
|
||||
<Profile />
|
||||
{:else if tab === 'friends'}
|
||||
<Friends />
|
||||
{:else if tab === 'wallet'}
|
||||
<Wallet />
|
||||
{:else}
|
||||
<About />
|
||||
{/if}
|
||||
@@ -63,6 +68,11 @@
|
||||
<span class="face"><span class="sq" aria-hidden="true">🤝{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span><span class="lbl">{t('friends.title')}</span></span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if !guest}
|
||||
<button class="tab" class:active={tab === 'wallet'} disabled={offlineMode.active} onclick={() => (tab = 'wallet')}>
|
||||
<span class="face"><span class="sq" aria-hidden="true">👛</span><span class="lbl">{t('wallet.tab')}</span></span>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')}>
|
||||
<span class="face"><span class="sq" aria-hidden="true">ℹ️{#if app.feedbackReplyUnread}<span class="badge">1</span>{/if}</span><span class="lbl">{t('about.tab')}</span></span>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import { handleError } from '../lib/app.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import { executionContext, formatAmount, needsWebSpendWarning, type SpendContext } from '../lib/wallet';
|
||||
import { isGooglePlayBuild } from '../lib/distribution';
|
||||
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
|
||||
|
||||
// The Wallet section: the context-visible chip balances, the active benefits, and the storefront.
|
||||
// Values are bought with chips (they work once the player has any); chip packs are bought with
|
||||
// money (the purchase itself arrives with payment intake — here they are shown priced only). On
|
||||
// the Google Play build the money purchases are hidden behind a RuStore stub (store policy), while
|
||||
// spending already-earned chips still works.
|
||||
|
||||
let wallet = $state<Wallet | null>(null);
|
||||
let catalog = $state<Catalog | null>(null);
|
||||
let loading = $state(true);
|
||||
let busy = $state(false);
|
||||
// The value awaiting a web-spend warning confirmation (a spend that would draw vk/tg chips).
|
||||
let warnProduct = $state<CatalogProduct | null>(null);
|
||||
|
||||
const context: SpendContext = executionContext();
|
||||
const gpBuild = isGooglePlayBuild();
|
||||
|
||||
const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []);
|
||||
const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [w, c] = await Promise.all([gateway.wallet(), gateway.catalog()]);
|
||||
wallet = w;
|
||||
catalog = c;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
onMount(load);
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
return t(`wallet.source.${source}` as MessageKey);
|
||||
}
|
||||
|
||||
function currencyLabel(currency: string): string {
|
||||
return t(`wallet.cur.${currency}` as MessageKey);
|
||||
}
|
||||
|
||||
function adsLine(): string {
|
||||
if (!wallet) return '';
|
||||
if (wallet.adsForever) return t('wallet.noAdsForever');
|
||||
if (wallet.adsPaidUntilMs > Date.now()) {
|
||||
const date = new Date(wallet.adsPaidUntilMs).toLocaleDateString(i18n.locale === 'ru' ? 'ru-RU' : 'en-US');
|
||||
return t('wallet.noAdsUntil', { date });
|
||||
}
|
||||
return t('wallet.adsOn');
|
||||
}
|
||||
|
||||
function onBuy(p: CatalogProduct) {
|
||||
if (needsWebSpendWarning(context, wallet?.segments ?? [], p.chips)) {
|
||||
warnProduct = p;
|
||||
return;
|
||||
}
|
||||
void doBuy(p);
|
||||
}
|
||||
|
||||
async function doBuy(p: CatalogProduct) {
|
||||
warnProduct = null;
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
wallet = await gateway.walletBuy(p.productId);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wallet" data-testid="wallet">
|
||||
<section>
|
||||
<h3>{t('wallet.balance')}</h3>
|
||||
{#if wallet && wallet.segments.length > 0}
|
||||
{#each wallet.segments as s (s.source)}
|
||||
<div class="row">
|
||||
<span class="name">{sourceLabel(s.source)}</span>
|
||||
<span class="value"
|
||||
>🪙 {s.chips}{#if !s.spendable} <span class="muted">({t('wallet.viewOnly')})</span>{/if}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
{:else if !loading}
|
||||
<p class="empty">{t('wallet.noChips')}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>{t('wallet.benefits')}</h3>
|
||||
<div class="row"><span class="name">{adsLine()}</span></div>
|
||||
<div class="row"><span class="name">{t('wallet.hints', { n: wallet?.hints ?? 0 })}</span></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>{t('wallet.store')}</h3>
|
||||
{#each values as p (p.productId)}
|
||||
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
|
||||
<span class="name">{p.title}</span>
|
||||
<span class="price">🪙 {p.chips}</span>
|
||||
<button class="buy" data-testid="buy" disabled={busy} onclick={() => onBuy(p)}>{t('wallet.buy')}</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if gpBuild}
|
||||
<p class="stub" data-testid="gp-stub">{t('wallet.gpStub')}</p>
|
||||
{:else}
|
||||
{#each packs as p (p.productId)}
|
||||
<div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}>
|
||||
<span class="name">{p.title}</span>
|
||||
<span class="price">{formatAmount(p.moneyAmount, p.moneyCurrency)} {currencyLabel(p.moneyCurrency)}</span>
|
||||
<button class="buy" disabled>{t('wallet.soon')}</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if !loading && values.length === 0 && (gpBuild || packs.length === 0)}
|
||||
<p class="empty">{t('wallet.empty')}</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if warnProduct}
|
||||
<Modal title={t('wallet.warnTitle')} onclose={() => (warnProduct = null)}>
|
||||
<p class="warn-body" data-testid="warn">{t('wallet.warnBody')}</p>
|
||||
<div class="warn-actions">
|
||||
<button class="cancel" onclick={() => (warnProduct = null)}>{t('wallet.warnCancel')}</button>
|
||||
<button class="buy" data-testid="warn-confirm" onclick={() => warnProduct && doBuy(warnProduct)}
|
||||
>{t('wallet.warnConfirm')}</button
|
||||
>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.wallet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: var(--pad);
|
||||
}
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
.name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.value,
|
||||
.price {
|
||||
flex: 0 0 auto;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.buy {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.buy:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.empty,
|
||||
.stub {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stub {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.warn-body {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.warn-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.cancel {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user