feat(miniapp): refine UI and add utility bill management

- Fix collapsible padding and button spacing
- Add subtotal to balance card
- Add utility bill management for admins
- Fix lints and type checks across the monorepo
- Implement rejectPendingHouseholdMember in repository and service
This commit is contained in:
2026-03-13 05:52:34 +04:00
parent 25c4928ca9
commit 94a5904f54
58 changed files with 5400 additions and 7006 deletions

View File

@@ -0,0 +1,357 @@
import { createContext, createMemo, createSignal, useContext, type ParentProps } from 'solid-js'
import { majorStringToMinor, minorToMajorString } from '../lib/money'
import {
fetchDashboardQuery,
fetchAdminSettingsQuery,
fetchBillingCycleQuery,
fetchPendingMembersQuery
} from '../app/miniapp-queries'
import { absoluteMinor } from '../lib/ledger-helpers'
import type {
MiniAppAdminCycleState,
MiniAppAdminSettingsPayload,
MiniAppDashboard,
MiniAppPendingMember
} from '../miniapp-api'
import {
demoAdminSettings,
demoCycleState,
demoDashboard,
demoPendingMembers
} from '../demo/miniapp-demo'
import { useSession } from './session-context'
import { useI18n } from './i18n-context'
/* ── Types ──────────────────────────────────────────── */
export type TestingRolePreview = 'admin' | 'resident'
export type BillingFormState = {
householdName: string
settlementCurrency: 'USD' | 'GEL'
paymentBalanceAdjustmentPolicy: 'utilities' | 'rent' | 'separate'
rentAmountMajor: string
rentCurrency: 'USD' | 'GEL'
rentDueDay: number
rentWarningDay: number
utilitiesDueDay: number
utilitiesReminderDay: number
timezone: string
assistantContext: string
assistantTone: string
}
export type CycleFormState = {
period: string
rentCurrency: 'USD' | 'GEL'
utilityCurrency: 'USD' | 'GEL'
rentAmountMajor: string
utilityCategorySlug: string
utilityAmountMajor: string
}
const chartPalette = ['#3ecf8e', '#6fd3c0', '#94a8ff', '#f06a8d', '#f3d36f', '#7dc96d'] as const
type DashboardContextValue = {
dashboard: () => MiniAppDashboard | null
setDashboard: (
value: MiniAppDashboard | null | ((prev: MiniAppDashboard | null) => MiniAppDashboard | null)
) => void
adminSettings: () => MiniAppAdminSettingsPayload | null
setAdminSettings: (
value:
| MiniAppAdminSettingsPayload
| null
| ((prev: MiniAppAdminSettingsPayload | null) => MiniAppAdminSettingsPayload | null)
) => void
cycleState: () => MiniAppAdminCycleState | null
setCycleState: (
value:
| MiniAppAdminCycleState
| null
| ((prev: MiniAppAdminCycleState | null) => MiniAppAdminCycleState | null)
) => void
pendingMembers: () => readonly MiniAppPendingMember[]
setPendingMembers: (
value:
| readonly MiniAppPendingMember[]
| ((prev: readonly MiniAppPendingMember[]) => readonly MiniAppPendingMember[])
) => void
effectiveIsAdmin: () => boolean
currentMemberLine: () => MiniAppDashboard['members'][number] | null
purchaseLedger: () => MiniAppDashboard['ledger'][number][]
utilityLedger: () => MiniAppDashboard['ledger'][number][]
paymentLedger: () => MiniAppDashboard['ledger'][number][]
utilityTotalMajor: () => string
purchaseTotalMajor: () => string
memberBalanceVisuals: () => ReturnType<typeof computeMemberBalanceVisuals>
purchaseInvestmentChart: () => ReturnType<typeof computePurchaseInvestmentChart>
testingRolePreview: () => TestingRolePreview | null
setTestingRolePreview: (value: TestingRolePreview | null) => void
loadDashboardData: (initData: string, isAdmin: boolean) => Promise<void>
applyDemoState: () => void
}
const DashboardContext = createContext<DashboardContextValue>()
/* ── Derived computations ───────────────────────────── */
function computeMemberBalanceVisuals(
data: MiniAppDashboard | null,
copyFn: () => { shareRent: string; shareUtilities: string; shareOffset: string }
) {
if (!data) return []
const copy = copyFn()
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
}))
}))
}
function computePurchaseInvestmentChart(
data: MiniAppDashboard | null,
entries: MiniAppDashboard['ledger'][number][],
fallbackLabel: string
) {
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 entries) {
const key = entry.memberId ?? entry.actorDisplayName ?? entry.id
const label =
(entry.memberId ? membersById.get(entry.memberId) : null) ??
entry.actorDisplayName ??
fallbackLabel
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
})
}
}
/* ── Provider ───────────────────────────────────────── */
export function DashboardProvider(props: ParentProps) {
const { readySession } = useSession()
const { copy } = useI18n()
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null)
const [adminSettings, setAdminSettings] = createSignal<MiniAppAdminSettingsPayload | null>(null)
const [cycleState, setCycleState] = createSignal<MiniAppAdminCycleState | null>(null)
const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([])
const [testingRolePreview, setTestingRolePreview] = createSignal<TestingRolePreview | null>(null)
const effectiveIsAdmin = createMemo(() => {
const current = readySession()
if (!current?.member.isAdmin) return false
const preview = testingRolePreview()
if (!preview) return true
return preview === 'admin'
})
const currentMemberLine = createMemo(() => {
const current = readySession()
const data = dashboard()
if (!current || !data) return null
return data.members.find((m) => m.memberId === current.member.id) ?? null
})
const purchaseLedger = createMemo(() =>
(dashboard()?.ledger ?? []).filter((e) => e.kind === 'purchase')
)
const utilityLedger = createMemo(() =>
(dashboard()?.ledger ?? []).filter((e) => e.kind === 'utility')
)
const paymentLedger = createMemo(() =>
(dashboard()?.ledger ?? []).filter((e) => e.kind === 'payment')
)
const utilityTotalMajor = createMemo(() =>
minorToMajorString(
utilityLedger().reduce((sum, e) => sum + majorStringToMinor(e.displayAmountMajor), 0n)
)
)
const purchaseTotalMajor = createMemo(() =>
minorToMajorString(
purchaseLedger().reduce((sum, e) => sum + majorStringToMinor(e.displayAmountMajor), 0n)
)
)
const memberBalanceVisuals = createMemo(() => computeMemberBalanceVisuals(dashboard(), copy))
const purchaseInvestmentChart = createMemo(() =>
computePurchaseInvestmentChart(dashboard(), purchaseLedger(), copy().ledgerActorFallback)
)
async function loadDashboardData(initData: string, isAdmin: boolean) {
// In demo mode, use demo data
if (!initData) {
applyDemoState()
return
}
try {
const nextDashboard = await fetchDashboardQuery(initData)
setDashboard(nextDashboard)
} catch (error) {
if (import.meta.env.DEV) {
console.warn('Failed to load mini app dashboard', error)
}
setDashboard(null)
}
if (isAdmin) {
try {
const [settings, cycle, pending] = await Promise.all([
fetchAdminSettingsQuery(initData),
fetchBillingCycleQuery(initData),
fetchPendingMembersQuery(initData)
])
setAdminSettings(settings)
setCycleState(cycle)
setPendingMembers(pending)
} catch (error) {
if (import.meta.env.DEV) {
console.warn('Failed to load admin data', error)
}
}
}
}
function applyDemoState() {
setDashboard(demoDashboard)
setPendingMembers([...demoPendingMembers])
setAdminSettings(demoAdminSettings)
setCycleState(demoCycleState)
}
return (
<DashboardContext.Provider
value={{
dashboard,
setDashboard,
adminSettings,
setAdminSettings,
cycleState,
setCycleState,
pendingMembers,
setPendingMembers,
effectiveIsAdmin,
currentMemberLine,
purchaseLedger,
utilityLedger,
paymentLedger,
utilityTotalMajor,
purchaseTotalMajor,
memberBalanceVisuals,
purchaseInvestmentChart,
testingRolePreview,
setTestingRolePreview,
loadDashboardData,
applyDemoState
}}
>
{props.children}
</DashboardContext.Provider>
)
}
export function useDashboard(): DashboardContextValue {
const context = useContext(DashboardContext)
if (!context) {
throw new Error('useDashboard must be used within a DashboardProvider')
}
return context
}

