feat: add quick payment action and improve copy button UX

Mini App Home Screen:
- Add 'Record Payment' button to utilities and rent period cards
- Pre-fill payment amount with member's share (rentShare/utilityShare)
- Modal dialog with amount input and currency display
- Toast notifications for copy and payment success/failure feedback

Copy Button Improvements:
- Increase spacing between icon and text (4px → 8px)
- Add hover background and padding for better touch target
- Green background highlight when copied (in addition to icon color change)
- Toast notification appears when copying any value

Backend:
- Add /api/miniapp/payments/add endpoint for quick payments
- Payment notifications sent to 'reminders' topic in Telegram
- Include member name, payment type, amount, and period in notification

Files:
- New: apps/miniapp/src/components/ui/toast.tsx
- Modified: apps/miniapp/src/routes/home.tsx, apps/miniapp/src/index.css,
  apps/miniapp/src/theme.css, apps/miniapp/src/i18n.ts,
  apps/bot/src/miniapp-billing.ts, apps/bot/src/server.ts

Quality Gates:  format, lint, typecheck, build, test

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-03-14 08:51:53 +04:00
parent 771d64aa4e
commit 488a488137
45 changed files with 2236 additions and 101 deletions

View File

@@ -7,6 +7,7 @@ import type {
HouseholdMemberLifecycleStatus,
HouseholdMemberRecord,
HouseholdPendingMemberRecord,
HouseholdRentPaymentDestination,
HouseholdTopicBindingRecord,
HouseholdUtilityCategoryRecord
} from '@household/ports'
@@ -25,6 +26,40 @@ function parseCurrency(raw: string): CurrencyCode {
return normalized
}
function normalizeOptionalString(value: unknown): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
function normalizeRentPaymentDestinations(
value: unknown
): readonly HouseholdRentPaymentDestination[] | null {
if (value === null) return null
if (!Array.isArray(value)) {
throw new Error('Invalid rent payment destinations')
}
return value
.map((entry): HouseholdRentPaymentDestination | null => {
if (!entry || typeof entry !== 'object') return null
const record = entry as Record<string, unknown>
const label = normalizeOptionalString(record.label)
const account = normalizeOptionalString(record.account)
if (!label || !account) return null
return {
label,
recipientName: normalizeOptionalString(record.recipientName),
bankName: normalizeOptionalString(record.bankName),
account,
note: normalizeOptionalString(record.note),
link: normalizeOptionalString(record.link)
}
})
.filter((entry): entry is HouseholdRentPaymentDestination => Boolean(entry))
}
export interface MiniAppAdminService {
getSettings(input: { householdId: string; actorIsAdmin: boolean }): Promise<
| {
@@ -55,6 +90,7 @@ export interface MiniAppAdminService {
utilitiesDueDay: number
utilitiesReminderDay: number
timezone: string
rentPaymentDestinations?: unknown
assistantContext?: string
assistantTone?: string
}): Promise<
@@ -402,6 +438,18 @@ export function createMiniAppAdminService(
rentCurrency = parseCurrency(input.rentCurrency ?? 'USD')
}
let rentPaymentDestinations: readonly HouseholdRentPaymentDestination[] | null | undefined
if (input.rentPaymentDestinations !== undefined) {
try {
rentPaymentDestinations = normalizeRentPaymentDestinations(input.rentPaymentDestinations)
} catch {
return {
status: 'rejected',
reason: 'invalid_settings'
}
}
}
const shouldUpdateAssistantConfig =
assistantContext !== undefined || assistantTone !== undefined
@@ -432,7 +480,12 @@ export function createMiniAppAdminService(
rentWarningDay: input.rentWarningDay,
utilitiesDueDay: input.utilitiesDueDay,
utilitiesReminderDay: input.utilitiesReminderDay,
timezone
timezone,
...(rentPaymentDestinations !== undefined
? {
rentPaymentDestinations
}
: {})
}),
repository.updateHouseholdAssistantConfig && shouldUpdateAssistantConfig
? repository.updateHouseholdAssistantConfig({