Files
boc/iom/quixzoom-app/screens/ProfileScreen.tsx
T
Bernt 6989a98d75 feat: Passwordless cross-device authentication
- 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
2026-07-07 07:11:50 +00:00

270 lines
8.5 KiB
TypeScript

/**
* ProfileScreen — zoomer-profil, stats, badges
*/
import { useCallback, useEffect, useState } from 'react'
import {
View, Text, ScrollView, TouchableOpacity,
StyleSheet, ActivityIndicator, Alert, RefreshControl,
} from 'react-native'
import { auth, profile, type Zoomer } from '../lib/api'
const LEVEL_LABELS: Record<number, string> = {
1: 'Rookie Zoomer',
2: 'Junior Zoomer',
3: 'Zoomer',
4: 'Senior Zoomer',
5: 'Elite Zoomer',
6: 'Legend Zoomer',
}
const LEVEL_COLOR: Record<number, string> = {
1: '#6b7280',
2: '#10b981',
3: '#3b82f6',
4: '#8b5cf6',
5: '#f59e0b',
6: '#ef4444',
}
function levelProgress(level: number, totalEarned: number): number {
// Simple XP curve: each level needs level*500 kr more
const levelThreshold = level * 50000 // in öre = level * 500 SEK
const prevThreshold = (level - 1) * 50000
const progress = (totalEarned - prevThreshold) / (levelThreshold - prevThreshold)
return Math.min(Math.max(progress, 0), 1)
}
export function ProfileScreen() {
const [zoomer, setZoomer] = useState<Zoomer | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(async () => {
try {
const data = await profile.get()
setZoomer(data)
} catch {
// silent
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => { load() }, [])
const onRefresh = useCallback(() => { setRefreshing(true); load() }, [load])
const handleSignOut = () => {
Alert.alert(
'Logga ut',
'Är du säker på att du vill logga ut?',
[
{ text: 'Avbryt', style: 'cancel' },
{
text: 'Logga ut',
style: 'destructive',
onPress: async () => {
await auth.logout()
// AppNavigator will detect missing token and show AuthScreen
},
},
],
)
}
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator color="#6366f1" size="large" />
</View>
)
}
const level = zoomer?.level ?? 1
const levelLabel = LEVEL_LABELS[level] ?? `Nivå ${level}`
const levelColor = LEVEL_COLOR[level] ?? '#6366f1'
const progress = zoomer ? levelProgress(level, zoomer.total_earned) : 0
const initial = (zoomer?.display_name ?? zoomer?.email ?? '?')[0].toUpperCase()
return (
<ScrollView
style={styles.container}
contentContainerStyle={{ paddingBottom: 40 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#6366f1" />}
>
{/* Avatar + name */}
<View style={styles.headerSection}>
<View style={[styles.avatar, { borderColor: levelColor }]}>
<Text style={styles.avatarText}>{initial}</Text>
</View>
<Text style={styles.displayName}>
{zoomer?.display_name || 'Namnlös zoomer'}
</Text>
<Text style={styles.email}>{zoomer?.email}</Text>
{/* Level badge */}
<View style={[styles.levelBadge, { backgroundColor: levelColor + '22', borderColor: levelColor + '55' }]}>
<Text style={[styles.levelBadgeText, { color: levelColor }]}>
{levelLabel}
</Text>
</View>
{/* XP bar */}
<View style={styles.xpBarWrap}>
<View style={styles.xpBarBg}>
<View style={[styles.xpBarFill, { width: `${(progress * 100).toFixed(0)}%` as any, backgroundColor: levelColor }]} />
</View>
<Text style={styles.xpLabel}>
Nivå {level} {level + 1} ({(progress * 100).toFixed(0)}%)
</Text>
</View>
</View>
{/* Stats */}
<View style={styles.statsGrid}>
<View style={styles.statCard}>
<Text style={styles.statValue}>
{zoomer ? (zoomer.total_earned / 100).toFixed(0) : '0'} kr
</Text>
<Text style={styles.statLabel}>Totalt intjänat</Text>
</View>
<View style={styles.statCard}>
<Text style={[styles.statValue, { color: '#10b981' }]}>
{zoomer?.missions_completed ?? 0}
</Text>
<Text style={styles.statLabel}>Uppdrag klara</Text>
</View>
<View style={styles.statCard}>
<Text style={[styles.statValue, { color: levelColor }]}>
{level}
</Text>
<Text style={styles.statLabel}>Nivå</Text>
</View>
<View style={styles.statCard}>
<Text style={[styles.statValue, { color: '#f59e0b' }]}>
{zoomer?.missions_completed ?? 0}
</Text>
<Text style={styles.statLabel}>Uppdrag</Text>
</View>
</View>
{/* Badges — kommer när backend har stöd */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Badges</Text>
<View style={styles.emptyBadges}>
<Text style={styles.emptyEmoji}>🏅</Text>
<Text style={styles.emptyText}>
Slutför ditt första uppdrag för att låsa upp din första badge!
</Text>
</View>
</View>
{/* Member since */}
{zoomer?.joined_at && (
<Text style={styles.memberSince}>
Zoomer sedan {new Date(zoomer.joined_at).toLocaleDateString('sv-SE', { month: 'long', year: 'numeric' })}
</Text>
)}
{/* Sign out */}
<View style={styles.section}>
<TouchableOpacity style={styles.signOutBtn} onPress={handleSignOut}>
<Text style={styles.signOutText}>Logga ut</Text>
</TouchableOpacity>
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0A0A1B' },
center: { flex: 1, backgroundColor: '#0A0A1B', alignItems: 'center', justifyContent: 'center' },
headerSection: {
alignItems: 'center',
paddingTop: 64,
paddingHorizontal: 24,
paddingBottom: 24,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.06)',
},
avatar: {
width: 88, height: 88, borderRadius: 44,
backgroundColor: 'rgba(99,102,241,0.3)',
alignItems: 'center', justifyContent: 'center',
marginBottom: 16,
borderWidth: 3,
},
avatarText: { color: '#fff', fontSize: 36, fontWeight: '800' },
displayName: { color: '#fff', fontSize: 22, fontWeight: '800', marginBottom: 4 },
email: { color: 'rgba(255,255,255,0.4)', fontSize: 14, marginBottom: 16 },
levelBadge: {
borderRadius: 20, paddingHorizontal: 16, paddingVertical: 8,
borderWidth: 1, marginBottom: 16,
},
levelBadgeText: { fontWeight: '700', fontSize: 13 },
xpBarWrap: { width: '100%', gap: 6 },
xpBarBg: {
height: 6, backgroundColor: 'rgba(255,255,255,0.08)',
borderRadius: 3, overflow: 'hidden',
},
xpBarFill: { height: '100%', borderRadius: 3 },
xpLabel: { color: 'rgba(255,255,255,0.3)', fontSize: 11, textAlign: 'center' },
statsGrid: {
flexDirection: 'row', flexWrap: 'wrap',
padding: 16, gap: 10,
},
statCard: {
width: '47%',
backgroundColor: 'rgba(255,255,255,0.04)',
borderRadius: 16, padding: 16,
borderWidth: 1, borderColor: 'rgba(255,255,255,0.07)',
alignItems: 'center',
},
statValue: { color: '#fff', fontSize: 24, fontWeight: '800', marginBottom: 4 },
statLabel: { color: 'rgba(255,255,255,0.35)', fontSize: 12 },
section: { paddingHorizontal: 16, marginTop: 8 },
sectionTitle: {
color: 'rgba(255,255,255,0.35)', fontSize: 12, fontWeight: '700',
letterSpacing: 1, textTransform: 'uppercase', marginBottom: 12,
},
badgesGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
badgeCard: {
width: '30%',
backgroundColor: 'rgba(255,255,255,0.04)',
borderRadius: 14, padding: 12,
borderWidth: 1, borderColor: 'rgba(255,255,255,0.07)',
alignItems: 'center', gap: 4,
},
badgeEmoji: { fontSize: 28 },
badgeLabel: { color: '#fff', fontSize: 11, fontWeight: '600', textAlign: 'center' },
badgeDate: { color: 'rgba(255,255,255,0.3)', fontSize: 9 },
emptyBadges: {
backgroundColor: 'rgba(255,255,255,0.03)',
borderRadius: 16, padding: 24,
alignItems: 'center', gap: 8,
borderWidth: 1, borderColor: 'rgba(255,255,255,0.06)',
},
emptyEmoji: { fontSize: 36 },
emptyText: { color: 'rgba(255,255,255,0.4)', fontSize: 13, textAlign: 'center', lineHeight: 20 },
memberSince: {
color: 'rgba(255,255,255,0.2)', fontSize: 12, textAlign: 'center',
marginTop: 16, marginBottom: 8,
},
signOutBtn: {
borderWidth: 1, borderColor: 'rgba(239,68,68,0.35)',
borderRadius: 14, paddingVertical: 14,
alignItems: 'center', marginTop: 8,
},
signOutText: { color: '#ef4444', fontWeight: '700', fontSize: 15 },
})