Files
scrabble-game/ui/src/screens/Profile.svelte
T
Ilia Denisov cf66ed7e26
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Stage 9: Telegram integration (connector side-service, Mini App, out-of-app push)
New platform/telegram connector (own container, bot token only there):
- go-telegram/bot long-poll loop: /start deep-links + Mini App launch button.
- gRPC API pkg/proto/telegram/v1 (Telegram service): ValidateInitData, Notify
  (renders a localized message + deep-link button), SendToUser/SendToGameChannel
  (admin, wired in Stage 10). Generic methods are platform-agnostic (external_id).
- Bot API base override for Telegram's test environment; Dockerfile + compose
  (VPN sidecar, no public ingress); README.

Gateway:
- initData validation relocated from the gateway into the connector; the gateway
  calls ValidateInitData over gRPC (GATEWAY_CONNECTOR_ADDR), drops the bot token,
  and deletes internal/auth.
- Out-of-app push: runPushPump routes events whose recipient has no live in-app
  stream to connector.Notify, gated by /internal/push-target + the in-app-only
  flag (race-free de-dup); HasSubscribers added to the push hub.

Backend:
- Migration 00007 accounts.notifications_in_app_only (default true) + jetgen.
- ProvisionTelegram seeds a new account's language/display name from the launch
  fields; IdentityExternalID reverse lookup; /internal/push-target handler.

UI:
- Telegram Mini App launch: detect initData, apply themeParams, authTelegram,
  route the deep-link start_param (g/i/f); /telegram/ guard redirects outside
  Telegram. Vite relative base + telegram-web-app.js. In-app-only profile toggle;
  share-to-Telegram link for a friend code. Vitest + Playwright coverage.

Wire/docs/CI: fbs Profile/UpdateProfileRequest gain notifications_in_app_only
(Go + TS); go.work uses ./platform/telegram; go-unit.yaml covers it; PLAN,
ARCHITECTURE, FUNCTIONAL (+ru), UI_DESIGN, READMEs updated.
2026-06-04 07:10:21 +02:00

