diff --git a/ui/e2e/wallet.spec.ts b/ui/e2e/wallet.spec.ts index df85476..4298fd6 100644 --- a/ui/e2e/wallet.spec.ts +++ b/ui/e2e/wallet.spec.ts @@ -37,8 +37,27 @@ test('wallet: balances, benefits and the storefront render for a durable account 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(); + // The pack purchase is wired to money intake: an enabled Buy action, with the public-offer link. + await expect(pack.getByTestId('buy-pack')).toBeEnabled(); + await expect(page.getByTestId('offer')).toContainText('Public offer'); +}); + +test('wallet: buying a chip pack opens the provider payment page', async ({ page }) => { + await loginLobby(page); + await openWallet(page); + + // The purchase opens the provider's hosted-payment page in a new tab (window.open); capture it. + await page.evaluate(() => { + (window as { __opened?: string }).__opened = ''; + window.open = ((u: string) => { + (window as { __opened?: string }).__opened = u; + return null; + }) as typeof window.open; + }); + await page.locator('[data-kind="pack"]').getByTestId('buy-pack').click(); + await expect + .poll(() => page.evaluate(() => (window as { __opened?: string }).__opened)) + .toContain('robokassa'); }); test('wallet: the tab sits between Friends and About', async ({ page }) => { diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 738466c..009a994 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -35,6 +35,7 @@ import type { Variant, WordCheckResult, Wallet, + WalletOrder, Catalog, } from './model'; @@ -147,6 +148,9 @@ export interface GatewayClient { /** 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; + /** walletOrder opens a money order to fund a chip pack and returns the provider launch URL the + * client opens; chips are credited later, by the verified server callback. Direct rail only. */ + walletOrder(productId: string): Promise; // --- friends --- friendsList(): Promise; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 3ef1a78..127f8bf 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -17,6 +17,8 @@ import { decodeWallet, decodeCatalog, encodeWalletBuy, + encodeWalletOrder, + decodeWalletOrder, decodeSession, decodeFeedbackState, decodeFeedbackUnread, @@ -73,6 +75,25 @@ describe('codec', () => { expect(w.hints).toBe(5); }); + it('round-trips the wallet order request and response', () => { + // order request: pack id survives the wire + const req = fb.WalletOrderRequest.getRootAsWalletOrderRequest(new ByteBuffer(encodeWalletOrder('pack-7'))); + expect(req.productId()).toBe('pack-7'); + + // order response: the created id and the provider launch URL decode back + const b = new Builder(64); + const oid = b.createString('order-123'); + const url = b.createString('https://pay.example/abc'); + fb.WalletOrderResponse.startWalletOrderResponse(b); + fb.WalletOrderResponse.addOrderId(b, oid); + fb.WalletOrderResponse.addRedirectUrl(b, url); + b.finish(fb.WalletOrderResponse.endWalletOrderResponse(b)); + + const order = decodeWalletOrder(b.asUint8Array()); + expect(order.orderId).toBe('order-123'); + expect(order.redirectUrl).toBe('https://pay.example/abc'); + }); + it('round-trips the catalog view (value + pack)', () => { const b = new Builder(256); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 38a03c0..709ec03 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -39,6 +39,7 @@ import type { MoveResult, Profile, Wallet, + WalletOrder, WalletSegment, Catalog, CatalogProduct, @@ -373,6 +374,15 @@ export function encodeWalletBuy(productId: string): Uint8Array { return finish(b, fb.WalletBuyRequest.endWalletBuyRequest(b)); } +// encodeWalletOrder wraps the pack id for a money order (POST /user/wallet/order). +export function encodeWalletOrder(productId: string): Uint8Array { + const b = new Builder(64); + const pid = b.createString(productId); + fb.WalletOrderRequest.startWalletOrderRequest(b); + fb.WalletOrderRequest.addProductId(b, pid); + return finish(b, fb.WalletOrderRequest.endWalletOrderRequest(b)); +} + // decodeWallet reads the wallet payload: the context-visible chip segments and the // context-applicable benefits. export function decodeWallet(buf: Uint8Array): Wallet { @@ -391,6 +401,12 @@ export function decodeWallet(buf: Uint8Array): Wallet { }; } +// decodeWalletOrder reads the created order: its id and the provider launch URL the client opens. +export function decodeWalletOrder(buf: Uint8Array): WalletOrder { + const o = fb.WalletOrderResponse.getRootAsWalletOrderResponse(new ByteBuffer(buf)); + return { orderId: s(o.orderId()), redirectUrl: s(o.redirectUrl()) }; +} + // 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 { diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 3924ed3..373fafe 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -242,7 +242,7 @@ export const en = { 'wallet.store': 'Store', 'wallet.empty': 'The store is empty for now', 'wallet.buy': 'Buy', - 'wallet.soon': 'Soon', + 'wallet.offer': 'Public offer', 'wallet.gpStub': 'To buy chips, install the RuStore build.', 'wallet.cur.RUB': '₽', 'wallet.cur.VOTE': 'votes', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 4c58d99..28055c1 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -242,7 +242,7 @@ export const ru: Record = { 'wallet.store': 'Магазин', 'wallet.empty': 'Магазин пока пуст', 'wallet.buy': 'Купить', - 'wallet.soon': 'Скоро', + 'wallet.offer': 'Публичная оферта', 'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.', 'wallet.cur.RUB': '₽', 'wallet.cur.VOTE': 'голосов', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 84b5764..2314208 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -41,6 +41,7 @@ import type { Variant, WordCheckResult, Wallet, + WalletOrder, Catalog, } from '../model'; import { valueForLetter } from '../alphabet'; @@ -225,6 +226,14 @@ export class MockGateway implements GatewayClient { } return this.cloneWallet(); } + + async walletOrder(productId: string): Promise { + const p = this.mockCatalog.products.find((x) => x.productId === productId); + if (!p || p.kind !== 'pack') throw new GatewayError('not_a_pack'); + // No real provider in mock: return a stub launch URL so the e2e can assert the purchase reaches + // the provider hand-off without leaving the app. + return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/robokassa?order=' + productId }; + } private cloneWallet(): Wallet { return { segments: this.mockWallet.segments.map((s) => ({ ...s })), diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index e376f58..d71a9d7 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -172,6 +172,14 @@ export interface Wallet { /** 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. */ +// WalletOrder is a created money order to fund a chip pack: its id and the provider launch URL the +// client opens (the Robokassa hosted-payment page). Chips arrive later, by the verified server +// callback. +export interface WalletOrder { + orderId: string; + redirectUrl: string; +} + export interface CatalogAtom { atomType: string; quantity: number; diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 8794deb..7a7e5d2 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -237,6 +237,9 @@ export function createTransport(baseUrl: string): GatewayClient { async walletBuy(productId: string) { return codec.decodeWallet(await exec('wallet.buy', codec.encodeWalletBuy(productId))); }, + async walletOrder(productId: string) { + return codec.decodeWalletOrder(await exec('wallet.order', codec.encodeWalletOrder(productId))); + }, async friendsList() { return codec.decodeFriendList(await exec('friends.list', codec.empty())); diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index 7ade3db..23c5a29 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -6,6 +6,7 @@ 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 { openExternalUrl } from '../lib/links'; import type { Wallet, Catalog, CatalogProduct } from '../lib/model'; // The Wallet section: the context-visible chip balances, the active benefits, and the storefront. @@ -78,6 +79,22 @@ busy = false; } } + + // onOrder funds a chip pack with money: it opens a pending order and sends the player to the + // provider's hosted-payment page. The chips are credited later, by the verified server callback; + // paying accepts the public offer (linked below the packs). + async function onOrder(p: CatalogProduct) { + if (busy) return; + busy = true; + try { + const order = await gateway.walletOrder(p.productId); + openExternalUrl(order.redirectUrl); + } catch (e) { + handleError(e); + } finally { + busy = false; + } + }
@@ -120,9 +137,14 @@
{p.title} {formatAmount(p.moneyAmount, p.moneyCurrency)} {currencyLabel(p.moneyCurrency)} - +
{/each} + {#if packs.length > 0} +

+ {t('wallet.offer')} +

+ {/if} {/if} {#if !loading && values.length === 0 && (gpBuild || packs.length === 0)} @@ -207,6 +229,14 @@ border: 1px dashed var(--border); border-radius: var(--radius-sm); } + .offer { + margin: 2px 0 0; + text-align: center; + font-size: 0.85rem; + } + .offer a { + color: var(--text-muted); + } .warn-body { margin: 0 0 14px; }