diff --git a/app/screens/CreditBalanceScreen.tsx b/app/screens/CreditBalanceScreen.tsx index 6e96b01c..02bc3145 100644 --- a/app/screens/CreditBalanceScreen.tsx +++ b/app/screens/CreditBalanceScreen.tsx @@ -1,61 +1,305 @@ -import React, { useState } from 'react'; -import { View, Text, TextInput, Button, FlatList, StyleSheet } from 'react-native'; -import { useCreditStore, CreditTransaction } from '../stores/creditStore'; +import React, { useMemo, useState } from 'react'; +import { + View, + Text, + TextInput, + StyleSheet, + FlatList, + ScrollView, + Pressable, + Alert, +} from 'react-native'; +import { useCreditStore, type AccountCredit, type CreditLot } from '../stores/creditStore'; + +type Tab = 'balance' | 'analytics' | 'history' | 'wallets'; + +const colorScheme = { + bg: '#f7fafc', + card: '#ffffff', + border: '#e2e8f0', + primary: '#1d4ed8', + credit: '#15803d', + debit: '#b91c1c', + warning: '#c2410c', + text: '#0f172a', + textMuted: '#475569', +}; + +const currency = (n: number): string => n.toLocaleString(undefined, { minimumFractionDigits: 0 }); const styles = StyleSheet.create({ - container: { flex: 1, padding: 16 }, - header: { fontSize: 18, fontWeight: '700', marginVertical: 8 }, - balance: { fontSize: 32, fontWeight: '800', color: '#15803d' }, - label: { fontSize: 12, color: '#666', marginTop: 8 }, - input: { height: 40, borderColor: '#ccc', borderWidth: 1, paddingHorizontal: 8, borderRadius: 4, marginTop: 4 }, - row: { flexDirection: 'row', gap: 8, marginTop: 8 }, - item: { paddingVertical: 6, borderBottomWidth: 1, borderColor: '#eee' }, - credit: { color: '#15803d' }, - debit: { color: '#b91c1c' }, + container: { flex: 1, backgroundColor: colorScheme.bg }, + scrollContent: { padding: 16, gap: 12 }, + heroCard: { + backgroundColor: colorScheme.card, + borderRadius: 12, + padding: 20, + borderWidth: 1, + borderColor: colorScheme.border, + }, + heroLabel: { color: colorScheme.textMuted, fontSize: 12, textTransform: 'uppercase', letterSpacing: 1 }, + heroBalance: { color: colorScheme.text, fontSize: 36, fontWeight: '800', marginTop: 4 }, + heroSub: { color: colorScheme.textMuted, fontSize: 13, marginTop: 6 }, + tabRow: { flexDirection: 'row', gap: 8, marginBottom: 8 }, + tab: { + flex: 1, + paddingVertical: 10, + paddingHorizontal: 12, + backgroundColor: colorScheme.card, + borderRadius: 8, + borderWidth: 1, + borderColor: colorScheme.border, + alignItems: 'center', + }, + tabActive: { backgroundColor: colorScheme.primary, borderColor: colorScheme.primary }, + tabLabel: { color: colorScheme.text, fontWeight: '600' }, + tabLabelActive: { color: '#fff' }, + card: { + backgroundColor: colorScheme.card, + borderRadius: 12, + padding: 16, + borderWidth: 1, + borderColor: colorScheme.border, + gap: 12, + }, + cardTitle: { fontWeight: '700', fontSize: 16, color: colorScheme.text }, + cardSubtitle: { color: colorScheme.textMuted, fontSize: 12 }, + row: { flexDirection: 'row', gap: 8 }, + input: { + flex: 1, + borderColor: colorScheme.border, + borderWidth: 1, + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 8, + color: colorScheme.text, + backgroundColor: '#fff', + }, + button: { + paddingHorizontal: 14, + paddingVertical: 10, + backgroundColor: colorScheme.primary, + borderRadius: 8, + alignItems: 'center', + }, + buttonLabel: { color: '#fff', fontWeight: '600' }, + buttonGhost: { + paddingHorizontal: 14, + paddingVertical: 10, + backgroundColor: '#fff', + borderWidth: 1, + borderColor: colorScheme.border, + borderRadius: 8, + alignItems: 'center', + }, + buttonGhostLabel: { color: colorScheme.text, fontWeight: '600' }, + itemRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 8, + borderBottomWidth: 1, + borderColor: colorScheme.border, + }, + itemAmount: { fontWeight: '600' }, + credit: { color: colorScheme.credit }, + debit: { color: colorScheme.debit }, + muted: { color: colorScheme.textMuted }, + expiryPill: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + alignSelf: 'flex-start', + }, + statGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, + statTile: { + flexBasis: '48%', + backgroundColor: '#fff', + borderRadius: 8, + borderWidth: 1, + borderColor: colorScheme.border, + padding: 12, + }, + statLabel: { color: colorScheme.textMuted, fontSize: 12 }, + statValue: { color: colorScheme.text, fontSize: 18, fontWeight: '700', marginTop: 2 }, + chartBar: { + height: 8, + backgroundColor: '#e2e8f0', + borderRadius: 4, + overflow: 'hidden', + }, + chartFill: { + height: '100%', + backgroundColor: colorScheme.primary, + }, }); -const sign = (tx: CreditTransaction) => (tx.amount >= 0 ? styles.credit : styles.debit); +const BucketBar: React.FC<{ label: string; value: number; total: number; tone?: 'pos' | 'neg' | 'mute' }> = ({ + label, + value, + total, + tone = 'pos', +}) => { + const pct = total > 0 ? Math.min(100, Math.round((value / total) * 100)) : 0; + const fill = + tone === 'neg' ? colorScheme.debit : tone === 'mute' ? colorScheme.textMuted : colorScheme.primary; + return ( + + + {label} + {currency(value)} + + + + + + ); +}; -export const CreditBalanceScreen: React.FC<{ subscriber?: string }> = ({ subscriber = 'me' }) => { - const { issueCredit, applyCredit, expireCredits, getBalance, getAccount } = useCreditStore(); - const [amount, setAmount] = useState('100'); - const [due, setDue] = useState('50'); - // Read from store on each render so issuing/applying refreshes the figures. - const balance = useCreditStore((s) => s.accounts[subscriber]?.balance ?? 0); - void balance; // stored balance; available balance shown below accounts for expiry +const AnalyticsPanel: React.FC<{ account: AccountCredit }> = ({ account }) => { + const issued = useMemo( + () => + account.transactions + .filter((t) => t.kind === 'issue') + .reduce((s, t) => s + t.amount, 0), + [account.transactions] + ); + const applied = useMemo( + () => + account.transactions + .filter((t) => t.kind === 'apply') + .reduce((s, t) => s + Math.abs(t.amount), 0), + [account.transactions] + ); + const transferIn = useMemo( + () => + account.transactions + .filter((t) => t.kind === 'transfer_in') + .reduce((s, t) => s + t.amount, 0), + [account.transactions] + ); + const transferOut = useMemo( + () => + account.transactions + .filter((t) => t.kind === 'transfer_out') + .reduce((s, t) => s + Math.abs(t.amount), 0), + [account.transactions] + ); + const expired = useMemo( + () => + account.transactions + .filter((t) => t.kind === 'expire') + .reduce((s, t) => s + Math.abs(t.amount), 0), + [account.transactions] + ); - const available = getBalance(subscriber); - const account = getAccount(subscriber); + const consumptionRate = issued > 0 ? Math.round((applied / issued) * 100) : 0; + const channelTotal = issued + transferIn; + + const expiryBuckets = useMemo(() => { + const nowSec = Math.floor(Date.now() / 1000); + const buckets = { within_7d: 0, within_30d: 0, beyond_30d: 0, no_expiry: 0 }; + for (const lot of account.lots) { + if (lot.remaining <= 0) continue; + if (!lot.expiresAt) { + buckets.no_expiry += lot.remaining; + continue; + } + const secToExpiry = lot.expiresAt - nowSec; + if (secToExpiry <= 7 * 86_400) buckets.within_7d += lot.remaining; + else if (secToExpiry <= 30 * 86_400) buckets.within_30d += lot.remaining; + else buckets.beyond_30d += lot.remaining; + } + return buckets; + }, [account.lots]); return ( - - Account Credit - {available.toLocaleString()} - Available credit (excludes expired) - - Issue credit - -