From 94a5904f54bfec830b47596e978ac925b856688f Mon Sep 17 00:00:00 2001 From: whekin Date: Fri, 13 Mar 2026 05:52:34 +0400 Subject: [PATCH] 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 --- apps/bot/src/anonymous-feedback.test.ts | 1 + apps/bot/src/bot-i18n.test.ts | 1 + apps/bot/src/dm-assistant.test.ts | 1 + apps/bot/src/finance-commands.test.ts | 1 + apps/bot/src/household-setup.test.ts | 3 + apps/bot/src/index.ts | 10 + apps/bot/src/miniapp-admin.test.ts | 56 + apps/bot/src/miniapp-admin.ts | 84 + apps/bot/src/miniapp-auth.test.ts | 1 + apps/bot/src/miniapp-billing.test.ts | 1 + apps/bot/src/miniapp-dashboard.test.ts | 1 + apps/bot/src/miniapp-locale.test.ts | 1 + apps/bot/src/server.ts | 24 + apps/miniapp/package.json | 2 + apps/miniapp/src/App.tsx | 2708 +--------------- .../finance/finance-summary-cards.tsx | 53 - .../components/finance/finance-visuals.tsx | 161 - .../finance/member-balance-card.tsx | 153 - .../src/components/layout/navigation-tabs.tsx | 47 +- .../src/components/layout/profile-card.tsx | 25 - apps/miniapp/src/components/layout/shell.tsx | 164 + .../miniapp/src/components/layout/top-bar.tsx | 49 - apps/miniapp/src/components/ui/badge.tsx | 26 + apps/miniapp/src/components/ui/button.tsx | 19 +- apps/miniapp/src/components/ui/card.tsx | 15 +- .../miniapp/src/components/ui/collapsible.tsx | 32 + apps/miniapp/src/components/ui/field.tsx | 6 +- apps/miniapp/src/components/ui/index.ts | 6 + apps/miniapp/src/components/ui/input.tsx | 65 + apps/miniapp/src/components/ui/select.tsx | 66 + apps/miniapp/src/components/ui/skeleton.tsx | 19 + apps/miniapp/src/components/ui/toggle.tsx | 29 + .../src/contexts/dashboard-context.tsx | 357 ++ apps/miniapp/src/contexts/i18n-context.tsx | 38 + apps/miniapp/src/contexts/session-context.tsx | 353 ++ apps/miniapp/src/i18n.ts | 6 + apps/miniapp/src/index.css | 2868 +++++++++-------- apps/miniapp/src/index.tsx | 1 + apps/miniapp/src/lib/ledger-helpers.ts | 234 ++ apps/miniapp/src/miniapp-api.ts | 59 + apps/miniapp/src/routes/balances.tsx | 151 + apps/miniapp/src/routes/home.tsx | 158 + apps/miniapp/src/routes/ledger.tsx | 971 ++++++ apps/miniapp/src/routes/settings.tsx | 618 ++++ apps/miniapp/src/screens/balances-screen.tsx | 199 -- apps/miniapp/src/screens/home-screen.tsx | 395 --- apps/miniapp/src/screens/house-screen.tsx | 1357 -------- apps/miniapp/src/screens/ledger-screen.tsx | 636 ---- apps/miniapp/src/theme.css | 90 + bun.lock | 6 + .../src/household-config-repository.ts | 14 + .../src/household-admin-service.test.ts | 3 + .../src/household-onboarding-service.test.ts | 6 +- .../src/household-setup-service.test.ts | 7 +- .../src/locale-preference-service.test.ts | 6 +- .../src/miniapp-admin-service.test.ts | 1 + .../application/src/miniapp-admin-service.ts | 38 + packages/ports/src/household-config.ts | 4 + 58 files changed, 5400 insertions(+), 7006 deletions(-) delete mode 100644 apps/miniapp/src/components/finance/finance-summary-cards.tsx delete mode 100644 apps/miniapp/src/components/finance/finance-visuals.tsx delete mode 100644 apps/miniapp/src/components/finance/member-balance-card.tsx delete mode 100644 apps/miniapp/src/components/layout/profile-card.tsx create mode 100644 apps/miniapp/src/components/layout/shell.tsx delete mode 100644 apps/miniapp/src/components/layout/top-bar.tsx create mode 100644 apps/miniapp/src/components/ui/badge.tsx create mode 100644 apps/miniapp/src/components/ui/collapsible.tsx create mode 100644 apps/miniapp/src/components/ui/input.tsx create mode 100644 apps/miniapp/src/components/ui/select.tsx create mode 100644 apps/miniapp/src/components/ui/skeleton.tsx create mode 100644 apps/miniapp/src/components/ui/toggle.tsx create mode 100644 apps/miniapp/src/contexts/dashboard-context.tsx create mode 100644 apps/miniapp/src/contexts/i18n-context.tsx create mode 100644 apps/miniapp/src/contexts/session-context.tsx create mode 100644 apps/miniapp/src/lib/ledger-helpers.ts create mode 100644 apps/miniapp/src/routes/balances.tsx create mode 100644 apps/miniapp/src/routes/home.tsx create mode 100644 apps/miniapp/src/routes/ledger.tsx create mode 100644 apps/miniapp/src/routes/settings.tsx delete mode 100644 apps/miniapp/src/screens/balances-screen.tsx delete mode 100644 apps/miniapp/src/screens/home-screen.tsx delete mode 100644 apps/miniapp/src/screens/house-screen.tsx delete mode 100644 apps/miniapp/src/screens/ledger-screen.tsx create mode 100644 apps/miniapp/src/theme.css diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts index 058ff14..958734b 100644 --- a/apps/bot/src/anonymous-feedback.test.ts +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -201,6 +201,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit ], listPendingHouseholdMembers: async () => [], approvePendingHouseholdMember: async () => null, + rejectPendingHouseholdMember: async () => false, updateHouseholdDefaultLocale: async (_householdId, locale) => ({ householdId: 'household-1', householdName: 'Kojori House', diff --git a/apps/bot/src/bot-i18n.test.ts b/apps/bot/src/bot-i18n.test.ts index 13af8eb..a1a1c71 100644 --- a/apps/bot/src/bot-i18n.test.ts +++ b/apps/bot/src/bot-i18n.test.ts @@ -127,6 +127,7 @@ function createRepository(isAdmin = false): HouseholdConfigurationRepository { : [], listPendingHouseholdMembers: async () => [], approvePendingHouseholdMember: async () => null, + rejectPendingHouseholdMember: async () => false, updateHouseholdDefaultLocale: async () => { throw new Error('not implemented') }, diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts index b36a373..f676efb 100644 --- a/apps/bot/src/dm-assistant.test.ts +++ b/apps/bot/src/dm-assistant.test.ts @@ -261,6 +261,7 @@ function createHouseholdRepository(): HouseholdConfigurationRepository { ], listPendingHouseholdMembers: async () => [], approvePendingHouseholdMember: async () => null, + rejectPendingHouseholdMember: async () => false, updateHouseholdDefaultLocale: async () => household, updateMemberPreferredLocale: async () => null, updateHouseholdMemberDisplayName: async () => null, diff --git a/apps/bot/src/finance-commands.test.ts b/apps/bot/src/finance-commands.test.ts index 2123112..fc41c38 100644 --- a/apps/bot/src/finance-commands.test.ts +++ b/apps/bot/src/finance-commands.test.ts @@ -105,6 +105,7 @@ function createRepository(): HouseholdConfigurationRepository { }, listPendingHouseholdMembers: async () => [], approvePendingHouseholdMember: async () => null, + rejectPendingHouseholdMember: async () => false, updateHouseholdDefaultLocale: async () => { throw new Error('not implemented') }, diff --git a/apps/bot/src/household-setup.test.ts b/apps/bot/src/household-setup.test.ts index 1c39c15..ea377c8 100644 --- a/apps/bot/src/household-setup.test.ts +++ b/apps/bot/src/household-setup.test.ts @@ -520,6 +520,9 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit members.set(key, member) return member }, + async rejectPendingHouseholdMember() { + return false + }, async updateHouseholdDefaultLocale(householdId, locale) { const household = [...households.values()].find((entry) => entry.householdId === householdId) if (!household) { diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index df41968..4ee3c52 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -50,6 +50,7 @@ import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-au import { createMiniAppDashboardHandler } from './miniapp-dashboard' import { createMiniAppApproveMemberHandler, + createMiniAppRejectMemberHandler, createMiniAppPendingMembersHandler, createMiniAppPromoteMemberHandler, createMiniAppSettingsHandler, @@ -576,6 +577,15 @@ const server = createBotWebhookServer({ logger: getLogger('miniapp-admin') }) : undefined, + miniAppRejectMember: householdOnboardingService + ? createMiniAppRejectMemberHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + miniAppAdminService: miniAppAdminService!, + logger: getLogger('miniapp-admin') + }) + : undefined, miniAppSettings: householdOnboardingService ? createMiniAppSettingsHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, diff --git a/apps/bot/src/miniapp-admin.test.ts b/apps/bot/src/miniapp-admin.test.ts index 8db4a17..b52431b 100644 --- a/apps/bot/src/miniapp-admin.test.ts +++ b/apps/bot/src/miniapp-admin.test.ts @@ -8,6 +8,7 @@ import type { import { createMiniAppApproveMemberHandler, + createMiniAppRejectMemberHandler, createMiniAppPendingMembersHandler, createMiniAppPromoteMemberHandler, createMiniAppSettingsHandler, @@ -131,6 +132,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { isAdmin: false } : null, + rejectPendingHouseholdMember: async (input) => input.telegramUserId === '555777', updateHouseholdDefaultLocale: async (_householdId, locale) => ({ ...household, defaultLocale: locale @@ -407,6 +409,60 @@ describe('createMiniAppApproveMemberHandler', () => { }) }) +describe('createMiniAppRejectMemberHandler', () => { + test('rejects a pending member for an authenticated admin', async () => { + const authDate = Math.floor(Date.now() / 1000) + const repository = onboardingRepository() + repository.listHouseholdMembersByTelegramUserId = async () => [ + { + id: 'member-123456', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: true + } + ] + + const handler = createMiniAppRejectMemberHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + onboardingService: createHouseholdOnboardingService({ + repository + }), + miniAppAdminService: createMiniAppAdminService(repository) + }) + + const response = await handler.handler( + new Request('http://localhost/api/miniapp/admin/reject-member', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: buildMiniAppInitData('test-bot-token', authDate, { + id: 123456, + first_name: 'Stan', + username: 'stanislav', + language_code: 'ru' + }), + pendingTelegramUserId: '555777' + }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true + }) + }) +}) + describe('createMiniAppSettingsHandler', () => { test('returns billing settings and admin members for an authenticated admin', async () => { const authDate = Math.floor(Date.now() / 1000) diff --git a/apps/bot/src/miniapp-admin.ts b/apps/bot/src/miniapp-admin.ts index edf104e..de6df3a 100644 --- a/apps/bot/src/miniapp-admin.ts +++ b/apps/bot/src/miniapp-admin.ts @@ -1396,3 +1396,87 @@ export function createMiniAppApproveMemberHandler(options: { } } } + +export function createMiniAppRejectMemberHandler(options: { + allowedOrigins: readonly string[] + botToken: string + onboardingService: HouseholdOnboardingService + miniAppAdminService: MiniAppAdminService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + onboardingService: options.onboardingService + }) + + return { + handler: async (request) => { + const origin = allowedMiniAppOrigin(request, options.allowedOrigins) + + if (request.method === 'OPTIONS') { + return miniAppJsonResponse({ ok: true }, 204, origin) + } + + if (request.method !== 'POST') { + return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin) + } + + try { + const payload = await readApprovalPayload(request) + + const session = await sessionService.authenticate({ + initData: payload.initData + }) + if (!session) { + return miniAppJsonResponse( + { ok: false, error: 'Invalid Telegram init data' }, + 401, + origin + ) + } + + if ( + !session.authorized || + !session.member || + session.member.status !== 'active' || + !session.member.isAdmin + ) { + return miniAppJsonResponse( + { ok: false, error: 'Admin access required for active household members' }, + 403, + origin + ) + } + + const result = await options.miniAppAdminService.rejectPendingMember({ + householdId: session.member.householdId, + actorIsAdmin: session.member.isAdmin, + pendingTelegramUserId: payload.pendingTelegramUserId + }) + + if (result.status === 'rejected') { + const status = result.reason === 'pending_not_found' ? 404 : 403 + const error = + result.reason === 'pending_not_found' + ? 'Pending member not found' + : 'Admin access required' + + return miniAppJsonResponse({ ok: false, error }, status, origin) + } + + return miniAppJsonResponse( + { + ok: true, + authorized: true + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index 7008617..3d87018 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -123,6 +123,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { pending = null return member }, + rejectPendingHouseholdMember: async () => false, updateHouseholdDefaultLocale: async (_householdId, locale) => ({ ...household, defaultLocale: locale diff --git a/apps/bot/src/miniapp-billing.test.ts b/apps/bot/src/miniapp-billing.test.ts index add3a92..54056fb 100644 --- a/apps/bot/src/miniapp-billing.test.ts +++ b/apps/bot/src/miniapp-billing.test.ts @@ -126,6 +126,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { ], listPendingHouseholdMembers: async () => [], approvePendingHouseholdMember: async () => null, + rejectPendingHouseholdMember: async () => false, updateHouseholdDefaultLocale: async (_householdId, locale) => ({ ...household, defaultLocale: locale diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index 160c4c8..93a4482 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -201,6 +201,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { listHouseholdMembersByTelegramUserId: async () => [], listPendingHouseholdMembers: async () => [], approvePendingHouseholdMember: async () => null, + rejectPendingHouseholdMember: async () => false, updateHouseholdDefaultLocale: async (_householdId, locale) => ({ ...household, defaultLocale: locale diff --git a/apps/bot/src/miniapp-locale.test.ts b/apps/bot/src/miniapp-locale.test.ts index e337bd5..2aac123 100644 --- a/apps/bot/src/miniapp-locale.test.ts +++ b/apps/bot/src/miniapp-locale.test.ts @@ -109,6 +109,7 @@ function repository(): HouseholdConfigurationRepository { }, listPendingHouseholdMembers: async () => [], approvePendingHouseholdMember: async () => null, + rejectPendingHouseholdMember: async () => false, updateHouseholdDefaultLocale: async (_householdId, locale) => { household.defaultLocale = locale for (const [id, member] of members.entries()) { diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index 74ce863..99173f2 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -32,6 +32,12 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppRejectMember?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined miniAppSettings?: | { path?: string @@ -128,6 +134,12 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppAddPurchase?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined miniAppUpdatePurchase?: | { path?: string @@ -201,6 +213,8 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { options.miniAppPendingMembers?.path ?? '/api/miniapp/admin/pending-members' const miniAppApproveMemberPath = options.miniAppApproveMember?.path ?? '/api/miniapp/admin/approve-member' + const miniAppRejectMemberPath = + options.miniAppRejectMember?.path ?? '/api/miniapp/admin/reject-member' const miniAppSettingsPath = options.miniAppSettings?.path ?? '/api/miniapp/admin/settings' const miniAppUpdateSettingsPath = options.miniAppUpdateSettings?.path ?? '/api/miniapp/admin/settings/update' @@ -231,6 +245,8 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { options.miniAppUpdateUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/update' const miniAppDeleteUtilityBillPath = options.miniAppDeleteUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/delete' + const miniAppAddPurchasePath = + options.miniAppAddPurchase?.path ?? '/api/miniapp/admin/purchases/add' const miniAppUpdatePurchasePath = options.miniAppUpdatePurchase?.path ?? '/api/miniapp/admin/purchases/update' const miniAppDeletePurchasePath = @@ -274,6 +290,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppApproveMember.handler(request) } + if (options.miniAppRejectMember && url.pathname === miniAppRejectMemberPath) { + return await options.miniAppRejectMember.handler(request) + } + if (options.miniAppSettings && url.pathname === miniAppSettingsPath) { return await options.miniAppSettings.handler(request) } @@ -350,6 +370,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppDeleteUtilityBill.handler(request) } + if (options.miniAppAddPurchase && url.pathname === miniAppAddPurchasePath) { + return await options.miniAppAddPurchase.handler(request) + } + if (options.miniAppUpdatePurchase && url.pathname === miniAppUpdatePurchasePath) { return await options.miniAppUpdatePurchase.handler(request) } diff --git a/apps/miniapp/package.json b/apps/miniapp/package.json index eed07c8..88111e5 100644 --- a/apps/miniapp/package.json +++ b/apps/miniapp/package.json @@ -11,10 +11,12 @@ }, "dependencies": { "@kobalte/core": "0.13.11", + "@solidjs/router": "0.15.4", "@tanstack/solid-query": "5.90.23", "@twa-dev/sdk": "8.0.2", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "lucide-solid": "0.577.0", "solid-js": "^1.9.9", "zod": "4.3.6" }, diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 476146a..ba21c74 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -1,2529 +1,36 @@ -import { Match, Show, Switch, createMemo, createSignal, onMount } from 'solid-js' +import { Route, Router } from '@solidjs/router' +import { Match, Switch } from 'solid-js' -import { dictionary, type Locale } from './i18n' -import { majorStringToMinor, minorToMajorString } from './lib/money' -import { - fetchAdminSettingsQuery, - fetchBillingCycleQuery, - fetchDashboardQuery, - fetchPendingMembersQuery, - fetchSessionQuery, - invalidateHouseholdQueries -} from './app/miniapp-queries' -import { - addMiniAppUtilityBill, - addMiniAppPayment, - approveMiniAppPendingMember, - 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, - HomeIcon, - HouseIcon, - MiniChip, - Modal, - ReceiptIcon, - WalletIcon -} from './components/ui' -import { NavigationTabs } from './components/layout/navigation-tabs' -import { TopBar } from './components/layout/top-bar' -import { BlockedState } from './components/session/blocked-state' +import { I18nProvider, useI18n } from './contexts/i18n-context' +import { SessionProvider, useSession, joinDeepLink } from './contexts/session-context' +import { DashboardProvider, useDashboard } from './contexts/dashboard-context' +import { AppShell } from './components/layout/shell' import { LoadingState } from './components/session/loading-state' +import { BlockedState } from './components/session/blocked-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 - 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 NavigationKey = 'home' | 'balances' | 'ledger' | 'house' - -const chartPalette = ['#f7b389', '#6fd3c0', '#f06a8d', '#94a8ff', '#f3d36f', '#7dc96d'] as const - -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' -} - -type TestingRolePreview = 'admin' | 'resident' - -const TESTING_ROLE_TAP_WINDOW_MS = 30 * 60 * 1000 - -const demoSession: Extract = { - 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 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 { - 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 { - 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 { - 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('en') - const [session, setSession] = createSignal({ - status: 'loading' - }) - const [activeNav, setActiveNav] = createSignal('home') - const [selectedBalanceMemberId, setSelectedBalanceMemberId] = createSignal(null) - const [dashboard, setDashboard] = createSignal(null) - const [pendingMembers, setPendingMembers] = createSignal([]) - const [adminSettings, setAdminSettings] = createSignal(null) - const [cycleState, setCycleState] = createSignal(null) - const [joining, setJoining] = createSignal(false) - const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal(null) - const [promotingMemberId, setPromotingMemberId] = createSignal(null) - const [savingOwnDisplayName, setSavingOwnDisplayName] = createSignal(false) - const [, setSavingMemberDisplayNameId] = createSignal(null) - const [, setSavingRentWeightMemberId] = createSignal(null) - const [, setSavingMemberStatusId] = createSignal(null) - const [, setSavingMemberAbsencePolicyId] = createSignal(null) - const [savingMemberEditorId, setSavingMemberEditorId] = createSignal(null) - const [displayNameDraft, setDisplayNameDraft] = createSignal('') - const [memberDisplayNameDrafts, setMemberDisplayNameDrafts] = createSignal< - Record - >({}) - const [rentWeightDrafts, setRentWeightDrafts] = createSignal>({}) - const [memberStatusDrafts, setMemberStatusDrafts] = createSignal< - Record - >({}) - const [memberAbsencePolicyDrafts, setMemberAbsencePolicyDrafts] = createSignal< - Record - >({}) - const [savingMemberLocale, setSavingMemberLocale] = createSignal(false) - const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false) - const [savingBillingSettings, setSavingBillingSettings] = createSignal(false) - const [savingCategorySlug, setSavingCategorySlug] = createSignal(null) - const [openingCycle, setOpeningCycle] = createSignal(false) - const [savingCycleRent, setSavingCycleRent] = createSignal(false) - const [savingUtilityBill, setSavingUtilityBill] = createSignal(false) - const [savingUtilityBillId, setSavingUtilityBillId] = createSignal(null) - const [deletingUtilityBillId, setDeletingUtilityBillId] = createSignal(null) - const [utilityBillDrafts, setUtilityBillDrafts] = createSignal>( - {} - ) - const [purchaseDraftMap, setPurchaseDraftMap] = createSignal>({}) - const [paymentDraftMap, setPaymentDraftMap] = createSignal>({}) - const [savingPurchaseId, setSavingPurchaseId] = createSignal(null) - const [deletingPurchaseId, setDeletingPurchaseId] = createSignal(null) - const [savingPaymentId, setSavingPaymentId] = createSignal(null) - const [deletingPaymentId, setDeletingPaymentId] = createSignal(null) - const [editingPurchaseId, setEditingPurchaseId] = createSignal(null) - const [editingPaymentId, setEditingPaymentId] = createSignal(null) - const [editingUtilityBillId, setEditingUtilityBillId] = createSignal(null) - const [editingMemberId, setEditingMemberId] = createSignal(null) - const [editingCategorySlug, setEditingCategorySlug] = createSignal(null) - const [editingCategoryDraft, setEditingCategoryDraft] = createSignal<{ - name: string - isActive: boolean - } | 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 [testingSurfaceOpen, setTestingSurfaceOpen] = createSignal(false) - const [roleChipTapHistory, setRoleChipTapHistory] = createSignal([]) - const [testingRolePreview, setTestingRolePreview] = createSignal(null) - const [addingPayment, setAddingPayment] = createSignal(false) - const [billingForm, setBillingForm] = createSignal({ - householdName: '', - 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', - assistantContext: '', - assistantTone: '' - }) - 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({ - 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 effectiveIsAdmin = createMemo(() => { - const current = readySession() - if (!current) { - return false - } - - 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((member) => member.memberId === current.member.id) ?? null - }) - const inspectedBalanceMember = createMemo(() => { - const data = dashboard() - if (!data) { - return null - } - - const selected = selectedBalanceMemberId() - - return ( - data.members.find((member) => member.memberId === selected) ?? - currentMemberLine() ?? - data.members[0] ?? - 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 defaultPaymentMemberId = createMemo(() => adminSettings()?.members[0]?.id ?? '') - 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() - - 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 handleRoleChipTap() { - const currentReady = readySession() - if (!currentReady?.member.isAdmin) { - return - } - - const now = Date.now() - const nextHistory = [ - ...roleChipTapHistory().filter((timestamp) => now - timestamp < TESTING_ROLE_TAP_WINDOW_MS), - now - ] - - if (nextHistory.length >= 5) { - setRoleChipTapHistory([]) - setTestingSurfaceOpen(true) - return - } - - setRoleChipTapHistory(nextHistory) - } - - 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({ - householdName: payload.householdName, - 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, - assistantContext: payload.assistantConfig.assistantContext ?? '', - assistantTone: payload.assistantConfig.assistantTone ?? '' - }) - setPaymentForm((current) => ({ - ...current, - memberId: - (current.memberId && payload.members.some((member) => member.id === 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({ - householdName: demoAdminSettings.householdName, - 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, - assistantContext: demoAdminSettings.assistantConfig.assistantContext ?? '', - assistantTone: demoAdminSettings.assistantConfig.assistantTone ?? '' - }) - 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) - await refreshHouseholdData(initData, true, true) - } 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) - } - - await refreshHouseholdData(initData, true, true) - } 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 { householdName, settings, assistantConfig } = await updateMiniAppBillingSettings( - initData, - billingForm() - ) - setAdminSettings((current) => - current - ? { - ...current, - householdName, - settings, - assistantConfig - } - : current - ) - setBillingForm((current) => ({ - ...current, - householdName - })) - setSession((current) => - current.status === 'ready' - ? { - ...current, - member: { - ...current.member, - householdName - } - } - : current - ) - setCycleForm((current) => ({ - ...current, - rentCurrency: settings.rentCurrency, - utilityCurrency: settings.settlementCurrency - })) - await refreshHouseholdData(initData, true, true) - 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 - })) - await refreshHouseholdData(initData, true, true) - setCycleRentOpen(false) - } finally { - setOpeningCycle(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)) - await refreshHouseholdData(initData, true, true) - 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: '' - })) - await refreshHouseholdData(initData, true, true) - 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)) - await refreshHouseholdData(initData, true, true) - 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)) - await refreshHouseholdData(initData, true, true) - 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() - const memberId = draft.memberId.trim() || defaultPaymentMemberId() - if ( - !initData || - currentReady?.mode !== 'live' || - !currentReady.member.isAdmin || - memberId.length === 0 || - draft.amountMajor.trim().length === 0 - ) { - return - } - - setAddingPayment(true) - - try { - await addMiniAppPayment(initData, { - ...draft, - memberId - }) - setPaymentForm((current) => ({ - ...current, - memberId, - 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, - refreshAfterSave = 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 (refreshAfterSave) { - await refreshHouseholdData(initData, true, true) - } - if (closeEditor) { - setEditingMemberId(null) - } - } finally { - setSavingRentWeightMemberId(null) - } - } - - async function handleSaveMemberStatus( - memberId: string, - closeEditor = true, - refreshAfterSave = 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 (refreshAfterSave) { - await refreshHouseholdData(initData, true, true) - } - if (closeEditor) { - setEditingMemberId(null) - } - } finally { - setSavingMemberStatusId(null) - } - } - - async function handleSaveMemberAbsencePolicy( - memberId: string, - closeEditor = true, - refreshAfterSave = 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 (refreshAfterSave) { - await refreshHouseholdData(initData, true, true) - } - 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 - const requiresDashboardRefresh = hasStatusChange || wantsAwayPolicySave || hasWeightChange - - if (!hasNameChange && !hasStatusChange && !wantsAwayPolicySave && !hasWeightChange) { - return - } - - setSavingMemberEditorId(memberId) - - try { - if (hasNameChange) { - await handleSaveMemberDisplayName(memberId, false) - } - - if (hasStatusChange) { - await handleSaveMemberStatus(memberId, false, false) - } - - if (wantsAwayPolicySave) { - await handleSaveMemberAbsencePolicy(memberId, false, false) - } - - if (hasWeightChange) { - await handleSaveRentWeight(memberId, false, false) - } - - if (requiresDashboardRefresh) { - const initData = webApp?.initData?.trim() - if (initData) { - await refreshHouseholdData(initData, true, true) - } - } - - 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 panel = createMemo(() => { - switch (activeNav()) { - case 'balances': - return ( - - ) - case 'ledger': - return ( - 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={() => { - setPaymentForm((current) => ({ - ...current, - memberId: current.memberId.trim() || defaultPaymentMemberId(), - currency: adminSettings()?.settings.settlementCurrency ?? current.currency - })) - 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 ( - - resolvedMemberAbsencePolicy(memberId, status) - } - onChangeHouseholdLocale={handleHouseholdLocaleChange} - onOpenProfileEditor={() => setProfileEditorOpen(true)} - onOpenCycleModal={() => setCycleRentOpen(true)} - onCloseCycleModal={() => setCycleRentOpen(false)} - onSaveCycleRent={handleSaveCycleRent} - onOpenCycle={handleOpenCycle} - 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 - })) - } - onBillingHouseholdNameChange={(value) => - setBillingForm((current) => ({ - ...current, - householdName: value - })) - } - onBillingAdjustmentPolicyChange={(value) => - setBillingForm((current) => ({ - ...current, - paymentBalanceAdjustmentPolicy: value - })) - } - onBillingRentAmountChange={(value) => - setBillingForm((current) => ({ - ...current, - rentAmountMajor: value - })) - } - onBillingRentCurrencyChange={(value) => - setBillingForm((current) => ({ - ...current, - rentCurrency: value - })) - } - onBillingRentDueDayChange={(value) => - value === null - ? undefined - : setBillingForm((current) => ({ - ...current, - rentDueDay: value - })) - } - onBillingRentWarningDayChange={(value) => - value === null - ? undefined - : setBillingForm((current) => ({ - ...current, - rentWarningDay: value - })) - } - onBillingUtilitiesDueDayChange={(value) => - value === null - ? undefined - : setBillingForm((current) => ({ - ...current, - utilitiesDueDay: value - })) - } - onBillingUtilitiesReminderDayChange={(value) => - value === null - ? undefined - : setBillingForm((current) => ({ - ...current, - utilitiesReminderDay: value - })) - } - onBillingTimezoneChange={(value) => - setBillingForm((current) => ({ - ...current, - timezone: value - })) - } - onBillingAssistantContextChange={(value) => - setBillingForm((current) => ({ - ...current, - assistantContext: value - })) - } - onBillingAssistantToneChange={(value) => - setBillingForm((current) => ({ - ...current, - assistantTone: 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={(slug) => { - setEditingCategorySlug(slug) - if (slug === '__new__') { - setNewCategoryName('') - setEditingCategoryDraft(null) - return - } - - const category = - adminSettings()?.categories.find((item) => item.slug === slug) ?? null - setEditingCategoryDraft( - category - ? { - name: category.name, - isActive: category.isActive - } - : null - ) - }} - onCloseCategoryEditor={() => { - setEditingCategorySlug(null) - setEditingCategoryDraft(null) - setNewCategoryName('') - }} - 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: editingCategoryDraft()?.name ?? category.name, - sortOrder: category.sortOrder, - isActive: editingCategoryDraft()?.isActive ?? category.isActive - }) - }} - editingCategoryDraft={editingCategoryDraft()} - onEditingCategoryNameChange={(value) => - setEditingCategoryDraft((current) => - current - ? { - ...current, - name: value - } - : current - ) - } - onEditingCategoryActiveChange={(value) => - setEditingCategoryDraft((current) => - current - ? { - ...current, - isActive: value - } - : 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 ( - { - setSelectedBalanceMemberId(currentMemberLine()?.memberId ?? null) - setActiveNav('balances') - }} - /> - ) - } - }) +import HomeRoute from './routes/home' +import BalancesRoute from './routes/balances' +import LedgerRoute from './routes/ledger' +import SettingsRoute from './routes/settings' + +function AppContent() { + const { session, onboardingSession, blockedSession, joining, handleJoinHousehold } = useSession() + const { copy } = useI18n() return ( -
-
-
- - void handleMemberLocaleChange(nextLocale)} - /> - - - + + +
- +
+
- + +
window.location.reload()} /> - +
+
- + +
window.location.reload()} /> - +
+
- -
-
- - {readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge} - - - {effectiveIsAdmin() ? copy().adminTag : copy().residentTag} - - } - > - - - - {readySession()?.member.status - ? memberStatusLabel(readySession()!.member.status) - : copy().memberStatusActive} - - - {(preview) => ( - {`${copy().testingViewBadge ?? ''}: ${preview() === 'admin' ? copy().adminTag : copy().residentTag}`} - )} - -
-
+ + + + + +
+ ) +} -
{panel()}
-
- }, - { key: 'balances', label: copy().balances, icon: }, - { key: 'ledger', label: copy().ledger, icon: }, - { key: 'house', label: copy().house, icon: } - ] as const - } - active={activeNav()} - onChange={setActiveNav} - /> -
- setTestingSurfaceOpen(false)} - footer={ - - } - > -
-
- {copy().testingCurrentRoleLabel ?? ''} - - {readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag} - -
-
- {copy().testingPreviewRoleLabel ?? ''} - - {testingRolePreview() - ? testingRolePreview() === 'admin' - ? copy().adminTag - : copy().residentTag - : copy().testingUseRealRoleAction} - -
-
- - -
-
-
- setProfileEditorOpen(false)} - footer={ - - } - > -
- - setDisplayNameDraft(event.currentTarget.value)} - /> - -
-
-
-
-
+function AuthenticatedApp() { + const { initData } = useSession() + const { loadDashboardData } = useDashboard() + + // Load dashboard data once the component mounts + const data = initData() + void loadDashboardData(data ?? '', true) + + return ( + + + + + + + ) +} + +function App() { + return ( + + + + + ) } diff --git a/apps/miniapp/src/components/finance/finance-summary-cards.tsx b/apps/miniapp/src/components/finance/finance-summary-cards.tsx deleted file mode 100644 index 58ed000..0000000 --- a/apps/miniapp/src/components/finance/finance-summary-cards.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { For } from 'solid-js' - -import type { MiniAppDashboard } from '../../miniapp-api' -import { StatCard } from '../ui' - -type SummaryItem = { - label: string - value: string -} - -type Props = { - dashboard: MiniAppDashboard - utilityTotalMajor: string - purchaseTotalMajor: string - labels: { - remaining: string - rent: string - utilities: string - purchases: string - } -} - -export function FinanceSummaryCards(props: Props) { - const items: SummaryItem[] = [ - { - label: props.labels.remaining, - value: `${props.dashboard.totalRemainingMajor} ${props.dashboard.currency}` - }, - { - label: props.labels.rent, - value: `${props.dashboard.rentDisplayAmountMajor} ${props.dashboard.currency}` - }, - { - label: props.labels.utilities, - value: `${props.utilityTotalMajor} ${props.dashboard.currency}` - }, - { - label: props.labels.purchases, - value: `${props.purchaseTotalMajor} ${props.dashboard.currency}` - } - ] - - return ( - - {(item) => ( - - {item.label} - {item.value} - - )} - - ) -} diff --git a/apps/miniapp/src/components/finance/finance-visuals.tsx b/apps/miniapp/src/components/finance/finance-visuals.tsx deleted file mode 100644 index d388696..0000000 --- a/apps/miniapp/src/components/finance/finance-visuals.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { For, Match, Switch } from 'solid-js' - -import type { MiniAppDashboard } from '../../miniapp-api' - -type MemberVisual = { - member: MiniAppDashboard['members'][number] - totalMinor: bigint - barWidthPercent: number - segments: { - key: string - label: string - amountMajor: string - amountMinor: bigint - widthPercent: number - }[] -} - -type PurchaseSlice = { - key: string - label: string - amountMajor: string - color: string - percentage: number - dasharray: string - dashoffset: string -} - -type Props = { - dashboard: MiniAppDashboard - memberVisuals: readonly MemberVisual[] - purchaseChart: { - totalMajor: string - slices: readonly PurchaseSlice[] - } - labels: { - financeVisualsTitle: string - financeVisualsBody: string - membersCount: string - purchaseInvestmentsTitle: string - purchaseInvestmentsBody: string - purchaseInvestmentsEmpty: string - purchaseTotalLabel: string - purchaseShareLabel: string - } - remainingClass: (member: MiniAppDashboard['members'][number]) => string -} - -export function FinanceVisuals(props: Props) { - return ( - <> -
-
- {props.labels.financeVisualsTitle} - - {props.labels.membersCount}: {String(props.dashboard.members.length)} - -
-

{props.labels.financeVisualsBody}

-
- - {(item) => ( -
-
- {item.member.displayName} - - {item.member.remainingMajor} {props.dashboard.currency} - -
-
-
- - {(segment) => ( - - )} - -
-
-
- - {(segment) => ( - - {segment.label}: {segment.amountMajor} {props.dashboard.currency} - - )} - -
-
- )} -
-
-
- -
-
- {props.labels.purchaseInvestmentsTitle} - - {props.labels.purchaseTotalLabel}: {props.purchaseChart.totalMajor}{' '} - {props.dashboard.currency} - -
-

{props.labels.purchaseInvestmentsBody}

- - -

{props.labels.purchaseInvestmentsEmpty}

-
- 0}> -
-
- -
- {props.purchaseChart.totalMajor} - {props.dashboard.currency} -
-
-
- - {(slice) => ( -
-
- - {slice.label} -
-

- {slice.amountMajor} {props.dashboard.currency} ·{' '} - {props.labels.purchaseShareLabel} {slice.percentage}% -

-
- )} -
-
-
-
-
-
- - ) -} diff --git a/apps/miniapp/src/components/finance/member-balance-card.tsx b/apps/miniapp/src/components/finance/member-balance-card.tsx deleted file mode 100644 index 8753f41..0000000 --- a/apps/miniapp/src/components/finance/member-balance-card.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { Show } from 'solid-js' - -import { cn } from '../../lib/cn' -import { formatCyclePeriod, formatFriendlyDate } from '../../lib/dates' -import { majorStringToMinor, sumMajorStrings } from '../../lib/money' -import type { MiniAppDashboard } from '../../miniapp-api' -import { MiniChip, StatCard } from '../ui' - -type Props = { - copy: Record - locale: 'en' | 'ru' - dashboard: MiniAppDashboard - member: MiniAppDashboard['members'][number] - detail?: boolean -} - -export function MemberBalanceCard(props: Props) { - const utilitiesAdjustedMajor = () => - sumMajorStrings(props.member.utilityShareMajor, props.member.purchaseOffsetMajor) - - const adjustmentClass = () => { - const value = majorStringToMinor(props.member.purchaseOffsetMajor) - - if (value < 0n) { - return 'is-credit' - } - - if (value > 0n) { - return 'is-due' - } - - return 'is-settled' - } - - return ( -
-
-
- {props.copy.yourBalanceTitle ?? ''} - {(body) =>

{body()}

}
-
-
- {props.copy.remainingLabel ?? ''} - - {props.member.remainingMajor} {props.dashboard.currency} - - 0n}> - - {props.copy.totalDue ?? ''}: {props.member.netDueMajor} {props.dashboard.currency} - - -
-
- -
- - {props.copy.currentCycleLabel ?? ''} - {formatCyclePeriod(props.dashboard.period, props.locale)} - - - {props.copy.paidLabel ?? ''} - - {props.member.paidMajor} {props.dashboard.currency} - - - - {props.copy.remainingLabel ?? ''} - - {props.member.remainingMajor} {props.dashboard.currency} - - -
- -
-
-
- {props.copy.shareRent ?? ''} - - {props.member.rentShareMajor} {props.dashboard.currency} - -
-
- -
-
- {props.copy.pureUtilitiesLabel ?? props.copy.shareUtilities ?? ''} - - {props.member.utilityShareMajor} {props.dashboard.currency} - -
-
- -
-
- {props.copy.balanceAdjustmentLabel ?? props.copy.shareOffset ?? ''} - - {props.member.purchaseOffsetMajor} {props.dashboard.currency} - -
-
- - -
-
- {props.copy.utilitiesAdjustedTotalLabel ?? ''} - - {utilitiesAdjustedMajor()} {props.dashboard.currency} - -
-
-
-
- - -
-
- {props.copy.rentFxTitle ?? ''} - - {(date) => ( - - {props.copy.fxEffectiveDateLabel ?? ''}:{' '} - {formatFriendlyDate(date(), props.locale)} - - )} - -
- -
-
- {props.copy.sourceAmountLabel ?? ''} - - {props.dashboard.rentSourceAmountMajor} {props.dashboard.rentSourceCurrency} - -
-
- {props.copy.settlementAmountLabel ?? ''} - - {props.dashboard.rentDisplayAmountMajor} {props.dashboard.currency} - -
-
-
-
-
- ) -} diff --git a/apps/miniapp/src/components/layout/navigation-tabs.tsx b/apps/miniapp/src/components/layout/navigation-tabs.tsx index 798d051..3b5edc1 100644 --- a/apps/miniapp/src/components/layout/navigation-tabs.tsx +++ b/apps/miniapp/src/components/layout/navigation-tabs.tsx @@ -1,28 +1,45 @@ -import type { JSX } from 'solid-js' +import { useNavigate, useLocation } from '@solidjs/router' +import { Home, Wallet, BookOpen } from 'lucide-solid' +import { type JSX } from 'solid-js' -type TabItem = { - key: T +import { useI18n } from '../../contexts/i18n-context' + +type TabItem = { + path: string label: string - icon?: JSX.Element + icon: JSX.Element } -type Props = { - items: readonly TabItem[] - active: T - onChange: (key: T) => void -} +/** + * Bottom navigation bar with 3 tabs (Bug #6 fix: reduced to 3 tabs, + * settings moved to top bar gear icon). + */ +export function NavigationTabs(): JSX.Element { + const navigate = useNavigate() + const location = useLocation() + const { copy } = useI18n() + + const tabs = (): TabItem[] => [ + { path: '/', label: copy().home, icon: }, + { path: '/balances', label: copy().balances, icon: }, + { path: '/ledger', label: copy().ledger, icon: } + ] + + const isActive = (path: string) => { + if (path === '/') return location.pathname === '/' + return location.pathname.startsWith(path) + } -export function NavigationTabs(props: Props): JSX.Element { return ( diff --git a/apps/miniapp/src/components/layout/profile-card.tsx b/apps/miniapp/src/components/layout/profile-card.tsx deleted file mode 100644 index a1ae035..0000000 --- a/apps/miniapp/src/components/layout/profile-card.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Card, MiniChip } from '../ui' - -type Props = { - displayName: string - roleLabel: string - statusSummary: string - modeBadge: string - localeBadge: string -} - -export function ProfileCard(props: Props) { - return ( - -
- {props.displayName} - {props.roleLabel} -
-

{props.statusSummary}

-
- {props.modeBadge} - {props.localeBadge} -
-
- ) -} diff --git a/apps/miniapp/src/components/layout/shell.tsx b/apps/miniapp/src/components/layout/shell.tsx new file mode 100644 index 0000000..57f5ae1 --- /dev/null +++ b/apps/miniapp/src/components/layout/shell.tsx @@ -0,0 +1,164 @@ +import { useNavigate } from '@solidjs/router' +import { Show, createSignal, type ParentProps } from 'solid-js' +import { Settings } from 'lucide-solid' + +import { useSession } from '../../contexts/session-context' +import { useI18n } from '../../contexts/i18n-context' +import { useDashboard } from '../../contexts/dashboard-context' +import { NavigationTabs } from './navigation-tabs' +import { Badge } from '../ui/badge' +import { Button, IconButton } from '../ui/button' +import { Modal } from '../ui/dialog' + +export function AppShell(props: ParentProps) { + const { readySession } = useSession() + const { copy, locale, setLocale } = useI18n() + const { effectiveIsAdmin, testingRolePreview, setTestingRolePreview } = useDashboard() + const navigate = useNavigate() + + const [testingSurfaceOpen, setTestingSurfaceOpen] = createSignal(false) + + function memberStatusLabel(status: 'active' | 'away' | 'left') { + const labels = { + active: copy().memberStatusActive, + away: copy().memberStatusAway, + left: copy().memberStatusLeft + } + return labels[status] + } + + let tapCount = 0 + let tapTimer: ReturnType | undefined + function handleRoleChipTap() { + tapCount++ + if (tapCount >= 5) { + setTestingSurfaceOpen(true) + tapCount = 0 + } + clearTimeout(tapTimer) + tapTimer = setTimeout(() => { + tapCount = 0 + }, 1000) + } + + return ( +
+ {/* ── Top bar ──────────────────────────────────── */} +
+
+

{copy().appSubtitle}

+

{readySession()?.member.householdName ?? copy().appTitle}

+
+ +
+
+
+ + +
+
+ navigate('/settings')}> + + +
+
+ + {/* ── Context badges ───────────────────────────── */} +
+
+ + {readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge} + + + {effectiveIsAdmin() ? copy().adminTag : copy().residentTag} + + } + > + + + + {readySession()?.member.status + ? memberStatusLabel(readySession()!.member.status) + : copy().memberStatusActive} + + + {(preview) => ( + + {`${copy().testingViewBadge ?? ''}: ${preview() === 'admin' ? copy().adminTag : copy().residentTag}`} + + )} + +
+
+ + {/* ── Route content ────────────────────────────── */} +
{props.children}
+ + {/* ── Bottom nav (Bug #6: 3 tabs, proper padding) */} +
+ +
+ + {/* ── Modals at route/shell level (Bug #1/#2 fix) */} + setTestingSurfaceOpen(false)} + footer={ + + } + > +
+
+ {copy().testingCurrentRoleLabel ?? ''} + {readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag} +
+
+ {copy().testingPreviewRoleLabel ?? ''} + + {testingRolePreview() + ? testingRolePreview() === 'admin' + ? copy().adminTag + : copy().residentTag + : copy().testingUseRealRoleAction} + +
+
+ + +
+
+
+
+ ) +} diff --git a/apps/miniapp/src/components/layout/top-bar.tsx b/apps/miniapp/src/components/layout/top-bar.tsx deleted file mode 100644 index 657882b..0000000 --- a/apps/miniapp/src/components/layout/top-bar.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { Locale } from '../../i18n' -import { GlobeIcon } from '../ui' - -type Props = { - subtitle: string - title: string - languageLabel: string - locale: Locale - saving: boolean - onChange: (locale: Locale) => void -} - -export function TopBar(props: Props) { - return ( -
-
-

{props.subtitle}

-

{props.title}

-
- -
- {props.languageLabel} -
- - - -
-
-
- ) -} diff --git a/apps/miniapp/src/components/ui/badge.tsx b/apps/miniapp/src/components/ui/badge.tsx new file mode 100644 index 0000000..c551eae --- /dev/null +++ b/apps/miniapp/src/components/ui/badge.tsx @@ -0,0 +1,26 @@ +import type { ParentProps } from 'solid-js' + +import { cn } from '../../lib/cn' + +type BadgeProps = ParentProps<{ + variant?: 'default' | 'muted' | 'accent' | 'danger' + class?: string +}> + +export function Badge(props: BadgeProps) { + return ( + + {props.children} + + ) +} diff --git a/apps/miniapp/src/components/ui/button.tsx b/apps/miniapp/src/components/ui/button.tsx index 718314b..81116f8 100644 --- a/apps/miniapp/src/components/ui/button.tsx +++ b/apps/miniapp/src/components/ui/button.tsx @@ -1,5 +1,6 @@ import { cva, type VariantProps } from 'class-variance-authority' -import type { JSX, ParentProps } from 'solid-js' +import { Show, type JSX, type ParentProps } from 'solid-js' +import { Loader2 } from 'lucide-solid' import { cn } from '../../lib/cn' @@ -11,10 +12,16 @@ const buttonVariants = cva('ui-button', { danger: 'ui-button--danger', ghost: 'ui-button--ghost', icon: 'ui-button--icon' + }, + size: { + sm: 'ui-button--sm', + md: '', + lg: 'ui-button--lg' } }, defaultVariants: { - variant: 'secondary' + variant: 'secondary', + size: 'md' } }) @@ -22,6 +29,7 @@ type ButtonProps = ParentProps<{ type?: 'button' | 'submit' | 'reset' class?: string disabled?: boolean + loading?: boolean onClick?: JSX.EventHandlerUnion }> & VariantProps @@ -30,10 +38,13 @@ export function Button(props: ButtonProps) { return ( ) diff --git a/apps/miniapp/src/components/ui/card.tsx b/apps/miniapp/src/components/ui/card.tsx index 53b519c..08a2b2f 100644 --- a/apps/miniapp/src/components/ui/card.tsx +++ b/apps/miniapp/src/components/ui/card.tsx @@ -2,9 +2,19 @@ import type { ParentProps } from 'solid-js' import { cn } from '../../lib/cn' -export function Card(props: ParentProps<{ class?: string; accent?: boolean }>) { +export function Card( + props: ParentProps<{ class?: string; accent?: boolean; muted?: boolean; wide?: boolean }> +) { return ( -
+
{props.children}
) @@ -14,6 +24,7 @@ export function StatCard(props: ParentProps<{ class?: string }>) { return
{props.children}
} +/** @deprecated Use Badge component instead */ export function MiniChip(props: ParentProps<{ muted?: boolean; class?: string }>) { return ( diff --git a/apps/miniapp/src/components/ui/collapsible.tsx b/apps/miniapp/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..b6bcea8 --- /dev/null +++ b/apps/miniapp/src/components/ui/collapsible.tsx @@ -0,0 +1,32 @@ +import * as CollapsiblePrimitive from '@kobalte/core/collapsible' +import { Show, type ParentProps } from 'solid-js' +import { ChevronDown } from 'lucide-solid' + +import { cn } from '../../lib/cn' + +type CollapsibleProps = ParentProps<{ + title: string + body?: string + defaultOpen?: boolean + class?: string +}> + +export function Collapsible(props: CollapsibleProps) { + return ( + + +
+ {props.title} + {(body) =>

{body()}

}
+
+ +
+ + {props.children} + +
+ ) +} diff --git a/apps/miniapp/src/components/ui/field.tsx b/apps/miniapp/src/components/ui/field.tsx index ff38076..417d355 100644 --- a/apps/miniapp/src/components/ui/field.tsx +++ b/apps/miniapp/src/components/ui/field.tsx @@ -11,10 +11,10 @@ export function Field( }> ) { return ( -