mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 19:24: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:
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