From 8ce986922a712aa6c915f2f86e7a9500fb5b4c4d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 17:46:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20wallet=20storefront=20redesign=20?= =?UTF-8?q?=E2=80=94=20split=20buy/spend,=20compact=20balance,=20exchange?= =?UTF-8?q?=20confirm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the Wallet screen to be more compact and to separate the two flows, and fixes a reported bug. Bug: buying a value with too few chips showed the generic "Something went wrong" toast — the backend's `insufficient_chips` (409) code was propagated correctly but had no i18n mapping, so `errorKey` fell back to `error.generic`. Add `error.insufficient_chips` / `error.product_not_found`. Also disable a value's Exchange button up front when the spendable balance cannot cover it (the server stays authoritative). Redesign: - a compact **balance** row leads the screen — the context's own chips (🪙 N) then any linked other-platform chips behind that platform's logo (monospaced digits); inside a VK/TG store only that store's own segment shows; - the benefits section is renamed **Active**, moved above the store, shows one inline line (hints + ad-free) and hides when nothing is active; - the **store** splits by a two-way toggle into **Buy chips** (money packs + the watch-an-ad row at the top) and **Spend chips** (values, exchanged for chips); - a value's action reads **Exchange** and opens a single confirmation dialog (chip spends are instant, with no provider window to confirm them); the existing cross-platform store-compliance warning folds into that same dialog when the spend would draw VK/TG chips. No payments-model or wire change; the money→chips→values model is unchanged (verified: money buys only chip packs, values are chips-only — "no-ads for money" is structurally impossible). Tests: wallet e2e updated for the new layout (chromium + webkit green). Docs: FUNCTIONAL (+_ru) wallet story rewritten. App bundle budget 126 → 127 KB (always-loaded settings hub). --- docs/FUNCTIONAL.md | 32 +++-- docs/FUNCTIONAL_ru.md | 37 +++-- ui/e2e/wallet.spec.ts | 65 +++++---- ui/scripts/bundle-size.mjs | 9 +- ui/src/lib/i18n/en.ts | 18 +-- ui/src/lib/i18n/ru.ts | 18 +-- ui/src/screens/Wallet.svelte | 265 ++++++++++++++++++++++------------- 7 files changed, 274 insertions(+), 170 deletions(-) diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index e8b7f0b..d93dc5e 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -534,21 +534,29 @@ shown defensively (text escaped, attachments downloaded rather than rendered). ### Wallet Durable players have a **Wallet** — a tab in the Settings hub, between Friends and About; guests have -none. It shows their **chips** (the in-game currency, split by where they were funded — VK, Telegram -or the web), their **active benefits** (ads off until a date or forever, and the available hint -count), and a **store**. What is visible and spendable depends on **where the app runs**, by the -store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that store's chips are -usable; on the open web all attached chips are, drawn web → VK → Telegram; on VK-iOS the balance is -shown but frozen for spending. +none. A compact **balance** leads the screen: the running context's own **chips** (the in-game +currency) first, then any linked other-platform chips behind that platform's logo — VK, Telegram or +the web, split by where they were funded. What is visible and spendable depends on **where the app +runs**, by the store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that +store's chips are usable; on the open web all attached chips are, drawn web → VK → Telegram; on +VK-iOS the balance is shown but frozen for spending. + +Below the balance an **Active** line lists what the player currently holds — the remaining hint count +and, if bought, the ad-free end date (or "forever"); it is hidden when nothing is active. The +**store** then splits, by a two-way toggle, into **Buy chips** and **Spend chips**: + +- **Buy chips** — the **chip packs** bought with real money, priced in the running context's currency + (VK Votes, Telegram Stars, or roubles on the web), plus, at the top, a **watch-an-ad-for-chips** + option where it is offered. Paying opens the provider's payment page (accepting the public offer). +- **Spend chips** — the **values** bought with chips (extra hints, days without ads) at a fixed chip + price. Their action reads **Exchange**, and a value the balance cannot cover is disabled. Tapping + Exchange asks the player to **confirm** (the spend is instant, with no provider window to stand in + for a confirmation); that same dialog also carries the store-compliance **warning** when a web + spend would draw VK/Telegram chips — the benefit then works only on the web and in the app. -The **store** lists **values** bought with chips (extra hints, days without ads) at a fixed chip -price shown in every context, and **chip packs** bought with real money, priced in the running -context's currency (VK Votes, Telegram Stars, or roubles on the web). Buying a value applies its -benefit at once. Before a web purchase that would draw VK/Telegram chips, the app **warns** that the -benefit will then work only on the web and in the app — a store rule — and asks the player to confirm. On the **Google Play** build the money purchases are hidden behind a note pointing to the RuStore build (Google's in-app-currency rule); spending already-earned chips still works. The wallet keeps -**no purchase history** — only the current balances, benefits and store. +**no purchase history** — only the current balances, active benefits and store. ### Advertising banner diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 40efc4c..7e27cf5 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -548,21 +548,30 @@ high-rate флага. С карточки пользователя операт ### Кошелёк У постоянных (не гостевых) игроков есть **Кошелёк** — вкладка в разделе настроек, между «Друзьями» и -«О программе»; у гостей его нет. Он показывает **фишки** (внутриигровую валюту, разделённую по тому, -где она была пополнена — VK, Telegram или веб), **активные блага** (реклама выключена до даты или -навсегда, и число доступных подсказок) и **магазин**. Что видно и что можно потратить, зависит от -того, **где запущено приложение**, по правилам соответствия магазинам из [`PAYMENTS.md`](PAYMENTS.md): -внутри магазина доступны только его фишки; в открытом вебе — все привязанные, списываются в порядке -веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена. +«О программе»; у гостей его нет. Экран открывает компактный **баланс**: сначала собственные **фишки** +(внутриигровая валюта) текущего контекста, затем привязанные фишки других платформ за их логотипом — +VK, Telegram или веб, разделённые по тому, где они были пополнены. Что видно и что можно потратить, +зависит от того, **где запущено приложение**, по правилам соответствия магазинам из +[`PAYMENTS.md`](PAYMENTS.md): внутри магазина доступны только его фишки; в открытом вебе — все +привязанные, списываются в порядке веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена. -**Магазин** перечисляет **ценности**, покупаемые за фишки (дополнительные подсказки, дни без рекламы), -по фиксированной цене в фишках, одинаковой во всех контекстах, и **наборы фишек**, покупаемые за -настоящие деньги, в валюте текущего контекста (голоса VK, звёзды Telegram или рубли в вебе). Покупка -ценности сразу применяет её благо. Перед покупкой в вебе, которая списала бы фишки VK/Telegram, -приложение **предупреждает**, что благо будет работать только в вебе и в приложении — это правило -магазинов — и просит подтверждения. В сборке для **Google Play** покупки за деньги скрыты за подсказкой -установить сборку из RuStore (правило Google о внутриигровой валюте); трата уже заработанных фишек -по-прежнему работает. Кошелёк **не хранит историю покупок** — только текущие балансы, блага и магазин. +Под балансом строка **«Активные»** перечисляет, что у игрока есть сейчас — остаток подсказок и, если +куплено, дату окончания «без рекламы» (или «навсегда»); она скрыта, когда активного ничего нет. Далее +**магазин** двухпозиционным переключателем делится на **«Купить фишки»** и **«Потратить фишки»**: + +- **Купить фишки** — **наборы фишек** за настоящие деньги, в валюте текущего контекста (голоса VK, + звёзды Telegram или рубли в вебе), а сверху — вариант **«ролик за фишки»**, где он предлагается. + Оплата открывает страницу провайдера (с принятием публичной оферты). +- **Потратить фишки** — **ценности** за фишки (дополнительные подсказки, дни без рекламы) по + фиксированной цене в фишках. Их действие называется **«Обмен»**, а ценность, на которую не хватает + баланса, недоступна. Тап по «Обмену» просит **подтверждения** (трата мгновенна, окна провайдера, + которое сошло бы за подтверждение, нет); это же окно несёт и **предупреждение** соответствия + магазинам, когда трата в вебе списала бы фишки VK/Telegram — благо тогда работает только в вебе и в + приложении. + +В сборке для **Google Play** покупки за деньги скрыты за подсказкой установить сборку из RuStore +(правило Google о внутриигровой валюте); трата уже заработанных фишек по-прежнему работает. Кошелёк +**не хранит историю покупок** — только текущие балансы, активные блага и магазин. ### Рекламный баннер diff --git a/ui/e2e/wallet.spec.ts b/ui/e2e/wallet.spec.ts index 4298fd6..1651bc0 100644 --- a/ui/e2e/wallet.spec.ts +++ b/ui/e2e/wallet.spec.ts @@ -1,9 +1,10 @@ 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. +// account holding a direct (120) + vk (400) chip segment and a small catalog (two chip-priced values +// and one rouble-priced chip pack — see lib/mock/client.ts), so the compact balance, the split +// storefront, the exchange-confirm dialog (with its store-compliance warning) and the Google Play +// stub are all exercisable without a backend. async function loginLobby(page: Page): Promise { await page.goto('/'); @@ -22,24 +23,33 @@ async function openWallet(page: Page): Promise { await expect(page.getByTestId('wallet')).toBeVisible(); } -test('wallet: balances, benefits and the storefront render for a durable account', async ({ page }) => { +test('wallet: balance, active benefits and the split 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(); + // The compact balance leads with the context's own (direct/"Web") chips, then the linked vk + // segment behind its logo. + const balance = page.getByTestId('balance'); + await expect(balance).toContainText('120'); + await expect(balance).toContainText('400'); + await expect(balance.locator('.plogo')).toHaveCount(1); // the one linked (vk) platform - // Benefits + the storefront: two values priced in chips, one pack priced in roubles. - await expect(page.getByRole('heading', { name: 'Benefits' })).toBeVisible(); + // The active benefits (the seeded 5 hints) sit above the store. + await expect(page.getByRole('heading', { name: 'Active' })).toBeVisible(); + await expect(page.getByTestId('active')).toContainText('Hints: 5'); 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 is wired to money intake: an enabled Buy action, with the public-offer link. - await expect(pack.getByTestId('buy-pack')).toBeEnabled(); + + // Default tab — Buy chips: one rouble-priced pack + the public offer, and no values. + await expect(page.locator('[data-kind="pack"]')).toHaveCount(1); + await expect(page.locator('[data-kind="pack"]')).toContainText('₽'); + await expect(page.locator('[data-kind="pack"]').getByTestId('buy-pack')).toBeEnabled(); await expect(page.getByTestId('offer')).toContainText('Public offer'); + await expect(page.locator('[data-kind="value"]')).toHaveCount(0); + + // Spend chips tab: the two chip-priced values, exchanged (not bought) with the "Exchange" button. + await page.getByTestId('tab-spend').click(); + await expect(page.locator('[data-kind="value"]')).toHaveCount(2); + await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toHaveText('Exchange'); }); test('wallet: buying a chip pack opens the provider payment page', async ({ page }) => { @@ -87,32 +97,39 @@ test('wallet: the Google Play build hides the money purchases behind the RuStore 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. + // Buy chips: the chip pack is gone; the stub points at the RuStore build. await expect(page.getByTestId('gp-stub')).toBeVisible(); await expect(page.locator('[data-kind="pack"]')).toHaveCount(0); + // Spending earned chips still works — the values live in the Spend tab. + await page.getByTestId('tab-spend').click(); 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 }) => { +test('wallet: a web spend that would draw store chips confirms with a warning, then completes', async ({ page }) => { await loginLobby(page); await openWallet(page); + await page.getByTestId('tab-spend').click(); - // The 300-chip value drains direct (120) then reaches into vk (180) → a store segment → warn. + // The 300-chip value drains direct (120) then reaches into vk (180) → a store segment → the confirm + // dialog carries the cross-platform warning. 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 page.getByTestId('spend-confirm').click(); + // The spend applied: the no-ads benefit now shows an end date in the Active section. await expect(page.getByTestId('wallet')).toContainText('No ads until'); }); -test('wallet: a spend the direct segment covers alone does not warn', async ({ page }) => { +test('wallet: a spend the direct segment covers alone confirms without a warning', async ({ page }) => { await loginLobby(page); await openWallet(page); + await page.getByTestId('tab-spend').click(); - // The 100-chip value is covered by the direct segment (120) alone → no store draw, no warning. + // The 100-chip value is covered by the direct segment (120) alone → the confirm dialog shows, but + // with no cross-platform warning. await page.locator('[data-pid="val-hints-50"]').getByTestId('buy').click(); + await expect(page.getByTestId('spend-body')).toBeVisible(); await expect(page.getByTestId('warn')).toBeHidden(); + await page.getByTestId('spend-confirm').click(); // The hints benefit rose from 5 to 55. - await expect(page.getByTestId('wallet')).toContainText('Hints 55'); + await expect(page.getByTestId('wallet')).toContainText('Hints: 55'); }); diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index 5f33ae8..8be029a 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -34,10 +34,11 @@ const DIST = 'dist'; // Telegram Stars openInvoice / VK order-box launch and the per-button in-flight state ride the same // always-loaded Wallet screen, then to 126 for the in-game board-feedback UX — the staged-play // highlight geometry (lib/formed.ts), the on-board score badge and the bag / turn-strip / board-zoom -// wiring live in the always-loaded Game / Board / Rack screens. 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: 126, shared: 31, landing: 5 }; +// wiring live in the always-loaded Game / Board / Rack screens, then to 127 for the wallet storefront +// redesign — its buy/spend toggle, compact balance and exchange-confirm dialog ride the same +// always-loaded Wallet screen. 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: 127, shared: 31, 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. diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index f28b730..1e6334e 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -228,20 +228,19 @@ export const en = { // --- 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.active': 'Active', + 'wallet.activeHints': 'Hints: {n}', 'wallet.noAdsUntil': 'No ads until {date}', 'wallet.noAdsForever': 'No ads forever', - 'wallet.adsOn': 'Ads are on', - 'wallet.hints': 'Hints {n}', 'wallet.store': 'Store', + 'wallet.tabBuy': 'Buy chips', + 'wallet.tabSpend': 'Spend chips', 'wallet.empty': 'The store is empty for now', 'wallet.buy': 'Buy', + 'wallet.spend': 'Exchange', 'wallet.rewardWatch': 'Watch an ad for {n} 🪙', 'wallet.rewardCta': 'Watch', 'wallet.rewardCredited': '+{n} 🪙 for watching', @@ -256,10 +255,11 @@ export const en = { 'wallet.cur.RUB': '₽', 'wallet.cur.VOTE': 'votes', 'wallet.cur.XTR': '⭐', - 'wallet.warnTitle': 'Web-only purchase', + 'wallet.spendTitle': 'Confirm exchange', + 'wallet.spendBody': 'Exchange {n} 🪙 for “{title}”?', + 'wallet.spendConfirm': 'Exchange', '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', @@ -313,6 +313,8 @@ export const en = { 'error.invalid_email': 'Enter a valid email address.', 'error.invalid_config': 'Invalid game settings.', 'error.not_found': 'Not found.', + 'error.insufficient_chips': 'Not enough chips.', + 'error.product_not_found': 'This item is no longer available.', 'error.session_invalid': 'Your session expired. Please sign in again.', 'error.unauthenticated': 'Please sign in.', 'error.rate_limited': 'Too many requests, slow down.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 8505381..a9df0cd 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -228,20 +228,19 @@ export const ru: Record = { // --- 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.active': 'Активные', + 'wallet.activeHints': 'Подсказки: {n}', 'wallet.noAdsUntil': 'Без рекламы до {date}', 'wallet.noAdsForever': 'Без рекламы навсегда', - 'wallet.adsOn': 'Реклама включена', - 'wallet.hints': 'Подсказки {n}', 'wallet.store': 'Магазин', + 'wallet.tabBuy': 'Купить фишки', + 'wallet.tabSpend': 'Потратить фишки', 'wallet.empty': 'Магазин пока пуст', 'wallet.buy': 'Купить', + 'wallet.spend': 'Обмен', 'wallet.rewardWatch': 'Ролик за {n} 🪙', 'wallet.rewardCta': 'Смотреть', 'wallet.rewardCredited': '+{n} 🪙 за просмотр', @@ -256,10 +255,11 @@ export const ru: Record = { 'wallet.cur.RUB': '₽', 'wallet.cur.VOTE': 'голосов', 'wallet.cur.XTR': '⭐', - 'wallet.warnTitle': 'Покупка только для веба', + 'wallet.spendTitle': 'Подтверждение обмена', + 'wallet.spendBody': 'Обменять {n} 🪙 на «{title}»?', + 'wallet.spendConfirm': 'Обменять', 'wallet.warnBody': 'Оплата спишет фишки VK/Telegram, и благо будет работать только в вебе и приложении — из-за правил магазинов.', - 'wallet.warnConfirm': 'Продолжить', 'wallet.warnCancel': 'Отмена', 'about.title': 'О программе', 'about.tab': 'Инфо', @@ -313,6 +313,8 @@ export const ru: Record = { 'error.invalid_email': 'Введите корректный адрес почты.', 'error.invalid_config': 'Неверные настройки игры.', 'error.not_found': 'Не найдено.', + 'error.insufficient_chips': 'Не хватает фишек.', + 'error.product_not_found': 'Этого товара больше нет.', 'error.session_invalid': 'Сессия истекла. Войдите снова.', 'error.unauthenticated': 'Пожалуйста, войдите.', 'error.rate_limited': 'Слишком много запросов, помедленнее.', diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index 332b00a..1dbbe42 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -5,7 +5,7 @@ import { gateway } from '../lib/gateway'; import { normalizeSubtype } from '../lib/platform'; import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte'; - import { executionContext, formatAmount, needsWebSpendWarning, type SpendContext } from '../lib/wallet'; + import { executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet'; import { isGooglePlayBuild } from '../lib/distribution'; import { onExternalLinkClick, openExternalUrl } from '../lib/links'; import { vkPlatform, vkShowOrderBox } from '../lib/vk'; @@ -14,11 +14,10 @@ import { GatewayError } from '../lib/client'; 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. + // The Wallet section: a compact chip balance, the active benefits, and the storefront. The store + // splits into "buy chips" (chip packs, funded with money — a provider redirect) and "spend chips" + // (values bought with chips — an instant exchange). 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(null); let catalog = $state(null); @@ -27,10 +26,17 @@ // (busy) and scopes the pressed/dimmed visual to the tapped button — not every buy button. let busyId = $state(null); const busy = $derived(busyId !== null); - // The value awaiting a web-spend warning confirmation (a spend that would draw vk/tg chips). - let warnProduct = $state(null); - const context: SpendContext = executionContext(); + // The storefront half in view: buying chips for money, or spending chips on values. + let tab = $state<'buy' | 'spend'>('buy'); + // The value awaiting an exchange confirmation, or null. Every chip-spend confirms (it is instant — + // no provider redirect to stand in as a confirmation), and the dialog folds in the cross-platform + // warning when the spend would draw vk/tg chips. + let spendProduct = $state(null); + const spendWarn = $derived( + !!spendProduct && needsWebSpendWarning(context, wallet?.segments ?? [], spendProduct.chips), + ); + const gpBuild = isGooglePlayBuild(); // Money purchases are not permitted inside the VK iOS app (Apple ToS); the pack CTA is shown // muted and taps explain why, rather than hiding the storefront. VK's device family is not on the @@ -42,6 +48,29 @@ const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []); const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []); + // The chips the player can actually spend in this context — a value costing more is out of reach, + // so its exchange button is disabled (the server stays authoritative and also rejects it). + const spendable = $derived(wallet ? spendableChips(wallet) : 0); + + // The balance reads as the context's own segment first (🪙 N), with any linked other-platform + // segments appended behind their logo — on the web all linked segments show and spend together; + // inside a VK/Telegram store only that store's own segment is present, so it stands alone. + const mainSeg = $derived(wallet?.segments.find((s) => s.source === context)); + const linkedSegs = $derived((wallet?.segments ?? []).filter((s) => s.source !== context)); + + // The active benefits, one short phrase each, only for what the player actually holds — nothing + // to show (ad-free lapsed and no hints) hides the whole section. + const activeItems = $derived.by(() => { + const items: string[] = []; + if (!wallet) return items; + if (wallet.hints > 0) items.push(t('wallet.activeHints', { n: wallet.hints })); + if (wallet.adsForever) items.push(t('wallet.noAdsForever')); + else if (wallet.adsPaidUntilMs > Date.now()) { + const date = new Date(wallet.adsPaidUntilMs).toLocaleDateString(i18n.locale === 'ru' ? 'ru-RU' : 'en-US'); + items.push(t('wallet.noAdsUntil', { date })); + } + return items; + }); // Rewarded video (VK ads): the "watch for chips" CTA shows only when the server offers a payout in // this context (wallet.rewardChips > 0 — VK, configured) and an ad is ready to show. @@ -121,26 +150,17 @@ 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 platformLogo(source: string): string { + return source === 'vk' ? 'vk-logo.svg' : 'telegram-logo.svg'; } - function onBuy(p: CatalogProduct) { - if (needsWebSpendWarning(context, wallet?.segments ?? [], p.chips)) { - warnProduct = p; - return; - } - void doBuy(p); + // onSpend opens the exchange confirmation for a chip-priced value; doBuy runs the actual spend. + function onSpend(p: CatalogProduct) { + spendProduct = p; } async function doBuy(p: CatalogProduct) { - warnProduct = null; + spendProduct = null; if (busy) return; busyId = p.productId; try { @@ -183,92 +203,93 @@
-
-

