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>
This commit is contained in:
chahinebrini 2026-05-17 20:09:53 +02:00
parent 33aa3464b8
commit 3c5c9ebfba
8 changed files with 453 additions and 82 deletions

View File

@ -11,6 +11,30 @@ 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,
@ -87,7 +111,7 @@ export function DigaCodeSlide({
autoFocus
value={code}
onChangeText={(v) => {
setCode(v.toUpperCase());
setCode(formatDigaCode(v));
if (errorKey) setErrorKey(null);
}}
onSubmitEditing={redeem}

View File

@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react';
import { Animated, Easing, Text, View } from 'react-native';
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';
@ -7,6 +7,8 @@ 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,
@ -39,11 +41,13 @@ export function DoneSlide({
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: 40,
marginTop: 24,
alignItems: 'center',
opacity,
transform: [{ scale }],
@ -51,9 +55,9 @@ export function DoneSlide({
>
<View
style={{
width: 120,
height: 120,
borderRadius: 60,
width: 96,
height: 96,
borderRadius: 48,
backgroundColor: 'rgba(34,197,94,0.14)',
alignItems: 'center',
justifyContent: 'center',
@ -61,14 +65,14 @@ export function DoneSlide({
borderColor: 'rgba(34,197,94,0.40)',
}}
>
<Ionicons name="checkmark" size={64} color={colors.success} />
<Ionicons name="checkmark" size={52} color={colors.success} />
</View>
<Text
style={{
marginTop: 24,
marginTop: 18,
fontFamily: 'Nunito_800ExtraBold',
fontSize: 26,
fontSize: 24,
color: colors.text,
textAlign: 'center',
letterSpacing: -0.3,
@ -78,10 +82,10 @@ export function DoneSlide({
</Text>
<Text
style={{
marginTop: 8,
marginTop: 6,
fontFamily: 'Nunito_400Regular',
fontSize: 15,
lineHeight: 22,
fontSize: 14,
lineHeight: 20,
color: colors.textMuted,
textAlign: 'center',
paddingHorizontal: 16,
@ -90,6 +94,166 @@ export function DoneSlide({
{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>
);
}

View File

@ -1,6 +1,7 @@
import { useRef, useState } from 'react';
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';
@ -8,6 +9,10 @@ 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,
@ -22,10 +27,49 @@ export function NicknameSlide({
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 = trimmed.length >= 2;
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;
@ -51,6 +95,14 @@ export function NicknameSlide({
}
}
// Visual-state für Border + Hint
const borderColor =
checkResult && !checkResult.available
? colors.error
: valid
? colors.success
: 'transparent';
return (
<OnboardingShell
current={current}
@ -78,40 +130,68 @@ export function NicknameSlide({
>
{t('onboarding.nickname.label')}
</Text>
<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,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
borderWidth: 2,
borderColor: valid ? colors.brandOrange : 'transparent',
}}
/>
<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: colors.textMuted,
color:
checkResult && !checkResult.available ? colors.error : colors.textMuted,
minHeight: 32,
}}
>
{t('onboarding.nickname.hint')}
{checkResult && !checkResult.available
? t(`onboarding.nickname.error_${checkResult.reason}`)
: t('onboarding.nickname.hint')}
</Text>
</View>
</OnboardingShell>

View File

@ -360,16 +360,16 @@
},
"onboarding": {
"lyra": {
"welcome": { "body": "أهلاً، أنا Lyra. سعيدة أنك خطوت هذه الخطوة — سنجد طريق الخروج من القمار معاً." },
"privacy": { "body": "قبل أن نبدأ — وعد سريع. نعرفك فقط باسمك المستعار. لا اسم حقيقي، لا تتبع، لا إعلانات. أنت في أمان هنا." },
"nickname": { "body": "بم أناديك؟ اختر اسماً مستعاراً — فقط المجتمع يراه، لا حاجة لاسم حقيقي." },
"diga_choice": { "body": "هل لديك رمز وصفة طبية من تأمينك الصحي؟ إذن تدخل مباشرة." },
"welcome": { "body": "أهلاً، أنا Lyra. سعيدة بوجودك هنا — الخطوة الأولى هي الأصعب، وقد قمت بها بالفعل." },
"privacy": { "body": "قبل أن نبدأ — وعد. نعرفك فقط باسمك المستعار. لا اسم حقيقي، لا تتبع، لا إعلانات. أنت في أمان هنا." },
"nickname": { "body": "بم أناديك؟ اختر اسماً مستعاراً — يراه المجتمع فقط، دون الحاجة لاسم حقيقي." },
"diga_choice": { "body": "هل لديك رمز وصفة طبية من تأمينك الصحي؟ إذن كل شيء مفتوح لك." },
"diga_code": { "body": "اكتب رمزك — سأتحقق منه لك." },
"plan": { "body": "حماية جهازك تكلف بعض الشيء — لكن 14 يوماً مجاناً. أي خطة تناسبك؟" },
"payment": { "body": "خطوة قصيرة: أكّد تجربتك. يمكنك الإلغاء في أي وقت — Apple يتولى ذلك لك." },
"protection": { "body": "الآن الجزء الأهم — الحماية على جهازك. مستعد؟" },
"protection_url": { "body": "ستظهر نافذة iOS. اضغط «السماح» — الزر السفلي (وليس الأزرق الكبير في الأعلى — هذا فخ)." },
"protection_lock": { "body": "الآن قفل التطبيق. iOS يطلب الوصول إلى مدة استخدام الجهاز — اضغط «متابعة»، مرة أخرى الزر السفلي." },
"plan": { "body": "لكي تستمر الحماية على جهازك، نحتاج إلى خطة — أول 14 يوماً مجاناً. ما الذي يناسبك؟" },
"payment": { "body": "خطوة قصيرة: أكّد تجربتك. يمكنك الإلغاء في أي وقت — كل شيء يتم عبر Apple." },
"protection": { "body": "الآن الجزء الأهم — الحماية على جهازك. هل أنت مستعد؟" },
"protection_url": { "body": "ستظهر نافذة iOS. اضغط «السماح» — الزر السفلي (وليس الأزرق الكبير في الأعلى — هذا هو الفخ)." },
"protection_lock": { "body": "الآن قفل التطبيق. iOS يطلب الوصول إلى مدة استخدام الجهاز — اضغط «متابعة»، مرة أخرى الزر السفلي (وليس الأزرق)." },
"done": { "body": "تم. اليوم الأول من سلسلتك — ولست وحدك." }
},
"welcome": {
@ -389,7 +389,11 @@
"cta_primary": "حفظ",
"label": "اسمك المستعار",
"placeholder": "مثلاً wanderer84",
"hint": "2 إلى 32 حرف. قابل للتغيير في أي وقت."
"hint": "3 إلى 32 حرف. قابل للتغيير في أي وقت.",
"error_too_short": "الحد الأدنى 3 أحرف.",
"error_too_long": "الحد الأقصى 32 حرفاً.",
"error_profanity": "اختر اسماً مستعاراً محايداً من فضلك.",
"error_taken": "هذا الاسم مستخدم بالفعل."
},
"diga_choice": {
"cta_yes": "نعم، لدي رمز",
@ -465,7 +469,8 @@
"done": {
"cta_primary": "ادخل التطبيق",
"headline": "أنت معنا.",
"subhead": "اليوم الأول من سلسلتك. لست وحدك — المجتمع هنا، وLyra أيضاً."
"subhead": "اليوم الأول من سلسلتك. لست وحدك — المجتمع هنا، وLyra أيضاً.",
"faq_section_title": "أسئلة متكررة"
},
"step_progress": "الخطوة %{current} من %{total}",
"block_spotlight": {

View File

@ -360,16 +360,16 @@
},
"onboarding": {
"lyra": {
"welcome": { "body": "Hi, ich bin Lyra. Schön dass du den Schritt gemacht hast — wir gehen den Weg raus aus dem Glücksspiel zusammen." },
"privacy": { "body": "Bevor wir starten — ein kurzes Versprechen. Wir kennen dich nur unter einem Alias. Kein Klarname, keine Tracker, kein Werbe-Spam. Du bist sicher hier." },
"welcome": { "body": "Hi, ich bin Lyra. Schön dass du da bist — der erste Schritt ist oft der schwerste, und den hast du schon gemacht." },
"privacy": { "body": "Bevor wir loslegen — ein Versprechen. Wir kennen dich nur unter deinem Alias. Kein Klarname, keine Tracker, keine Werbung. Du bist sicher hier." },
"nickname": { "body": "Wie soll ich dich nennen? Wähle einen Alias — den sieht nur die Community, kein echter Name nötig." },
"diga_choice": { "body": "Hast du einen Rezept-Code von deiner Krankenkasse? Dann kommst du direkt rein." },
"diga_code": { "body": "Tippe deinen Code ein — ich check ihn für dich." },
"plan": { "body": "Schutz auf deinem Gerät kostet etwas — aber 14 Tage gratis. Welcher Plan passt zu dir?" },
"payment": { "body": "Kurzer Schritt: bestätige deinen Trial. Du kannst jederzeit kündigen — Apple regelt das für dich." },
"diga_choice": { "body": "Hast du einen Rezept-Code von deiner Krankenkasse? Dann ist alles für dich freigeschaltet." },
"diga_code": { "body": "Tippe deinen Code ein — ich prüfe ihn für dich." },
"plan": { "body": "Damit der Schutz auf deinem Gerät läuft, brauchen wir einen Plan — die ersten 14 Tage sind gratis. Was passt zu dir?" },
"payment": { "body": "Kurzer Schritt: bestätige deinen Trial. Du kannst jederzeit kündigen — das läuft direkt über Apple." },
"protection": { "body": "Jetzt der wichtigste Teil — der Schutz auf deinem Gerät. Bereit?" },
"protection_url": { "body": "Gleich kommt ein iOS-Dialog. Tippe \"Erlauben\" — den unteren Button (nicht den großen blauen oben — der ist die Falle)." },
"protection_lock": { "body": "Jetzt der App-Schutz. iOS fragt nach Bildschirmzeit-Zugriff — tippe \"Fortfahren\", wieder den unteren Button." },
"protection_url": { "body": "Gleich kommt ein iOS-Dialog. Tippe \"Erlauben\" — den unteren Button (nicht den großen blauen oben — das ist die Falle)." },
"protection_lock": { "body": "Jetzt der App-Schutz. iOS fragt nach Bildschirmzeit-Zugriff — tippe \"Fortfahren\", wieder den unteren Button (nicht den blauen)." },
"done": { "body": "Geschafft. Tag 1 deiner neuen Streak — und du gehst nicht allein." }
},
"welcome": {
@ -389,7 +389,11 @@
"cta_primary": "Speichern",
"label": "DEIN ALIAS",
"placeholder": "z.B. wanderer84",
"hint": "232 Zeichen. Kannst du jederzeit ändern."
"hint": "332 Zeichen. Kannst du jederzeit ändern.",
"error_too_short": "Mindestens 3 Zeichen.",
"error_too_long": "Maximal 32 Zeichen.",
"error_profanity": "Bitte wähle einen neutralen Alias.",
"error_taken": "Dieser Alias ist schon vergeben."
},
"diga_choice": {
"cta_yes": "Ja, ich habe einen Code",
@ -465,7 +469,8 @@
"done": {
"cta_primary": "In die App",
"headline": "Du bist drin.",
"subhead": "Tag 1 deiner Streak. Du gehst nicht allein — die Community ist da, Lyra auch."
"subhead": "Tag 1 deiner Streak. Du gehst nicht allein — die Community ist da, Lyra auch.",
"faq_section_title": "Häufige Fragen"
},
"step_progress": "Schritt %{current} von %{total}",
"block_spotlight": {

View File

@ -360,16 +360,16 @@
},
"onboarding": {
"lyra": {
"welcome": { "body": "Hi, I'm Lyra. Glad you took this step — we'll find your way out of gambling together." },
"privacy": { "body": "Before we start — a quick promise. We only know you by your alias. No real name, no trackers, no ad spam. You're safe here." },
"welcome": { "body": "Hi, I'm Lyra. Glad you're here — the first step is often the hardest, and you've already taken it." },
"privacy": { "body": "Before we start — a promise. We only know you by your alias. No real name, no trackers, no ads. You're safe here." },
"nickname": { "body": "What should I call you? Pick an alias — only the community sees it, no real name needed." },
"diga_choice": { "body": "Do you have a prescription code from your health insurance? Then you skip straight in." },
"diga_choice": { "body": "Do you have a prescription code from your health insurance? Then everything's unlocked for you." },
"diga_code": { "body": "Type your code — I'll check it for you." },
"plan": { "body": "Protecting your device costs a bit to run — but 14 days free. Which plan fits you?" },
"payment": { "body": "Quick step: confirm your trial. You can cancel anytime — Apple handles that for you." },
"plan": { "body": "To keep the protection running on your device, we need a plan — first 14 days are free. What feels right for you?" },
"payment": { "body": "Quick step: confirm your trial. You can cancel anytime — it all runs through Apple." },
"protection": { "body": "Now the important part — the protection on your device. Ready?" },
"protection_url": { "body": "An iOS dialog will appear. Tap \"Allow\" — the bottom button (not the big blue one on top — that's the trap)." },
"protection_lock": { "body": "Now the app lock. iOS asks for Screen Time access — tap \"Continue\", again the bottom button." },
"protection_url": { "body": "An iOS dialog is coming. Tap \"Allow\" — the bottom button (not the big blue one on top — that's the trap)." },
"protection_lock": { "body": "Now the app lock. iOS asks for Screen Time access — tap \"Continue\", again the bottom button (not the blue one)." },
"done": { "body": "Done. Day 1 of your new streak — and you're not walking alone." }
},
"welcome": {
@ -389,7 +389,11 @@
"cta_primary": "Save",
"label": "YOUR ALIAS",
"placeholder": "e.g. wanderer84",
"hint": "232 characters. Changeable anytime."
"hint": "332 characters. Changeable anytime.",
"error_too_short": "Minimum 3 characters.",
"error_too_long": "Maximum 32 characters.",
"error_profanity": "Please pick a neutral alias.",
"error_taken": "This alias is already taken."
},
"diga_choice": {
"cta_yes": "Yes, I have a code",
@ -465,7 +469,8 @@
"done": {
"cta_primary": "Enter the app",
"headline": "You're in.",
"subhead": "Day 1 of your streak. You're not alone — the community is here, Lyra too."
"subhead": "Day 1 of your streak. You're not alone — the community is here, Lyra too.",
"faq_section_title": "Frequently asked"
},
"step_progress": "Step %{current} of %{total}",
"block_spotlight": {

View File

@ -358,17 +358,17 @@
},
"onboarding": {
"lyra": {
"welcome": { "body": "Salut, je suis Lyra. Content·e que tu aies franchi ce pas — on trouve ta voie hors du jeu ensemble." },
"welcome": { "body": "Salut, je suis Lyra. Contente que tu sois là — le premier pas est souvent le plus dur, et tu l'as déjà fait." },
"privacy": { "body": "Avant de commencer — une promesse. On te connaît uniquement par ton alias. Pas de vrai nom, pas de trackers, pas de pub. Tu es en sécurité ici." },
"nickname": { "body": "Comment je t'appelle ? Choisis un alias — seul·e la communauté le voit, pas de vrai nom nécessaire." },
"diga_choice": { "body": "Tu as un code d'ordonnance de ta caisse d'assurance ? Alors tu rentres directement." },
"nickname": { "body": "Comment je t'appelle ? Choisis un alias — seule la communauté le voit, pas de vrai nom nécessaire." },
"diga_choice": { "body": "Tu as un code d'ordonnance de ta caisse d'assurance ? Alors tout est débloqué pour toi." },
"diga_code": { "body": "Tape ton code — je le vérifie pour toi." },
"plan": { "body": "Protéger ton appareil coûte un peu à faire tourner — mais 14 jours gratuits. Quel plan te convient ?" },
"payment": { "body": "Étape rapide : confirme ton essai. Tu peux annuler à tout moment — Apple s'en occupe pour toi." },
"protection": { "body": "Maintenant la partie importante — la protection sur ton appareil. Prêt·e ?" },
"plan": { "body": "Pour faire tourner la protection sur ton appareil, il nous faut un plan — les 14 premiers jours sont offerts. Qu'est-ce qui te convient ?" },
"payment": { "body": "Étape rapide : confirme ton essai. Tu peux annuler à tout moment — tout passe par Apple." },
"protection": { "body": "Maintenant la partie importante — la protection sur ton appareil. Prêt ?" },
"protection_url": { "body": "Une fenêtre iOS va apparaître. Touche « Autoriser » — le bouton du bas (pas le grand bleu en haut — c'est le piège)." },
"protection_lock": { "body": "Maintenant le verrou d'app. iOS demande l'accès à Temps d'écran — touche « Continuer », encore le bouton du bas." },
"done": { "body": "Voilà. Jour 1 de ta nouvelle série — et tu n'es pas seul·e." }
"protection_lock": { "body": "Maintenant le verrou d'app. iOS demande l'accès à Temps d'écran — touche « Continuer », encore le bouton du bas (pas le bleu)." },
"done": { "body": "Voilà. Jour 1 de ta nouvelle série — et tu n'es pas seul." }
},
"welcome": {
"cta_primary": "On y va",
@ -387,7 +387,11 @@
"cta_primary": "Enregistrer",
"label": "TON ALIAS",
"placeholder": "ex. wanderer84",
"hint": "2 à 32 caractères. Modifiable à tout moment."
"hint": "3 à 32 caractères. Modifiable à tout moment.",
"error_too_short": "Minimum 3 caractères.",
"error_too_long": "Maximum 32 caractères.",
"error_profanity": "Choisis un alias neutre.",
"error_taken": "Cet alias est déjà pris."
},
"diga_choice": {
"cta_yes": "Oui, j'ai un code",
@ -463,7 +467,8 @@
"done": {
"cta_primary": "Entrer dans l'app",
"headline": "Tu es dedans.",
"subhead": "Jour 1 de ta série. Tu n'es pas seul·e — la communauté est là, Lyra aussi."
"subhead": "Jour 1 de ta série. Tu n'es pas seul·e — la communauté est là, Lyra aussi.",
"faq_section_title": "Questions fréquentes"
},
"step_progress": "Étape %{current} sur %{total}",
"block_spotlight": {

View File

@ -0,0 +1,83 @@
import { usePrisma } from "../../utils/prisma";
/**
* GET /api/profile/check-nickname?nickname=foo
*
* Validiert ob ein Nickname für den User OK ist:
* - Min 3 Zeichen
* - Max 32 Zeichen
* - Nicht in Profanity-Blocklist (kleines hartcodiertes Set)
* - Nicht von einem anderen User belegt (case-insensitive)
*
* Returns: { available: boolean, reason?: 'too_short' | 'too_long' | 'profanity' | 'taken' }
*
* Genutzt von der Nickname-Slide im Onboarding mit ~500ms Debounce um
* Live-Feedback zu geben. Idempotent + günstig (1 SELECT auf einen Index).
*/
const PROFANITY_BLOCKLIST: ReadonlySet<string> = new Set([
// Minimal-Set DE/EN — slurs + bot-impersonation. Erweiterbar; lib-frei
// damit Bundle-Size klein bleibt.
"admin",
"administrator",
"rebreak",
"lyra",
"support",
"moderator",
"mod",
"system",
"root",
"nigger",
"nazi",
"fuck",
"shit",
"fotze",
"hure",
"schwuchtel",
"fag",
"bitch",
"cunt",
"arsch",
"wichser",
]);
function isProfanity(nickname: string): boolean {
const lower = nickname.toLowerCase().trim();
if (PROFANITY_BLOCKLIST.has(lower)) return true;
for (const word of PROFANITY_BLOCKLIST) {
if (lower.includes(word)) return true;
}
return false;
}
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const query = getQuery(event);
const raw = String(query.nickname ?? "").trim();
if (raw.length < 3) {
return { success: true, data: { available: false, reason: "too_short" } };
}
if (raw.length > 32) {
return { success: true, data: { available: false, reason: "too_long" } };
}
if (isProfanity(raw)) {
return { success: true, data: { available: false, reason: "profanity" } };
}
// Case-insensitive lookup. Eigener Nickname (= aktueller User) ist OK
// — sonst kann User seinen eigenen Namen nicht "behalten".
const db = usePrisma();
const existing = await db.profile.findFirst({
where: {
nickname: { equals: raw, mode: "insensitive" },
id: { not: user.id },
deletedAt: null,
},
select: { id: true },
});
if (existing) {
return { success: true, data: { available: false, reason: "taken" } };
}
return { success: true, data: { available: true } };
});