mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 22:34:03 +00:00
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:
357
apps/miniapp/src/contexts/dashboard-context.tsx
Normal file
357
apps/miniapp/src/contexts/dashboard-context.tsx
Normal 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
|
||||
}
|
||||
38
apps/miniapp/src/contexts/i18n-context.tsx
Normal file
38
apps/miniapp/src/contexts/i18n-context.tsx
Normal 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
|
||||
}
|
||||
353
apps/miniapp/src/contexts/session-context.tsx
Normal file
353
apps/miniapp/src/contexts/session-context.tsx
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user