mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 22:14:02 +00:00
feat(miniapp): add telegram-authenticated shell
This commit is contained in:
@@ -1,8 +1,255 @@
|
||||
import { Match, Switch, createMemo, createSignal, onMount } from 'solid-js'
|
||||
|
||||
import { dictionary, type Locale } from './i18n'
|
||||
import { fetchMiniAppSession } from './miniapp-api'
|
||||
import { getTelegramWebApp } from './telegram-webapp'
|
||||
|
||||
type SessionState =
|
||||
| {
|
||||
status: 'loading'
|
||||
}
|
||||
| {
|
||||
status: 'blocked'
|
||||
reason: 'not_member' | 'telegram_only' | 'error'
|
||||
}
|
||||
| {
|
||||
status: 'ready'
|
||||
mode: 'live' | 'demo'
|
||||
member: {
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
telegramUser: {
|
||||
firstName: string | null
|
||||
username: string | null
|
||||
languageCode: string | null
|
||||
}
|
||||
}
|
||||
|
||||
type NavigationKey = 'home' | 'balances' | 'ledger' | 'house'
|
||||
|
||||
const demoSession: Extract<SessionState, { status: 'ready' }> = {
|
||||
status: 'ready',
|
||||
mode: 'demo',
|
||||
member: {
|
||||
displayName: 'Demo Resident',
|
||||
isAdmin: false
|
||||
},
|
||||
telegramUser: {
|
||||
firstName: 'Demo',
|
||||
username: 'demo_user',
|
||||
languageCode: 'en'
|
||||
}
|
||||
}
|
||||
|
||||
function detectLocale(): Locale {
|
||||
const telegramLocale = getTelegramWebApp()?.initDataUnsafe?.user?.language_code
|
||||
const browserLocale = navigator.language.toLowerCase()
|
||||
|
||||
return (telegramLocale ?? browserLocale).startsWith('ru') ? 'ru' : 'en'
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [locale, setLocale] = createSignal<Locale>('en')
|
||||
const [session, setSession] = createSignal<SessionState>({
|
||||
status: 'loading'
|
||||
})
|
||||
const [activeNav, setActiveNav] = createSignal<NavigationKey>('home')
|
||||
|
||||
const copy = createMemo(() => dictionary[locale()])
|
||||
const blockedSession = createMemo(() => {
|
||||
const current = session()
|
||||
return current.status === 'blocked' ? current : null
|
||||
})
|
||||
const readySession = createMemo(() => {
|
||||
const current = session()
|
||||
return current.status === 'ready' ? current : null
|
||||
})
|
||||
const webApp = getTelegramWebApp()
|
||||
|
||||
onMount(async () => {
|
||||
setLocale(detectLocale())
|
||||
|
||||
webApp?.ready?.()
|
||||
webApp?.expand?.()
|
||||
|
||||
const initData = webApp?.initData?.trim()
|
||||
if (!initData) {
|
||||
if (import.meta.env.DEV) {
|
||||
setSession(demoSession)
|
||||
return
|
||||
}
|
||||
|
||||
setSession({
|
||||
status: 'blocked',
|
||||
reason: 'telegram_only'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await fetchMiniAppSession(initData)
|
||||
if (!payload.authorized || !payload.member || !payload.telegramUser) {
|
||||
setSession({
|
||||
status: 'blocked',
|
||||
reason: payload.reason === 'not_member' ? 'not_member' : 'error'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setSession({
|
||||
status: 'ready',
|
||||
mode: 'live',
|
||||
member: payload.member,
|
||||
telegramUser: payload.telegramUser
|
||||
})
|
||||
} catch {
|
||||
if (import.meta.env.DEV) {
|
||||
setSession(demoSession)
|
||||
return
|
||||
}
|
||||
|
||||
setSession({
|
||||
status: 'blocked',
|
||||
reason: 'error'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const renderPanel = () => {
|
||||
switch (activeNav()) {
|
||||
case 'balances':
|
||||
return copy().balancesEmpty
|
||||
case 'ledger':
|
||||
return copy().ledgerEmpty
|
||||
case 'house':
|
||||
return copy().houseEmpty
|
||||
default:
|
||||
return copy().summaryBody
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Household Mini App</h1>
|
||||
<p>SolidJS scaffold is ready</p>
|
||||
<main class="shell">
|
||||
<div class="shell__backdrop shell__backdrop--top" />
|
||||
<div class="shell__backdrop shell__backdrop--bottom" />
|
||||
|
||||
<section class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">{copy().appSubtitle}</p>
|
||||
<h1>{copy().appTitle}</h1>
|
||||
</div>
|
||||
|
||||
<label class="locale-switch">
|
||||
<span>{copy().language}</span>
|
||||
<div class="locale-switch__buttons">
|
||||
<button
|
||||
classList={{ 'is-active': locale() === 'en' }}
|
||||
type="button"
|
||||
onClick={() => setLocale('en')}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<button
|
||||
classList={{ 'is-active': locale() === 'ru' }}
|
||||
type="button"
|
||||
onClick={() => setLocale('ru')}
|
||||
>
|
||||
RU
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<Switch>
|
||||
<Match when={session().status === 'loading'}>
|
||||
<section class="hero-card">
|
||||
<span class="pill">{copy().navHint}</span>
|
||||
<h2>{copy().loadingTitle}</h2>
|
||||
<p>{copy().loadingBody}</p>
|
||||
</section>
|
||||
</Match>
|
||||
|
||||
<Match when={session().status === 'blocked'}>
|
||||
<section class="hero-card">
|
||||
<span class="pill">{copy().navHint}</span>
|
||||
<h2>
|
||||
{blockedSession()?.reason === 'telegram_only'
|
||||
? copy().telegramOnlyTitle
|
||||
: copy().unauthorizedTitle}
|
||||
</h2>
|
||||
<p>
|
||||
{blockedSession()?.reason === 'telegram_only'
|
||||
? copy().telegramOnlyBody
|
||||
: copy().unauthorizedBody}
|
||||
</p>
|
||||
<button class="ghost-button" type="button" onClick={() => window.location.reload()}>
|
||||
{copy().reload}
|
||||
</button>
|
||||
</section>
|
||||
</Match>
|
||||
|
||||
<Match when={session().status === 'ready'}>
|
||||
<section class="hero-card">
|
||||
<div class="hero-card__meta">
|
||||
<span class="pill">
|
||||
{readySession()?.mode === 'demo' ? copy().demoBadge : copy().navHint}
|
||||
</span>
|
||||
<span class="pill pill--muted">
|
||||
{readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2>
|
||||
{copy().welcome},{' '}
|
||||
{readySession()?.telegramUser.firstName ?? readySession()?.member.displayName}
|
||||
</h2>
|
||||
<p>{copy().sectionBody}</p>
|
||||
</section>
|
||||
|
||||
<nav class="nav-grid">
|
||||
{(
|
||||
[
|
||||
['home', copy().home],
|
||||
['balances', copy().balances],
|
||||
['ledger', copy().ledger],
|
||||
['house', copy().house]
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
classList={{ 'is-active': activeNav() === key }}
|
||||
type="button"
|
||||
onClick={() => setActiveNav(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section class="content-grid">
|
||||
<article class="panel panel--wide">
|
||||
<p class="eyebrow">{copy().summaryTitle}</p>
|
||||
<h3>{readySession()?.member.displayName}</h3>
|
||||
<p>{renderPanel()}</p>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<p class="eyebrow">{copy().cardAccess}</p>
|
||||
<p>{copy().cardAccessBody}</p>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<p class="eyebrow">{copy().cardLocale}</p>
|
||||
<p>{copy().cardLocaleBody}</p>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<p class="eyebrow">{copy().cardNext}</p>
|
||||
<p>{copy().cardNextBody}</p>
|
||||
</article>
|
||||
</section>
|
||||
</Match>
|
||||
</Switch>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
80
apps/miniapp/src/i18n.ts
Normal file
80
apps/miniapp/src/i18n.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export type Locale = 'en' | 'ru'
|
||||
|
||||
export const dictionary = {
|
||||
en: {
|
||||
appTitle: 'Kojori House',
|
||||
appSubtitle: 'Shared home dashboard',
|
||||
loadingTitle: 'Checking your household access',
|
||||
loadingBody: 'Validating Telegram session and membership…',
|
||||
demoBadge: 'Demo mode',
|
||||
unauthorizedTitle: 'Access is limited to active household members',
|
||||
unauthorizedBody:
|
||||
'Open the mini app from Telegram after the bot admin adds you to the household.',
|
||||
telegramOnlyTitle: 'Open this app from Telegram',
|
||||
telegramOnlyBody:
|
||||
'The real session gate needs Telegram mini app data. Local development falls back to a preview shell.',
|
||||
reload: 'Retry',
|
||||
language: 'Language',
|
||||
home: 'Home',
|
||||
balances: 'Balances',
|
||||
ledger: 'Ledger',
|
||||
house: 'House',
|
||||
navHint: 'Shell v1',
|
||||
welcome: 'Welcome back',
|
||||
adminTag: 'Admin',
|
||||
residentTag: 'Resident',
|
||||
summaryTitle: 'Current shell',
|
||||
summaryBody:
|
||||
'Balances, ledger, and house wiki will land in the next tickets. This shell focuses on verified access, navigation, and mobile layout.',
|
||||
cardAccess: 'Access',
|
||||
cardAccessBody: 'Telegram identity verified and matched to a household member.',
|
||||
cardLocale: 'Locale',
|
||||
cardLocaleBody: 'Switch RU/EN immediately without reloading the shell.',
|
||||
cardNext: 'Next up',
|
||||
cardNextBody: 'Balances, ledger, and house pages will plug into this navigation.',
|
||||
sectionTitle: 'Ready for the next features',
|
||||
sectionBody:
|
||||
'This layout is intentionally narrow and mobile-first so it behaves well inside the Telegram webview.',
|
||||
balancesEmpty: 'Balances will appear here once the dashboard API lands.',
|
||||
ledgerEmpty: 'Ledger entries will appear here after the finance view is connected.',
|
||||
houseEmpty: 'House rules, Wi-Fi info, and practical notes will live here.'
|
||||
},
|
||||
ru: {
|
||||
appTitle: 'Kojori House',
|
||||
appSubtitle: 'Панель общего дома',
|
||||
loadingTitle: 'Проверяем доступ к дому',
|
||||
loadingBody: 'Проверяем Telegram-сессию и членство…',
|
||||
demoBadge: 'Демо режим',
|
||||
unauthorizedTitle: 'Доступ открыт только для активных участников дома',
|
||||
unauthorizedBody:
|
||||
'Открой мини-апп из Telegram после того, как админ бота добавит тебя в household.',
|
||||
telegramOnlyTitle: 'Открой приложение из Telegram',
|
||||
telegramOnlyBody:
|
||||
'Настоящая проверка требует данные Telegram Mini App. Локально показывается демо-оболочка.',
|
||||
reload: 'Повторить',
|
||||
language: 'Язык',
|
||||
home: 'Главная',
|
||||
balances: 'Баланс',
|
||||
ledger: 'Леджер',
|
||||
house: 'Дом',
|
||||
navHint: 'Shell v1',
|
||||
welcome: 'С возвращением',
|
||||
adminTag: 'Админ',
|
||||
residentTag: 'Житель',
|
||||
summaryTitle: 'Текущая оболочка',
|
||||
summaryBody:
|
||||
'Баланс, леджер и вики дома появятся в следующих тикетах. Сейчас приоритет — проверенный доступ, навигация и мобильный layout.',
|
||||
cardAccess: 'Доступ',
|
||||
cardAccessBody: 'Telegram-личность подтверждена и сопоставлена с участником household.',
|
||||
cardLocale: 'Локаль',
|
||||
cardLocaleBody: 'RU/EN переключаются сразу, без перезагрузки.',
|
||||
cardNext: 'Дальше',
|
||||
cardNextBody: 'Баланс, леджер и страницы дома подключатся к этой навигации.',
|
||||
sectionTitle: 'Основа готова для следующих функций',
|
||||
sectionBody:
|
||||
'Этот layout специально сделан узким и mobile-first, чтобы хорошо жить внутри Telegram webview.',
|
||||
balancesEmpty: 'Баланс появится здесь, когда подключим dashboard API.',
|
||||
ledgerEmpty: 'Записи леджера появятся здесь после подключения finance view.',
|
||||
houseEmpty: 'Правила дома, Wi-Fi и полезные инструкции будут здесь.'
|
||||
}
|
||||
} satisfies Record<Locale, Record<string, string>>
|
||||
@@ -1 +1,231 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
:root {
|
||||
color: #f5efe1;
|
||||
background:
|
||||
radial-gradient(circle at top, rgb(225 116 58 / 0.32), transparent 32%),
|
||||
radial-gradient(circle at bottom left, rgb(79 120 149 / 0.26), transparent 28%),
|
||||
linear-gradient(180deg, #121a24 0%, #0b1118 100%);
|
||||
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: #f5efe1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
padding: 24px 18px 32px;
|
||||
}
|
||||
|
||||
.shell__backdrop {
|
||||
position: absolute;
|
||||
border-radius: 999px;
|
||||
filter: blur(12px);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.shell__backdrop--top {
|
||||
top: -120px;
|
||||
right: -60px;
|
||||
width: 260px;
|
||||
height: 260px;
|
||||
background: rgb(237 131 74 / 0.3);
|
||||
}
|
||||
|
||||
.shell__backdrop--bottom {
|
||||
bottom: -140px;
|
||||
left: -80px;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: rgb(87 129 159 / 0.22);
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.hero-card,
|
||||
.nav-grid,
|
||||
.content-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.topbar h1,
|
||||
.hero-card h2,
|
||||
.panel h3 {
|
||||
margin: 0;
|
||||
font-family: 'Space Grotesk', 'IBM Plex Sans', sans-serif;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
font-size: clamp(2rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: #f7b389;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.locale-switch {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 116px;
|
||||
color: #d8d6cf;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.locale-switch__buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.locale-switch__buttons button,
|
||||
.nav-grid button,
|
||||
.ghost-button {
|
||||
border: 1px solid rgb(255 255 255 / 0.12);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
color: inherit;
|
||||
transition:
|
||||
transform 140ms ease,
|
||||
border-color 140ms ease,
|
||||
background 140ms ease;
|
||||
}
|
||||
|
||||
.locale-switch__buttons button {
|
||||
border-radius: 999px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.locale-switch__buttons button.is-active,
|
||||
.nav-grid button.is-active {
|
||||
border-color: rgb(247 179 137 / 0.7);
|
||||
background: rgb(247 179 137 / 0.14);
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.panel {
|
||||
border: 1px solid rgb(255 255 255 / 0.1);
|
||||
background: linear-gradient(180deg, rgb(255 255 255 / 0.06), rgb(255 255 255 / 0.02));
|
||||
backdrop-filter: blur(16px);
|
||||
box-shadow: 0 24px 64px rgb(0 0 0 / 0.22);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
margin-top: 28px;
|
||||
border-radius: 28px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.hero-card__meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero-card h2 {
|
||||
font-size: clamp(1.5rem, 4vw, 2.4rem);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.hero-card p,
|
||||
.panel p {
|
||||
margin: 0;
|
||||
color: #d6d3cc;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
background: rgb(247 179 137 / 0.14);
|
||||
color: #ffd5b7;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pill--muted {
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
color: #e5e2d8;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
margin-top: 18px;
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.nav-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.nav-grid button {
|
||||
border-radius: 18px;
|
||||
padding: 14px 8px;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 24px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel--wide {
|
||||
min-height: 170px;
|
||||
}
|
||||
|
||||
@media (min-width: 760px) {
|
||||
.shell {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 40px;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
grid-template-columns: 1.3fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.panel--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
66
apps/miniapp/src/miniapp-api.ts
Normal file
66
apps/miniapp/src/miniapp-api.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { runtimeBotApiUrl } from './runtime-config'
|
||||
|
||||
export interface MiniAppSession {
|
||||
authorized: boolean
|
||||
member?: {
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
telegramUser?: {
|
||||
firstName: string | null
|
||||
username: string | null
|
||||
languageCode: string | null
|
||||
}
|
||||
reason?: string
|
||||
}
|
||||
|
||||
function apiBaseUrl(): string {
|
||||
const runtimeConfigured = runtimeBotApiUrl()
|
||||
if (runtimeConfigured) {
|
||||
return runtimeConfigured.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
const configured = import.meta.env.VITE_BOT_API_URL?.trim()
|
||||
|
||||
if (configured) {
|
||||
return configured.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
return 'http://localhost:3000'
|
||||
}
|
||||
|
||||
return window.location.origin
|
||||
}
|
||||
|
||||
export async function fetchMiniAppSession(initData: string): Promise<MiniAppSession> {
|
||||
const response = await fetch(`${apiBaseUrl()}/api/miniapp/session`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
initData
|
||||
})
|
||||
})
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean
|
||||
authorized?: boolean
|
||||
member?: MiniAppSession['member']
|
||||
telegramUser?: MiniAppSession['telegramUser']
|
||||
reason?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error)
|
||||
}
|
||||
|
||||
return {
|
||||
authorized: payload.authorized === true,
|
||||
...(payload.member ? { member: payload.member } : {}),
|
||||
...(payload.telegramUser ? { telegramUser: payload.telegramUser } : {}),
|
||||
...(payload.reason ? { reason: payload.reason } : {})
|
||||
}
|
||||
}
|
||||
13
apps/miniapp/src/runtime-config.ts
Normal file
13
apps/miniapp/src/runtime-config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__HOUSEHOLD_CONFIG__?: {
|
||||
botApiUrl?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function runtimeBotApiUrl(): string | undefined {
|
||||
const configured = window.__HOUSEHOLD_CONFIG__?.botApiUrl?.trim()
|
||||
|
||||
return configured && configured.length > 0 ? configured : undefined
|
||||
}
|
||||
27
apps/miniapp/src/telegram-webapp.ts
Normal file
27
apps/miniapp/src/telegram-webapp.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface TelegramWebAppUser {
|
||||
id: number
|
||||
first_name?: string
|
||||
username?: string
|
||||
language_code?: string
|
||||
}
|
||||
|
||||
export interface TelegramWebApp {
|
||||
initData: string
|
||||
initDataUnsafe?: {
|
||||
user?: TelegramWebAppUser
|
||||
}
|
||||
ready?: () => void
|
||||
expand?: () => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Telegram?: {
|
||||
WebApp?: TelegramWebApp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getTelegramWebApp(): TelegramWebApp | undefined {
|
||||
return window.Telegram?.WebApp
|
||||
}
|
||||
9
apps/miniapp/src/vite-env.d.ts
vendored
Normal file
9
apps/miniapp/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_BOT_API_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Reference in New Issue
Block a user