Stage 9: Telegram integration (connector side-service, Mini App, out-of-app push)
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

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.
This commit is contained in:
Ilia Denisov
2026-06-04 01:42:54 +02:00
parent 1012fb47a0
commit cf66ed7e26
86 changed files with 3624 additions and 372 deletions
+12 -2
View File
@@ -82,8 +82,13 @@ awayEnd(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
notificationsInAppOnly():boolean {
const offset = this.bb!.__offset(this.bb_pos, 24);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : true;
}
static startProfile(builder:flatbuffers.Builder) {
builder.startObject(10);
builder.startObject(11);
}
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
@@ -126,12 +131,16 @@ static addAwayEnd(builder:flatbuffers.Builder, awayEndOffset:flatbuffers.Offset)
builder.addFieldOffset(9, awayEndOffset, 0);
}
static addNotificationsInAppOnly(builder:flatbuffers.Builder, notificationsInAppOnly:boolean) {
builder.addFieldInt8(10, +notificationsInAppOnly, +true);
}
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, hintBalance:number, blockChat:boolean, blockFriendRequests:boolean, isGuest:boolean, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset):flatbuffers.Offset {
static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, hintBalance:number, blockChat:boolean, blockFriendRequests:boolean, isGuest:boolean, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, notificationsInAppOnly:boolean):flatbuffers.Offset {
Profile.startProfile(builder);
Profile.addUserId(builder, userIdOffset);
Profile.addDisplayName(builder, displayNameOffset);
@@ -143,6 +152,7 @@ static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offse
Profile.addIsGuest(builder, isGuest);
Profile.addAwayStart(builder, awayStartOffset);
Profile.addAwayEnd(builder, awayEndOffset);
Profile.addNotificationsInAppOnly(builder, notificationsInAppOnly);
return Profile.endProfile(builder);
}
}
@@ -65,8 +65,13 @@ blockFriendRequests():boolean {
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
notificationsInAppOnly():boolean {
const offset = this.bb!.__offset(this.bb_pos, 18);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : true;
}
static startUpdateProfileRequest(builder:flatbuffers.Builder) {
builder.startObject(7);
builder.startObject(8);
}
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
@@ -97,12 +102,16 @@ static addBlockFriendRequests(builder:flatbuffers.Builder, blockFriendRequests:b
builder.addFieldInt8(6, +blockFriendRequests, +false);
}
static addNotificationsInAppOnly(builder:flatbuffers.Builder, notificationsInAppOnly:boolean) {
builder.addFieldInt8(7, +notificationsInAppOnly, +true);
}
static endUpdateProfileRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, blockChat:boolean, blockFriendRequests:boolean):flatbuffers.Offset {
static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, blockChat:boolean, blockFriendRequests:boolean, notificationsInAppOnly:boolean):flatbuffers.Offset {
UpdateProfileRequest.startUpdateProfileRequest(builder);
UpdateProfileRequest.addDisplayName(builder, displayNameOffset);
UpdateProfileRequest.addPreferredLanguage(builder, preferredLanguageOffset);
@@ -111,6 +120,7 @@ static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset
UpdateProfileRequest.addAwayEnd(builder, awayEndOffset);
UpdateProfileRequest.addBlockChat(builder, blockChat);
UpdateProfileRequest.addBlockFriendRequests(builder, blockFriendRequests);
UpdateProfileRequest.addNotificationsInAppOnly(builder, notificationsInAppOnly);
return UpdateProfileRequest.endUpdateProfileRequest(builder);
}
}
+52 -1
View File
@@ -8,7 +8,9 @@ import { gateway } from './gateway';
import { GatewayError } from './client';
import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { applyReduceMotion, applyTheme, type ThemePref } from './theme';
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
import { insideTelegram, onTelegramPath, telegramLaunch } from './telegram';
import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import type { BoardLabelMode } from './boardlabels';
@@ -144,6 +146,28 @@ export async function bootstrap(): Promise<void> {
setLocale(guess);
}
// Telegram Mini App launch: apply the platform theme, authenticate via initData,
// and route any deep-link start parameter. On the dedicated /telegram/ entry path
// outside Telegram (no initData), refuse to render and send the visitor to the
// site root.
if (onTelegramPath() && !insideTelegram()) {
if (typeof location !== 'undefined') location.replace('/');
return;
}
if (insideTelegram()) {
const launch = telegramLaunch();
if (launch.theme) applyTelegramTheme(launch.theme);
try {
await adoptSession(await gateway.authTelegram(launch.initData));
await routeStartParam(launch.startParam);
} catch (err) {
handleError(err);
navigate('/login');
}
app.ready = true;
return;
}
const saved = await loadSession();
if (saved) {
await adoptSession(saved);
@@ -154,6 +178,32 @@ export async function bootstrap(): Promise<void> {
app.ready = true;
}
/**
* routeStartParam navigates a Telegram deep-link start parameter to its target: a
* specific game, the friends screen with a friend-code redemption, or the lobby
* (where invitations surface as a badge).
*/
async function routeStartParam(param: string): Promise<void> {
const link = parseStartParam(param);
switch (link.kind) {
case 'game':
navigate(`/game/${link.id}`);
return;
case 'friendCode':
navigate('/friends');
try {
const friend = await gateway.friendCodeRedeem(link.code);
showToast(t('friends.added', { name: friend.displayName }));
void refreshNotifications();
} catch (err) {
handleError(err);
}
return;
default:
navigate('/');
}
}
export async function loginGuest(): Promise<void> {
try {
const s = await gateway.authGuest(app.locale);
@@ -233,6 +283,7 @@ async function persistLanguageToServer(locale: Locale): Promise<void> {
awayEnd: p.awayEnd,
blockChat: p.blockChat,
blockFriendRequests: p.blockFriendRequests,
notificationsInAppOnly: p.notificationsInAppOnly,
});
} catch {
// The client locale already changed; the server sync is best-effort.
+1
View File
@@ -52,6 +52,7 @@ export type Unsubscribe = () => void;
export interface GatewayClient {
// --- auth (unauthenticated) ---
authTelegram(initData: string): Promise<Session>;
authGuest(locale?: string): Promise<Session>;
authEmailRequest(email: string): Promise<void>;
authEmailLogin(email: string, code: string): Promise<Session>;
+10
View File
@@ -146,6 +146,14 @@ export function encodeChatPost(gameId: string, body: string): Uint8Array {
return finish(b, fb.ChatPostRequest.endChatPostRequest(b));
}
export function encodeTelegramLogin(initData: string): Uint8Array {
const b = new Builder(512);
const d = b.createString(initData);
fb.TelegramLoginRequest.startTelegramLoginRequest(b);
fb.TelegramLoginRequest.addInitData(b, d);
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
}
export function encodeGuestLogin(locale: string): Uint8Array {
const b = new Builder(64);
const l = b.createString(locale);
@@ -264,6 +272,7 @@ export function decodeProfile(buf: Uint8Array): Profile {
blockChat: p.blockChat(),
blockFriendRequests: p.blockFriendRequests(),
isGuest: p.isGuest(),
notificationsInAppOnly: p.notificationsInAppOnly(),
};
}
@@ -444,6 +453,7 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
fb.UpdateProfileRequest.addAwayEnd(b, ae);
fb.UpdateProfileRequest.addBlockChat(b, p.blockChat);
fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests);
fb.UpdateProfileRequest.addNotificationsInAppOnly(b, p.notificationsInAppOnly);
return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b));
}
+38
View File
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink } from './deeplink';
describe('parseStartParam', () => {
it('classifies game / invitation / friend code', () => {
expect(parseStartParam('g7c9e6679')).toEqual({ kind: 'game', id: '7c9e6679' });
expect(parseStartParam('iabc-123')).toEqual({ kind: 'invitation', id: 'abc-123' });
expect(parseStartParam('f123456')).toEqual({ kind: 'friendCode', code: '123456' });
});
it('falls back to the lobby for empty / unknown / value-less params', () => {
expect(parseStartParam('')).toEqual({ kind: 'lobby' });
expect(parseStartParam(undefined)).toEqual({ kind: 'lobby' });
expect(parseStartParam(null)).toEqual({ kind: 'lobby' });
expect(parseStartParam('x-nope')).toEqual({ kind: 'lobby' });
expect(parseStartParam('g')).toEqual({ kind: 'lobby' });
});
it('round-trips the build helpers', () => {
expect(parseStartParam(gameParam('id1'))).toEqual({ kind: 'game', id: 'id1' });
expect(parseStartParam(invitationParam('id2'))).toEqual({ kind: 'invitation', id: 'id2' });
expect(parseStartParam(friendCodeParam('654321'))).toEqual({ kind: 'friendCode', code: '654321' });
});
});
describe('shareLink', () => {
afterEach(() => vi.unstubAllEnvs());
it('returns null without a configured base', () => {
vi.stubEnv('VITE_TELEGRAM_LINK', '');
expect(shareLink('gx')).toBeNull();
});
it('wraps a payload in a startapp link', () => {
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app');
expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456');
});
});
+49
View File
@@ -0,0 +1,49 @@
// Telegram Mini App deep-link "start parameters", mirroring the connector's Go
// scheme (platform/telegram/internal/deeplink): a one-character kind prefix plus a
// value —
// g<game uuid> open that game
// i<invitation uuid> open that invitation
// f<6-digit code> redeem that friend code
// An empty or unrecognised parameter opens the lobby.
export type DeepLink =
| { kind: 'lobby' }
| { kind: 'game'; id: string }
| { kind: 'invitation'; id: string }
| { kind: 'friendCode'; code: string };
/** parseStartParam classifies a Telegram start parameter into a routing target. */
export function parseStartParam(param: string | undefined | null): DeepLink {
if (!param) return { kind: 'lobby' };
const value = param.slice(1);
if (!value) return { kind: 'lobby' };
switch (param[0]) {
case 'g':
return { kind: 'game', id: value };
case 'i':
return { kind: 'invitation', id: value };
case 'f':
return { kind: 'friendCode', code: value };
default:
return { kind: 'lobby' };
}
}
/** gameParam builds the start parameter that opens a game. */
export const gameParam = (id: string): string => 'g' + id;
/** invitationParam builds the start parameter that opens an invitation. */
export const invitationParam = (id: string): string => 'i' + id;
/** friendCodeParam builds the start parameter that redeems a friend code. */
export const friendCodeParam = (code: string): string => 'f' + code;
/**
* shareLink wraps a deep-link start parameter in a t.me Mini App link, using the
* VITE_TELEGRAM_LINK base (e.g. https://t.me/<bot>/<app>). It returns null when the
* base is not configured, so callers can hide the share affordance.
*/
export function shareLink(param: string): string | null {
const base = import.meta.env.VITE_TELEGRAM_LINK as string | undefined;
if (!base) return null;
const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}startapp=${encodeURIComponent(param)}`;
}
+2
View File
@@ -110,6 +110,7 @@ export const en = {
'profile.to': 'To',
'profile.blockChat': 'Disable chat',
'profile.blockFriendRequests': 'Disable friend requests',
'profile.notificationsInAppOnly': 'Notifications in the app only',
'profile.email': 'Email',
'profile.bindEmail': 'Bind email',
'profile.emailCode': 'Confirmation code',
@@ -180,6 +181,7 @@ export const en = {
'friends.redeem': 'Add',
'friends.copy': 'Copy',
'friends.codeCopied': 'Code copied.',
'friends.shareTelegram': 'Share via Telegram',
'friends.added': 'Added {name}.',
'friends.blockedList': 'Blocked players',
'friends.unblock': 'Unblock',
+2
View File
@@ -111,6 +111,7 @@ export const ru: Record<MessageKey, string> = {
'profile.to': 'До',
'profile.blockChat': 'Отключить чат',
'profile.blockFriendRequests': 'Отключить заявки в друзья',
'profile.notificationsInAppOnly': 'Уведомления только в приложении',
'profile.email': 'Эл. почта',
'profile.bindEmail': 'Привязать почту',
'profile.emailCode': 'Код подтверждения',
@@ -181,6 +182,7 @@ export const ru: Record<MessageKey, string> = {
'friends.redeem': 'Добавить',
'friends.copy': 'Копировать',
'friends.codeCopied': 'Код скопирован.',
'friends.shareTelegram': 'Поделиться через Telegram',
'friends.added': 'Добавлен(а) {name}.',
'friends.blockedList': 'Заблокированные',
'friends.unblock': 'Разблокировать',
+3
View File
@@ -100,6 +100,9 @@ export class MockGateway implements GatewayClient {
}
// --- auth ---
async authTelegram(): Promise<Session> {
return { ...SESSION, isGuest: false };
}
async authGuest(): Promise<Session> {
return { ...SESSION };
}
+1
View File
@@ -36,6 +36,7 @@ export const PROFILE: Profile = {
blockChat: false,
blockFriendRequests: false,
isGuest: false,
notificationsInAppOnly: true,
};
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
+3
View File
@@ -107,6 +107,8 @@ export interface Profile {
blockChat: boolean;
blockFriendRequests: boolean;
isGuest: boolean;
/** Confine notifications to the in-app stream (no out-of-app platform push). */
notificationsInAppOnly: boolean;
}
/** The full editable profile sent to profileUpdate (overwrites every field). */
@@ -118,6 +120,7 @@ export interface ProfileUpdate {
awayEnd: string;
blockChat: boolean;
blockFriendRequests: boolean;
notificationsInAppOnly: boolean;
}
/** A referenced account with its display name (friend, blocked user, invitee). */
+39
View File
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { insideTelegram, telegramLaunch } from './telegram';
function stubWebApp(initData: string, startParam?: string) {
vi.stubGlobal('window', {
Telegram: {
WebApp: {
initData,
initDataUnsafe: startParam ? { start_param: startParam } : {},
themeParams: { bg_color: '#101418' },
ready: () => {},
expand: () => {},
},
},
});
}
describe('telegram launch detection', () => {
afterEach(() => vi.unstubAllGlobals());
it('is not inside Telegram without a window', () => {
expect(insideTelegram()).toBe(false);
});
it('is inside Telegram only with non-empty initData', () => {
stubWebApp('');
expect(insideTelegram()).toBe(false);
stubWebApp('query_id=abc');
expect(insideTelegram()).toBe(true);
});
it('telegramLaunch returns initData, start param and theme', () => {
stubWebApp('query_id=abc', 'g123');
const launch = telegramLaunch();
expect(launch.initData).toBe('query_id=abc');
expect(launch.startParam).toBe('g123');
expect(launch.theme?.bg_color).toBe('#101418');
});
});
+67
View File
@@ -0,0 +1,67 @@
// Telegram Mini App SDK access. The official telegram-web-app.js (loaded in
// index.html) exposes window.Telegram.WebApp; this wraps the subset the app uses:
// launch detection, initData (for auth.telegram), the deep-link start parameter,
// theme params, and ready()/expand(). Every helper is safe to call outside Telegram.
import type { TelegramThemeParams } from './theme';
interface TelegramWebApp {
initData: string;
initDataUnsafe?: { start_param?: string };
themeParams?: TelegramThemeParams;
ready?: () => void;
expand?: () => void;
}
function webApp(): TelegramWebApp | undefined {
if (typeof window === 'undefined') return undefined;
return (window as unknown as { Telegram?: { WebApp?: TelegramWebApp } }).Telegram?.WebApp;
}
/**
* insideTelegram reports whether the app launched as a Telegram Mini App — the SDK
* is present and carries non-empty initData (an ordinary browser tab has neither).
*/
export function insideTelegram(): boolean {
const w = webApp();
return !!w && typeof w.initData === 'string' && w.initData.length > 0;
}
/** TelegramLaunch is the data a Mini App launch carries. */
export interface TelegramLaunch {
initData: string;
startParam: string;
theme: TelegramThemeParams | undefined;
}
/**
* telegramLaunch readies the Mini App (full-height, ready signal) and returns its
* launch data: the raw initData (for auth.telegram), the deep-link start parameter
* (from the SDK or, for a bot web_app button, the page URL), and the theme params.
*/
export function telegramLaunch(): TelegramLaunch {
const w = webApp();
if (!w) return { initData: '', startParam: startParamFromURL(), theme: undefined };
w.ready?.();
w.expand?.();
const startParam = w.initDataUnsafe?.start_param ?? startParamFromURL();
return { initData: w.initData, startParam, theme: w.themeParams };
}
/**
* startParamFromURL reads a startapp parameter from the page URL — a bot web_app
* launch button carries the deep-link there rather than in initDataUnsafe.
*/
function startParamFromURL(): string {
if (typeof location === 'undefined') return '';
return new URLSearchParams(location.search).get('startapp') ?? '';
}
/**
* onTelegramPath reports whether the app is served under the dedicated Telegram
* entry path (/telegram/); outside Telegram on that path the app refuses to render.
*/
export function onTelegramPath(): boolean {
if (typeof location === 'undefined') return false;
return location.pathname.startsWith('/telegram/');
}
+3
View File
@@ -54,6 +54,9 @@ export function createTransport(baseUrl: string): GatewayClient {
token = t;
},
async authTelegram(initData) {
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData)));
},
async authGuest(locale) {
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '')));
},
+5
View File
@@ -4,6 +4,7 @@
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { t } from '../lib/i18n/index.svelte';
import { friendCodeParam, shareLink } from '../lib/deeplink';
import type { AccountRef, FriendCode } from '../lib/model';
let friends = $state<AccountRef[]>([]);
@@ -97,6 +98,7 @@
<button class="btn" onclick={redeem}>{t('friends.redeem')}</button>
</div>
{#if code}
{@const tg = shareLink(friendCodeParam(code.code))}
<div class="code" data-testid="friend-code">
<div class="coderow">
<button class="codeval" onclick={copyCode}>{code.code}</button>
@@ -105,6 +107,9 @@
<span class="codehint">
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span>
{#if tg}
<a class="link tgshare" href={tg} target="_blank" rel="noopener">{t('friends.shareTelegram')}</a>
{/if}
</div>
{:else}
<button class="link" onclick={getCode}>{t('friends.getCode')}</button>
+7
View File
@@ -25,6 +25,7 @@
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);
@@ -47,6 +48,7 @@
[endH, endM] = splitTime(p.awayEnd);
blockChat = p.blockChat;
blockFriendRequests = p.blockFriendRequests;
notificationsInAppOnly = p.notificationsInAppOnly;
editing = true;
}
@@ -68,6 +70,7 @@
awayEnd,
blockChat,
blockFriendRequests,
notificationsInAppOnly,
});
editing = false;
showToast(t('profile.saved'));
@@ -143,6 +146,10 @@
<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>