mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 19:14:03 +00:00
641 lines
18 KiB
TypeScript
641 lines
18 KiB
TypeScript
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<SessionState, { status: 'ready' }> = {
|
|
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<Locale>('en')
|
|
const [session, setSession] = createSignal<SessionState>({
|
|
status: 'loading'
|
|
})
|
|
const [activeNav, setActiveNav] = createSignal<NavigationKey>('home')
|
|
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null)
|
|
const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([])
|
|
const [joining, setJoining] = createSignal(false)
|
|
const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal<string | null>(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 (
|
|
<div class="balance-list">
|
|
<ShowDashboard
|
|
dashboard={dashboard()}
|
|
fallback={<p>{copy().emptyDashboard}</p>}
|
|
render={(data) =>
|
|
data.members.map((member) => (
|
|
<article class="balance-item">
|
|
<header>
|
|
<strong>{member.displayName}</strong>
|
|
<span>
|
|
{member.netDueMajor} {data.currency}
|
|
</span>
|
|
</header>
|
|
<p>
|
|
{copy().shareRent}: {member.rentShareMajor} {data.currency}
|
|
</p>
|
|
<p>
|
|
{copy().shareUtilities}: {member.utilityShareMajor} {data.currency}
|
|
</p>
|
|
<p>
|
|
{copy().shareOffset}: {member.purchaseOffsetMajor} {data.currency}
|
|
</p>
|
|
</article>
|
|
))
|
|
}
|
|
/>
|
|
</div>
|
|
)
|
|
case 'ledger':
|
|
return (
|
|
<div class="ledger-list">
|
|
<ShowDashboard
|
|
dashboard={dashboard()}
|
|
fallback={<p>{copy().emptyDashboard}</p>}
|
|
render={(data) =>
|
|
data.ledger.map((entry) => (
|
|
<article class="ledger-item">
|
|
<header>
|
|
<strong>{entry.title}</strong>
|
|
<span>
|
|
{entry.amountMajor} {data.currency}
|
|
</span>
|
|
</header>
|
|
<p>{entry.actorDisplayName ?? 'Household'}</p>
|
|
</article>
|
|
))
|
|
}
|
|
/>
|
|
</div>
|
|
)
|
|
case 'house':
|
|
return readySession()?.member.isAdmin ? (
|
|
<div class="balance-list">
|
|
<article class="balance-item">
|
|
<header>
|
|
<strong>{copy().pendingMembersTitle}</strong>
|
|
</header>
|
|
<p>{copy().pendingMembersBody}</p>
|
|
</article>
|
|
{pendingMembers().length === 0 ? (
|
|
<article class="balance-item">
|
|
<p>{copy().pendingMembersEmpty}</p>
|
|
</article>
|
|
) : (
|
|
pendingMembers().map((member) => (
|
|
<article class="balance-item">
|
|
<header>
|
|
<strong>{member.displayName}</strong>
|
|
<span>{member.telegramUserId}</span>
|
|
</header>
|
|
<p>
|
|
{member.username
|
|
? copy().pendingMemberHandle.replace('{username}', member.username)
|
|
: (member.languageCode ?? 'Telegram')}
|
|
</p>
|
|
<button
|
|
class="ghost-button"
|
|
type="button"
|
|
disabled={approvingTelegramUserId() === member.telegramUserId}
|
|
onClick={() => void handleApprovePendingMember(member.telegramUserId)}
|
|
>
|
|
{approvingTelegramUserId() === member.telegramUserId
|
|
? copy().approvingMember
|
|
: copy().approveMemberAction}
|
|
</button>
|
|
</article>
|
|
))
|
|
)}
|
|
</div>
|
|
) : (
|
|
copy().houseEmpty
|
|
)
|
|
default:
|
|
return (
|
|
<ShowDashboard
|
|
dashboard={dashboard()}
|
|
fallback={<p>{copy().summaryBody}</p>}
|
|
render={(data) => (
|
|
<>
|
|
<p>
|
|
{copy().totalDue}: {data.totalDueMajor} {data.currency}
|
|
</p>
|
|
<p>{copy().summaryBody}</p>
|
|
</>
|
|
)}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main class="shell">
|
|
<div class="shell__backdrop shell__backdrop--top" />
|
|
<div class="shell__backdrop shell__backdrop--bottom" />
|
|
|
|
<section class="topbar">
|
|
<div>
|
|
<p class="eyebrow">{copy().appSubtitle}</p>
|
|
<h1>{copy().appTitle}</h1>
|
|
</div>
|
|
|
|
<label class="locale-switch">
|
|
<span>{copy().language}</span>
|
|
<div class="locale-switch__buttons">
|
|
<button
|
|
classList={{ 'is-active': locale() === 'en' }}
|
|
type="button"
|
|
onClick={() => setLocale('en')}
|
|
>
|
|
EN
|
|
</button>
|
|
<button
|
|
classList={{ 'is-active': locale() === 'ru' }}
|
|
type="button"
|
|
onClick={() => setLocale('ru')}
|
|
>
|
|
RU
|
|
</button>
|
|
</div>
|
|
</label>
|
|
</section>
|
|
|
|
<Switch>
|
|
<Match when={session().status === 'loading'}>
|
|
<section class="hero-card">
|
|
<span class="pill">{copy().navHint}</span>
|
|
<h2>{copy().loadingTitle}</h2>
|
|
<p>{copy().loadingBody}</p>
|
|
</section>
|
|
</Match>
|
|
|
|
<Match when={session().status === 'blocked'}>
|
|
<section class="hero-card">
|
|
<span class="pill">{copy().navHint}</span>
|
|
<h2>
|
|
{blockedSession()?.reason === 'telegram_only'
|
|
? copy().telegramOnlyTitle
|
|
: copy().unexpectedErrorTitle}
|
|
</h2>
|
|
<p>
|
|
{blockedSession()?.reason === 'telegram_only'
|
|
? copy().telegramOnlyBody
|
|
: copy().unexpectedErrorBody}
|
|
</p>
|
|
<button class="ghost-button" type="button" onClick={() => window.location.reload()}>
|
|
{copy().reload}
|
|
</button>
|
|
</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">
|
|
<span class="pill">
|
|
{readySession()?.mode === 'demo' ? copy().demoBadge : copy().navHint}
|
|
</span>
|
|
<span class="pill pill--muted">
|
|
{readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag}
|
|
</span>
|
|
</div>
|
|
|
|
<h2>
|
|
{copy().welcome},{' '}
|
|
{readySession()?.telegramUser.firstName ?? readySession()?.member.displayName}
|
|
</h2>
|
|
<p>{copy().sectionBody}</p>
|
|
</section>
|
|
|
|
<nav class="nav-grid">
|
|
{(
|
|
[
|
|
['home', copy().home],
|
|
['balances', copy().balances],
|
|
['ledger', copy().ledger],
|
|
['house', copy().house]
|
|
] as const
|
|
).map(([key, label]) => (
|
|
<button
|
|
classList={{ 'is-active': activeNav() === key }}
|
|
type="button"
|
|
onClick={() => setActiveNav(key)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
<section class="content-grid">
|
|
<article class="panel panel--wide">
|
|
<p class="eyebrow">{copy().summaryTitle}</p>
|
|
<h3>{readySession()?.member.displayName}</h3>
|
|
<div>{renderPanel()}</div>
|
|
</article>
|
|
|
|
<article class="panel">
|
|
<p class="eyebrow">{copy().cardAccess}</p>
|
|
<p>{copy().cardAccessBody}</p>
|
|
</article>
|
|
|
|
<article class="panel">
|
|
<p class="eyebrow">{copy().cardLocale}</p>
|
|
<p>{copy().cardLocaleBody}</p>
|
|
</article>
|
|
|
|
<article class="panel">
|
|
<p class="eyebrow">{copy().cardNext}</p>
|
|
<p>{copy().cardNextBody}</p>
|
|
</article>
|
|
</section>
|
|
</Match>
|
|
</Switch>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
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
|