feat(onboarding): add mini app household join flow

This commit is contained in:
2026-03-09 04:16:34 +04:00
parent e63d81cda2
commit 8109163067
22 changed files with 3702 additions and 160 deletions

View File

@@ -1,7 +1,12 @@
import { Match, Switch, createMemo, createSignal, onMount, type JSX } from 'solid-js'
import { dictionary, type Locale } from './i18n'
import { fetchMiniAppDashboard, fetchMiniAppSession, type MiniAppDashboard } from './miniapp-api'
import {
fetchMiniAppDashboard,
fetchMiniAppSession,
joinMiniAppHousehold,
type MiniAppDashboard
} from './miniapp-api'
import { getTelegramWebApp } from './telegram-webapp'
type SessionState =
@@ -10,7 +15,17 @@ type SessionState =
}
| {
status: 'blocked'
reason: 'not_member' | 'telegram_only' | 'error'
reason: 'telegram_only' | 'error'
}
| {
status: 'onboarding'
mode: 'join_required' | 'pending' | 'open_from_group'
householdName?: string
telegramUser: {
firstName: string | null
username: string | null
languageCode: string | null
}
}
| {
status: 'ready'
@@ -49,6 +64,41 @@ function detectLocale(): Locale {
return (telegramLocale ?? browserLocale).startsWith('ru') ? 'ru' : 'en'
}
function joinContext(): {
joinToken?: string
botUsername?: string
} {
if (typeof window === 'undefined') {
return {}
}
const params = new URLSearchParams(window.location.search)
const joinToken = params.get('join')?.trim()
const botUsername = params.get('bot')?.trim()
return {
...(joinToken
? {
joinToken
}
: {}),
...(botUsername
? {
botUsername
}
: {})
}
}
function joinDeepLink(): string | null {
const context = joinContext()
if (!context.botUsername || !context.joinToken) {
return null
}
return `https://t.me/${context.botUsername}?start=join_${encodeURIComponent(context.joinToken)}`
}
function App() {
const [locale, setLocale] = createSignal<Locale>('en')
const [session, setSession] = createSignal<SessionState>({
@@ -56,8 +106,13 @@ function App() {
})
const [activeNav, setActiveNav] = createSignal<NavigationKey>('home')
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null)
const [joining, setJoining] = createSignal(false)
const copy = createMemo(() => dictionary[locale()])
const onboardingSession = createMemo(() => {
const current = session()
return current.status === 'onboarding' ? current : null
})
const blockedSession = createMemo(() => {
const current = session()
return current.status === 'blocked' ? current : null
@@ -68,7 +123,19 @@ function App() {
})
const webApp = getTelegramWebApp()
onMount(async () => {
async function loadDashboard(initData: string) {
try {
setDashboard(await fetchMiniAppDashboard(initData))
} catch (error) {
if (import.meta.env.DEV) {
console.warn('Failed to load mini app dashboard', error)
}
setDashboard(null)
}
}
async function bootstrap() {
setLocale(detectLocale())
webApp?.ready?.()
@@ -89,11 +156,21 @@ function App() {
}
try {
const payload = await fetchMiniAppSession(initData)
const payload = await fetchMiniAppSession(initData, joinContext().joinToken)
if (!payload.authorized || !payload.member || !payload.telegramUser) {
setSession({
status: 'blocked',
reason: payload.reason === 'not_member' ? 'not_member' : 'error'
status: 'onboarding',
mode: payload.onboarding?.status ?? 'open_from_group',
...(payload.onboarding?.householdName
? {
householdName: payload.onboarding.householdName
}
: {}),
telegramUser: payload.telegramUser ?? {
firstName: null,
username: null,
languageCode: null
}
})
return
}
@@ -105,15 +182,7 @@ function App() {
telegramUser: payload.telegramUser
})
try {
setDashboard(await fetchMiniAppDashboard(initData))
} catch (error) {
if (import.meta.env.DEV) {
console.warn('Failed to load mini app dashboard', error)
}
setDashboard(null)
}
await loadDashboard(initData)
} catch {
if (import.meta.env.DEV) {
setSession(demoSession)
@@ -168,8 +237,59 @@ function App() {
reason: 'error'
})
}
}
onMount(() => {
void bootstrap()
})
async function handleJoinHousehold() {
const initData = webApp?.initData?.trim()
const joinToken = joinContext().joinToken
if (!initData || !joinToken || joining()) {
return
}
setJoining(true)
try {
const payload = await joinMiniAppHousehold(initData, joinToken)
if (payload.authorized && payload.member && payload.telegramUser) {
setSession({
status: 'ready',
mode: 'live',
member: payload.member,
telegramUser: payload.telegramUser
})
await loadDashboard(initData)
return
}
setSession({
status: 'onboarding',
mode: payload.onboarding?.status ?? 'pending',
...(payload.onboarding?.householdName
? {
householdName: payload.onboarding.householdName
}
: {}),
telegramUser: payload.telegramUser ?? {
firstName: null,
username: null,
languageCode: null
}
})
} catch {
setSession({
status: 'blocked',
reason: 'error'
})
} finally {
setJoining(false)
}
}
const renderPanel = () => {
switch (activeNav()) {
case 'balances':
@@ -291,12 +411,12 @@ function App() {
<h2>
{blockedSession()?.reason === 'telegram_only'
? copy().telegramOnlyTitle
: copy().unauthorizedTitle}
: copy().unexpectedErrorTitle}
</h2>
<p>
{blockedSession()?.reason === 'telegram_only'
? copy().telegramOnlyBody
: copy().unauthorizedBody}
: copy().unexpectedErrorBody}
</p>
<button class="ghost-button" type="button" onClick={() => window.location.reload()}>
{copy().reload}
@@ -304,6 +424,57 @@ function App() {
</section>
</Match>
<Match when={session().status === 'onboarding'}>
<section class="hero-card">
<span class="pill">{copy().navHint}</span>
<h2>
{onboardingSession()?.mode === 'pending'
? copy().pendingTitle
: onboardingSession()?.mode === 'open_from_group'
? copy().openFromGroupTitle
: copy().joinTitle}
</h2>
<p>
{onboardingSession()?.mode === 'pending'
? copy().pendingBody.replace(
'{household}',
onboardingSession()?.householdName ?? copy().householdFallback
)
: onboardingSession()?.mode === 'open_from_group'
? copy().openFromGroupBody
: copy().joinBody.replace(
'{household}',
onboardingSession()?.householdName ?? copy().householdFallback
)}
</p>
<div class="nav-grid">
{onboardingSession()?.mode === 'join_required' ? (
<button
class="ghost-button"
type="button"
disabled={joining()}
onClick={handleJoinHousehold}
>
{joining() ? copy().joining : copy().joinAction}
</button>
) : null}
{joinDeepLink() ? (
<a
class="ghost-button"
href={joinDeepLink() ?? '#'}
target="_blank"
rel="noreferrer"
>
{copy().botLinkAction}
</a>
) : null}
<button class="ghost-button" type="button" onClick={() => window.location.reload()}>
{copy().reload}
</button>
</div>
</section>
</Match>
<Match when={session().status === 'ready'}>
<section class="hero-card">
<div class="hero-card__meta">

View File

@@ -7,9 +7,21 @@ export const dictionary = {
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.',
joinTitle: 'Welcome to your household',
joinBody:
'You are not a member of {household} yet. Send a join request and wait for admin approval.',
pendingTitle: 'Join request sent',
pendingBody: 'Your request for {household} is pending admin approval.',
openFromGroupTitle: 'Open this from your household group',
openFromGroupBody:
'Use the join button from the household group setup message so the app knows which household you want to join.',
unexpectedErrorTitle: 'Unable to load the household app',
unexpectedErrorBody:
'Retry in Telegram. If this keeps failing, ask the household admin to resend the join button.',
householdFallback: 'this household',
joinAction: 'Join household',
joining: 'Sending request…',
botLinkAction: 'Open bot chat',
telegramOnlyTitle: 'Open this app from Telegram',
telegramOnlyBody:
'The real session gate needs Telegram mini app data. Local development falls back to a preview shell.',
@@ -51,9 +63,21 @@ export const dictionary = {
loadingTitle: 'Проверяем доступ к дому',
loadingBody: 'Проверяем Telegram-сессию и членство…',
demoBadge: 'Демо режим',
unauthorizedTitle: 'Доступ открыт только для активных участников дома',
unauthorizedBody:
'Открой мини-апп из Telegram после того, как админ бота добавит тебя в household.',
joinTitle: 'Добро пожаловать домой',
joinBody:
'Ты пока не участник {household}. Отправь заявку на вступление и дождись подтверждения админа.',
pendingTitle: 'Заявка отправлена',
pendingBody: 'Твоя заявка в {household} ждёт подтверждения админа.',
openFromGroupTitle: 'Открой приложение из группового чата',
openFromGroupBody:
'Используй кнопку подключения из сообщения настройки household, чтобы приложение поняло, к какому дому ты хочешь присоединиться.',
unexpectedErrorTitle: 'Не удалось открыть household app',
unexpectedErrorBody:
'Попробуй снова из Telegram. Если ошибка повторяется, попроси админа ещё раз прислать кнопку подключения.',
householdFallback: 'этот household',
joinAction: 'Вступить в household',
joining: 'Отправляем заявку…',
botLinkAction: 'Открыть чат с ботом',
telegramOnlyTitle: 'Открой приложение из Telegram',
telegramOnlyBody:
'Настоящая проверка требует данные Telegram Mini App. Локально показывается демо-оболочка.',

View File

@@ -11,7 +11,10 @@ export interface MiniAppSession {
username: string | null
languageCode: string | null
}
reason?: string
onboarding?: {
status: 'join_required' | 'pending' | 'open_from_group'
householdName?: string
}
}
export interface MiniAppDashboard {
@@ -56,14 +59,22 @@ function apiBaseUrl(): string {
return window.location.origin
}
export async function fetchMiniAppSession(initData: string): Promise<MiniAppSession> {
export async function fetchMiniAppSession(
initData: string,
joinToken?: string
): Promise<MiniAppSession> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/session`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
initData
initData,
...(joinToken
? {
joinToken
}
: {})
})
})
@@ -72,7 +83,7 @@ export async function fetchMiniAppSession(initData: string): Promise<MiniAppSess
authorized?: boolean
member?: MiniAppSession['member']
telegramUser?: MiniAppSession['telegramUser']
reason?: string
onboarding?: MiniAppSession['onboarding']
error?: string
}
@@ -84,7 +95,43 @@ export async function fetchMiniAppSession(initData: string): Promise<MiniAppSess
authorized: payload.authorized === true,
...(payload.member ? { member: payload.member } : {}),
...(payload.telegramUser ? { telegramUser: payload.telegramUser } : {}),
...(payload.reason ? { reason: payload.reason } : {})
...(payload.onboarding ? { onboarding: payload.onboarding } : {})
}
}
export async function joinMiniAppHousehold(
initData: string,
joinToken: string
): Promise<MiniAppSession> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/join`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
initData,
joinToken
})
})
const payload = (await response.json()) as {
ok: boolean
authorized?: boolean
member?: MiniAppSession['member']
telegramUser?: MiniAppSession['telegramUser']
onboarding?: MiniAppSession['onboarding']
error?: string
}
if (!response.ok) {
throw new Error(payload.error ?? 'Failed to join household')
}
return {
authorized: payload.authorized === true,
...(payload.member ? { member: payload.member } : {}),
...(payload.telegramUser ? { telegramUser: payload.telegramUser } : {}),
...(payload.onboarding ? { onboarding: payload.onboarding } : {})
}
}