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:
parent
33aa3464b8
commit
3c5c9ebfba
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
83
backend/server/api/profile/check-nickname.get.ts
Normal file
83
backend/server/api/profile/check-nickname.get.ts
Normal 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 } };
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user