## 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>
200 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|