diff --git a/apps/rebreak-native/components/onboarding/slides/DigaCodeSlide.tsx b/apps/rebreak-native/components/onboarding/slides/DigaCodeSlide.tsx
index e36e7c1..57a66b2 100644
--- a/apps/rebreak-native/components/onboarding/slides/DigaCodeSlide.tsx
+++ b/apps/rebreak-native/components/onboarding/slides/DigaCodeSlide.tsx
@@ -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}
diff --git a/apps/rebreak-native/components/onboarding/slides/DoneSlide.tsx b/apps/rebreak-native/components/onboarding/slides/DoneSlide.tsx
index f40f097..cc3c1e7 100644
--- a/apps/rebreak-native/components/onboarding/slides/DoneSlide.tsx
+++ b/apps/rebreak-native/components/onboarding/slides/DoneSlide.tsx
@@ -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={}
>
+
+
-
+
+
+ {/* Inline Top-5-FAQ Accordion */}
+
+
+ {t('onboarding.done.faq_section_title')}
+
+
+ {FAQ_KEYS.map((qkey) => (
+
+ ))}
+
+
);
}
+
+// ─── 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 (
+
+ {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 (
+
+ );
+ })}
+
+ );
+}
+
+// ─── FAQ Accordion-Row ───────────────────────────────────────────────────────
+
+function FaqRow({
+ question,
+ answer,
+ colors,
+}: {
+ question: string;
+ answer: string;
+ colors: import('../../../lib/theme').ColorScheme;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ return (
+
+ setExpanded((v) => !v)}
+ activeOpacity={0.7}
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}
+ >
+
+ {question}
+
+
+
+ {expanded ? (
+
+ {answer}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/rebreak-native/components/onboarding/slides/NicknameSlide.tsx b/apps/rebreak-native/components/onboarding/slides/NicknameSlide.tsx
index 722cda6..1ce9ac3 100644
--- a/apps/rebreak-native/components/onboarding/slides/NicknameSlide.tsx
+++ b/apps/rebreak-native/components/onboarding/slides/NicknameSlide.tsx
@@ -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(null);
const inputRef = useRef(null);
+ const debounceTimerRef = useRef | 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(
+ `/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 (
{t('onboarding.nickname.label')}
-
+
+
+ {/* Status-Indicator rechts im Input */}
+
+ {trimmed.length === 0 ? null : checking ? (
+
+ ) : valid ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Hint / Error-Text */}
- {t('onboarding.nickname.hint')}
+ {checkResult && !checkResult.available
+ ? t(`onboarding.nickname.error_${checkResult.reason}`)
+ : t('onboarding.nickname.hint')}
diff --git a/apps/rebreak-native/locales/ar.json b/apps/rebreak-native/locales/ar.json
index 9025df4..2c5a954 100644
--- a/apps/rebreak-native/locales/ar.json
+++ b/apps/rebreak-native/locales/ar.json
@@ -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": {
diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json
index 78dcd2f..dd1c2c0 100644
--- a/apps/rebreak-native/locales/de.json
+++ b/apps/rebreak-native/locales/de.json
@@ -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": "2–32 Zeichen. Kannst du jederzeit ändern."
+ "hint": "3–32 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": {
diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json
index 5bc59bb..d03b71f 100644
--- a/apps/rebreak-native/locales/en.json
+++ b/apps/rebreak-native/locales/en.json
@@ -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": "2–32 characters. Changeable anytime."
+ "hint": "3–32 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": {
diff --git a/apps/rebreak-native/locales/fr.json b/apps/rebreak-native/locales/fr.json
index e71e0cd..e7e0c75 100644
--- a/apps/rebreak-native/locales/fr.json
+++ b/apps/rebreak-native/locales/fr.json
@@ -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": {
diff --git a/backend/server/api/profile/check-nickname.get.ts b/backend/server/api/profile/check-nickname.get.ts
new file mode 100644
index 0000000..e32e690
--- /dev/null
+++ b/backend/server/api/profile/check-nickname.get.ts
@@ -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 = 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 } };
+});