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

176 lines
5.2 KiB
TypeScript

import { useState } from 'react';
import { 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 } from '../../../hooks/useMe';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
import { CTABar } from '../CTABar';
type RedeemError = 'not_found' | 'already_used' | 'expired' | 'invalid_input';
/**
* Live-Format-Mask für DiGA-Codes. User tippt "REBREAKTEST001" → wird
* automatisch zu "REBREAK-TEST-001". Strip-then-segment:
* 1. Alles außer alphanumerisch entfernen
* 2. Erste 7 Zeichen = "REBREAK"-Block, Rest in Gruppen
*
* Liberal: erlaubt User auch händisch Dashes zu setzen (wird neu segmentiert).
*/
function formatDigaCode(raw: string): string {
const clean = raw
.toUpperCase()
.replace(/[^A-Z0-9]/g, '')
.slice(0, 18); // 7 (REBREAK) + 4 (TEST/RXxx) + 7 (xxx-xxx) ohne Dashes
if (clean.length <= 7) return clean;
// Block 1: REBREAK (7 chars)
const block1 = clean.slice(0, 7);
if (clean.length <= 11) return `${block1}-${clean.slice(7)}`;
// Block 2: TEST oder ähnliches (4 chars)
const block2 = clean.slice(7, 11);
// Block 3: laufende Nummer
const block3 = clean.slice(11);
return `${block1}-${block2}-${block3}`;
}
export function DigaCodeSlide({
onSuccess,
onBack,
current,
total,
}: {
/** Wird gerufen wenn der Code erfolgreich eingelöst wurde. Backend hat dann
* plan='legend' + onboarding_step='pre_protection' gesetzt. */
onSuccess: () => void;
/** Zurück zum DigaChoiceSlide (User hat sich's anders überlegt). */
onBack: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const colors = useColors();
const [code, setCode] = useState('');
const [submitting, setSubmitting] = useState(false);
const [errorKey, setErrorKey] = useState<RedeemError | null>(null);
const trimmed = code.trim();
const valid = trimmed.length >= 6;
async function redeem() {
if (!valid || submitting) return;
setSubmitting(true);
setErrorKey(null);
try {
await apiFetch('/api/onboarding/redeem-diga-code', {
method: 'POST',
body: { code: trimmed },
});
invalidateMe();
onSuccess();
} catch (e: any) {
// apiFetch wirft Error mit `code` Feld bei strukturierten 4xx
const code = (e?.code ?? e?.data?.error) as RedeemError | undefined;
setErrorKey(code ?? 'not_found');
} finally {
setSubmitting(false);
}
}
return (
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={t('onboarding.diga_code.cta_primary')}
onPrimary={redeem}
primaryDisabled={!valid}
primaryLoading={submitting}
secondaryLabel={t('onboarding.diga_code.cta_secondary')}
onSecondary={onBack}
/>
}
>
<LyraBubble text={t('onboarding.lyra.diga_code.body')} emotion="thinking" />
<View style={{ marginTop: 24 }}>
<Text
style={{
fontFamily: 'Nunito_700Bold',
fontSize: 12,
color: colors.textMuted,
letterSpacing: 0.8,
marginBottom: 8,
}}
>
{t('onboarding.diga_code.label')}
</Text>
<TextInput
autoFocus
value={code}
onChangeText={(v) => {
setCode(formatDigaCode(v));
if (errorKey) setErrorKey(null);
}}
onSubmitEditing={redeem}
placeholder="REBREAK-XXXX-XXX"
placeholderTextColor="#a3a3a3"
autoCapitalize="characters"
autoCorrect={false}
maxLength={32}
returnKeyType="done"
style={{
fontSize: 16,
lineHeight: 22,
paddingVertical: 14,
paddingHorizontal: 16,
color: colors.text,
fontFamily: 'Nunito_700Bold',
letterSpacing: 1,
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
borderWidth: 2,
borderColor: errorKey ? colors.error : valid ? colors.brandOrange : 'transparent',
}}
/>
{errorKey ? (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginTop: 10,
paddingHorizontal: 2,
}}
>
<Ionicons name="alert-circle" size={16} color={colors.error} />
<Text
style={{
flex: 1,
fontFamily: 'Nunito_600SemiBold',
fontSize: 13,
color: colors.error,
}}
>
{t(`onboarding.diga_code.error_${errorKey}`)}
</Text>
</View>
) : (
<Text
style={{
marginTop: 8,
fontFamily: 'Nunito_400Regular',
fontSize: 12,
color: colors.textMuted,
}}
>
{t('onboarding.diga_code.hint')}
</Text>
)}
</View>
</OnboardingShell>
);
}