feat(ui): wallet storefront redesign — split buy/spend, compact balance, exchange confirm
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s

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).
This commit is contained in:
Ilia Denisov
2026-07-10 17:46:22 +02:00
parent 3469278260
commit 8ce986922a
7 changed files with 274 additions and 170 deletions
+10 -8
View File
@@ -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.',
+10 -8
View File
@@ -228,20 +228,19 @@ export const ru: Record<MessageKey, string> = {
// --- 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<MessageKey, string> = {
'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<MessageKey, string> = {
'error.invalid_email': 'Введите корректный адрес почты.',
'error.invalid_config': 'Неверные настройки игры.',
'error.not_found': 'Не найдено.',
'error.insufficient_chips': 'Не хватает фишек.',
'error.product_not_found': 'Этого товара больше нет.',
'error.session_invalid': 'Сессия истекла. Войдите снова.',
'error.unauthenticated': 'Пожалуйста, войдите.',
'error.rate_limited': 'Слишком много запросов, помедленнее.',
+165 -100
View File
@@ -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<Wallet | null>(null);
let catalog = $state<Catalog | null>(null);
@@ -27,10 +26,17 @@
// (busy) and scopes the pressed/dimmed visual to the tapped button — not every buy button.
let busyId = $state<string | null>(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<CatalogProduct | null>(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<CatalogProduct | null>(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 @@
</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}&nbsp;<span class="muted">({t('wallet.viewOnly')})</span>{/if}</span
>
</div>
{/each}
{:else if !loading}
<p class="empty">{t('wallet.noChips')}</p>
{/if}
</section>
<div class="balance" data-testid="balance">
<span class="coin" aria-hidden="true">🪙</span><span class="num" class:dim={mainSeg && !mainSeg.spendable}>{mainSeg?.chips ?? 0}</span>
{#each linkedSegs as s (s.source)}
<span class="plus" aria-hidden="true">+</span><img class="plogo" src={platformLogo(s.source)} width="16" height="16" alt={sourceLabel(s.source)} /><span class="num" class:dim={!s.spendable}>{s.chips}</span>
{/each}
</div>
<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>
{#if activeItems.length > 0}
<section data-testid="active">
<h3>{t('wallet.active')}</h3>
<div class="row"><span class="name">{activeItems.join('. ')}</span></div>
</section>
{/if}
<section>
<h3>{t('wallet.store')}</h3>
{#if purchaseBlocked}
<!-- prettier-ignore -->
<p class="ios-note" data-testid="ios-note">{t('wallet.iosBlockedPre')}<a href={siteRoot} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{t('wallet.iosBlockedLink')}</a>{t('wallet.iosBlockedPost')}</p>
{/if}
{#if canReward}
<div class="row reward" data-testid="reward-row">
<span class="name">{t('wallet.rewardWatch', { n: wallet?.rewardChips ?? 0 })}</span>
<button class="buy" class:working={busyId === 'reward'} data-testid="watch-ad" disabled={busy} onclick={onWatchAd}
>{t('wallet.rewardCta')}</button
>
</div>
{/if}
{#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" class:working={busyId === p.productId} data-testid="buy" disabled={busy} onclick={() => onBuy(p)}
>{t('wallet.buy')}</button
>
</div>
{/each}
<div class="seg">
<button class="opt" class:active={tab === 'buy'} data-testid="tab-buy" onclick={() => (tab = 'buy')}>{t('wallet.tabBuy')}</button>
<button class="opt" class:active={tab === 'spend'} data-testid="tab-spend" onclick={() => (tab = 'spend')}>{t('wallet.tabSpend')}</button>
</div>
{#if gpBuild}
<p class="stub" data-testid="gp-stub">{t('wallet.gpStub')}</p>
{#if tab === 'buy'}
{#if purchaseBlocked}
<!-- prettier-ignore -->
<p class="ios-note" data-testid="ios-note">{t('wallet.iosBlockedPre')}<a href={siteRoot} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{t('wallet.iosBlockedLink')}</a>{t('wallet.iosBlockedPost')}</p>
{/if}
{#if canReward}
<div class="row reward" data-testid="reward-row">
<span class="name">{t('wallet.rewardWatch', { n: wallet?.rewardChips ?? 0 })}</span>
<button class="buy" class:working={busyId === 'reward'} data-testid="watch-ad" disabled={busy} onclick={onWatchAd}
>{t('wallet.rewardCta')}</button
>
</div>
{/if}
{#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)}&nbsp;{currencyLabel(p.moneyCurrency)}</span>
<button
class="buy"
class:blocked={purchaseBlocked}
class:working={busyId === p.productId}
data-testid="buy-pack"
disabled={busy}
onclick={() => {
if (purchaseBlocked) showToast(t('wallet.platformNoBuy'));
else void onOrder(p);
}}>{t('wallet.buy')}</button
>
</div>
{/each}
{#if packs.length > 0}
<p class="offer" data-testid="offer">
<a href="/offer/" target="_blank" rel="noopener noreferrer">{t('wallet.offer')}</a>
</p>
{:else if !loading}
<p class="empty">{t('wallet.empty')}</p>
{/if}
{/if}
{:else}
{#each packs as p (p.productId)}
<div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}>
{#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">{formatAmount(p.moneyAmount, p.moneyCurrency)}&nbsp;{currencyLabel(p.moneyCurrency)}</span>
<button
class="buy"
class:blocked={purchaseBlocked}
class:working={busyId === p.productId}
data-testid="buy-pack"
disabled={busy}
onclick={() => {
if (purchaseBlocked) showToast(t('wallet.platformNoBuy'));
else void onOrder(p);
}}>{t('wallet.buy')}</button
<span class="price">🪙 {p.chips}</span>
<button class="buy" class:working={busyId === p.productId} class:cantafford={spendable < p.chips} data-testid="buy" disabled={busy || spendable < p.chips} onclick={() => onSpend(p)}
>{t('wallet.spend')}</button
>
</div>
{/each}
{#if packs.length > 0}
<p class="offer" data-testid="offer">
<a href="/offer/" target="_blank" rel="noopener noreferrer">{t('wallet.offer')}</a>
</p>
{#if !loading && values.length === 0}
<p class="empty">{t('wallet.empty')}</p>
{/if}
{/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>
{#if spendProduct}
<Modal title={t('wallet.spendTitle')} onclose={() => (spendProduct = null)}>
<p class="spend-body" data-testid="spend-body">{t('wallet.spendBody', { n: spendProduct.chips, title: spendProduct.title })}</p>
{#if spendWarn}<p class="warn-body" data-testid="warn">{t('wallet.warnBody')}</p>{/if}
<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
<button class="cancel" onclick={() => (spendProduct = null)}>{t('wallet.warnCancel')}</button>
<button class="buy" data-testid="spend-confirm" onclick={() => spendProduct && doBuy(spendProduct)}
>{t('wallet.spendConfirm')}</button
>
</div>
</Modal>
@@ -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;