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 } }; +});