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
)}
{onboardingSession()?.mode === 'join_required' ? (
) : null}
{joinDeepLink() ? (
{copy().botLinkAction}
) : null}
{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