Files
scrabble-game/ui/src/screens/Wallet.svelte
T
Ilia Denisov e33b9864c8
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m23s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
fix(ui): point the VK-iOS "other version" link at the site root
The landing at / lists every version's entry; /app/ dropped the player straight
into the web game. Link the iOS store note to the root instead.
2026-07-09 20:18:42 +02:00

314 lines
9.6 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte';
import { app, handleError, showToast } from '../lib/app.svelte';
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 { isGooglePlayBuild } from '../lib/distribution';
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
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();
// 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
// client platformSubtype (server-derived) — read it from the signed vk_platform launch param.
const purchaseBlocked = context === 'vk' && normalizeSubtype(vkPlatform()) === 'ios';
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(() => {
void load();
// Fallback to the payment push: re-fetch when the tab regains focus, i.e. the user returned
// from the provider's payment window.
const onVisible = () => {
if (!document.hidden) void load();
};
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
});
// Main path: a payment-intake push bumps app.walletRefresh; re-fetch in place when it changes
// (a credit landed while the screen is open).
let seenRefresh = app.walletRefresh;
$effect(() => {
if (app.walletRefresh !== seenRefresh) {
seenRefresh = app.walletRefresh;
void 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;
}
}
// 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);
if (context === 'vk') {
// VK settles the payment in-app via the bridge; the chips arrive on the server callback.
await vkShowOrderBox(order.orderId);
} else {
openExternalUrl(order.redirectUrl);
}
} 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}&nbsp;<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>
{#if purchaseBlocked}
<!-- prettier-ignore -->
<p class="ios-note" data-testid="ios-note">{t('wallet.iosBlockedPre')}<a href="https://erudit-game.ru/" target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{t('wallet.iosBlockedLink')}</a>{t('wallet.iosBlockedPost')}</p>
{/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" 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)}&nbsp;{currencyLabel(p.moneyCurrency)}</span>
<button
class="buy"
class:blocked={purchaseBlocked}
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>
{/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>
<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;
}
.buy.blocked {
opacity: 0.5;
}
.ios-note {
margin: 0 0 4px;
padding: 8px 12px;
color: var(--text-muted);
font-size: 0.85rem;
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
}
.ios-note a {
color: var(--accent);
}
.empty,
.stub {
margin: 0;
padding: 10px 12px;
color: var(--text-muted);
}
.stub {
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;
}
.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>