Files
household-bot/apps/miniapp/src/App.tsx

2518 lines
80 KiB
TypeScript

import { Match, Switch, createMemo, createSignal, onMount } from 'solid-js'
import { dictionary, type Locale } from './i18n'
import {
fetchAdminSettingsQuery,
fetchBillingCycleQuery,
fetchDashboardQuery,
fetchPendingMembersQuery,
fetchSessionQuery,
invalidateHouseholdQueries
} from './app/miniapp-queries'
import {
addMiniAppUtilityBill,
addMiniAppPayment,
approveMiniAppPendingMember,
closeMiniAppBillingCycle,
deleteMiniAppPayment,
deleteMiniAppPurchase,
deleteMiniAppUtilityBill,
joinMiniAppHousehold,
openMiniAppBillingCycle,
promoteMiniAppMember,
updateMiniAppMemberDisplayName,
updateMiniAppMemberAbsencePolicy,
updateMiniAppMemberStatus,
updateMiniAppMemberRentWeight,
updateMiniAppOwnDisplayName,
type MiniAppAdminCycleState,
type MiniAppAdminSettingsPayload,
type MiniAppMemberAbsencePolicy,
updateMiniAppLocalePreference,
updateMiniAppBillingSettings,
updateMiniAppCycleRent,
updateMiniAppPayment,
updateMiniAppPurchase,
upsertMiniAppUtilityCategory,
updateMiniAppUtilityBill,
type MiniAppDashboard,
type MiniAppPendingMember
} from './miniapp-api'
import { Button, Field, Modal } from './components/ui'
import { HeroBanner } from './components/layout/hero-banner'
import { NavigationTabs } from './components/layout/navigation-tabs'
import { ProfileCard } from './components/layout/profile-card'
import { TopBar } from './components/layout/top-bar'
import { BlockedState } from './components/session/blocked-state'
import { LoadingState } from './components/session/loading-state'
import { OnboardingState } from './components/session/onboarding-state'
import { BalancesScreen } from './screens/balances-screen'
import { HomeScreen } from './screens/home-screen'
import { HouseScreen } from './screens/house-screen'
import { LedgerScreen } from './screens/ledger-screen'
import {
demoAdminSettings,
demoCycleState,
demoDashboard,
demoMember,
demoPendingMembers,
demoTelegramUser
} from './demo/miniapp-demo'
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: {
id: string
displayName: string
status: 'active' | 'away' | 'left'
isAdmin: boolean
preferredLocale: Locale | null
householdDefaultLocale: Locale
}
telegramUser: {
firstName: string | null
username: string | null
languageCode: string | null
}
}
type NavigationKey = 'home' | 'balances' | 'ledger' | 'house'
type HouseSectionKey = 'billing' | 'utilities' | 'members' | 'topics'
type UtilityBillDraft = {
billName: string
amountMajor: string
currency: 'USD' | 'GEL'
}
type PurchaseDraft = {
description: string
amountMajor: string
currency: 'USD' | 'GEL'
splitMode: 'equal' | 'custom_amounts'
participants: {
memberId: string
shareAmountMajor: string
}[]
}
type PaymentDraft = {
memberId: string
kind: 'rent' | 'utilities'
amountMajor: string
currency: 'USD' | 'GEL'
}
const chartPalette = ['#f7b389', '#6fd3c0', '#f06a8d', '#94a8ff', '#f3d36f', '#7dc96d'] as const
const demoSession: Extract<SessionState, { status: 'ready' }> = {
status: 'ready',
mode: 'demo',
member: demoMember,
telegramUser: demoTelegramUser
}
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 defaultCyclePeriod(): string {
return new Date().toISOString().slice(0, 7)
}
function majorStringToMinor(value: string): bigint {
const trimmed = value.trim()
const negative = trimmed.startsWith('-')
const normalized = negative ? trimmed.slice(1) : trimmed
const [whole = '0', fraction = ''] = normalized.split('.')
const major = BigInt(whole || '0')
const cents = BigInt((fraction.padEnd(2, '0').slice(0, 2) || '00').replace(/\D/g, '') || '0')
const minor = major * 100n + cents
return negative ? -minor : minor
}
function minorToMajorString(value: bigint): string {
const negative = value < 0n
const absolute = negative ? -value : value
const whole = absolute / 100n
const fraction = String(absolute % 100n).padStart(2, '0')
return `${negative ? '-' : ''}${whole.toString()}.${fraction}`
}
function absoluteMinor(value: bigint): bigint {
return value < 0n ? -value : value
}
function memberBaseDueMajor(member: MiniAppDashboard['members'][number]): string {
return minorToMajorString(
majorStringToMinor(member.rentShareMajor) + majorStringToMinor(member.utilityShareMajor)
)
}
function memberRemainingClass(member: MiniAppDashboard['members'][number]): string {
const remainingMinor = majorStringToMinor(member.remainingMajor)
if (remainingMinor < 0n) {
return 'is-credit'
}
if (remainingMinor === 0n) {
return 'is-settled'
}
return 'is-due'
}
function ledgerPrimaryAmount(entry: MiniAppDashboard['ledger'][number]): string {
return `${entry.displayAmountMajor} ${entry.displayCurrency}`
}
function ledgerSecondaryAmount(entry: MiniAppDashboard['ledger'][number]): string | null {
if (entry.currency === entry.displayCurrency && entry.amountMajor === entry.displayAmountMajor) {
return null
}
return `${entry.amountMajor} ${entry.currency}`
}
function cycleUtilityBillDrafts(
bills: MiniAppAdminCycleState['utilityBills']
): Record<string, UtilityBillDraft> {
return Object.fromEntries(
bills.map((bill) => [
bill.id,
{
billName: bill.billName,
amountMajor: minorToMajorString(BigInt(bill.amountMinor)),
currency: bill.currency
}
])
)
}
function purchaseDrafts(
entries: readonly MiniAppDashboard['ledger'][number][]
): Record<string, PurchaseDraft> {
return Object.fromEntries(
entries
.filter((entry) => entry.kind === 'purchase')
.map((entry) => [
entry.id,
{
description: entry.title,
amountMajor: entry.amountMajor,
currency: entry.currency,
splitMode: entry.purchaseSplitMode ?? 'equal',
participants:
entry.purchaseParticipants
?.filter((participant) => participant.included)
.map((participant) => ({
memberId: participant.memberId,
shareAmountMajor: participant.shareAmountMajor ?? ''
})) ?? []
}
])
)
}
function purchaseDraftForEntry(entry: MiniAppDashboard['ledger'][number]): PurchaseDraft {
return {
description: entry.title,
amountMajor: entry.amountMajor,
currency: entry.currency,
splitMode: entry.purchaseSplitMode ?? 'equal',
participants:
entry.purchaseParticipants
?.filter((participant) => participant.included)
.map((participant) => ({
memberId: participant.memberId,
shareAmountMajor: participant.shareAmountMajor ?? ''
})) ?? []
}
}
function paymentDrafts(
entries: readonly MiniAppDashboard['ledger'][number][]
): Record<string, PaymentDraft> {
return Object.fromEntries(
entries
.filter((entry) => entry.kind === 'payment')
.map((entry) => [
entry.id,
{
memberId: entry.memberId ?? '',
kind: entry.paymentKind ?? 'rent',
amountMajor: entry.amountMajor,
currency: entry.currency
}
])
)
}
function paymentDraftForEntry(entry: MiniAppDashboard['ledger'][number]): PaymentDraft {
return {
memberId: entry.memberId ?? '',
kind: entry.paymentKind ?? 'rent',
amountMajor: entry.amountMajor,
currency: entry.currency
}
}
function App() {
const [locale, setLocale] = createSignal<Locale>('en')
const [session, setSession] = createSignal<SessionState>({
status: 'loading'
})
const [activeNav, setActiveNav] = createSignal<NavigationKey>('home')
const [activeHouseSection, setActiveHouseSection] = createSignal<HouseSectionKey>('billing')
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null)
const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([])
const [adminSettings, setAdminSettings] = createSignal<MiniAppAdminSettingsPayload | null>(null)
const [cycleState, setCycleState] = createSignal<MiniAppAdminCycleState | null>(null)
const [joining, setJoining] = createSignal(false)
const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal<string | null>(null)
const [promotingMemberId, setPromotingMemberId] = createSignal<string | null>(null)
const [savingOwnDisplayName, setSavingOwnDisplayName] = createSignal(false)
const [, setSavingMemberDisplayNameId] = createSignal<string | null>(null)
const [, setSavingRentWeightMemberId] = createSignal<string | null>(null)
const [, setSavingMemberStatusId] = createSignal<string | null>(null)
const [, setSavingMemberAbsencePolicyId] = createSignal<string | null>(null)
const [savingMemberEditorId, setSavingMemberEditorId] = createSignal<string | null>(null)
const [displayNameDraft, setDisplayNameDraft] = createSignal('')
const [memberDisplayNameDrafts, setMemberDisplayNameDrafts] = createSignal<
Record<string, string>
>({})
const [rentWeightDrafts, setRentWeightDrafts] = createSignal<Record<string, string>>({})
const [memberStatusDrafts, setMemberStatusDrafts] = createSignal<
Record<string, 'active' | 'away' | 'left'>
>({})
const [memberAbsencePolicyDrafts, setMemberAbsencePolicyDrafts] = createSignal<
Record<string, MiniAppMemberAbsencePolicy>
>({})
const [savingMemberLocale, setSavingMemberLocale] = createSignal(false)
const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false)
const [savingBillingSettings, setSavingBillingSettings] = createSignal(false)
const [savingCategorySlug, setSavingCategorySlug] = createSignal<string | null>(null)
const [openingCycle, setOpeningCycle] = createSignal(false)
const [closingCycle, setClosingCycle] = createSignal(false)
const [savingCycleRent, setSavingCycleRent] = createSignal(false)
const [savingUtilityBill, setSavingUtilityBill] = createSignal(false)
const [savingUtilityBillId, setSavingUtilityBillId] = createSignal<string | null>(null)
const [deletingUtilityBillId, setDeletingUtilityBillId] = createSignal<string | null>(null)
const [utilityBillDrafts, setUtilityBillDrafts] = createSignal<Record<string, UtilityBillDraft>>(
{}
)
const [purchaseDraftMap, setPurchaseDraftMap] = createSignal<Record<string, PurchaseDraft>>({})
const [paymentDraftMap, setPaymentDraftMap] = createSignal<Record<string, PaymentDraft>>({})
const [savingPurchaseId, setSavingPurchaseId] = createSignal<string | null>(null)
const [deletingPurchaseId, setDeletingPurchaseId] = createSignal<string | null>(null)
const [savingPaymentId, setSavingPaymentId] = createSignal<string | null>(null)
const [deletingPaymentId, setDeletingPaymentId] = createSignal<string | null>(null)
const [editingPurchaseId, setEditingPurchaseId] = createSignal<string | null>(null)
const [editingPaymentId, setEditingPaymentId] = createSignal<string | null>(null)
const [editingUtilityBillId, setEditingUtilityBillId] = createSignal<string | null>(null)
const [editingMemberId, setEditingMemberId] = createSignal<string | null>(null)
const [editingCategorySlug, setEditingCategorySlug] = createSignal<string | null>(null)
const [billingSettingsOpen, setBillingSettingsOpen] = createSignal(false)
const [cycleRentOpen, setCycleRentOpen] = createSignal(false)
const [addingUtilityBillOpen, setAddingUtilityBillOpen] = createSignal(false)
const [addingPaymentOpen, setAddingPaymentOpen] = createSignal(false)
const [profileEditorOpen, setProfileEditorOpen] = createSignal(false)
const [addingPayment, setAddingPayment] = createSignal(false)
const [billingForm, setBillingForm] = createSignal({
settlementCurrency: 'GEL' as 'USD' | 'GEL',
paymentBalanceAdjustmentPolicy: 'utilities' as 'utilities' | 'rent' | 'separate',
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(),
rentCurrency: 'USD' as 'USD' | 'GEL',
utilityCurrency: 'GEL' as 'USD' | 'GEL',
rentAmountMajor: '',
utilityCategorySlug: '',
utilityAmountMajor: ''
})
const [paymentForm, setPaymentForm] = createSignal<PaymentDraft>({
memberId: '',
kind: 'rent',
amountMajor: '',
currency: 'GEL'
})
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 currentMemberLine = createMemo(() => {
const current = readySession()
const data = dashboard()
if (!current || !data) {
return null
}
return data.members.find((member) => member.memberId === current.member.id) ?? null
})
const purchaseLedger = createMemo(() =>
(dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'purchase')
)
const utilityLedger = createMemo(() =>
(dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'utility')
)
const paymentLedger = createMemo(() =>
(dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'payment')
)
const editingPurchaseEntry = createMemo(
() => purchaseLedger().find((entry) => entry.id === editingPurchaseId()) ?? null
)
const editingPaymentEntry = createMemo(
() => paymentLedger().find((entry) => entry.id === editingPaymentId()) ?? null
)
const editingUtilityBill = createMemo(
() => cycleState()?.utilityBills.find((bill) => bill.id === editingUtilityBillId()) ?? null
)
const editingMember = createMemo(
() => adminSettings()?.members.find((member) => member.id === editingMemberId()) ?? null
)
const editingCategory = createMemo(
() =>
adminSettings()?.categories.find((category) => category.slug === editingCategorySlug()) ??
null
)
const utilityTotalMajor = createMemo(() =>
minorToMajorString(
utilityLedger().reduce((sum, entry) => sum + majorStringToMinor(entry.displayAmountMajor), 0n)
)
)
const purchaseTotalMajor = createMemo(() =>
minorToMajorString(
purchaseLedger().reduce(
(sum, entry) => sum + majorStringToMinor(entry.displayAmountMajor),
0n
)
)
)
const memberBalanceVisuals = createMemo(() => {
const data = dashboard()
if (!data) {
return []
}
const totals = data.members.map((member) => {
const rentMinor = absoluteMinor(majorStringToMinor(member.rentShareMajor))
const utilityMinor = absoluteMinor(majorStringToMinor(member.utilityShareMajor))
const purchaseMinor = absoluteMinor(majorStringToMinor(member.purchaseOffsetMajor))
return {
member,
totalMinor: rentMinor + utilityMinor + purchaseMinor,
segments: [
{
key: 'rent',
label: copy().shareRent,
amountMajor: member.rentShareMajor,
amountMinor: rentMinor
},
{
key: 'utilities',
label: copy().shareUtilities,
amountMajor: member.utilityShareMajor,
amountMinor: utilityMinor
},
{
key:
majorStringToMinor(member.purchaseOffsetMajor) < 0n
? 'purchase-credit'
: 'purchase-debit',
label: copy().shareOffset,
amountMajor: member.purchaseOffsetMajor,
amountMinor: purchaseMinor
}
]
}
})
const maxTotalMinor = totals.reduce(
(max, item) => (item.totalMinor > max ? item.totalMinor : max),
0n
)
return totals
.sort((left, right) => {
const leftRemaining = majorStringToMinor(left.member.remainingMajor)
const rightRemaining = majorStringToMinor(right.member.remainingMajor)
if (rightRemaining === leftRemaining) {
return left.member.displayName.localeCompare(right.member.displayName)
}
return rightRemaining > leftRemaining ? 1 : -1
})
.map((item) => ({
...item,
barWidthPercent:
maxTotalMinor > 0n ? (Number(item.totalMinor) / Number(maxTotalMinor)) * 100 : 0,
segments: item.segments.map((segment) => ({
...segment,
widthPercent:
item.totalMinor > 0n ? (Number(segment.amountMinor) / Number(item.totalMinor)) * 100 : 0
}))
}))
})
const purchaseInvestmentChart = createMemo(() => {
const data = dashboard()
if (!data) {
return {
totalMajor: '0.00',
slices: []
}
}
const membersById = new Map(data.members.map((member) => [member.memberId, member.displayName]))
const totals = new Map<string, { label: string; amountMinor: bigint }>()
for (const entry of purchaseLedger()) {
const key = entry.memberId ?? entry.actorDisplayName ?? entry.id
const label =
(entry.memberId ? membersById.get(entry.memberId) : null) ??
entry.actorDisplayName ??
copy().ledgerActorFallback
const current = totals.get(key) ?? {
label,
amountMinor: 0n
}
totals.set(key, {
label,
amountMinor:
current.amountMinor + absoluteMinor(majorStringToMinor(entry.displayAmountMajor))
})
}
const items = [...totals.entries()]
.map(([key, value], index) => ({
key,
label: value.label,
amountMinor: value.amountMinor,
amountMajor: minorToMajorString(value.amountMinor),
color: chartPalette[index % chartPalette.length]!
}))
.filter((item) => item.amountMinor > 0n)
.sort((left, right) => (right.amountMinor > left.amountMinor ? 1 : -1))
const totalMinor = items.reduce((sum, item) => sum + item.amountMinor, 0n)
const circumference = 2 * Math.PI * 42
let offset = 0
return {
totalMajor: minorToMajorString(totalMinor),
slices: items.map((item) => {
const ratio = totalMinor > 0n ? Number(item.amountMinor) / Number(totalMinor) : 0
const dash = ratio * circumference
const slice = {
...item,
percentage: Math.round(ratio * 100),
dasharray: `${dash} ${Math.max(circumference - dash, 0)}`,
dashoffset: `${-offset}`
}
offset += dash
return slice
})
}
})
const webApp = getTelegramWebApp()
function ledgerTitle(entry: MiniAppDashboard['ledger'][number]): string {
if (entry.kind !== 'payment') {
return entry.title
}
return entry.paymentKind === 'utilities'
? copy().paymentLedgerUtilities
: copy().paymentLedgerRent
}
function purchaseParticipantSummary(entry: MiniAppDashboard['ledger'][number]): string {
if (entry.kind !== 'purchase') {
return ''
}
const includedCount =
entry.purchaseParticipants?.filter((participant) => participant.included).length ?? 0
const splitLabel =
entry.purchaseSplitMode === 'custom_amounts'
? copy().purchaseSplitCustom
: copy().purchaseSplitEqual
return `${includedCount} ${copy().participantsLabel} · ${splitLabel}`
}
function paymentMemberName(entry: MiniAppDashboard['ledger'][number]): string {
if (!entry.memberId) {
return entry.actorDisplayName ?? copy().ledgerActorFallback
}
return (
adminSettings()?.members.find((member) => member.id === entry.memberId)?.displayName ??
dashboard()?.members.find((member) => member.memberId === entry.memberId)?.displayName ??
entry.actorDisplayName ??
copy().ledgerActorFallback
)
}
function topicRoleLabel(role: 'purchase' | 'feedback' | 'reminders' | 'payments'): string {
switch (role) {
case 'purchase':
return copy().topicPurchase
case 'feedback':
return copy().topicFeedback
case 'reminders':
return copy().topicReminders
case 'payments':
return copy().topicPayments
}
}
function memberStatusLabel(status: 'active' | 'away' | 'left'): string {
switch (status) {
case 'active':
return copy().memberStatusActive
case 'away':
return copy().memberStatusAway
case 'left':
return copy().memberStatusLeft
}
}
function defaultAbsencePolicyForStatus(
status: 'active' | 'away' | 'left'
): MiniAppMemberAbsencePolicy {
if (status === 'away') {
return 'away_rent_and_utilities'
}
if (status === 'left') {
return 'inactive'
}
return 'resident'
}
function resolvedMemberAbsencePolicy(
memberId: string,
status: 'active' | 'away' | 'left',
settings = adminSettings()
) {
const current = settings?.memberAbsencePolicies
.filter((policy) => policy.memberId === memberId)
.sort((left, right) => left.effectiveFromPeriod.localeCompare(right.effectiveFromPeriod))
.at(-1)
return (
current ?? {
memberId,
effectiveFromPeriod: '',
policy: defaultAbsencePolicyForStatus(status)
}
)
}
function syncDisplayName(memberId: string, displayName: string) {
setSession((current) =>
current.status === 'ready' && current.member.id === memberId
? {
...current,
member: {
...current.member,
displayName
}
}
: current
)
setAdminSettings((current) =>
current
? {
...current,
members: current.members.map((member) =>
member.id === memberId
? {
...member,
displayName
}
: member
)
}
: current
)
setDashboard((current) =>
current
? {
...current,
members: current.members.map((member) =>
member.memberId === memberId
? {
...member,
displayName
}
: member
),
ledger: current.ledger.map((entry) =>
entry.memberId === memberId
? {
...entry,
actorDisplayName: displayName
}
: entry
)
}
: current
)
setDisplayNameDraft((current) =>
readySession()?.member.id === memberId ? displayName : current
)
setMemberDisplayNameDrafts((current) => ({
...current,
[memberId]: displayName
}))
}
function updatePurchaseDraft(
purchaseId: string,
entry: MiniAppDashboard['ledger'][number],
update: (draft: PurchaseDraft) => PurchaseDraft
) {
setPurchaseDraftMap((current) => {
const draft = current[purchaseId] ?? purchaseDraftForEntry(entry)
return {
...current,
[purchaseId]: update(draft)
}
})
}
function updatePaymentDraft(
paymentId: string,
entry: MiniAppDashboard['ledger'][number],
update: (draft: PaymentDraft) => PaymentDraft
) {
setPaymentDraftMap((current) => {
const draft = current[paymentId] ?? paymentDraftForEntry(entry)
return {
...current,
[paymentId]: update(draft)
}
})
}
function togglePurchaseParticipant(
purchaseId: string,
entry: MiniAppDashboard['ledger'][number],
memberId: string,
included: boolean
) {
updatePurchaseDraft(purchaseId, entry, (draft) => ({
...draft,
participants: included
? [
...draft.participants.filter((participant) => participant.memberId !== memberId),
{
memberId,
shareAmountMajor: ''
}
]
: draft.participants.filter((participant) => participant.memberId !== memberId)
}))
}
function updateUtilityBillDraft(
billId: string,
bill: MiniAppAdminCycleState['utilityBills'][number],
update: (draft: UtilityBillDraft) => UtilityBillDraft
) {
setUtilityBillDrafts((current) => {
const draft = current[billId] ?? {
billName: bill.billName,
amountMajor: minorToMajorString(BigInt(bill.amountMinor)),
currency: bill.currency
}
return {
...current,
[billId]: update(draft)
}
})
}
async function loadDashboard(initData: string) {
try {
const nextDashboard = await fetchDashboardQuery(initData)
setDashboard(nextDashboard)
setPurchaseDraftMap(purchaseDrafts(nextDashboard.ledger))
setPaymentDraftMap(paymentDrafts(nextDashboard.ledger))
} catch (error) {
if (import.meta.env.DEV) {
console.warn('Failed to load mini app dashboard', error)
}
setDashboard(null)
setPurchaseDraftMap({})
setPaymentDraftMap({})
}
}
async function loadPendingMembers(initData: string) {
try {
setPendingMembers(await fetchPendingMembersQuery(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 fetchAdminSettingsQuery(initData)
setAdminSettings(payload)
setMemberDisplayNameDrafts(
Object.fromEntries(payload.members.map((member) => [member.id, member.displayName]))
)
setRentWeightDrafts(
Object.fromEntries(
payload.members.map((member) => [member.id, String(member.rentShareWeight)])
)
)
setMemberStatusDrafts(
Object.fromEntries(payload.members.map((member) => [member.id, member.status]))
)
setMemberAbsencePolicyDrafts(
Object.fromEntries(
payload.members.map((member) => [
member.id,
resolvedMemberAbsencePolicy(member.id, member.status, payload).policy
])
)
)
setCycleForm((current) => ({
...current,
rentCurrency: payload.settings.rentCurrency,
utilityCurrency: payload.settings.settlementCurrency,
utilityCategorySlug:
current.utilityCategorySlug ||
payload.categories.find((category) => category.isActive)?.slug ||
''
}))
setBillingForm({
settlementCurrency: payload.settings.settlementCurrency,
paymentBalanceAdjustmentPolicy: payload.settings.paymentBalanceAdjustmentPolicy,
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
})
setPaymentForm((current) => ({
...current,
memberId: current.memberId || payload.members[0]?.id || '',
currency: payload.settings.settlementCurrency
}))
} 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 fetchBillingCycleQuery(initData)
setCycleState(payload)
setUtilityBillDrafts(cycleUtilityBillDrafts(payload.utilityBills))
setCycleForm((current) => ({
...current,
period: payload.cycle?.period ?? current.period,
rentCurrency:
payload.rentRule?.currency ??
adminSettings()?.settings.rentCurrency ??
current.rentCurrency,
utilityCurrency: adminSettings()?.settings.settlementCurrency ?? current.utilityCurrency,
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 refreshHouseholdData(
initData: string,
includeAdmin = false,
forceRefresh = false
) {
if (forceRefresh) {
await invalidateHouseholdQueries(initData)
}
await loadDashboard(initData)
if (includeAdmin) {
await Promise.all([
loadAdminSettings(initData),
loadCycleState(initData),
loadPendingMembers(initData)
])
return
}
const currentReady = readySession()
if (currentReady?.mode === 'live' && currentReady.member.isAdmin) {
await Promise.all([
loadAdminSettings(initData),
loadCycleState(initData),
loadPendingMembers(initData)
])
}
}
function applyDemoState() {
setDisplayNameDraft(demoSession.member.displayName)
setSession(demoSession)
setDashboard(demoDashboard)
setPendingMembers([...demoPendingMembers])
setAdminSettings(demoAdminSettings)
setCycleState(demoCycleState)
setPurchaseDraftMap(purchaseDrafts(demoDashboard.ledger))
setPaymentDraftMap(paymentDrafts(demoDashboard.ledger))
setMemberDisplayNameDrafts(
Object.fromEntries(demoAdminSettings.members.map((member) => [member.id, member.displayName]))
)
setRentWeightDrafts(
Object.fromEntries(
demoAdminSettings.members.map((member) => [member.id, String(member.rentShareWeight)])
)
)
setMemberStatusDrafts(
Object.fromEntries(demoAdminSettings.members.map((member) => [member.id, member.status]))
)
setMemberAbsencePolicyDrafts(
Object.fromEntries(
demoAdminSettings.members.map((member) => [
member.id,
resolvedMemberAbsencePolicy(member.id, member.status, demoAdminSettings).policy
])
)
)
setBillingForm({
settlementCurrency: demoAdminSettings.settings.settlementCurrency,
paymentBalanceAdjustmentPolicy: demoAdminSettings.settings.paymentBalanceAdjustmentPolicy,
rentAmountMajor: demoAdminSettings.settings.rentAmountMinor
? (Number(demoAdminSettings.settings.rentAmountMinor) / 100).toFixed(2)
: '',
rentCurrency: demoAdminSettings.settings.rentCurrency,
rentDueDay: demoAdminSettings.settings.rentDueDay,
rentWarningDay: demoAdminSettings.settings.rentWarningDay,
utilitiesDueDay: demoAdminSettings.settings.utilitiesDueDay,
utilitiesReminderDay: demoAdminSettings.settings.utilitiesReminderDay,
timezone: demoAdminSettings.settings.timezone
})
setCycleForm((current) => ({
...current,
period: demoCycleState.cycle?.period ?? current.period,
rentCurrency: demoAdminSettings.settings.rentCurrency,
utilityCurrency: demoAdminSettings.settings.settlementCurrency,
rentAmountMajor: demoAdminSettings.settings.rentAmountMinor
? (Number(demoAdminSettings.settings.rentAmountMinor) / 100).toFixed(2)
: '',
utilityCategorySlug:
demoAdminSettings.categories.find((category) => category.isActive)?.slug ?? '',
utilityAmountMajor: ''
}))
setPaymentForm({
memberId: demoAdminSettings.members[0]?.id ?? '',
kind: 'rent',
amountMajor: '',
currency: demoAdminSettings.settings.settlementCurrency
})
setUtilityBillDrafts(cycleUtilityBillDrafts(demoCycleState.utilityBills))
}
async function bootstrap() {
const fallbackLocale = detectLocale()
setLocale(fallbackLocale)
webApp?.ready?.()
webApp?.expand?.()
const initData = webApp?.initData?.trim()
if (!initData) {
if (import.meta.env.DEV) {
applyDemoState()
return
}
setSession({
status: 'blocked',
reason: 'telegram_only'
})
return
}
try {
const payload = await fetchSessionQuery(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)
setDisplayNameDraft(payload.member.displayName)
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) {
applyDemoState()
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)
setDisplayNameDraft(payload.member.displayName)
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 handleSaveOwnDisplayName() {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const nextDisplayName = displayNameDraft().trim()
if (!initData || currentReady?.mode !== 'live' || nextDisplayName.length === 0) {
return
}
setSavingOwnDisplayName(true)
try {
const updatedMember = await updateMiniAppOwnDisplayName(initData, nextDisplayName)
syncDisplayName(updatedMember.id, updatedMember.displayName)
} finally {
setSavingOwnDisplayName(false)
}
}
async function handleSaveMemberDisplayName(memberId: string, closeEditor = true) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const nextDisplayName = memberDisplayNameDrafts()[memberId]?.trim()
if (
!initData ||
currentReady?.mode !== 'live' ||
!currentReady.member.isAdmin ||
!nextDisplayName
) {
return
}
setSavingMemberDisplayNameId(memberId)
try {
const updatedMember = await updateMiniAppMemberDisplayName(
initData,
memberId,
nextDisplayName
)
syncDisplayName(updatedMember.id, updatedMember.displayName)
if (closeEditor) {
setEditingMemberId(null)
}
} finally {
setSavingMemberDisplayNameId(null)
}
}
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
)
setCycleForm((current) => ({
...current,
rentCurrency: settings.rentCurrency,
utilityCurrency: settings.settlementCurrency
}))
setBillingSettingsOpen(false)
} 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: billingForm().settlementCurrency
})
setCycleState(state)
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
setCycleForm((current) => ({
...current,
period: state.cycle?.period ?? current.period,
utilityCurrency: billingForm().settlementCurrency
}))
setCycleRentOpen(false)
} 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)
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
setCycleRentOpen(false)
} 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().rentCurrency,
...(cycleState()?.cycle?.period
? {
period: cycleState()!.cycle!.period
}
: {})
})
setCycleState(state)
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
setCycleRentOpen(false)
} 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().utilityCurrency
})
setCycleState(state)
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
setCycleForm((current) => ({
...current,
utilityAmountMajor: ''
}))
setAddingUtilityBillOpen(false)
} finally {
setSavingUtilityBill(false)
}
}
async function handleUpdateUtilityBill(billId: string) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const draft = utilityBillDrafts()[billId]
if (
!initData ||
currentReady?.mode !== 'live' ||
!currentReady.member.isAdmin ||
!draft ||
draft.billName.trim().length === 0 ||
draft.amountMajor.trim().length === 0
) {
return
}
setSavingUtilityBillId(billId)
try {
const state = await updateMiniAppUtilityBill(initData, {
billId,
billName: draft.billName,
amountMajor: draft.amountMajor,
currency: draft.currency
})
setCycleState(state)
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
setEditingUtilityBillId(null)
} finally {
setSavingUtilityBillId(null)
}
}
async function handleDeleteUtilityBill(billId: string) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
return
}
setDeletingUtilityBillId(billId)
try {
const state = await deleteMiniAppUtilityBill(initData, billId)
setCycleState(state)
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
setEditingUtilityBillId((current) => (current === billId ? null : current))
} finally {
setDeletingUtilityBillId(null)
}
}
async function handleUpdatePurchase(purchaseId: string) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const draft = purchaseDraftMap()[purchaseId]
if (
!initData ||
currentReady?.mode !== 'live' ||
!currentReady.member.isAdmin ||
!draft ||
draft.description.trim().length === 0 ||
draft.amountMajor.trim().length === 0 ||
draft.participants.length === 0 ||
(draft.splitMode === 'custom_amounts' &&
draft.participants.some((participant) => participant.shareAmountMajor.trim().length === 0))
) {
return
}
setSavingPurchaseId(purchaseId)
try {
await updateMiniAppPurchase(initData, {
purchaseId,
description: draft.description,
amountMajor: draft.amountMajor,
currency: draft.currency,
split: {
mode: draft.splitMode,
participants: (adminSettings()?.members ?? []).map((member) => {
const participant = draft.participants.find(
(currentParticipant) => currentParticipant.memberId === member.id
)
return {
memberId: member.id,
included: Boolean(participant),
...(draft.splitMode === 'custom_amounts' && participant
? {
shareAmountMajor: participant.shareAmountMajor
}
: {})
}
})
}
})
await refreshHouseholdData(initData, true, true)
setEditingPurchaseId(null)
} finally {
setSavingPurchaseId(null)
}
}
async function handleDeletePurchase(purchaseId: string) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
return
}
setDeletingPurchaseId(purchaseId)
try {
await deleteMiniAppPurchase(initData, purchaseId)
await refreshHouseholdData(initData, true, true)
setEditingPurchaseId((current) => (current === purchaseId ? null : current))
} finally {
setDeletingPurchaseId(null)
}
}
async function handleAddPayment() {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const draft = paymentForm()
if (
!initData ||
currentReady?.mode !== 'live' ||
!currentReady.member.isAdmin ||
draft.memberId.trim().length === 0 ||
draft.amountMajor.trim().length === 0
) {
return
}
setAddingPayment(true)
try {
await addMiniAppPayment(initData, draft)
setPaymentForm((current) => ({
...current,
amountMajor: ''
}))
await refreshHouseholdData(initData, true, true)
setAddingPaymentOpen(false)
} finally {
setAddingPayment(false)
}
}
async function handleUpdatePayment(paymentId: string) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const draft = paymentDraftMap()[paymentId]
if (
!initData ||
currentReady?.mode !== 'live' ||
!currentReady.member.isAdmin ||
!draft ||
draft.memberId.trim().length === 0 ||
draft.amountMajor.trim().length === 0
) {
return
}
setSavingPaymentId(paymentId)
try {
await updateMiniAppPayment(initData, {
paymentId,
memberId: draft.memberId,
kind: draft.kind,
amountMajor: draft.amountMajor,
currency: draft.currency
})
await refreshHouseholdData(initData, true, true)
setEditingPaymentId(null)
} finally {
setSavingPaymentId(null)
}
}
async function handleDeletePayment(paymentId: string) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
return
}
setDeletingPaymentId(paymentId)
try {
await deleteMiniAppPayment(initData, paymentId)
await refreshHouseholdData(initData, true, true)
setEditingPaymentId((current) => (current === paymentId ? null : current))
} finally {
setDeletingPaymentId(null)
}
}
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('')
}
setEditingCategorySlug(null)
} 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
)
setRentWeightDrafts((current) => ({
...current,
[member.id]: String(member.rentShareWeight)
}))
setEditingMemberId(null)
} finally {
setPromotingMemberId(null)
}
}
async function handleSaveRentWeight(memberId: string, closeEditor = true) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const nextWeight = Number(rentWeightDrafts()[memberId] ?? '')
if (
!initData ||
currentReady?.mode !== 'live' ||
!currentReady.member.isAdmin ||
!Number.isInteger(nextWeight) ||
nextWeight <= 0
) {
return
}
setSavingRentWeightMemberId(memberId)
try {
const member = await updateMiniAppMemberRentWeight(initData, memberId, nextWeight)
setAdminSettings((current) =>
current
? {
...current,
members: current.members.map((item) => (item.id === member.id ? member : item))
}
: current
)
setRentWeightDrafts((current) => ({
...current,
[member.id]: String(member.rentShareWeight)
}))
if (closeEditor) {
setEditingMemberId(null)
}
} finally {
setSavingRentWeightMemberId(null)
}
}
async function handleSaveMemberStatus(memberId: string, closeEditor = true) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const nextStatus = memberStatusDrafts()[memberId]
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin || !nextStatus) {
return
}
setSavingMemberStatusId(memberId)
try {
const member = await updateMiniAppMemberStatus(initData, memberId, nextStatus)
setAdminSettings((current) =>
current
? {
...current,
members: current.members.map((item) => (item.id === member.id ? member : item))
}
: current
)
setMemberStatusDrafts((current) => ({
...current,
[member.id]: member.status
}))
setMemberAbsencePolicyDrafts((current) => ({
...current,
[member.id]:
current[member.id] ??
resolvedMemberAbsencePolicy(member.id, member.status).policy ??
defaultAbsencePolicyForStatus(member.status)
}))
if (closeEditor) {
setEditingMemberId(null)
}
} finally {
setSavingMemberStatusId(null)
}
}
async function handleSaveMemberAbsencePolicy(memberId: string, closeEditor = true) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const member = adminSettings()?.members.find((entry) => entry.id === memberId)
const nextPolicy = memberAbsencePolicyDrafts()[memberId]
const effectiveStatus = memberStatusDrafts()[memberId] ?? member?.status
if (
!initData ||
currentReady?.mode !== 'live' ||
!currentReady.member.isAdmin ||
!member ||
!nextPolicy ||
effectiveStatus !== 'away'
) {
return
}
setSavingMemberAbsencePolicyId(memberId)
try {
const savedPolicy = await updateMiniAppMemberAbsencePolicy(initData, memberId, nextPolicy)
setAdminSettings((current) =>
current
? {
...current,
memberAbsencePolicies: [
...current.memberAbsencePolicies.filter(
(policy) =>
!(
policy.memberId === savedPolicy.memberId &&
policy.effectiveFromPeriod === savedPolicy.effectiveFromPeriod
)
),
savedPolicy
]
}
: current
)
setMemberAbsencePolicyDrafts((current) => ({
...current,
[memberId]: savedPolicy.policy
}))
if (closeEditor) {
setEditingMemberId(null)
}
} finally {
setSavingMemberAbsencePolicyId(null)
}
}
async function handleSaveMemberChanges(memberId: string) {
const currentReady = readySession()
const member = adminSettings()?.members.find((entry) => entry.id === memberId)
const nextDisplayName = memberDisplayNameDrafts()[memberId]?.trim() ?? member?.displayName ?? ''
const nextStatus = memberStatusDrafts()[memberId] ?? member?.status
const nextPolicy = memberAbsencePolicyDrafts()[memberId]
const nextWeight = Number(rentWeightDrafts()[memberId] ?? member?.rentShareWeight ?? 0)
if (
currentReady?.mode !== 'live' ||
!currentReady.member.isAdmin ||
!member ||
nextDisplayName.length < 2 ||
!nextStatus ||
!Number.isInteger(nextWeight) ||
nextWeight <= 0 ||
savingMemberEditorId() === memberId
) {
return
}
const currentPolicy = resolvedMemberAbsencePolicy(member.id, member.status).policy
const wantsAwayPolicySave = nextStatus === 'away' && nextPolicy && nextPolicy !== currentPolicy
const hasNameChange = nextDisplayName !== member.displayName
const hasStatusChange = nextStatus !== member.status
const hasWeightChange = nextWeight !== member.rentShareWeight
if (!hasNameChange && !hasStatusChange && !wantsAwayPolicySave && !hasWeightChange) {
return
}
setSavingMemberEditorId(memberId)
try {
if (hasNameChange) {
await handleSaveMemberDisplayName(memberId, false)
}
if (hasStatusChange) {
await handleSaveMemberStatus(memberId, false)
}
if (wantsAwayPolicySave) {
await handleSaveMemberAbsencePolicy(memberId, false)
}
if (hasWeightChange) {
await handleSaveRentWeight(memberId, false)
}
setEditingMemberId(null)
} finally {
setSavingMemberEditorId(null)
}
}
function purchaseSplitPreview(purchaseId: string): { memberId: string; amountMajor: string }[] {
const draft = purchaseDraftMap()[purchaseId]
if (!draft || draft.participants.length === 0) {
return []
}
if (draft.splitMode === 'custom_amounts') {
return draft.participants.map((participant) => ({
memberId: participant.memberId,
amountMajor: participant.shareAmountMajor
}))
}
const totalMinor = majorStringToMinor(draft.amountMajor)
const count = BigInt(draft.participants.length)
if (count <= 0n) {
return []
}
const base = totalMinor / count
const remainder = totalMinor % count
return draft.participants.map((participant, index) => ({
memberId: participant.memberId,
amountMajor: minorToMajorString(base + (BigInt(index) < remainder ? 1n : 0n))
}))
}
const renderPanel = () => {
switch (activeNav()) {
case 'balances':
return (
<BalancesScreen
copy={copy()}
dashboard={dashboard()}
currentMemberLine={currentMemberLine()}
utilityTotalMajor={utilityTotalMajor()}
purchaseTotalMajor={purchaseTotalMajor()}
memberBalanceVisuals={memberBalanceVisuals()}
purchaseChart={purchaseInvestmentChart()}
memberBaseDueMajor={memberBaseDueMajor}
memberRemainingClass={memberRemainingClass}
/>
)
case 'ledger':
return (
<LedgerScreen
copy={copy()}
dashboard={dashboard()}
readyIsAdmin={readySession()?.member.isAdmin === true}
adminMembers={adminSettings()?.members ?? []}
purchaseEntries={purchaseLedger()}
utilityEntries={utilityLedger()}
paymentEntries={paymentLedger()}
editingPurchaseEntry={editingPurchaseEntry()}
editingPaymentEntry={editingPaymentEntry()}
purchaseDraftMap={purchaseDraftMap()}
paymentDraftMap={paymentDraftMap()}
paymentForm={paymentForm()}
addingPaymentOpen={addingPaymentOpen()}
savingPurchaseId={savingPurchaseId()}
deletingPurchaseId={deletingPurchaseId()}
savingPaymentId={savingPaymentId()}
deletingPaymentId={deletingPaymentId()}
addingPayment={addingPayment()}
ledgerTitle={ledgerTitle}
ledgerPrimaryAmount={ledgerPrimaryAmount}
ledgerSecondaryAmount={ledgerSecondaryAmount}
purchaseParticipantSummary={purchaseParticipantSummary}
purchaseDraftForEntry={purchaseDraftForEntry}
paymentDraftForEntry={paymentDraftForEntry}
purchaseSplitPreview={purchaseSplitPreview}
paymentMemberName={paymentMemberName}
onOpenPurchaseEditor={setEditingPurchaseId}
onClosePurchaseEditor={() => setEditingPurchaseId(null)}
onDeletePurchase={handleDeletePurchase}
onSavePurchase={handleUpdatePurchase}
onPurchaseDescriptionChange={(purchaseId, entry, value) =>
updatePurchaseDraft(purchaseId, entry, (current) => ({
...current,
description: value
}))
}
onPurchaseAmountChange={(purchaseId, entry, value) =>
updatePurchaseDraft(purchaseId, entry, (current) => ({
...current,
amountMajor: value
}))
}
onPurchaseCurrencyChange={(purchaseId, entry, value) =>
updatePurchaseDraft(purchaseId, entry, (current) => ({
...current,
currency: value
}))
}
onPurchaseSplitModeChange={(purchaseId, entry, value) =>
updatePurchaseDraft(purchaseId, entry, (current) => ({
...current,
splitMode: value
}))
}
onTogglePurchaseParticipant={togglePurchaseParticipant}
onPurchaseParticipantShareChange={(purchaseId, entry, memberId, value) =>
updatePurchaseDraft(purchaseId, entry, (current) => ({
...current,
participants: current.participants.map((participant) =>
participant.memberId === memberId
? {
...participant,
shareAmountMajor: value
}
: participant
)
}))
}
onOpenAddPayment={() => setAddingPaymentOpen(true)}
onCloseAddPayment={() => setAddingPaymentOpen(false)}
onAddPayment={handleAddPayment}
onPaymentFormMemberChange={(value) =>
setPaymentForm((current) => ({
...current,
memberId: value
}))
}
onPaymentFormKindChange={(value) =>
setPaymentForm((current) => ({
...current,
kind: value
}))
}
onPaymentFormAmountChange={(value) =>
setPaymentForm((current) => ({
...current,
amountMajor: value
}))
}
onPaymentFormCurrencyChange={(value) =>
setPaymentForm((current) => ({
...current,
currency: value
}))
}
onOpenPaymentEditor={setEditingPaymentId}
onClosePaymentEditor={() => setEditingPaymentId(null)}
onDeletePayment={handleDeletePayment}
onSavePayment={handleUpdatePayment}
onPaymentDraftMemberChange={(paymentId, entry, value) =>
updatePaymentDraft(paymentId, entry, (current) => ({
...current,
memberId: value
}))
}
onPaymentDraftKindChange={(paymentId, entry, value) =>
updatePaymentDraft(paymentId, entry, (current) => ({
...current,
kind: value
}))
}
onPaymentDraftAmountChange={(paymentId, entry, value) =>
updatePaymentDraft(paymentId, entry, (current) => ({
...current,
amountMajor: value
}))
}
onPaymentDraftCurrencyChange={(paymentId, entry, value) =>
updatePaymentDraft(paymentId, entry, (current) => ({
...current,
currency: value
}))
}
/>
)
case 'house':
return (
<HouseScreen
copy={copy()}
readyIsAdmin={readySession()?.member.isAdmin === true}
householdDefaultLocale={readySession()?.member.householdDefaultLocale ?? 'en'}
dashboard={dashboard()}
adminSettings={adminSettings()}
cycleState={cycleState()}
pendingMembers={pendingMembers()}
activeHouseSection={activeHouseSection()}
onChangeHouseSection={setActiveHouseSection}
billingForm={billingForm()}
cycleForm={cycleForm()}
newCategoryName={newCategoryName()}
cycleRentOpen={cycleRentOpen()}
billingSettingsOpen={billingSettingsOpen()}
addingUtilityBillOpen={addingUtilityBillOpen()}
editingUtilityBill={editingUtilityBill()}
editingUtilityBillId={editingUtilityBillId()}
utilityBillDrafts={utilityBillDrafts()}
editingCategorySlug={editingCategorySlug()}
editingCategory={editingCategory()}
editingMember={editingMember()}
memberDisplayNameDrafts={memberDisplayNameDrafts()}
memberStatusDrafts={memberStatusDrafts()}
memberAbsencePolicyDrafts={memberAbsencePolicyDrafts()}
rentWeightDrafts={rentWeightDrafts()}
openingCycle={openingCycle()}
closingCycle={closingCycle()}
savingCycleRent={savingCycleRent()}
savingBillingSettings={savingBillingSettings()}
savingUtilityBill={savingUtilityBill()}
savingUtilityBillId={savingUtilityBillId()}
deletingUtilityBillId={deletingUtilityBillId()}
savingCategorySlug={savingCategorySlug()}
approvingTelegramUserId={approvingTelegramUserId()}
savingMemberEditorId={savingMemberEditorId()}
promotingMemberId={promotingMemberId()}
savingHouseholdLocale={savingHouseholdLocale()}
minorToMajorString={minorToMajorString}
memberStatusLabel={memberStatusLabel}
topicRoleLabel={topicRoleLabel}
resolvedMemberAbsencePolicy={(memberId, status) =>
resolvedMemberAbsencePolicy(memberId, status)
}
onChangeHouseholdLocale={handleHouseholdLocaleChange}
onOpenCycleModal={() => setCycleRentOpen(true)}
onCloseCycleModal={() => setCycleRentOpen(false)}
onSaveCycleRent={handleSaveCycleRent}
onOpenCycle={handleOpenCycle}
onCloseCycle={handleCloseCycle}
onCycleRentAmountChange={(value) =>
setCycleForm((current) => ({
...current,
rentAmountMajor: value
}))
}
onCycleRentCurrencyChange={(value) =>
setCycleForm((current) => ({
...current,
rentCurrency: value
}))
}
onCyclePeriodChange={(value) =>
setCycleForm((current) => ({
...current,
period: value
}))
}
onOpenBillingSettingsModal={() => setBillingSettingsOpen(true)}
onCloseBillingSettingsModal={() => setBillingSettingsOpen(false)}
onSaveBillingSettings={handleSaveBillingSettings}
onBillingSettlementCurrencyChange={(value) =>
setBillingForm((current) => ({
...current,
settlementCurrency: value
}))
}
onBillingAdjustmentPolicyChange={(value) =>
setBillingForm((current) => ({
...current,
paymentBalanceAdjustmentPolicy: value
}))
}
onBillingRentAmountChange={(value) =>
setBillingForm((current) => ({
...current,
rentAmountMajor: value
}))
}
onBillingRentCurrencyChange={(value) =>
setBillingForm((current) => ({
...current,
rentCurrency: value
}))
}
onBillingRentDueDayChange={(value) =>
setBillingForm((current) => ({
...current,
rentDueDay: value
}))
}
onBillingRentWarningDayChange={(value) =>
setBillingForm((current) => ({
...current,
rentWarningDay: value
}))
}
onBillingUtilitiesDueDayChange={(value) =>
setBillingForm((current) => ({
...current,
utilitiesDueDay: value
}))
}
onBillingUtilitiesReminderDayChange={(value) =>
setBillingForm((current) => ({
...current,
utilitiesReminderDay: value
}))
}
onBillingTimezoneChange={(value) =>
setBillingForm((current) => ({
...current,
timezone: value
}))
}
onOpenAddUtilityBill={() => setAddingUtilityBillOpen(true)}
onCloseAddUtilityBill={() => setAddingUtilityBillOpen(false)}
onAddUtilityBill={handleAddUtilityBill}
onCycleUtilityCategoryChange={(value) =>
setCycleForm((current) => ({
...current,
utilityCategorySlug: value
}))
}
onCycleUtilityAmountChange={(value) =>
setCycleForm((current) => ({
...current,
utilityAmountMajor: value
}))
}
onCycleUtilityCurrencyChange={(value) =>
setCycleForm((current) => ({
...current,
utilityCurrency: value
}))
}
onOpenUtilityBillEditor={setEditingUtilityBillId}
onCloseUtilityBillEditor={() => setEditingUtilityBillId(null)}
onDeleteUtilityBill={handleDeleteUtilityBill}
onSaveUtilityBill={handleUpdateUtilityBill}
onUtilityBillNameChange={(billId, bill, value) =>
updateUtilityBillDraft(billId, bill, (current) => ({
...current,
billName: value
}))
}
onUtilityBillAmountChange={(billId, bill, value) =>
updateUtilityBillDraft(billId, bill, (current) => ({
...current,
amountMajor: value
}))
}
onUtilityBillCurrencyChange={(billId, bill, value) =>
updateUtilityBillDraft(billId, bill, (current) => ({
...current,
currency: value
}))
}
onOpenCategoryEditor={setEditingCategorySlug}
onCloseCategoryEditor={() => setEditingCategorySlug(null)}
onNewCategoryNameChange={setNewCategoryName}
onSaveNewCategory={() =>
handleSaveUtilityCategory({
name: newCategoryName(),
sortOrder: adminSettings()?.categories.length ?? 0,
isActive: true
})
}
onSaveExistingCategory={() => {
const category = editingCategory()
if (!category) {
return Promise.resolve()
}
return handleSaveUtilityCategory({
slug: category.slug,
name: category.name,
sortOrder: category.sortOrder,
isActive: category.isActive
})
}}
onEditingCategoryNameChange={(value) =>
setAdminSettings((current) =>
current && editingCategory()
? {
...current,
categories: current.categories.map((item) =>
item.slug === editingCategory()!.slug
? {
...item,
name: value
}
: item
)
}
: current
)
}
onEditingCategoryActiveChange={(value) =>
setAdminSettings((current) =>
current && editingCategory()
? {
...current,
categories: current.categories.map((item) =>
item.slug === editingCategory()!.slug
? {
...item,
isActive: value
}
: item
)
}
: current
)
}
onOpenMemberEditor={setEditingMemberId}
onCloseMemberEditor={() => setEditingMemberId(null)}
onApprovePendingMember={handleApprovePendingMember}
onMemberDisplayNameDraftChange={(memberId, value) =>
setMemberDisplayNameDrafts((current) => ({
...current,
[memberId]: value
}))
}
onMemberStatusDraftChange={(memberId, value) =>
setMemberStatusDrafts((current) => ({
...current,
[memberId]: value
}))
}
onMemberAbsencePolicyDraftChange={(memberId, value) =>
setMemberAbsencePolicyDrafts((current) => ({
...current,
[memberId]: value
}))
}
onRentWeightDraftChange={(memberId, value) =>
setRentWeightDrafts((current) => ({
...current,
[memberId]: value
}))
}
onSaveMemberChanges={handleSaveMemberChanges}
onPromoteMember={handlePromoteMember}
/>
)
default:
return (
<HomeScreen
copy={copy()}
dashboard={dashboard()}
readyIsAdmin={Boolean(readySession()?.member.isAdmin)}
pendingMembersCount={pendingMembers().length}
currentMemberLine={currentMemberLine()}
utilityTotalMajor={utilityTotalMajor()}
purchaseTotalMajor={purchaseTotalMajor()}
memberBalanceVisuals={memberBalanceVisuals()}
purchaseChart={purchaseInvestmentChart()}
memberBaseDueMajor={memberBaseDueMajor}
memberRemainingClass={memberRemainingClass}
ledgerTitle={ledgerTitle}
ledgerPrimaryAmount={ledgerPrimaryAmount}
ledgerSecondaryAmount={ledgerSecondaryAmount}
/>
)
}
}
return (
<main class="shell">
<div class="shell__backdrop shell__backdrop--top" />
<div class="shell__backdrop shell__backdrop--bottom" />
<TopBar
subtitle={copy().appSubtitle}
title={copy().appTitle}
languageLabel={copy().language}
locale={locale()}
saving={savingMemberLocale()}
onChange={(nextLocale) => void handleMemberLocaleChange(nextLocale)}
/>
<Switch>
<Match when={session().status === 'loading'}>
<LoadingState
badge={copy().loadingBadge}
title={copy().loadingTitle}
body={copy().loadingBody}
/>
</Match>
<Match when={session().status === 'blocked'}>
<BlockedState
badge={copy().loadingBadge}
title={
blockedSession()?.reason === 'telegram_only'
? copy().telegramOnlyTitle
: copy().unexpectedErrorTitle
}
body={
blockedSession()?.reason === 'telegram_only'
? copy().telegramOnlyBody
: copy().unexpectedErrorBody
}
reloadLabel={copy().reload}
onReload={() => window.location.reload()}
/>
</Match>
<Match when={session().status === 'onboarding'}>
<OnboardingState
badge={copy().loadingBadge}
title={
onboardingSession()?.mode === 'pending'
? copy().pendingTitle
: onboardingSession()?.mode === 'open_from_group'
? copy().openFromGroupTitle
: copy().joinTitle
}
body={
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
)
}
canJoin={onboardingSession()?.mode === 'join_required'}
joining={joining()}
joinActionLabel={copy().joinAction}
joiningLabel={copy().joining}
botLinkLabel={copy().botLinkAction}
botLink={joinDeepLink()}
reloadLabel={copy().reload}
onJoin={handleJoinHousehold}
onReload={() => window.location.reload()}
/>
</Match>
<Match when={session().status === 'ready'}>
<HeroBanner
badges={[
readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge,
readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag,
readySession()?.member.status
? memberStatusLabel(readySession()!.member.status)
: copy().memberStatusActive
]}
title={`${copy().welcome}, ${readySession()?.telegramUser.firstName ?? readySession()?.member.displayName}`}
body={copy().overviewBody}
action={
readySession()?.mode === 'live'
? {
label: copy().manageProfileAction,
onClick: () => setProfileEditorOpen(true)
}
: undefined
}
/>
<NavigationTabs
items={
[
{ key: 'home', label: copy().home },
{ key: 'balances', label: copy().balances },
{ key: 'ledger', label: copy().ledger },
{ key: 'house', label: copy().house }
] as const
}
active={activeNav()}
onChange={setActiveNav}
/>
<section class="content-grid">
<ProfileCard
displayName={readySession()?.member.displayName ?? ''}
roleLabel={readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag}
statusSummary={copy().memberStatusSummary.replace(
'{status}',
readySession()?.member.status
? memberStatusLabel(readySession()!.member.status)
: copy().memberStatusActive
)}
modeBadge={readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge}
localeBadge={locale().toUpperCase()}
/>
<div class="content-stack">{renderPanel()}</div>
</section>
<Modal
open={profileEditorOpen()}
title={copy().displayNameLabel}
description={copy().profileEditorBody}
closeLabel={copy().closeEditorAction}
onClose={() => setProfileEditorOpen(false)}
footer={
<div class="modal-action-row modal-action-row--single">
<Button variant="ghost" onClick={() => setProfileEditorOpen(false)}>
{copy().closeEditorAction}
</Button>
<Button
variant="primary"
disabled={
savingOwnDisplayName() ||
displayNameDraft().trim().length < 2 ||
displayNameDraft().trim() === readySession()?.member.displayName
}
onClick={() => void handleSaveOwnDisplayName()}
>
{savingOwnDisplayName() ? copy().savingDisplayName : copy().saveDisplayName}
</Button>
</div>
}
>
<div class="editor-grid">
<Field label={copy().displayNameLabel} hint={copy().displayNameHint} wide>
<input
value={displayNameDraft()}
onInput={(event) => setDisplayNameDraft(event.currentTarget.value)}
/>
</Field>
</div>
</Modal>
</Match>
</Switch>
</main>
)
}
export default App