import { Match, Switch, createMemo, createSignal, onMount, type JSX } from 'solid-js' import { dictionary, type Locale } from './i18n' import { addMiniAppUtilityBill, approveMiniAppPendingMember, closeMiniAppBillingCycle, fetchMiniAppAdminSettings, fetchMiniAppBillingCycle, fetchMiniAppDashboard, fetchMiniAppPendingMembers, fetchMiniAppSession, joinMiniAppHousehold, openMiniAppBillingCycle, promoteMiniAppMember, type MiniAppAdminCycleState, type MiniAppAdminSettingsPayload, updateMiniAppLocalePreference, updateMiniAppBillingSettings, updateMiniAppCycleRent, upsertMiniAppUtilityCategory, 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 preferredLocale: Locale | null householdDefaultLocale: Locale } 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, preferredLocale: 'en', householdDefaultLocale: 'en' }, 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 dashboardMemberCount(dashboard: MiniAppDashboard | null): string { return dashboard ? String(dashboard.members.length) : '—' } function dashboardLedgerCount(dashboard: MiniAppDashboard | null): string { return dashboard ? String(dashboard.ledger.length) : '—' } function defaultCyclePeriod(): string { return new Date().toISOString().slice(0, 7) } 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 [adminSettings, setAdminSettings] = createSignal(null) const [cycleState, setCycleState] = createSignal(null) const [joining, setJoining] = createSignal(false) const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal(null) const [promotingMemberId, setPromotingMemberId] = createSignal(null) const [savingMemberLocale, setSavingMemberLocale] = createSignal(false) const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false) const [savingBillingSettings, setSavingBillingSettings] = createSignal(false) const [savingCategorySlug, setSavingCategorySlug] = createSignal(null) const [openingCycle, setOpeningCycle] = createSignal(false) const [closingCycle, setClosingCycle] = createSignal(false) const [savingCycleRent, setSavingCycleRent] = createSignal(false) const [savingUtilityBill, setSavingUtilityBill] = createSignal(false) const [billingForm, setBillingForm] = createSignal({ rentAmountMajor: '', rentCurrency: 'USD' as 'USD' | 'GEL', rentDueDay: 20, rentWarningDay: 17, utilitiesDueDay: 4, utilitiesReminderDay: 3, timezone: 'Asia/Tbilisi' }) const [newCategoryName, setNewCategoryName] = createSignal('') const [cycleForm, setCycleForm] = createSignal({ period: defaultCyclePeriod(), currency: 'USD' as 'USD' | 'GEL', rentAmountMajor: '', utilityCategorySlug: '', utilityAmountMajor: '' }) 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 loadAdminSettings(initData: string) { try { const payload = await fetchMiniAppAdminSettings(initData) setAdminSettings(payload) setCycleForm((current) => ({ ...current, utilityCategorySlug: current.utilityCategorySlug || payload.categories.find((category) => category.isActive)?.slug || '' })) setBillingForm({ rentAmountMajor: payload.settings.rentAmountMinor ? (Number(payload.settings.rentAmountMinor) / 100).toFixed(2) : '', rentCurrency: payload.settings.rentCurrency, rentDueDay: payload.settings.rentDueDay, rentWarningDay: payload.settings.rentWarningDay, utilitiesDueDay: payload.settings.utilitiesDueDay, utilitiesReminderDay: payload.settings.utilitiesReminderDay, timezone: payload.settings.timezone }) } catch (error) { if (import.meta.env.DEV) { console.warn('Failed to load mini app admin settings', error) } setAdminSettings(null) } } async function loadCycleState(initData: string) { try { const payload = await fetchMiniAppBillingCycle(initData) setCycleState(payload) setCycleForm((current) => ({ ...current, period: payload.cycle?.period ?? current.period, currency: payload.cycle?.currency ?? payload.rentRule?.currency ?? current.currency, rentAmountMajor: payload.rentRule ? (Number(payload.rentRule.amountMinor) / 100).toFixed(2) : '', utilityCategorySlug: current.utilityCategorySlug || adminSettings()?.categories.find((category) => category.isActive)?.slug || '', utilityAmountMajor: current.utilityAmountMajor })) } catch (error) { if (import.meta.env.DEV) { console.warn('Failed to load mini app billing cycle', error) } setCycleState(null) } } async function bootstrap() { const fallbackLocale = detectLocale() setLocale(fallbackLocale) 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) { setLocale( payload.onboarding?.householdDefaultLocale ?? ((payload.telegramUser?.languageCode ?? fallbackLocale).startsWith('ru') ? 'ru' : 'en') ) 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 } setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale) setSession({ status: 'ready', mode: 'live', member: payload.member, telegramUser: payload.telegramUser }) await loadDashboard(initData) if (payload.member.isAdmin) { await loadPendingMembers(initData) await loadAdminSettings(initData) await loadCycleState(initData) } else { setAdminSettings(null) setCycleState(null) } } 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) { setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale) setSession({ status: 'ready', mode: 'live', member: payload.member, telegramUser: payload.telegramUser }) await loadDashboard(initData) if (payload.member.isAdmin) { await loadPendingMembers(initData) await loadAdminSettings(initData) await loadCycleState(initData) } else { setAdminSettings(null) setCycleState(null) } return } setLocale( payload.onboarding?.householdDefaultLocale ?? ((payload.telegramUser?.languageCode ?? locale()).startsWith('ru') ? 'ru' : 'en') ) 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) } } async function handleMemberLocaleChange(nextLocale: Locale) { const initData = webApp?.initData?.trim() const currentReady = readySession() setLocale(nextLocale) if (!initData || currentReady?.mode !== 'live') { return } setSavingMemberLocale(true) try { const updated = await updateMiniAppLocalePreference(initData, nextLocale, 'member') setSession((current) => current.status === 'ready' ? { ...current, member: { ...current.member, preferredLocale: updated.memberPreferredLocale, householdDefaultLocale: updated.householdDefaultLocale } } : current ) setLocale(updated.effectiveLocale) } finally { setSavingMemberLocale(false) } } async function handleHouseholdLocaleChange(nextLocale: Locale) { const initData = webApp?.initData?.trim() const currentReady = readySession() if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) { return } setSavingHouseholdLocale(true) try { const updated = await updateMiniAppLocalePreference(initData, nextLocale, 'household') setSession((current) => current.status === 'ready' ? { ...current, member: { ...current.member, householdDefaultLocale: updated.householdDefaultLocale } } : current ) if (!currentReady.member.preferredLocale) { setLocale(updated.effectiveLocale) } } finally { setSavingHouseholdLocale(false) } } async function handleSaveBillingSettings() { const initData = webApp?.initData?.trim() const currentReady = readySession() if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) { return } setSavingBillingSettings(true) try { const settings = await updateMiniAppBillingSettings(initData, billingForm()) setAdminSettings((current) => current ? { ...current, settings } : current ) } finally { setSavingBillingSettings(false) } } async function handleOpenCycle() { const initData = webApp?.initData?.trim() const currentReady = readySession() if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) { return } setOpeningCycle(true) try { const state = await openMiniAppBillingCycle(initData, { period: cycleForm().period, currency: cycleForm().currency }) setCycleState(state) setCycleForm((current) => ({ ...current, period: state.cycle?.period ?? current.period, currency: state.cycle?.currency ?? current.currency })) } finally { setOpeningCycle(false) } } async function handleCloseCycle() { const initData = webApp?.initData?.trim() const currentReady = readySession() if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) { return } setClosingCycle(true) try { const state = await closeMiniAppBillingCycle(initData, cycleState()?.cycle?.period) setCycleState(state) } finally { setClosingCycle(false) } } async function handleSaveCycleRent() { const initData = webApp?.initData?.trim() const currentReady = readySession() if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) { return } setSavingCycleRent(true) try { const state = await updateMiniAppCycleRent(initData, { amountMajor: cycleForm().rentAmountMajor, currency: cycleForm().currency, ...(cycleState()?.cycle?.period ? { period: cycleState()!.cycle!.period } : {}) }) setCycleState(state) } finally { setSavingCycleRent(false) } } async function handleAddUtilityBill() { const initData = webApp?.initData?.trim() const currentReady = readySession() if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) { return } const selectedCategory = adminSettings()?.categories.find( (category) => category.slug === cycleForm().utilityCategorySlug ) ?? adminSettings()?.categories.find((category) => category.isActive) if (!selectedCategory || cycleForm().utilityAmountMajor.trim().length === 0) { return } setSavingUtilityBill(true) try { const state = await addMiniAppUtilityBill(initData, { billName: selectedCategory.name, amountMajor: cycleForm().utilityAmountMajor, currency: cycleForm().currency }) setCycleState(state) setCycleForm((current) => ({ ...current, utilityAmountMajor: '' })) } finally { setSavingUtilityBill(false) } } async function handleSaveUtilityCategory(input: { slug?: string name: string sortOrder: number isActive: boolean }) { const initData = webApp?.initData?.trim() const currentReady = readySession() if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) { return } setSavingCategorySlug(input.slug ?? '__new__') try { const category = await upsertMiniAppUtilityCategory(initData, input) setAdminSettings((current) => { if (!current) { return current } const categories = current.categories.some((item) => item.slug === category.slug) ? current.categories.map((item) => (item.slug === category.slug ? category : item)) : [...current.categories, category] return { ...current, categories: [...categories].sort((left, right) => left.sortOrder - right.sortOrder) } }) if (!input.slug) { setNewCategoryName('') } } finally { setSavingCategorySlug(null) } } async function handlePromoteMember(memberId: string) { const initData = webApp?.initData?.trim() const currentReady = readySession() if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) { return } setPromotingMemberId(memberId) try { const member = await promoteMiniAppMember(initData, memberId) setAdminSettings((current) => current ? { ...current, members: current.members.map((item) => (item.id === member.id ? member : item)) } : current ) } finally { setPromotingMemberId(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().householdSettingsTitle}

{copy().householdSettingsBody}

{copy().billingCycleTitle} {cycleState()?.cycle?.period ?? copy().billingCycleEmpty}
{cycleState()?.cycle ? ( <>

{copy().billingCycleStatus.replace( '{currency}', cycleState()?.cycle?.currency ?? cycleForm().currency )}

{cycleState()?.utilityBills.length ? ( cycleState()?.utilityBills.map((bill) => (
{bill.billName} {(Number(bill.amountMinor) / 100).toFixed(2)} {bill.currency}

{bill.createdAt.slice(0, 10)}

)) ) : (

{copy().utilityBillsEmpty}

)}
) : ( <>

{copy().billingCycleOpenHint}

)}
{copy().billingSettingsTitle}
{copy().householdLanguage} {readySession()?.member.householdDefaultLocale.toUpperCase()}
{copy().utilityCategoriesTitle}

{copy().utilityCategoriesBody}

{adminSettings()?.categories.map((category) => (
{category.name} {category.isActive ? 'ON' : 'OFF'}
))}
{copy().adminsTitle}

{copy().adminsBody}

{adminSettings()?.members.map((member) => (
{member.displayName} {member.isAdmin ? copy().adminTag : copy().residentTag}
{!member.isAdmin ? ( ) : null}
))}
{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().residentHouseTitle}

{copy().residentHouseBody}

) default: return (
{copy().totalDue} {dashboard() ? `${dashboard()!.totalDueMajor} ${dashboard()!.currency}` : '—'}
{copy().membersCount} {dashboardMemberCount(dashboard())}
{copy().ledgerEntries} {dashboardLedgerCount(dashboard())}
{readySession()?.member.isAdmin ? (
{copy().pendingRequests} {String(pendingMembers().length)}
) : null}
{copy().overviewTitle}

{copy().overviewBody}

{copy().latestActivityTitle}
{copy().latestActivityEmpty}

} render={(data) => data.ledger.length === 0 ? (

{copy().latestActivityEmpty}

) : (
{data.ledger.slice(0, 3).map((entry) => (
{entry.title} {entry.amountMajor} {data.currency}

{entry.actorDisplayName ?? 'Household'}

))}
) } />
) } } return (

{copy().appSubtitle}

{copy().appTitle}

{copy().loadingBadge}

{copy().loadingTitle}

{copy().loadingBody}

{copy().loadingBadge}

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

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

{copy().loadingBadge}

{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().liveBadge} {readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag}

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

{copy().overviewBody}

{copy().overviewTitle}

{readySession()?.member.displayName}

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