View File

@@ -0,0 +1,38 @@
import { createContext, createSignal, useContext, type ParentProps } from 'solid-js'
import { dictionary, type Locale } from '../i18n'
import { getTelegramWebApp } from '../telegram-webapp'
type I18nContextValue = {
locale: () => Locale
setLocale: (locale: Locale) => void
copy: () => (typeof dictionary)['en']
}
const I18nContext = createContext<I18nContextValue>()
function detectLocale(): Locale {
const telegramLocale = getTelegramWebApp()?.initDataUnsafe?.user?.language_code
const browserLocale = navigator.language.toLowerCase()
return (telegramLocale ?? browserLocale).startsWith('ru') ? 'ru' : 'en'
}
export function I18nProvider(props: ParentProps) {
const [locale, setLocale] = createSignal<Locale>(detectLocale())
const copy = () => dictionary[locale()]
return (
<I18nContext.Provider value={{ locale, setLocale, copy }}>
{props.children}
</I18nContext.Provider>
)
}
export function useI18n(): I18nContextValue {
const context = useContext(I18nContext)
if (!context) {
throw new Error('useI18n must be used within an I18nProvider')
}
return context
}

View File

@@ -0,0 +1,353 @@
import { createContext, createSignal, onMount, useContext, type ParentProps } from 'solid-js'
import type { Locale } from '../i18n'
import {
joinMiniAppHousehold,
updateMiniAppLocalePreference,
updateMiniAppOwnDisplayName
} from '../miniapp-api'
import { fetchSessionQuery, invalidateHouseholdQueries } from '../app/miniapp-queries'
import { getTelegramWebApp } from '../telegram-webapp'
import { demoMember, demoTelegramUser } from '../demo/miniapp-demo'
import { useI18n } from './i18n-context'
/* ── Types ──────────────────────────────────────────── */
export 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
householdName: 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 SessionContextValue = {
session: () => SessionState
setSession: (updater: SessionState | ((prev: SessionState) => SessionState)) => void
readySession: () => Extract<SessionState, { status: 'ready' }> | null
onboardingSession: () => Extract<SessionState, { status: 'onboarding' }> | null
blockedSession: () => Extract<SessionState, { status: 'blocked' }> | null
webApp: ReturnType<typeof getTelegramWebApp>
initData: () => string | undefined
joining: () => boolean
displayNameDraft: () => string
setDisplayNameDraft: (value: string | ((prev: string) => string)) => void
savingOwnDisplayName: () => boolean
handleJoinHousehold: () => Promise<void>
handleSaveOwnDisplayName: () => Promise<void>
handleMemberLocaleChange: (nextLocale: Locale) => Promise<void>
handleHouseholdLocaleChange: (nextLocale: Locale) => Promise<void>
refreshHouseholdData: (includeAdmin?: boolean, forceRefresh?: boolean) => Promise<void>
}
const SessionContext = createContext<SessionContextValue>()
/* ── Helpers ────────────────────────────────────────── */
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 } : {})
}
}
export 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)}`
}
const demoSession: Extract<SessionState, { status: 'ready' }> = {
status: 'ready',
mode: 'demo',
member: demoMember,
telegramUser: demoTelegramUser
}
/* ── Provider ───────────────────────────────────────── */
export function SessionProvider(
props: ParentProps<{
onReady?: (initData: string, isAdmin: boolean) => Promise<void>
}>
) {
const { locale, setLocale } = useI18n()
const webApp = getTelegramWebApp()
const [session, setSession] = createSignal<SessionState>({ status: 'loading' })
const [joining, setJoining] = createSignal(false)
const [displayNameDraft, setDisplayNameDraft] = createSignal('')
const [savingOwnDisplayName, setSavingOwnDisplayName] = createSignal(false)
const readySession = () => {
const current = session()
return current.status === 'ready' ? current : null
}
const onboardingSession = () => {
const current = session()
return current.status === 'onboarding' ? current : null
}
const blockedSession = () => {
const current = session()
return current.status === 'blocked' ? current : null
}
const initData = () => webApp?.initData?.trim() || undefined
async function bootstrap() {
webApp?.ready?.()
webApp?.expand?.()
const data = initData()
if (!data) {
if (import.meta.env.DEV) {
setSession(demoSession)
setDisplayNameDraft(demoSession.member.displayName)
await props.onReady?.('', true)
return
}
setSession({ status: 'blocked', reason: 'telegram_only' })
return
}
try {
const payload = await fetchSessionQuery(data, joinContext().joinToken)
if (!payload.authorized || !payload.member || !payload.telegramUser) {
setLocale(
payload.onboarding?.householdDefaultLocale ??
((payload.telegramUser?.languageCode ?? 'en').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 props.onReady?.(data, payload.member.isAdmin)
} catch {
if (import.meta.env.DEV) {
setSession(demoSession)
setDisplayNameDraft(demoSession.member.displayName)
await props.onReady?.('', true)
return
}
setSession({ status: 'blocked', reason: 'error' })
}
}
async function handleJoinHousehold() {
const data = initData()
const joinToken = joinContext().joinToken
if (!data || !joinToken || joining()) return
setJoining(true)
try {
const payload = await joinMiniAppHousehold(data, 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 props.onReady?.(data, payload.member.isAdmin)
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 handleSaveOwnDisplayName() {
const data = initData()
const current = readySession()
const nextName = displayNameDraft().trim()
if (!data || current?.mode !== 'live' || nextName.length === 0) return
setSavingOwnDisplayName(true)
try {
const updatedMember = await updateMiniAppOwnDisplayName(data, nextName)
setSession((prev) =>
prev.status === 'ready'
? { ...prev, member: { ...prev.member, displayName: updatedMember.displayName } }
: prev
)
setDisplayNameDraft(updatedMember.displayName)
} finally {
setSavingOwnDisplayName(false)
}
}
async function handleMemberLocaleChange(nextLocale: Locale) {
const data = initData()
const current = readySession()
setLocale(nextLocale)
if (!data || current?.mode !== 'live') return
try {
const updated = await updateMiniAppLocalePreference(data, nextLocale, 'member')
setSession((prev) =>
prev.status === 'ready'
? {
...prev,
member: {
...prev.member,
preferredLocale: updated.memberPreferredLocale,
householdDefaultLocale: updated.householdDefaultLocale
}
}
: prev
)
setLocale(updated.effectiveLocale)
} catch {
// Locale was already set optimistically
}
}
async function handleHouseholdLocaleChange(nextLocale: Locale) {
const data = initData()
const current = readySession()
if (!data || current?.mode !== 'live' || !current.member.isAdmin) return
try {
const updated = await updateMiniAppLocalePreference(data, nextLocale, 'household')
setSession((prev) =>
prev.status === 'ready'
? {
...prev,
member: { ...prev.member, householdDefaultLocale: updated.householdDefaultLocale }
}
: prev
)
if (!current.member.preferredLocale) {
setLocale(updated.effectiveLocale)
}
} catch {
// Ignore
}
}
async function refreshHouseholdData(includeAdmin = false, forceRefresh = false) {
const data = initData()
if (!data) return
if (forceRefresh) {
await invalidateHouseholdQueries(data)
}
// Delegate actual data loading to dashboard context via onReady
const current = readySession()
if (current) {
await props.onReady?.(data, includeAdmin || current.member.isAdmin)
}
}
onMount(() => {
void bootstrap()
})
return (
<SessionContext.Provider
value={{
session,
setSession,
readySession,
onboardingSession,
blockedSession,
webApp,
initData,
joining,
displayNameDraft,
setDisplayNameDraft,
savingOwnDisplayName,
handleJoinHousehold,
handleSaveOwnDisplayName,
handleMemberLocaleChange,
handleHouseholdLocaleChange,
refreshHouseholdData
}}
>
{props.children}
</SessionContext.Provider>
)
}
export function useSession(): SessionContextValue {
const context = useContext(SessionContext)
if (!context) {
throw new Error('useSession must be used within a SessionProvider')
}
return context
}