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
270 lines
8.5 KiB
TypeScript
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 },
|
|
})
|