feat(member): add household lifecycle states

This commit is contained in:
2026-03-11 13:44:38 +04:00
parent 015298281c
commit 773abf2531
32 changed files with 3671 additions and 38 deletions

View File

@@ -17,6 +17,7 @@ import {
joinMiniAppHousehold,
openMiniAppBillingCycle,
promoteMiniAppMember,
updateMiniAppMemberStatus,
updateMiniAppMemberRentWeight,
type MiniAppAdminCycleState,
type MiniAppAdminSettingsPayload,
@@ -56,6 +57,7 @@ type SessionState =
member: {
id: string
displayName: string
status: 'active' | 'away' | 'left'
isAdmin: boolean
preferredLocale: Locale | null
householdDefaultLocale: Locale
@@ -95,6 +97,7 @@ const demoSession: Extract<SessionState, { status: 'ready' }> = {
member: {
id: 'demo-member',
displayName: 'Demo Resident',
status: 'active',
isAdmin: false,
preferredLocale: 'en',
householdDefaultLocale: 'en'
@@ -278,7 +281,11 @@ function App() {
const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal<string | null>(null)
const [promotingMemberId, setPromotingMemberId] = createSignal<string | null>(null)
const [savingRentWeightMemberId, setSavingRentWeightMemberId] = createSignal<string | null>(null)
const [savingMemberStatusId, setSavingMemberStatusId] = createSignal<string | null>(null)
const [rentWeightDrafts, setRentWeightDrafts] = createSignal<Record<string, string>>({})
const [memberStatusDrafts, setMemberStatusDrafts] = createSignal<
Record<string, 'active' | 'away' | 'left'>
>({})
const [savingMemberLocale, setSavingMemberLocale] = createSignal(false)
const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false)
const [savingBillingSettings, setSavingBillingSettings] = createSignal(false)
@@ -382,6 +389,17 @@ function App() {
}
}
function memberStatusLabel(status: 'active' | 'away' | 'left'): string {
switch (status) {
case 'active':
return copy().memberStatusActive
case 'away':
return copy().memberStatusAway
case 'left':
return copy().memberStatusLeft
}
}
async function loadDashboard(initData: string) {
try {
const nextDashboard = await fetchMiniAppDashboard(initData)
@@ -420,6 +438,9 @@ function App() {
payload.members.map((member) => [member.id, String(member.rentShareWeight)])
)
)
setMemberStatusDrafts(
Object.fromEntries(payload.members.map((member) => [member.id, member.status]))
)
setCycleForm((current) => ({
...current,
rentCurrency: payload.settings.rentCurrency,
@@ -1231,6 +1252,35 @@ function App() {
}
}
async function handleSaveMemberStatus(memberId: string) {
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
}))
} finally {
setSavingMemberStatusId(null)
}
}
const renderPanel = () => {
switch (activeNav()) {
case 'balances':
@@ -2372,9 +2422,31 @@ function App() {
<article class="utility-bill-row">
<header>
<strong>{member.displayName}</strong>
<span>{member.isAdmin ? copy().adminTag : copy().residentTag}</span>
<span>
{member.isAdmin ? copy().adminTag : copy().residentTag}
{` · ${memberStatusLabel(member.status)}`}
</span>
</header>
<div class="settings-grid">
<label class="settings-field settings-field--wide">
<span>{copy().memberStatusLabel}</span>
<select
value={memberStatusDrafts()[member.id] ?? member.status}
onChange={(event) =>
setMemberStatusDrafts((current) => ({
...current,
[member.id]: event.currentTarget.value as
| 'active'
| 'away'
| 'left'
}))
}
>
<option value="active">{copy().memberStatusActive}</option>
<option value="away">{copy().memberStatusAway}</option>
<option value="left">{copy().memberStatusLeft}</option>
</select>
</label>
<label class="settings-field settings-field--wide">
<span>{copy().rentWeightLabel}</span>
<input
@@ -2392,6 +2464,16 @@ function App() {
</label>
</div>
<div class="inline-actions">
<button
class="ghost-button"
type="button"
disabled={savingMemberStatusId() === member.id}
onClick={() => void handleSaveMemberStatus(member.id)}
>
{savingMemberStatusId() === member.id
? copy().savingMemberStatus
: copy().saveMemberStatusAction}
</button>
<button
class="ghost-button"
type="button"
@@ -2770,6 +2852,11 @@ function App() {
<span class="pill pill--muted">
{readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag}
</span>
<span class="pill pill--muted">
{readySession()?.member.status
? memberStatusLabel(readySession()!.member.status)
: copy().memberStatusActive}
</span>
</div>
<h2>
@@ -2802,6 +2889,14 @@ function App() {
<article class="panel panel--wide">
<p class="eyebrow">{copy().overviewTitle}</p>
<h3>{readySession()?.member.displayName}</h3>
<p>
{copy().memberStatusSummary.replace(
'{status}',
readySession()?.member.status
? memberStatusLabel(readySession()!.member.status)
: copy().memberStatusActive
)}
</p>
<div>{renderPanel()}</div>
</article>
</section>

View File

@@ -138,6 +138,13 @@ export const dictionary = {
savingCategory: 'Saving…',
adminsTitle: 'Admins',
adminsBody: 'Promote trusted household members so they can manage billing and approvals.',
memberStatusLabel: 'Member status',
saveMemberStatusAction: 'Save status',
savingMemberStatus: 'Saving status…',
memberStatusActive: 'Active',
memberStatusAway: 'Away',
memberStatusLeft: 'Left',
memberStatusSummary: 'Your household status: {status}.',
rentWeightLabel: 'Rent weight',
saveRentWeightAction: 'Save rent weight',
savingRentWeight: 'Saving weight…',
@@ -296,6 +303,13 @@ export const dictionary = {
adminsTitle: 'Админы',
adminsBody:
'Повышай доверенных участников, чтобы они могли управлять биллингом и подтверждениями.',
memberStatusLabel: 'Статус участника',
saveMemberStatusAction: 'Сохранить статус',
savingMemberStatus: 'Сохраняем статус…',
memberStatusActive: 'Активный',
memberStatusAway: 'В отъезде',
memberStatusLeft: 'Выехал',
memberStatusSummary: 'Твой статус в household: {status}.',
rentWeightLabel: 'Вес аренды',
saveRentWeightAction: 'Сохранить вес аренды',
savingRentWeight: 'Сохраняем вес…',

View File

@@ -6,6 +6,7 @@ export interface MiniAppSession {
id: string
householdId: string
displayName: string
status: 'active' | 'away' | 'left'
isAdmin: boolean
preferredLocale: 'en' | 'ru' | null
householdDefaultLocale: 'en' | 'ru'
@@ -39,6 +40,7 @@ export interface MiniAppPendingMember {
export interface MiniAppMember {
id: string
displayName: string
status: 'active' | 'away' | 'left'
rentShareWeight: number
isAdmin: boolean
}
@@ -514,6 +516,37 @@ export async function updateMiniAppMemberRentWeight(
return payload.member
}
export async function updateMiniAppMemberStatus(
initData: string,
memberId: string,
status: 'active' | 'away' | 'left'
): Promise<MiniAppMember> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/members/status`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
initData,
memberId,
status
})
})
const payload = (await response.json()) as {
ok: boolean
authorized?: boolean
member?: MiniAppMember
error?: string
}
if (!response.ok || !payload.member) {
throw new Error(payload.error ?? 'Failed to update member status')
}
return payload.member
}
export async function fetchMiniAppBillingCycle(initData: string): Promise<MiniAppAdminCycleState> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/billing-cycle`, {
method: 'POST',