mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 22:54:02 +00:00
feat(onboarding): add mini app household join flow
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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. Локально показывается демо-оболочка.',
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user