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:
@@ -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