chahinebrini e76be7ee78 feat(profile): Profile-Page komplett + Header-Dropdown + UI-Pattern-Fixes
Profile (3 Iterationen):
- app/profile/index.tsx + components/profile/* (Header, StatsBar, Approved,
  Streak, UrgeStats, Demographics, DigaMissionBanner)
- echte Live-Daten via useMe-Hook (Avatar/Nickname/Plan/Email/Provider-Pill)
- Demographics mit echten Inputs (TextInput + Bottom-Sheet-Selects),
  debounced auto-save, Pro-Trial-Reward-Banner, Mikro-Why-Texte
- Approved Domains als plain integer (KEIN Plan-Slot/Cap)
- Friendly Hint-Text statt Progress-Bar (alignSelf:'stretch' Pattern)
- StatsBar zentriert mit 3 prominenten Cards (vertikale Dividers)
- Cooldown-Timeline als Liste mit 1px-Rail
- ApprovedDomainsList: Collapse-Chevron rechts in Title-Row (Pattern-Fix)
- Eigene vs fremde Profile-Ansicht streng getrennt (DSGVO/Anonymität)

Header-Dropdown (kein 3-Punkte-Icon):
- Avatar als Trigger im AppHeader (User-Wunsch)
- Custom-Modal beide Plattformen, Card-Style
- SOS prominent oben (nur Wort 'SOS' rot, Tagline 'wir sind für dich da' klein darunter)
- Profile/Settings/Games/Debug(__DEV__)/Logout
- Logout neutral (nicht rot — Recovery-tonal)
- AppHeader: neue showBack + title Props für Sub-Routes

Routes (Stub bis Phase C):
- app/profile/[userId].tsx — anonym (nur public-Stats)
- app/settings.tsx — Coming-Soon-Skeleton
- app/games.tsx — Standalone Games-Page mit GameCard-Grid
- app/debug.tsx — __DEV__-only

Game-Picker (Migration aus Nuxt):
- components/games/{GameCard, StarRating, GameRatingStars}
- 2x2 Grid, 56pt SVG-Icons (inline aus components/urge/gameSvgs.ts)
- Live-Backend /api/games/ratings (silent-fail)
- Re-use UrgeGames.tsx ohne TTS/Cooldown-Loop

UI-Pattern-Fixes (alle aus screenshot-User-Feedback 2026-05-07):
- Snake-Bug (food-pellet React-18-StrictMode-Reducer-double-call) gefixt
- Snake-Buttons platform-native (iOS-blue / Android-ripple)
- Tetris-Margins (16px paddingHorizontal)
- PostCard-Buttons Apple-44pt-Hit-Area (Image-Select, Image-Remove,
  Cancel, Share-Pill — via hitSlop)
- ProfileHeader Demographics-Hint: alignSelf:'stretch' Pattern
- ApprovedDomainsList Collapse: Title flex:1 + Chevron rechts
- ProtectionDetailsSheet FAQ-Items: alignSelf:'stretch' defensive
- AppHeader Back-Button: neue showBack-Prop + chevron-back

Memory + Plan-Docs:
- 17 Memory-Files dokumentieren System-Wissen + Patterns
- ops/{CUTOVER, UI_MIGRATION, PROFILE_PAGE, WEBHOOK, GAMES_1V1,
  RELEASE_READINESS, TESTING_STATE, MAESTRO_HOSTING}_*.md

Backend bleibt unverändert (Tier-LLM + Nickname + sort:latency
sind seit gestern deployed).
2026-05-07 18:22:58 +02:00

622 lines
18 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import {
View,
Text,
Pressable,
TextInput,
Modal,
LayoutAnimation,
Platform,
UIManager,
ScrollView,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../lib/theme';
import type { Plan } from '../../hooks/useUserPlan';
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
export type Demographics = {
birthYear: number | null;
gender: string | null;
maritalStatus: string | null;
profession: string | null;
bundesland: string | null;
city: string | null;
};
type Props = {
demographics: Demographics;
plan: Plan;
defaultExpanded?: boolean;
onChange?: (next: Demographics) => void;
onRevokeConsent?: () => void;
};
// Select-Optionen — Display-Label DE, value für DB-Persistenz
const GENDER_OPTIONS: Array<{ label: string; value: string }> = [
{ label: 'männlich', value: 'male' },
{ label: 'weiblich', value: 'female' },
{ label: 'divers', value: 'diverse' },
{ label: 'keine Angabe', value: 'none' },
];
const MARITAL_OPTIONS: Array<{ label: string; value: string }> = [
{ label: 'ledig', value: 'single' },
{ label: 'Partnerschaft', value: 'partnership' },
{ label: 'verheiratet', value: 'married' },
{ label: 'geschieden', value: 'divorced' },
{ label: 'verwitwet', value: 'widowed' },
{ label: 'keine Angabe', value: 'none' },
];
// ISO-3166-2:DE — value=ISO, label=DE-Display
const BUNDESLAND_OPTIONS: Array<{ label: string; value: string }> = [
{ label: 'Baden-Württemberg', value: 'BW' },
{ label: 'Bayern', value: 'BY' },
{ label: 'Berlin', value: 'BE' },
{ label: 'Brandenburg', value: 'BB' },
{ label: 'Bremen', value: 'HB' },
{ label: 'Hamburg', value: 'HH' },
{ label: 'Hessen', value: 'HE' },
{ label: 'Mecklenburg-Vorpommern', value: 'MV' },
{ label: 'Niedersachsen', value: 'NI' },
{ label: 'Nordrhein-Westfalen', value: 'NW' },
{ label: 'Rheinland-Pfalz', value: 'RP' },
{ label: 'Saarland', value: 'SL' },
{ label: 'Sachsen', value: 'SN' },
{ label: 'Sachsen-Anhalt', value: 'ST' },
{ label: 'Schleswig-Holstein', value: 'SH' },
{ label: 'Thüringen', value: 'TH' },
];
const FIELD_WHY: Record<keyof Demographics, string> = {
birthYear:
'Lyra spricht dich altersgerecht an, DiGA-Berichte erkennen Risiko nach Altersgruppe.',
gender: 'Glücksspiel-Muster unterscheiden sich; Lyra coacht gendersensibel.',
profession:
'Schichtarbeit, Banking-Stress, Selbstständigkeit haben verschiedene Trigger — Lyra kennt deinen Kontext.',
maritalStatus:
'Trennung/Beziehungs-Konflikte sind klassische Trigger — Lyra erkennt sie früher in dir.',
bundesland: 'Lokale Beratungsstellen + anonyme DiGA-Studien.',
city: 'Lokale Beratungsstellen + anonyme DiGA-Studien.',
};
function lookupLabel(options: Array<{ label: string; value: string }>, v: string | null) {
if (!v) return null;
return options.find((o) => o.value === v)?.label ?? v;
}
function isComplete(d: Demographics) {
return (
d.birthYear !== null &&
!!d.gender &&
!!d.maritalStatus &&
!!d.profession &&
!!d.bundesland &&
!!d.city
);
}
// TODO Phase C: PATCH /api/profile/me/demographics — debounced auto-save (~500ms idle).
// Bis Endpoint live: lokaler State + onChange-Callback Richtung Parent.
function mockPersist(_next: Demographics) {
// no-op placeholder — Parent ruft echten Endpoint
}
export function DemographicsAccordion({
demographics,
plan,
defaultExpanded = false,
onChange,
onRevokeConsent,
}: Props) {
const [expanded, setExpanded] = useState(defaultExpanded);
const [local, setLocal] = useState<Demographics>(demographics);
// Select-Sheet-State
const [pickerField, setPickerField] = useState<keyof Demographics | null>(null);
// Debounce-Save Ref
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
setLocal(demographics);
}, [demographics]);
function toggle() {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setExpanded((v) => !v);
}
function persist(next: Demographics) {
setLocal(next);
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
mockPersist(next);
onChange?.(next);
}, 500);
}
function flushSave(next: Demographics) {
if (saveTimer.current) clearTimeout(saveTimer.current);
mockPersist(next);
onChange?.(next);
setLocal(next);
}
const completed = isComplete(local);
const showProTrialBanner = plan === 'free' && completed;
return (
<View style={{ marginHorizontal: 16, marginTop: 24 }}>
{/* Privacy-Header */}
<Pressable
onPress={toggle}
style={({ pressed }) => ({
opacity: pressed ? 0.7 : 1,
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 14,
padding: 16,
})}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
}}
>
ANONYMER BEITRAG ZUR FORSCHUNG
</Text>
<Text
style={{
marginTop: 6,
fontSize: 13,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
Optional. Niemals mit Name oder Email verknüpft. Jederzeit löschbar.
</Text>
</View>
<Ionicons
name={expanded ? 'chevron-up' : 'chevron-down'}
size={18}
color={colors.textMuted}
/>
</View>
</Pressable>
{expanded ? (
<View
style={{
marginTop: 8,
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 14,
paddingVertical: 4,
}}
>
{/* Pro-Trial-Reward-Banner — nur free + (idealerweise) nicht-vollständig.
Wir zeigen ihn aber auch im "completed"-State als sanfte Bestätigung,
tatsächliche Trial-Vergabe ist Backend-Sache (Phase C). */}
{plan === 'free' ? (
<View
style={{
marginHorizontal: 8,
marginVertical: 8,
backgroundColor: '#fff7ed',
borderColor: '#fed7aa',
borderWidth: 1,
borderRadius: 12,
padding: 12,
flexDirection: 'row',
gap: 10,
alignItems: 'flex-start',
}}
>
<Ionicons name="gift-outline" size={18} color="#c2410c" style={{ marginTop: 1 }} />
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 13,
color: '#7c2d12',
fontFamily: 'Nunito_700Bold',
}}
>
{showProTrialBanner
? 'Du bekommst 1 Woche Pro geschenkt'
: 'Vervollständige dein Profil — 1 Woche Pro geschenkt'}
</Text>
<Text
style={{
marginTop: 4,
fontSize: 11,
color: '#9a3412',
fontFamily: 'Nunito_400Regular',
lineHeight: 15,
}}
>
Mit deinen anonymen Daten machen wir rebreak zur ersten DiGA-zertifizierten
Spielsucht-App. Als Dankeschön: 1 Woche Pro.
</Text>
</View>
</View>
) : null}
{/* Birth Year — Number-Input */}
<FieldRow
label="Geburtsjahr"
why={FIELD_WHY.birthYear}
>
<TextInput
value={local.birthYear !== null ? String(local.birthYear) : ''}
onChangeText={(raw) => {
const cleaned = raw.replace(/[^0-9]/g, '').slice(0, 4);
if (cleaned === '') {
persist({ ...local, birthYear: null });
return;
}
const n = parseInt(cleaned, 10);
// Erlaube tippen — Validierung beim Blur
persist({ ...local, birthYear: Number.isNaN(n) ? null : n });
}}
onBlur={() => {
const n = local.birthYear;
if (n !== null && (n < 1920 || n > 2010)) {
// ungültig — auf null zurücksetzen
flushSave({ ...local, birthYear: null });
}
}}
keyboardType="number-pad"
maxLength={4}
placeholder="z.B. 1989"
placeholderTextColor={colors.textMuted}
style={inputStyle}
/>
</FieldRow>
{/* Gender — Select */}
<FieldRow label="Geschlecht" why={FIELD_WHY.gender}>
<SelectButton
value={lookupLabel(GENDER_OPTIONS, local.gender)}
onPress={() => setPickerField('gender')}
/>
</FieldRow>
{/* Profession — TextInput */}
<FieldRow label="Beruf" why={FIELD_WHY.profession}>
<TextInput
value={local.profession ?? ''}
onChangeText={(t) => persist({ ...local, profession: t })}
onBlur={() => {
const trimmed = (local.profession ?? '').trim();
flushSave({ ...local, profession: trimmed === '' ? null : trimmed });
}}
maxLength={80}
placeholder="z.B. Pflege, IT, Schichtarbeit"
placeholderTextColor={colors.textMuted}
style={inputStyle}
/>
</FieldRow>
{/* Marital — Select */}
<FieldRow label="Familienstand" why={FIELD_WHY.maritalStatus}>
<SelectButton
value={lookupLabel(MARITAL_OPTIONS, local.maritalStatus)}
onPress={() => setPickerField('maritalStatus')}
/>
</FieldRow>
{/* Bundesland — Select */}
<FieldRow label="Bundesland" why={FIELD_WHY.bundesland}>
<SelectButton
value={lookupLabel(BUNDESLAND_OPTIONS, local.bundesland)}
onPress={() => setPickerField('bundesland')}
/>
</FieldRow>
{/* City — TextInput */}
<FieldRow label="Stadt" why={FIELD_WHY.city} isLast>
<TextInput
value={local.city ?? ''}
onChangeText={(t) => persist({ ...local, city: t })}
onBlur={() => {
const trimmed = (local.city ?? '').trim();
flushSave({ ...local, city: trimmed === '' ? null : trimmed });
}}
maxLength={60}
placeholder="z.B. München"
placeholderTextColor={colors.textMuted}
style={inputStyle}
/>
</FieldRow>
{/* Revoke Consent */}
<Pressable
onPress={onRevokeConsent}
style={({ pressed }) => ({
opacity: pressed ? 0.7 : 1,
marginTop: 4,
paddingHorizontal: 14,
paddingVertical: 12,
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.06)',
})}
>
<Text
style={{
fontSize: 12,
color: colors.error,
fontFamily: 'Nunito_600SemiBold',
textAlign: 'center',
}}
>
Einwilligung widerrufen
</Text>
</Pressable>
</View>
) : null}
<SelectSheet
visible={pickerField === 'gender'}
title="Geschlecht"
options={GENDER_OPTIONS}
selectedValue={local.gender}
onClose={() => setPickerField(null)}
onSelect={(v) => {
flushSave({ ...local, gender: v });
setPickerField(null);
}}
/>
<SelectSheet
visible={pickerField === 'maritalStatus'}
title="Familienstand"
options={MARITAL_OPTIONS}
selectedValue={local.maritalStatus}
onClose={() => setPickerField(null)}
onSelect={(v) => {
flushSave({ ...local, maritalStatus: v });
setPickerField(null);
}}
/>
<SelectSheet
visible={pickerField === 'bundesland'}
title="Bundesland"
options={BUNDESLAND_OPTIONS}
selectedValue={local.bundesland}
onClose={() => setPickerField(null)}
onSelect={(v) => {
flushSave({ ...local, bundesland: v });
setPickerField(null);
}}
/>
</View>
);
}
const inputStyle = {
fontSize: 14,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
paddingVertical: 8,
paddingHorizontal: 10,
backgroundColor: '#fafafa',
borderRadius: 8,
borderWidth: 1,
borderColor: '#ececec',
minWidth: 140,
textAlign: 'right' as const,
};
function FieldRow({
label,
why,
isLast,
children,
}: {
label: string;
why: string;
isLast?: boolean;
children: React.ReactNode;
}) {
return (
<View
style={{
paddingHorizontal: 14,
paddingVertical: 12,
borderBottomWidth: isLast ? 0 : 1,
borderBottomColor: 'rgba(0,0,0,0.06)',
}}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
}}
>
<Text
style={{
fontSize: 13,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
{label}
</Text>
<View style={{ flexShrink: 1 }}>{children}</View>
</View>
<Text
style={{
marginTop: 6,
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
lineHeight: 15,
}}
>
{why}
</Text>
</View>
);
}
function SelectButton({ value, onPress }: { value: string | null; onPress: () => void }) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
opacity: pressed ? 0.6 : 1,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 8,
paddingHorizontal: 10,
backgroundColor: '#fafafa',
borderRadius: 8,
borderWidth: 1,
borderColor: '#ececec',
minWidth: 140,
justifyContent: 'flex-end',
})}
>
<Text
style={{
fontSize: 14,
color: value ? colors.text : colors.textMuted,
fontFamily: value ? 'Nunito_600SemiBold' : 'Nunito_400Regular',
textAlign: 'right',
}}
>
{value ?? 'auswählen'}
</Text>
<Ionicons name="chevron-down" size={14} color={colors.textMuted} />
</Pressable>
);
}
function SelectSheet({
visible,
title,
options,
selectedValue,
onClose,
onSelect,
}: {
visible: boolean;
title: string;
options: Array<{ label: string; value: string }>;
selectedValue: string | null;
onClose: () => void;
onSelect: (v: string) => void;
}) {
const sortedOptions = useMemo(() => options, [options]);
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={onClose}
>
<Pressable
onPress={onClose}
style={{
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
justifyContent: 'flex-end',
}}
>
<Pressable
onPress={() => {
/* swallow */
}}
style={{
backgroundColor: '#ffffff',
borderTopLeftRadius: 18,
borderTopRightRadius: 18,
paddingHorizontal: 8,
paddingTop: 12,
paddingBottom: 24,
maxHeight: '70%',
}}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
paddingBottom: 8,
}}
>
<Text
style={{
fontSize: 15,
color: colors.text,
fontFamily: 'Nunito_700Bold',
}}
>
{title}
</Text>
<Pressable onPress={onClose} hitSlop={10}>
<Ionicons name="close" size={22} color={colors.textMuted} />
</Pressable>
</View>
<ScrollView style={{ maxHeight: 380 }}>
{sortedOptions.map((opt) => {
const isSelected = opt.value === selectedValue;
return (
<Pressable
key={opt.value}
onPress={() => onSelect(opt.value)}
style={({ pressed }) => ({
opacity: pressed ? 0.6 : 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 14,
paddingVertical: 14,
borderRadius: 10,
backgroundColor: isSelected ? '#f5f8ff' : 'transparent',
})}
>
<Text
style={{
fontSize: 14,
color: colors.text,
fontFamily: isSelected ? 'Nunito_700Bold' : 'Nunito_400Regular',
}}
>
{opt.label}
</Text>
{isSelected ? (
<Ionicons name="checkmark" size={18} color={colors.brandOrange} />
) : null}
</Pressable>
);
})}
</ScrollView>
</Pressable>
</Pressable>
</Modal>
);
}