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

200 lines
6.0 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { Alert, Text, TextInput, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Ionicons } from '@expo/vector-icons';
import { useColors } from '../../../lib/theme';
import { apiFetch } from '../../../lib/api';
import { invalidateMe, useMe } from '../../../hooks/useMe';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
import { CTABar } from '../CTABar';
type CheckResult =
| { available: true }
| { available: false; reason: 'too_short' | 'too_long' | 'profanity' | 'taken' };
export function NicknameSlide({
onNext,
current,
total,
}: {
onNext: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const colors = useColors();
const { me } = useMe();
const [nickname, setNickname] = useState(me?.nickname ?? '');
const [saving, setSaving] = useState(false);
const [checking, setChecking] = useState(false);
const [checkResult, setCheckResult] = useState<CheckResult | null>(null);
const inputRef = useRef<TextInput | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const checkSeqRef = useRef(0);
const trimmed = nickname.trim();
const valid = checkResult?.available === true;
// Debounced live-check während User tippt
useEffect(() => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
if (trimmed.length === 0) {
setCheckResult(null);
setChecking(false);
return;
}
if (trimmed.length < 3) {
setCheckResult({ available: false, reason: 'too_short' });
setChecking(false);
return;
}
setChecking(true);
debounceTimerRef.current = setTimeout(async () => {
const seq = ++checkSeqRef.current;
try {
const res = await apiFetch<CheckResult>(
`/api/profile/check-nickname?nickname=${encodeURIComponent(trimmed)}`,
);
// Race-guard — verwirf veraltete Antworten
if (seq !== checkSeqRef.current) return;
setCheckResult(res);
} catch {
// Network-Error: nicht blocken, lass Server-Side bei Save validieren
if (seq === checkSeqRef.current) setCheckResult({ available: true });
} finally {
if (seq === checkSeqRef.current) setChecking(false);
}
}, 450);
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
};
}, [trimmed]);
async function save() {
if (!valid || saving) return;
setSaving(true);
try {
await apiFetch('/api/auth/me', {
method: 'PATCH',
body: { nickname: trimmed },
});
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step: 'account' },
}).catch(() => {});
invalidateMe();
onNext();
} catch (e: unknown) {
Alert.alert(
t('common.error'),
e instanceof Error ? e.message : t('common.unknown_error'),
);
} finally {
setSaving(false);
}
}
// Visual-state für Border + Hint
const borderColor =
checkResult && !checkResult.available
? colors.error
: valid
? colors.success
: 'transparent';
return (
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={t('onboarding.nickname.cta_primary')}
onPrimary={save}
primaryDisabled={!valid}
primaryLoading={saving}
/>
}
>
<LyraBubble text={t('onboarding.lyra.nickname.body')} emotion="happy" />
<View style={{ marginTop: 24 }}>
<Text
style={{
fontFamily: 'Nunito_700Bold',
fontSize: 12,
color: colors.textMuted,
letterSpacing: 0.8,
marginBottom: 8,
}}
>
{t('onboarding.nickname.label')}
</Text>
<View style={{ position: 'relative' }}>
<TextInput
ref={inputRef}
autoFocus
value={nickname}
onChangeText={setNickname}
onSubmitEditing={save}
placeholder={t('onboarding.nickname.placeholder')}
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoCorrect={false}
maxLength={32}
returnKeyType="done"
style={{
fontSize: 16,
lineHeight: 22,
paddingVertical: 14,
paddingHorizontal: 16,
paddingRight: 44,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
borderWidth: 2,
borderColor,
}}
/>
{/* Status-Indicator rechts im Input */}
<View
style={{
position: 'absolute',
right: 14,
top: 0,
bottom: 0,
justifyContent: 'center',
}}
pointerEvents="none"
>
{trimmed.length === 0 ? null : checking ? (
<Ionicons name="hourglass-outline" size={18} color={colors.textMuted} />
) : valid ? (
<Ionicons name="checkmark-circle" size={20} color={colors.success} />
) : (
<Ionicons name="close-circle" size={20} color={colors.error} />
)}
</View>
</View>
{/* Hint / Error-Text */}
<Text
style={{
marginTop: 8,
fontFamily: 'Nunito_400Regular',
fontSize: 12,
color:
checkResult && !checkResult.available ? colors.error : colors.textMuted,
minHeight: 32,
}}
>
{checkResult && !checkResult.available
? t(`onboarding.nickname.error_${checkResult.reason}`)
: t('onboarding.nickname.hint')}
</Text>
</View>
</OnboardingShell>
);
}