chahinebrini 3c5c9ebfba feat(onboarding): polish bundle — nickname validation, diga format, confetti, FAQ accordion, lyra-voice tuned
## Nickname-Validation + Duplicate-Check

Bug-Prevention: User konnte einen bereits vergebenen Nickname setzen, was
zu Verwirrung führte (zwei User mit selbem Alias). + Profanity-Filter.

Backend:
- GET /api/profile/check-nickname?nickname=X — returns {available, reason?}
  reasons: 'too_short' | 'too_long' | 'profanity' | 'taken'
- Min 3, max 32 chars
- Profanity-Set (hardcoded, ~20 Wörter DE/EN — slurs + bot-impersonation
  wie "admin", "lyra", etc.)
- Case-insensitive lookup, ignoriert eigenen Nickname (= behalten ok)
- Soft-deleted Profile sind ausgeschlossen

Frontend:
- NicknameSlide refactored mit Live-Debounce (450ms)
- Race-guard via checkSeqRef damit veraltete Antworten verworfen werden
- Visueller Feedback: Border-Color (success/error/transparent), Status-
  Icon im Input (hourglass/checkmark/X), inline Error-Text statt Alert
- Save-Button disabled wenn invalid
- Network-Error: fail-soft, lass Server-Side bei Save validieren

## DiGA-Code Auto-Format

Live-Format-Mask: User tippt "REBREAKTEST001" → wird zu "REBREAK-TEST-001"
beim Tippen. Strip-then-segment Logik:
  1. Alles außer A-Z0-9 entfernen
  2. Erste 7 chars = "REBREAK", Rest in 4+restliche Blöcke

Liberal — erlaubt User dashes händisch zu setzen (wird neu segmentiert).

## DoneSlide Confetti + FAQ

- Confetti-Overlay mit 22 Partikeln, gestaffelt 40ms, native-driver Animation
  (translateY + drift + rotate + opacity fade). One-shot beim Mount.
- Inline Top-5-FAQ Accordion unter dem Checkmark-Hero. Tap auf row → expand
  + zeige Antwort. Nutzt existing help.faq_q1..q5 + .faq_a1..a5 locale keys.

## Lyra Voice-Review (Agent)

lyra-persona Agent hat alle Lyra-Speech-Texte in 4 Sprachen reviewed:
- Welcome entstigmatisiert (kein "Glücksspiel"-Trigger im First-Touch)
- Plan vermenschlicht (Erklärungs- statt Verkaufs-Ton)
- DiGA-Choice sanfter (Geschenk-Frame statt Zugangs-Frame)
- protection_lock parallelisiert mit "blaue Falle"-Warnung
- FR/AR Stilglättung (Lyra-Femininum konsistent, AR Frage-Forms)

## Locale-Additions

- onboarding.nickname.error_{too_short, too_long, profanity, taken} × 4 langs
- onboarding.done.faq_section_title × 4 langs
- Lyra-bodies × 4 langs (vom Agent getuned)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 20:09:53 +02:00

