From a38686c8b0d50d3bd670ebdca7f788d6336d83e2 Mon Sep 17 00:00:00 2001 From: whekin Date: Thu, 12 Mar 2026 12:45:00 +0400 Subject: [PATCH] fix(miniapp): tighten house layout and restore balance details --- apps/miniapp/src/App.tsx | 160 +++++++++++++++++++ apps/miniapp/src/i18n.ts | 9 +- apps/miniapp/src/index.css | 38 +++-- apps/miniapp/src/screens/balances-screen.tsx | 108 ++++++++++++- apps/miniapp/src/screens/house-screen.tsx | 33 ++-- apps/miniapp/src/screens/ledger-screen.tsx | 8 +- 6 files changed, 319 insertions(+), 37 deletions(-) diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 6021c2f..0b0b8f0 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -106,6 +106,8 @@ type SessionState = type NavigationKey = 'home' | 'balances' | 'ledger' | 'house' +const chartPalette = ['#f7b389', '#6fd3c0', '#f06a8d', '#94a8ff', '#f3d36f', '#7dc96d'] as const + type UtilityBillDraft = { billName: string amountMajor: string @@ -187,6 +189,30 @@ 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}` } @@ -458,6 +484,134 @@ function App() { ) ) ) + 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 { @@ -1870,6 +2024,12 @@ function App() { locale={locale()} dashboard={dashboard()} currentMemberLine={currentMemberLine()} + utilityTotalMajor={utilityTotalMajor()} + purchaseTotalMajor={purchaseTotalMajor()} + memberBalanceVisuals={memberBalanceVisuals()} + purchaseChart={purchaseInvestmentChart()} + memberBaseDueMajor={memberBaseDueMajor} + memberRemainingClass={memberRemainingClass} /> ) case 'ledger': diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index 7c4b075..9f98e20 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -30,8 +30,7 @@ export const dictionary = { reload: 'Retry', language: 'Language', householdLanguage: 'Household language', - generalSettingsBody: - 'Household identity, default language, and personal profile controls live here.', + generalSettingsBody: 'Household name, default language, and your profile controls live here.', householdNameLabel: 'Household name', householdNameHint: 'This appears in the mini app, join flow, and bot responses.', savingLanguage: 'Saving…', @@ -237,6 +236,8 @@ export const dictionary = { editCategoryAction: 'Edit category', adminsTitle: 'Admins', adminsBody: 'Promote trusted household members so they can manage billing and approvals.', + membersTitle: 'Members', + membersBody: 'Review roles, billing weights, and status for everyone in the household.', displayNameLabel: 'Household display name', displayNameHint: 'This name appears in balances, ledger entries, and assistant replies.', manageProfileAction: 'Edit profile', @@ -310,7 +311,7 @@ export const dictionary = { reload: 'Повторить', language: 'Язык', householdLanguage: 'Язык дома', - generalSettingsBody: 'Здесь живут имя дома, язык по умолчанию и доступ к личному профилю.', + generalSettingsBody: 'Здесь настраиваются название дома, язык по умолчанию и твой профиль.', householdNameLabel: 'Название дома', householdNameHint: 'Показывается в приложении, при вступлении и в ответах бота.', savingLanguage: 'Сохраняем…', @@ -518,6 +519,8 @@ export const dictionary = { adminsTitle: 'Админы', adminsBody: 'Повышай доверенных участников, чтобы они могли управлять биллингом и подтверждениями.', + membersTitle: 'Участники', + membersBody: 'Здесь собраны роли, веса аренды и статусы всех участников дома.', displayNameLabel: 'Имя в доме', displayNameHint: 'Это имя будет видно в балансе, леджере и ответах ассистента.', manageProfileAction: 'Редактировать профиль', diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index 878893f..cd4cc56 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -34,6 +34,11 @@ body { button { font: inherit; + cursor: pointer; +} + +button:disabled { + cursor: default; } #root { @@ -44,7 +49,7 @@ button { position: relative; min-height: 100vh; overflow: hidden; - padding: 24px 18px 108px; + padding: 24px 18px calc(140px + env(safe-area-inset-bottom)); } .shell__backdrop { @@ -343,7 +348,7 @@ button { .app-bottom-nav { position: fixed; right: 18px; - bottom: 20px; + bottom: calc(12px + env(safe-area-inset-bottom)); left: 18px; z-index: 3; } @@ -358,6 +363,7 @@ button { display: grid; gap: 12px; margin-top: 12px; + padding-bottom: 12px; } .summary-card-grid { @@ -387,6 +393,7 @@ button { .activity-row, .utility-bill-row { display: grid; + align-content: start; gap: 10px; border: 1px solid rgb(255 255 255 / 0.08); border-radius: 18px; @@ -769,25 +776,23 @@ button { .admin-grid { display: grid; grid-template-columns: minmax(0, 1fr); + align-items: start; gap: 12px; } .admin-section { display: grid; + align-items: start; gap: 12px; } .admin-disclosure { border: 1px solid rgb(255 255 255 / 0.08); border-radius: 20px; - background: rgb(255 255 255 / 0.03); - overflow: hidden; -} - -.admin-disclosure[open] { background: linear-gradient(180deg, rgb(255 255 255 / 0.05), rgb(255 255 255 / 0.02)), rgb(255 255 255 / 0.03); + overflow: hidden; } .admin-disclosure__summary { @@ -795,13 +800,11 @@ button { grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; + width: 100%; + border: 0; + background: transparent; padding: 16px; - cursor: pointer; - list-style: none; -} - -.admin-disclosure__summary::-webkit-details-marker { - display: none; + text-align: left; } .admin-disclosure__copy { @@ -813,7 +816,7 @@ button { transition: transform 140ms ease; } -.admin-disclosure[open] .admin-disclosure__icon { +.admin-disclosure__icon.is-open { transform: rotate(180deg); } @@ -966,7 +969,7 @@ button { -webkit-appearance: none; border: 0; background: transparent; - padding: 0; + padding: 6px 10px; font: inherit; line-height: inherit; cursor: pointer; @@ -979,6 +982,7 @@ button { } .timezone-chip { + padding: 6px 10px; border: 1px solid transparent; } @@ -1194,7 +1198,7 @@ button { .shell { max-width: 920px; margin: 0 auto; - padding: 32px 24px 40px; + padding: 32px 24px 136px; } .summary-card-grid { @@ -1266,7 +1270,7 @@ button { @media (max-width: 759px) { .shell { - padding: 18px 14px 28px; + padding: 18px 14px calc(132px + env(safe-area-inset-bottom)); } .topbar { diff --git a/apps/miniapp/src/screens/balances-screen.tsx b/apps/miniapp/src/screens/balances-screen.tsx index 06cd133..78fa57d 100644 --- a/apps/miniapp/src/screens/balances-screen.tsx +++ b/apps/miniapp/src/screens/balances-screen.tsx @@ -1,5 +1,7 @@ -import { Show } from 'solid-js' +import { For, Show } from 'solid-js' +import { FinanceSummaryCards } from '../components/finance/finance-summary-cards' +import { FinanceVisuals } from '../components/finance/finance-visuals' import { MemberBalanceCard } from '../components/finance/member-balance-card' import { formatCyclePeriod } from '../lib/dates' import type { MiniAppDashboard } from '../miniapp-api' @@ -9,6 +11,34 @@ type Props = { locale: 'en' | 'ru' dashboard: MiniAppDashboard | null currentMemberLine: MiniAppDashboard['members'][number] | null + utilityTotalMajor: string + purchaseTotalMajor: string + memberBalanceVisuals: { + member: MiniAppDashboard['members'][number] + totalMinor: bigint + barWidthPercent: number + segments: { + key: string + label: string + amountMajor: string + amountMinor: bigint + widthPercent: number + }[] + }[] + purchaseChart: { + totalMajor: string + slices: { + key: string + label: string + amountMajor: string + color: string + percentage: number + dasharray: string + dashoffset: string + }[] + } + memberBaseDueMajor: (member: MiniAppDashboard['members'][number]) => string + memberRemainingClass: (member: MiniAppDashboard['members'][number]) => string } export function BalancesScreen(props: Props) { @@ -41,6 +71,82 @@ export function BalancesScreen(props: Props) {

{props.copy.balanceScreenScopeBody ?? ''}

+
+
+ {props.copy.houseSnapshotTitle ?? ''} + {formatCyclePeriod(dashboard().period, props.locale)} +
+

{props.copy.houseSnapshotBody ?? ''}

+
+ +
+
+ +
+
+ {props.copy.householdBalancesTitle ?? ''} + {String(dashboard().members.length)} +
+

{props.copy.householdBalancesBody ?? ''}

+
+ + {(member) => ( +
+
+ {member.displayName} + + {member.remainingMajor} {dashboard().currency} + +
+

+ {props.copy.baseDue ?? ''}: {props.memberBaseDueMajor(member)}{' '} + {dashboard().currency} +

+

+ {props.copy.shareRent ?? ''}: {member.rentShareMajor} {dashboard().currency} +

+

+ {props.copy.shareUtilities ?? ''}: {member.utilityShareMajor}{' '} + {dashboard().currency} +

+

+ {props.copy.shareOffset ?? ''}: {member.purchaseOffsetMajor}{' '} + {dashboard().currency} +

+

+ {props.copy.paidLabel ?? ''}: {member.paidMajor} {dashboard().currency} +

+

+ {props.copy.remainingLabel ?? ''}: {member.remainingMajor} {dashboard().currency} +

+
+ )} +
)} diff --git a/apps/miniapp/src/screens/house-screen.tsx b/apps/miniapp/src/screens/house-screen.tsx index 2bc756b..a934757 100644 --- a/apps/miniapp/src/screens/house-screen.tsx +++ b/apps/miniapp/src/screens/house-screen.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo, type JSX } from 'solid-js' +import { For, Show, createMemo, createSignal, type JSX } from 'solid-js' import { Button, @@ -177,17 +177,25 @@ function HouseSection(props: { defaultOpen?: boolean | undefined children: JSX.Element }) { + const [open, setOpen] = createSignal(props.defaultOpen ?? false) + return ( -
- +
+
-
{props.children}
-
+ + + +
{props.children}
+
+ ) } @@ -1047,13 +1055,16 @@ export function HouseScreen(props: Props) { - +
- {props.copy.adminsTitle ?? ''} - {String(props.adminSettings?.members.length ?? 0)} + {props.copy.membersTitle ?? props.copy.houseSectionMembers ?? ''} + + {String(props.adminSettings?.members.length ?? 0)}{' '} + {props.copy.membersCount ?? ''} +
@@ -1146,7 +1157,7 @@ export function HouseScreen(props: Props) {

{props.copy.paymentsAdminBody ?? ''}

- +
{props.paymentEntries.length === 0 ? (