{t('wallet.balance')}

- {#if wallet && wallet.segments.length > 0} - {#each wallet.segments as s (s.source)} -
- {sourceLabel(s.source)} - 🪙 {s.chips}{#if !s.spendable} ({t('wallet.viewOnly')}){/if} -
- {/each} - {:else if !loading} -

{t('wallet.noChips')}

- {/if} -
+
+ {mainSeg?.chips ?? 0} + {#each linkedSegs as s (s.source)} + {s.chips} + {/each} +
-
-

{t('wallet.benefits')}

-
{adsLine()}
-
{t('wallet.hints', { n: wallet?.hints ?? 0 })}
-
+ {#if activeItems.length > 0} +
+

{t('wallet.active')}

+
{activeItems.join('. ')}
+
+ {/if}

{t('wallet.store')}

- {#if purchaseBlocked} - -

{t('wallet.iosBlockedPre')}{t('wallet.iosBlockedLink')}{t('wallet.iosBlockedPost')}

- {/if} - {#if canReward} -
- {t('wallet.rewardWatch', { n: wallet?.rewardChips ?? 0 })} - -
- {/if} - {#each values as p (p.productId)} -
- {p.title} - 🪙 {p.chips} - -
- {/each} +
+ + +
- {#if gpBuild} -

{t('wallet.gpStub')}

+ {#if tab === 'buy'} + {#if purchaseBlocked} + +

{t('wallet.iosBlockedPre')}{t('wallet.iosBlockedLink')}{t('wallet.iosBlockedPost')}

+ {/if} + {#if canReward} +
+ {t('wallet.rewardWatch', { n: wallet?.rewardChips ?? 0 })} + +
+ {/if} + {#if gpBuild} +

{t('wallet.gpStub')}

+ {:else} + {#each packs as p (p.productId)} +
+ {p.title} + {formatAmount(p.moneyAmount, p.moneyCurrency)} {currencyLabel(p.moneyCurrency)} + +
+ {/each} + {#if packs.length > 0} +

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

+ {:else if !loading} +

{t('wallet.empty')}

+ {/if} + {/if} {:else} - {#each packs as p (p.productId)} -
+ {#each values as p (p.productId)} +
{p.title} - {formatAmount(p.moneyAmount, p.moneyCurrency)} {currencyLabel(p.moneyCurrency)} - 🪙 {p.chips} +
{/each} - {#if packs.length > 0} -

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

+ {#if !loading && values.length === 0} +

{t('wallet.empty')}

{/if} {/if} - - {#if !loading && values.length === 0 && (gpBuild || packs.length === 0)} -

{t('wallet.empty')}

- {/if}
-{#if warnProduct} - (warnProduct = null)}> -

{t('wallet.warnBody')}

+{#if spendProduct} + (spendProduct = null)}> +

{t('wallet.spendBody', { n: spendProduct.chips, title: spendProduct.title })}

+ {#if spendWarn}

{t('wallet.warnBody')}

{/if}
- - (spendProduct = null)}>{t('wallet.warnCancel')} +
@@ -281,6 +302,29 @@ gap: 18px; padding: var(--pad); } + /* The compact balance: the context's own chips (🪙 N) then any linked other-platform segments + behind their logo, digits monospaced so they align; a non-spendable (view-only) segment dims. */ + .balance { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; + font-weight: 600; + font-size: 1.05rem; + } + .balance .num { + font-family: monospace; + } + .balance .num.dim { + opacity: 0.5; + } + .balance .plus { + margin: 0 3px; + color: var(--text-muted); + } + .balance .plogo { + display: block; + } section { display: flex; flex-direction: column; @@ -291,6 +335,26 @@ font-size: 0.95rem; color: var(--text-muted); } + /* The buy/spend segmented control, matching the New Game type selector. */ + .seg { + display: flex; + gap: 8px; + } + .opt { + flex: 1; + min-width: 64px; + padding: 10px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + border-radius: var(--radius-sm); + cursor: pointer; + } + .opt.active { + background: var(--accent); + color: var(--accent-text); + border-color: var(--accent); + } .row { display: flex; align-items: center; @@ -304,16 +368,11 @@ 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; @@ -330,7 +389,8 @@ /* The dimmed look is scoped: the tapped button while its purchase is in flight (working), or a blocked pack on VK iOS — never every buy button at once when one purchase is busy. */ .buy.blocked, - .buy.working { + .buy.working, + .buy.cantafford { opacity: 0.5; } .ios-note { @@ -362,8 +422,13 @@ .offer a { color: var(--text-muted); } + .spend-body { + margin: 0 0 10px; + } .warn-body { margin: 0 0 14px; + color: var(--text-muted); + font-size: 0.9rem; } .warn-actions { display: flex;