260 lines
7.2 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { Animated, Easing, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Ionicons } from '@expo/vector-icons';
import { useColors } from '../../../lib/theme';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
import { CTABar } from '../CTABar';
const FAQ_KEYS = ['faq_q1', 'faq_q2', 'faq_q3', 'faq_q4', 'faq_q5'] as const;
export function DoneSlide({
onEnter,
current,
total,
}: {
onEnter: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const colors = useColors();
const scale = useRef(new Animated.Value(0.6)).current;
const opacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.parallel([
Animated.spring(scale, { toValue: 1, useNativeDriver: true, friction: 5, tension: 90 }),
Animated.timing(opacity, {
toValue: 1,
duration: 500,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
}),
]).start();
}, []);
return (
<OnboardingShell
current={current}
total={total}
cta={<CTABar primaryLabel={t('onboarding.done.cta_primary')} onPrimary={onEnter} />}
>
<ConfettiOverlay />
<LyraBubble text={t('onboarding.lyra.done.body')} emotion="happy" />
<Animated.View
style={{
marginTop: 24,
alignItems: 'center',
opacity,
transform: [{ scale }],
}}
>
<View
style={{
width: 96,
height: 96,
borderRadius: 48,
backgroundColor: 'rgba(34,197,94,0.14)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: 'rgba(34,197,94,0.40)',
}}
>
<Ionicons name="checkmark" size={52} color={colors.success} />
</View>
<Text
style={{
marginTop: 18,
fontFamily: 'Nunito_800ExtraBold',
fontSize: 24,
color: colors.text,
textAlign: 'center',
letterSpacing: -0.3,
}}
>
{t('onboarding.done.headline')}
</Text>
<Text
style={{
marginTop: 6,
fontFamily: 'Nunito_400Regular',
fontSize: 14,
lineHeight: 20,
color: colors.textMuted,
textAlign: 'center',
paddingHorizontal: 16,
}}
>
{t('onboarding.done.subhead')}
</Text>
</Animated.View>
{/* Inline Top-5-FAQ Accordion */}
<View style={{ marginTop: 28 }}>
<Text
style={{
fontFamily: 'Nunito_700Bold',
fontSize: 12,
letterSpacing: 0.8,
color: colors.textMuted,
textTransform: 'uppercase',
marginBottom: 10,
}}
>
{t('onboarding.done.faq_section_title')}
</Text>
<View style={{ gap: 8 }}>
{FAQ_KEYS.map((qkey) => (
<FaqRow
key={qkey}
question={t(`help.${qkey}`)}
answer={t(`help.${qkey.replace('q', 'a')}`)}
colors={colors}
/>
))}
</View>
</View>
</OnboardingShell>
);
}
// ─── Confetti-Overlay (Reanimated Particles) ─────────────────────────────────
const CONFETTI_COUNT = 22;
const CONFETTI_COLORS = ['#fbbf24', '#34d399', '#60a5fa', '#a78bfa', '#f472b6'];
function ConfettiOverlay() {
const { width: screenW } = useWindowDimensions();
// Stabile Particle-Definitionen — Math.random nur einmal beim Mount
const particles = useRef(
Array.from({ length: CONFETTI_COUNT }).map((_, i) => ({
key: i,
anim: new Animated.Value(0),
startX: Math.random() * screenW,
drift: (Math.random() - 0.5) * 80,
color: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
rotateStart: Math.random() * 360,
size: 6 + Math.random() * 6,
delay: Math.random() * 600,
duration: 2200 + Math.random() * 1300,
})),
).current;
useEffect(() => {
Animated.stagger(
40,
particles.map((p) =>
Animated.timing(p.anim, {
toValue: 1,
duration: p.duration,
delay: p.delay,
useNativeDriver: true,
easing: Easing.out(Easing.quad),
}),
),
).start();
}, []);
return (
<View
pointerEvents="none"
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 600, overflow: 'hidden' }}
>
{particles.map((p) => {
const translateY = p.anim.interpolate({ inputRange: [0, 1], outputRange: [-30, 580] });
const translateX = p.anim.interpolate({ inputRange: [0, 1], outputRange: [0, p.drift] });
const rotate = p.anim.interpolate({
inputRange: [0, 1],
outputRange: [`${p.rotateStart}deg`, `${p.rotateStart + 540}deg`],
});
const opacity = p.anim.interpolate({ inputRange: [0, 0.85, 1], outputRange: [1, 1, 0] });
return (
<Animated.View
key={p.key}
style={{
position: 'absolute',
left: p.startX - p.size / 2,
top: 0,
width: p.size,
height: p.size * 1.6,
backgroundColor: p.color,
borderRadius: 2,
opacity,
transform: [{ translateY }, { translateX }, { rotate }],
}}
/>
);
})}
</View>
);
}
// ─── FAQ Accordion-Row ───────────────────────────────────────────────────────
function FaqRow({
question,
answer,
colors,
}: {
question: string;
answer: string;
colors: import('../../../lib/theme').ColorScheme;
}) {
const [expanded, setExpanded] = useState(false);
return (
<View
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
paddingVertical: 12,
paddingHorizontal: 14,
}}
>
<TouchableOpacity
onPress={() => setExpanded((v) => !v)}
activeOpacity={0.7}
style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}
>
<Text
style={{
flex: 1,
fontFamily: 'Nunito_600SemiBold',
fontSize: 13,
lineHeight: 19,
color: colors.text,
}}
>
{question}
</Text>
<Ionicons
name={expanded ? 'chevron-up' : 'chevron-down'}
size={16}
color={colors.textMuted}
/>
</TouchableOpacity>
{expanded ? (
<Text
style={{
marginTop: 8,
paddingTop: 8,
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.06)',
fontFamily: 'Nunito_400Regular',
fontSize: 13,
lineHeight: 19,
color: colors.textMuted,
}}
>
{answer}
</Text>
) : null}
</View>
);
}