6989a98d75
- Arkitektur: docs/auth/passwordless-architecture.md - Backend: iom/quixzoom-auth-service/ (FastAPI + Redis) - Webb: quixzoom-market-pages/se/login/ (QR-kod + polling) - App: iom/quixzoom-app/src/features/auth/ (push + deep links) Flöde: QR-kod → app-godkännande → webb-inloggad
264 lines
9.7 KiB
TypeScript
264 lines
9.7 KiB
TypeScript
/**
|
||
* EarningsScreen — saldo + utbetalningshistorik
|
||
*/
|
||
import { useCallback, useEffect, useState } from 'react'
|
||
import {
|
||
View, Text, ScrollView, TouchableOpacity,
|
||
StyleSheet, ActivityIndicator, RefreshControl, Alert,
|
||
} from 'react-native'
|
||
import { wallet, payouts, type WalletBalance, type Payout } from '../lib/api'
|
||
|
||
type TabKey = 'oversikt' | 'utbetalningar'
|
||
|
||
const PAYOUT_STATUS: Record<string, { label: string; color: string }> = {
|
||
pending: { label: 'Väntar', color: '#f59e0b' },
|
||
approved: { label: 'Godkänd', color: '#10b981' },
|
||
paid: { label: 'Utbetald', color: '#6366f1' },
|
||
rejected: { label: 'Nekad', color: '#ef4444' },
|
||
}
|
||
|
||
export function EarningsScreen() {
|
||
const [tab, setTab] = useState<TabKey>('oversikt')
|
||
const [balance, setBalance] = useState<WalletBalance | null>(null)
|
||
const [payoutList, setPayoutList] = useState<Payout[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [refreshing, setRefreshing] = useState(false)
|
||
const [requesting, setRequesting] = useState(false)
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const [b, p] = await Promise.all([
|
||
wallet.balance(),
|
||
payouts.history(),
|
||
])
|
||
setBalance(b)
|
||
setPayoutList(p)
|
||
} catch {
|
||
// silent
|
||
} finally {
|
||
setLoading(false)
|
||
setRefreshing(false)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => { load() }, [])
|
||
|
||
const onRefresh = useCallback(() => { setRefreshing(true); load() }, [load])
|
||
|
||
const handleRequestPayout = () => {
|
||
if (!balance) return
|
||
const available = balance.balance - balance.pending_payouts
|
||
if (available < 10000) { // 100 SEK minimum
|
||
Alert.alert('Minsta utbetalning 100 kr', `Du har ${(available / 100).toFixed(0)} kr tillgängligt.`)
|
||
return
|
||
}
|
||
Alert.alert(
|
||
'Begär utbetalning',
|
||
`Vill du ta ut ${(available / 100).toFixed(0)} SEK till ditt bankkonto?`,
|
||
[
|
||
{ text: 'Avbryt', style: 'cancel' },
|
||
{
|
||
text: 'Ta ut',
|
||
onPress: async () => {
|
||
setRequesting(true)
|
||
try {
|
||
await payouts.request(available)
|
||
Alert.alert('✅ Utbetalning begärd!', 'Pengarna är hos dig inom 1–3 bankdagar.')
|
||
load()
|
||
} catch (e: any) {
|
||
Alert.alert('Fel', e?.message ?? 'Kunde inte begära utbetalning.')
|
||
} finally {
|
||
setRequesting(false)
|
||
}
|
||
},
|
||
},
|
||
],
|
||
)
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<View style={styles.center}>
|
||
<ActivityIndicator color="#6366f1" size="large" />
|
||
</View>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<ScrollView
|
||
style={styles.container}
|
||
contentContainerStyle={{ paddingBottom: 40 }}
|
||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#6366f1" />}
|
||
>
|
||
{/* Big balance card */}
|
||
<View style={styles.balanceCard}>
|
||
<Text style={styles.balanceLabel}>Tillgängligt</Text>
|
||
<Text style={styles.balanceAmount}>
|
||
{balance ? (balance.balance / 100).toFixed(0) : '0'} SEK
|
||
</Text>
|
||
{balance && balance.pending_payouts > 0 && (
|
||
<Text style={styles.pendingNote}>
|
||
+ {(balance.pending_payouts / 100).toFixed(0)} SEK under behandling
|
||
</Text>
|
||
)}
|
||
<TouchableOpacity
|
||
style={[styles.payoutBtn, requesting && styles.payoutBtnDisabled]}
|
||
onPress={handleRequestPayout}
|
||
disabled={requesting}
|
||
>
|
||
{requesting
|
||
? <ActivityIndicator color="#fff" size="small" />
|
||
: <Text style={styles.payoutBtnText}>💳 Begär utbetalning</Text>
|
||
}
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* Tab bar */}
|
||
<View style={styles.tabBar}>
|
||
{([['oversikt', 'Översikt'], ['utbetalningar', 'Utbetalningar']] as [TabKey, string][]).map(([key, label]) => (
|
||
<TouchableOpacity
|
||
key={key}
|
||
style={[styles.tab, tab === key && styles.tabActive]}
|
||
onPress={() => setTab(key)}
|
||
>
|
||
<Text style={[styles.tabText, tab === key && styles.tabTextActive]}>{label}</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
|
||
{/* Översikt */}
|
||
{tab === 'oversikt' && (
|
||
<View style={styles.section}>
|
||
<Text style={styles.sectionTitle}>Din ekonomi</Text>
|
||
<View style={styles.infoCard}>
|
||
<View style={styles.infoRow}>
|
||
<Text style={styles.infoLabel}>Valuta</Text>
|
||
<Text style={styles.infoValue}>{balance?.currency ?? 'SEK'}</Text>
|
||
</View>
|
||
<View style={styles.infoRow}>
|
||
<Text style={styles.infoLabel}>Tillgängligt</Text>
|
||
<Text style={styles.infoValue}>{balance ? (balance.balance / 100).toFixed(0) : '0'} kr</Text>
|
||
</View>
|
||
<View style={styles.infoRow}>
|
||
<Text style={styles.infoLabel}>Väntande utbetalningar</Text>
|
||
<Text style={styles.infoValue}>{balance ? (balance.pending_payouts / 100).toFixed(0) : '0'} kr</Text>
|
||
</View>
|
||
<View style={styles.infoRow}>
|
||
<Text style={styles.infoLabel}>Totalt utbetalt</Text>
|
||
<Text style={styles.infoValue}>
|
||
{payoutList
|
||
.filter(p => p.status === 'paid')
|
||
.reduce((sum, p) => sum + p.amount, 0) / 100} kr
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{/* Utbetalningar */}
|
||
{tab === 'utbetalningar' && (
|
||
<View style={styles.section}>
|
||
{payoutList.length === 0
|
||
? (
|
||
<View style={styles.emptyWrap}>
|
||
<Text style={styles.emptyEmoji}>💳</Text>
|
||
<Text style={styles.emptyTitle}>Inga utbetalningar ännu</Text>
|
||
<Text style={styles.empty}>Begär din första utbetalning ovan.</Text>
|
||
</View>
|
||
)
|
||
: payoutList.map(p => {
|
||
const s = PAYOUT_STATUS[p.status] ?? { label: p.status, color: '#fff' }
|
||
return (
|
||
<View key={p.id} style={styles.payoutRow}>
|
||
<View>
|
||
<Text style={styles.payoutDate}>
|
||
{new Date(p.created_at).toLocaleDateString('sv-SE', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||
</Text>
|
||
{p.paid_at && (
|
||
<Text style={styles.payoutPaidAt}>
|
||
Betald {new Date(p.paid_at).toLocaleDateString('sv-SE')}
|
||
</Text>
|
||
)}
|
||
</View>
|
||
<Text style={styles.payoutAmount}>
|
||
{(p.amount / 100).toFixed(0)} {p.currency}
|
||
</Text>
|
||
<View style={[styles.payoutStatusBadge, { backgroundColor: s.color + '22' }]}>
|
||
<Text style={[styles.payoutStatusText, { color: s.color }]}>{s.label}</Text>
|
||
</View>
|
||
</View>
|
||
)
|
||
})
|
||
}
|
||
</View>
|
||
)}
|
||
</ScrollView>
|
||
)
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: { flex: 1, backgroundColor: '#0A0A1B' },
|
||
center: { flex: 1, backgroundColor: '#0A0A1B', alignItems: 'center', justifyContent: 'center' },
|
||
|
||
balanceCard: {
|
||
margin: 16,
|
||
marginTop: 56,
|
||
backgroundColor: '#6366f1',
|
||
borderRadius: 24, padding: 28,
|
||
alignItems: 'center',
|
||
},
|
||
balanceLabel: { color: 'rgba(255,255,255,0.7)', fontSize: 14, marginBottom: 6 },
|
||
balanceAmount: { color: '#fff', fontSize: 48, fontWeight: '900', letterSpacing: -2 },
|
||
pendingNote: { color: 'rgba(255,255,255,0.6)', fontSize: 13, marginTop: 4 },
|
||
payoutBtn: {
|
||
marginTop: 20,
|
||
backgroundColor: 'rgba(255,255,255,0.15)',
|
||
borderRadius: 14, paddingHorizontal: 28, paddingVertical: 12,
|
||
borderWidth: 1, borderColor: 'rgba(255,255,255,0.25)',
|
||
},
|
||
payoutBtnDisabled: { opacity: 0.6 },
|
||
payoutBtnText: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
||
|
||
tabBar: {
|
||
flexDirection: 'row', marginHorizontal: 16, marginTop: 16, marginBottom: 4,
|
||
backgroundColor: 'rgba(255,255,255,0.04)',
|
||
borderRadius: 14, padding: 4,
|
||
},
|
||
tab: { flex: 1, paddingVertical: 8, alignItems: 'center', borderRadius: 10 },
|
||
tabActive: { backgroundColor: '#6366f1' },
|
||
tabText: { color: 'rgba(255,255,255,0.4)', fontSize: 12, fontWeight: '600' },
|
||
tabTextActive: { color: '#fff' },
|
||
|
||
section: { paddingHorizontal: 16, paddingTop: 16 },
|
||
sectionTitle: { color: 'rgba(255,255,255,0.35)', fontSize: 12, fontWeight: '700', letterSpacing: 1, textTransform: 'uppercase', marginBottom: 16 },
|
||
|
||
infoCard: {
|
||
backgroundColor: 'rgba(255,255,255,0.04)',
|
||
borderRadius: 16, padding: 20,
|
||
borderWidth: 1, borderColor: 'rgba(255,255,255,0.07)',
|
||
},
|
||
infoRow: {
|
||
flexDirection: 'row', justifyContent: 'space-between',
|
||
paddingVertical: 12,
|
||
borderBottomWidth: 1, borderBottomColor: 'rgba(255,255,255,0.05)',
|
||
},
|
||
infoLabel: { color: 'rgba(255,255,255,0.5)', fontSize: 14 },
|
||
infoValue: { color: '#fff', fontSize: 14, fontWeight: '700' },
|
||
|
||
emptyWrap: { alignItems: 'center', paddingTop: 40 },
|
||
emptyEmoji: { fontSize: 40, marginBottom: 12 },
|
||
emptyTitle: { color: '#fff', fontSize: 16, fontWeight: '700', marginBottom: 6 },
|
||
empty: { color: 'rgba(255,255,255,0.3)', textAlign: 'center', fontSize: 14 },
|
||
|
||
payoutRow: {
|
||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||
paddingVertical: 14,
|
||
borderBottomWidth: 1, borderBottomColor: 'rgba(255,255,255,0.05)',
|
||
},
|
||
payoutDate: { color: '#fff', fontSize: 13, fontWeight: '500' },
|
||
payoutPaidAt: { color: 'rgba(255,255,255,0.3)', fontSize: 11 },
|
||
payoutAmount: { color: '#fff', fontWeight: '700', fontSize: 14 },
|
||
payoutStatusBadge: { borderRadius: 20, paddingHorizontal: 10, paddingVertical: 4 },
|
||
payoutStatusText: { fontSize: 12, fontWeight: '600' },
|
||
})
|