357 lines
9.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import Screen from '../components/Screen.svelte';
import { app, handleError, logout, showToast } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { t } from '../lib/i18n/index.svelte';
import {
awayDurationOk,
awayHours,
awayMinutes,
browserOffset,
isOffsetZone,
timezoneOffsets,
validDisplayName,
validEmail,
} from '../lib/profileValidation';
let editing = $state(false);
let dn = $state('');
let tz = $state('+00:00');
// Away start/end as hour + 10-minute parts, so the picker is a <select> like every
// other profile control (consistent native control across iOS / desktop).
let startH = $state('00');
let startM = $state('00');
let endH = $state('07');
let endM = $state('00');
let blockChat = $state(false);
let blockFriendRequests = $state(false);
let notificationsInAppOnly = $state(true);
let emailInput = $state('');
let codeInput = $state('');
let emailSent = $state(false);
function defaultTz(): string {
const b = browserOffset();
return timezoneOffsets.includes(b) ? b : '+00:00';
}
function splitTime(hhmm: string): [string, string] {
const m = /^(\d{2}):(\d{2})$/.exec(hhmm);
if (!m) return ['00', '00'];
return [m[1], awayMinutes.includes(m[2]) ? m[2] : '00'];
}
function startEdit() {
const p = app.profile!;
dn = p.displayName;
tz = isOffsetZone(p.timeZone) && timezoneOffsets.includes(p.timeZone) ? p.timeZone : defaultTz();
[startH, startM] = splitTime(p.awayStart);
[endH, endM] = splitTime(p.awayEnd);
blockChat = p.blockChat;
blockFriendRequests = p.blockFriendRequests;
notificationsInAppOnly = p.notificationsInAppOnly;
editing = true;
}
const awayStart = $derived(`${startH}:${startM}`);
const awayEnd = $derived(`${endH}:${endM}`);
const nameOk = $derived(validDisplayName(dn));
const awayOk = $derived(awayDurationOk(awayStart, awayEnd));
const formValid = $derived(nameOk && awayOk);
const emailOk = $derived(validEmail(emailInput));
async function save() {
if (!formValid) return;
try {
app.profile = await gateway.profileUpdate({
displayName: dn.trim(),
preferredLanguage: app.profile!.preferredLanguage, // language lives in Settings
timeZone: tz,
awayStart,
awayEnd,
blockChat,
blockFriendRequests,
notificationsInAppOnly,
});
editing = false;
showToast(t('profile.saved'));
} catch (e) {
handleError(e);
}
}
async function requestEmail() {
if (!emailOk) return;
try {
await gateway.emailBindRequest(emailInput.trim());
emailSent = true;
showToast(t('profile.emailSent', { email: emailInput.trim() }));
} catch (e) {
handleError(e);
}
}
async function confirmEmail() {
try {
app.profile = await gateway.emailBindConfirm(emailInput.trim(), codeInput.trim());
emailSent = false;
emailInput = '';
codeInput = '';
showToast(t('profile.emailBound'));
} catch (e) {
handleError(e);
}
}
</script>
<Screen title={t('profile.title')} back="/">
<div class="page">
{#if app.profile}
{@const p = app.profile}
<div class="name">{p.displayName}</div>
{#if p.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
{#if editing}
<form class="edit" onsubmit={(e) => { e.preventDefault(); void save(); }}>
<label>
<span>{t('profile.displayName')}</span>
<input class:invalid={!nameOk} bind:value={dn} maxlength="40" />
</label>
<label>
<span>{t('profile.timezone')}</span>
<select bind:value={tz}>
{#each timezoneOffsets as o (o)}<option value={o}>{o}</option>{/each}
</select>
</label>
<fieldset class="away" class:invalid={!awayOk}>
<legend>{t('profile.awayWindow')}</legend>
<div class="times">
<span class="tlabel">{t('profile.from')}</span>
<select bind:value={startH}>{#each awayHours as h (h)}<option>{h}</option>{/each}</select>
<span class="colon">:</span>
<select bind:value={startM}>{#each awayMinutes as m (m)}<option>{m}</option>{/each}</select>
</div>
<div class="times">
<span class="tlabel">{t('profile.to')}</span>
<select bind:value={endH}>{#each awayHours as h (h)}<option>{h}</option>{/each}</select>
<span class="colon">:</span>
<select bind:value={endM}>{#each awayMinutes as m (m)}<option>{m}</option>{/each}</select>
</div>
<p class="muted">{t('profile.awayHint')}</p>
</fieldset>
<label class="check">
<input type="checkbox" bind:checked={blockChat} />
<span>{t('profile.blockChat')}</span>
</label>
<label class="check">
<input type="checkbox" bind:checked={blockFriendRequests} />
<span>{t('profile.blockFriendRequests')}</span>
</label>
<label class="check">
<input type="checkbox" bind:checked={notificationsInAppOnly} />
<span>{t('profile.notificationsInAppOnly')}</span>
</label>
<div class="formacts">
<button type="submit" class="btn" disabled={!formValid}>{t('common.save')}</button>
<button type="button" class="ghost" onclick={() => (editing = false)}>{t('common.cancel')}</button>
</div>
</form>
{:else}
<dl>
<dt>{t('profile.timezone')}</dt>
<dd>{p.timeZone}</dd>
<dt>{t('profile.awayWindow')}</dt>
<dd>{p.awayStart}{p.awayEnd}</dd>
<dt>{t('profile.hintBalance')}</dt>
<dd>{p.hintBalance}</dd>
</dl>
{#if p.isGuest}
<p class="muted">{t('profile.guestLocked')}</p>
{:else}
<button class="btn" onclick={startEdit}>{t('profile.edit')}</button>
<section class="emailbox">
<h3>{t('profile.bindEmail')}</h3>
{#if !emailSent}
<div class="addrow">
<input
class:invalid={emailInput.length > 0 && !emailOk}
bind:value={emailInput}
placeholder={t('login.emailPlaceholder')}
type="email"
/>
<button class="ghost" onclick={requestEmail} disabled={!emailOk}>{t('login.sendCode')}</button>
</div>
{:else}
<div class="addrow">
<input
class="codein"
bind:value={codeInput}
placeholder={t('profile.emailCode')}
inputmode="numeric"
maxlength="6"
/>
<button class="btn" onclick={confirmEmail}>{t('common.ok')}</button>
</div>
{/if}
</section>
{/if}
{/if}
<button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button>
{/if}
</div>
</Screen>
<style>
.page {
padding: var(--pad);
display: flex;
flex-direction: column;
gap: 16px;
}
.name {
font-size: 1.4rem;
font-weight: 700;
}
.badge {
align-self: flex-start;
padding: 2px 8px;
border-radius: 999px;
background: var(--surface-2);
color: var(--text-muted);
font-size: 0.8rem;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 16px;
margin: 0;
}
dt {
color: var(--text-muted);
}
dd {
margin: 0;
text-align: right;
}
.muted {
color: var(--text-muted);
font-size: 0.9rem;
margin: 0;
}
.edit {
display: flex;
flex-direction: column;
gap: 14px;
}
.edit > label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.9rem;
color: var(--text-muted);
}
.edit input:not([type='checkbox']),
.edit select {
min-width: 0;
padding: 9px 11px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.invalid {
border-color: var(--danger, #c0392b) !important;
}
.away {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.away legend {
color: var(--text-muted);
font-size: 0.9rem;
padding: 0 4px;
}
.times {
display: flex;
align-items: center;
gap: 8px;
}
.tlabel {
min-width: 2.5em;
color: var(--text-muted);
font-size: 0.85rem;
}
.colon {
font-weight: 700;
}
.check {
flex-direction: row !important;
align-items: center;
gap: 10px !important;
color: var(--text) !important;
}
.formacts {
display: flex;
gap: 10px;
}
.btn:disabled {
opacity: 0.5;
}
.emailbox h3 {
margin: 0 0 8px;
font-size: 0.95rem;
color: var(--text-muted);
}
.addrow {
display: flex;
gap: 8px;
}
.addrow input {
flex: 1;
min-width: 0;
padding: 9px 11px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.addrow input.codein {
letter-spacing: 0.3em;
font-size: 1.1rem;
}
.btn {
align-self: flex-start;
padding: 9px 14px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
.ghost {
padding: 9px 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.ghost:disabled {
opacity: 0.5;
}
.logout {
margin-top: 8px;
align-self: flex-start;
padding: 8px 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
</style>