import { Match, Switch, createMemo, createSignal, onMount, type JSX } from 'solid-js' import { dictionary, type Locale } from './i18n' import { approveMiniAppPendingMember, fetchMiniAppDashboard, fetchMiniAppPendingMembers, fetchMiniAppSession, joinMiniAppHousehold, type MiniAppDashboard, type MiniAppPendingMember } from './miniapp-api' import { getTelegramWebApp } from './telegram-webapp' type SessionState = | { status: 'loading' } | { status: 'blocked' 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' 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 = { 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 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('en') const [session, setSession] = createSignal({ status: 'loading' }) const [activeNav, setActiveNav] = createSignal('home') const [dashboard, setDashboard] = createSignal(null) const [pendingMembers, setPendingMembers] = createSignal([]) const [joining, setJoining] = createSignal(false) const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal(null) 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 }) const readySession = createMemo(() => { const current = session() return current.status === 'ready' ? current : null }) const webApp = getTelegramWebApp() 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 loadPendingMembers(initData: string) { try { setPendingMembers(await fetchMiniAppPendingMembers(initData)) } catch (error) { if (import.meta.env.DEV) { console.warn('Failed to load pending mini app members', error) } setPendingMembers([]) } } async function bootstrap() { 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, joinContext().joinToken) if (!payload.authorized || !payload.member || !payload.telegramUser) { setSession({ 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 } setSession({ status: 'ready', mode: 'live', member: payload.member, telegramUser: payload.telegramUser }) await loadDashboard(initData) if (payload.member.isAdmin) { await loadPendingMembers(initData) } } catch { if (import.meta.env.DEV) { setSession(demoSession) setDashboard({ period: '2026-03', currency: 'USD', totalDueMajor: '820.00', members: [ { memberId: 'alice', displayName: 'Alice', rentShareMajor: '350.00', utilityShareMajor: '60.00', purchaseOffsetMajor: '-15.00', netDueMajor: '395.00', explanations: ['Equal utility split', 'Shared purchase offset'] }, { memberId: 'bob', displayName: 'Bob', rentShareMajor: '350.00', utilityShareMajor: '60.00', purchaseOffsetMajor: '15.00', netDueMajor: '425.00', explanations: ['Equal utility split'] } ], ledger: [ { id: 'purchase-1', kind: 'purchase', title: 'Soap', amountMajor: '30.00', actorDisplayName: 'Alice', occurredAt: '2026-03-12T11:00:00.000Z' }, { id: 'utility-1', kind: 'utility', title: 'Electricity', amountMajor: '120.00', actorDisplayName: 'Alice', occurredAt: '2026-03-12T12:00:00.000Z' } ] }) setPendingMembers([ { telegramUserId: '555777', displayName: 'Mia', username: 'mia', languageCode: 'ru' } ]) return } setSession({ status: 'blocked', 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) if (payload.member.isAdmin) { await loadPendingMembers(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) } } async function handleApprovePendingMember(pendingTelegramUserId: string) { const initData = webApp?.initData?.trim() if (!initData || approvingTelegramUserId()) { return } setApprovingTelegramUserId(pendingTelegramUserId) try { await approveMiniAppPendingMember(initData, pendingTelegramUserId) setPendingMembers((current) => current.filter((member) => member.telegramUserId !== pendingTelegramUserId) ) } finally { setApprovingTelegramUserId(null) } } const renderPanel = () => { switch (activeNav()) { case 'balances': return (
{copy().emptyDashboard}

} render={(data) => data.members.map((member) => (
{member.displayName} {member.netDueMajor} {data.currency}

{copy().shareRent}: {member.rentShareMajor} {data.currency}

{copy().shareUtilities}: {member.utilityShareMajor} {data.currency}

{copy().shareOffset}: {member.purchaseOffsetMajor} {data.currency}

)) } />
) case 'ledger': return (
{copy().emptyDashboard}

} render={(data) => data.ledger.map((entry) => (
{entry.title} {entry.amountMajor} {data.currency}

{entry.actorDisplayName ?? 'Household'}

)) } />
) case 'house': return readySession()?.member.isAdmin ? (
{copy().pendingMembersTitle}

{copy().pendingMembersBody}

{pendingMembers().length === 0 ? (

{copy().pendingMembersEmpty}

) : ( pendingMembers().map((member) => (
{member.displayName} {member.telegramUserId}

{member.username ? copy().pendingMemberHandle.replace('{username}', member.username) : (member.languageCode ?? 'Telegram')}

)) )}
) : ( copy().houseEmpty ) default: return ( {copy().summaryBody}

} render={(data) => ( <>

{copy().totalDue}: {data.totalDueMajor} {data.currency}

{copy().summaryBody}

)} /> ) } } return (

{copy().appSubtitle}

{copy().appTitle}

{copy().navHint}

{copy().loadingTitle}

{copy().loadingBody}

{copy().navHint}

{blockedSession()?.reason === 'telegram_only' ? copy().telegramOnlyTitle : copy().unexpectedErrorTitle}

{blockedSession()?.reason === 'telegram_only' ? copy().telegramOnlyBody : copy().unexpectedErrorBody}

{copy().navHint}

{onboardingSession()?.mode === 'pending' ? copy().pendingTitle : onboardingSession()?.mode === 'open_from_group' ? copy().openFromGroupTitle : copy().joinTitle}

{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 )}

{readySession()?.mode === 'demo' ? copy().demoBadge : copy().navHint} {readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag}

{copy().welcome},{' '} {readySession()?.telegramUser.firstName ?? readySession()?.member.displayName}

{copy().sectionBody}

{copy().summaryTitle}

{readySession()?.member.displayName}

{renderPanel()}

{copy().cardAccess}

{copy().cardAccessBody}

{copy().cardLocale}

{copy().cardLocaleBody}

{copy().cardNext}

{copy().cardNextBody}

) } function ShowDashboard(props: { dashboard: MiniAppDashboard | null fallback: JSX.Element render: (dashboard: MiniAppDashboard) => JSX.Element }) { return <>{props.dashboard ? props.render(props.dashboard) : props.fallback} } export default App