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';
|
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({
|
export function DigaCodeSlide({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onBack,
|
onBack,
|
||||||
@ -87,7 +111,7 @@ export function DigaCodeSlide({
|
|||||||
autoFocus
|
autoFocus
|
||||||
value={code}
|
value={code}
|
||||||
onChangeText={(v) => {
|
onChangeText={(v) => {
|
||||||
setCode(v.toUpperCase());
|
setCode(formatDigaCode(v));
|
||||||
if (errorKey) setErrorKey(null);
|
if (errorKey) setErrorKey(null);
|
||||||
}}
|
}}
|
||||||
onSubmitEditing={redeem}
|
onSubmitEditing={redeem}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Animated, Easing, Text, View } from 'react-native';
|
import { Animated, Easing, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useColors } from '../../../lib/theme';
|
import { useColors } from '../../../lib/theme';
|
||||||
@ -7,6 +7,8 @@ import { OnboardingShell } from '../OnboardingShell';
|
|||||||
import { LyraBubble } from '../LyraBubble';
|
import { LyraBubble } from '../LyraBubble';
|
||||||
import { CTABar } from '../CTABar';
|
import { CTABar } from '../CTABar';
|
||||||
|
|
||||||
|
const FAQ_KEYS = ['faq_q1', 'faq_q2', 'faq_q3', 'faq_q4', 'faq_q5'] as const;
|
||||||
|
|
||||||
export function DoneSlide({
|
export function DoneSlide({
|
||||||
onEnter,
|
onEnter,
|
||||||
current,
|
current,
|
||||||
@ -39,11 +41,13 @@ export function DoneSlide({
|
|||||||
total={total}
|
total={total}
|
||||||
cta={<CTABar primaryLabel={t('onboarding.done.cta_primary')} onPrimary={onEnter} />}
|
cta={<CTABar primaryLabel={t('onboarding.done.cta_primary')} onPrimary={onEnter} />}
|
||||||
>
|
>
|
||||||
|
<ConfettiOverlay />
|
||||||
|
|
||||||
<LyraBubble text={t('onboarding.lyra.done.body')} emotion="happy" />
|
<LyraBubble text={t('onboarding.lyra.done.body')} emotion="happy" />
|
||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
marginTop: 40,
|
marginTop: 24,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
opacity,
|
opacity,
|
||||||
transform: [{ scale }],
|
transform: [{ scale }],
|
||||||
@ -51,9 +55,9 @@ export function DoneSlide({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 120,
|
width: 96,
|
||||||
height: 120,
|
height: 96,
|
||||||
borderRadius: 60,
|
borderRadius: 48,
|
||||||
backgroundColor: 'rgba(34,197,94,0.14)',
|
backgroundColor: 'rgba(34,197,94,0.14)',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@ -61,14 +65,14 @@ export function DoneSlide({
|
|||||||
borderColor: 'rgba(34,197,94,0.40)',
|
borderColor: 'rgba(34,197,94,0.40)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="checkmark" size={64} color={colors.success} />
|
<Ionicons name="checkmark" size={52} color={colors.success} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
marginTop: 24,
|
marginTop: 18,
|
||||||
fontFamily: 'Nunito_800ExtraBold',
|
fontFamily: 'Nunito_800ExtraBold',
|
||||||
fontSize: 26,
|
fontSize: 24,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
letterSpacing: -0.3,
|
letterSpacing: -0.3,
|
||||||
@ -78,10 +82,10 @@ export function DoneSlide({
|
|||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
marginTop: 8,
|
marginTop: 6,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
fontSize: 15,
|
fontSize: 14,
|
||||||
lineHeight: 22,
|
lineHeight: 20,
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
@ -90,6 +94,166 @@ export function DoneSlide({
|
|||||||
{t('onboarding.done.subhead')}
|
{t('onboarding.done.subhead')}
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</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>
|
</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 { Alert, Text, TextInput, View } from 'react-native';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useColors } from '../../../lib/theme';
|
import { useColors } from '../../../lib/theme';
|
||||||
import { apiFetch } from '../../../lib/api';
|
import { apiFetch } from '../../../lib/api';
|
||||||
import { invalidateMe, useMe } from '../../../hooks/useMe';
|
import { invalidateMe, useMe } from '../../../hooks/useMe';
|
||||||
@ -8,6 +9,10 @@ import { OnboardingShell } from '../OnboardingShell';
|
|||||||
import { LyraBubble } from '../LyraBubble';
|
import { LyraBubble } from '../LyraBubble';
|
||||||
import { CTABar } from '../CTABar';
|
import { CTABar } from '../CTABar';
|
||||||
|
|
||||||
|
type CheckResult =
|
||||||
|
| { available: true }
|
||||||
|
| { available: false; reason: 'too_short' | 'too_long' | 'profanity' | 'taken' };
|
||||||
|
|
||||||
export function NicknameSlide({
|
export function NicknameSlide({
|
||||||
onNext,
|
onNext,
|
||||||
current,
|
current,
|
||||||
@ -22,10 +27,49 @@ export function NicknameSlide({
|
|||||||
const { me } = useMe();
|
const { me } = useMe();
|
||||||
const [nickname, setNickname] = useState(me?.nickname ?? '');
|
const [nickname, setNickname] = useState(me?.nickname ?? '');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [checkResult, setCheckResult] = useState<CheckResult | null>(null);
|
||||||
const inputRef = useRef<TextInput | 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 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() {
|
async function save() {
|
||||||
if (!valid || saving) return;
|
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 (
|
return (
|
||||||
<OnboardingShell
|
<OnboardingShell
|
||||||
current={current}
|
current={current}
|
||||||
@ -78,40 +130,68 @@ export function NicknameSlide({
|
|||||||
>
|
>
|
||||||
{t('onboarding.nickname.label')}
|
{t('onboarding.nickname.label')}
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
<View style={{ position: 'relative' }}>
|
||||||
ref={inputRef}
|
<TextInput
|
||||||
autoFocus
|
ref={inputRef}
|
||||||
value={nickname}
|
autoFocus
|
||||||
onChangeText={setNickname}
|
value={nickname}
|
||||||
onSubmitEditing={save}
|
onChangeText={setNickname}
|
||||||
placeholder={t('onboarding.nickname.placeholder')}
|
onSubmitEditing={save}
|
||||||
placeholderTextColor="#a3a3a3"
|
placeholder={t('onboarding.nickname.placeholder')}
|
||||||
autoCapitalize="none"
|
placeholderTextColor="#a3a3a3"
|
||||||
autoCorrect={false}
|
autoCapitalize="none"
|
||||||
maxLength={32}
|
autoCorrect={false}
|
||||||
returnKeyType="done"
|
maxLength={32}
|
||||||
style={{
|
returnKeyType="done"
|
||||||
fontSize: 16,
|
style={{
|
||||||
lineHeight: 22,
|
fontSize: 16,
|
||||||
paddingVertical: 14,
|
lineHeight: 22,
|
||||||
paddingHorizontal: 16,
|
paddingVertical: 14,
|
||||||
color: colors.text,
|
paddingHorizontal: 16,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
paddingRight: 44,
|
||||||
backgroundColor: colors.surfaceElevated,
|
color: colors.text,
|
||||||
borderRadius: 12,
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
borderWidth: 2,
|
backgroundColor: colors.surfaceElevated,
|
||||||
borderColor: valid ? colors.brandOrange : 'transparent',
|
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
|
<Text
|
||||||
style={{
|
style={{
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
fontSize: 12,
|
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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</OnboardingShell>
|
</OnboardingShell>
|
||||||
|
|||||||
@ -360,16 +360,16 @@
|
|||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"lyra": {
|
"lyra": {
|
||||||
"welcome": { "body": "أهلاً، أنا Lyra. سعيدة أنك خطوت هذه الخطوة — سنجد طريق الخروج من القمار معاً." },
|
"welcome": { "body": "أهلاً، أنا Lyra. سعيدة بوجودك هنا — الخطوة الأولى هي الأصعب، وقد قمت بها بالفعل." },
|
||||||
"privacy": { "body": "قبل أن نبدأ — وعد سريع. نعرفك فقط باسمك المستعار. لا اسم حقيقي، لا تتبع، لا إعلانات. أنت في أمان هنا." },
|
"privacy": { "body": "قبل أن نبدأ — وعد. نعرفك فقط باسمك المستعار. لا اسم حقيقي، لا تتبع، لا إعلانات. أنت في أمان هنا." },
|
||||||
"nickname": { "body": "بم أناديك؟ اختر اسماً مستعاراً — فقط المجتمع يراه، لا حاجة لاسم حقيقي." },
|
"nickname": { "body": "بم أناديك؟ اختر اسماً مستعاراً — يراه المجتمع فقط، دون الحاجة لاسم حقيقي." },
|
||||||
"diga_choice": { "body": "هل لديك رمز وصفة طبية من تأمينك الصحي؟ إذن تدخل مباشرة." },
|
"diga_choice": { "body": "هل لديك رمز وصفة طبية من تأمينك الصحي؟ إذن كل شيء مفتوح لك." },
|
||||||
"diga_code": { "body": "اكتب رمزك — سأتحقق منه لك." },
|
"diga_code": { "body": "اكتب رمزك — سأتحقق منه لك." },
|
||||||
"plan": { "body": "حماية جهازك تكلف بعض الشيء — لكن 14 يوماً مجاناً. أي خطة تناسبك؟" },
|
"plan": { "body": "لكي تستمر الحماية على جهازك، نحتاج إلى خطة — أول 14 يوماً مجاناً. ما الذي يناسبك؟" },
|
||||||
"payment": { "body": "خطوة قصيرة: أكّد تجربتك. يمكنك الإلغاء في أي وقت — Apple يتولى ذلك لك." },
|
"payment": { "body": "خطوة قصيرة: أكّد تجربتك. يمكنك الإلغاء في أي وقت — كل شيء يتم عبر Apple." },
|
||||||
"protection": { "body": "الآن الجزء الأهم — الحماية على جهازك. مستعد؟" },
|
"protection": { "body": "الآن الجزء الأهم — الحماية على جهازك. هل أنت مستعد؟" },
|
||||||
"protection_url": { "body": "ستظهر نافذة iOS. اضغط «السماح» — الزر السفلي (وليس الأزرق الكبير في الأعلى — هذا فخ)." },
|
"protection_url": { "body": "ستظهر نافذة iOS. اضغط «السماح» — الزر السفلي (وليس الأزرق الكبير في الأعلى — هذا هو الفخ)." },
|
||||||
"protection_lock": { "body": "الآن قفل التطبيق. iOS يطلب الوصول إلى مدة استخدام الجهاز — اضغط «متابعة»، مرة أخرى الزر السفلي." },
|
"protection_lock": { "body": "الآن قفل التطبيق. iOS يطلب الوصول إلى مدة استخدام الجهاز — اضغط «متابعة»، مرة أخرى الزر السفلي (وليس الأزرق)." },
|
||||||
"done": { "body": "تم. اليوم الأول من سلسلتك — ولست وحدك." }
|
"done": { "body": "تم. اليوم الأول من سلسلتك — ولست وحدك." }
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
@ -389,7 +389,11 @@
|
|||||||
"cta_primary": "حفظ",
|
"cta_primary": "حفظ",
|
||||||
"label": "اسمك المستعار",
|
"label": "اسمك المستعار",
|
||||||
"placeholder": "مثلاً wanderer84",
|
"placeholder": "مثلاً wanderer84",
|
||||||
"hint": "2 إلى 32 حرف. قابل للتغيير في أي وقت."
|
"hint": "3 إلى 32 حرف. قابل للتغيير في أي وقت.",
|
||||||
|
"error_too_short": "الحد الأدنى 3 أحرف.",
|
||||||
|
"error_too_long": "الحد الأقصى 32 حرفاً.",
|
||||||
|
"error_profanity": "اختر اسماً مستعاراً محايداً من فضلك.",
|
||||||
|
"error_taken": "هذا الاسم مستخدم بالفعل."
|
||||||
},
|
},
|
||||||
"diga_choice": {
|
"diga_choice": {
|
||||||
"cta_yes": "نعم، لدي رمز",
|
"cta_yes": "نعم، لدي رمز",
|
||||||
@ -465,7 +469,8 @@
|
|||||||
"done": {
|
"done": {
|
||||||
"cta_primary": "ادخل التطبيق",
|
"cta_primary": "ادخل التطبيق",
|
||||||
"headline": "أنت معنا.",
|
"headline": "أنت معنا.",
|
||||||
"subhead": "اليوم الأول من سلسلتك. لست وحدك — المجتمع هنا، وLyra أيضاً."
|
"subhead": "اليوم الأول من سلسلتك. لست وحدك — المجتمع هنا، وLyra أيضاً.",
|
||||||
|
"faq_section_title": "أسئلة متكررة"
|
||||||
},
|
},
|
||||||
"step_progress": "الخطوة %{current} من %{total}",
|
"step_progress": "الخطوة %{current} من %{total}",
|
||||||
"block_spotlight": {
|
"block_spotlight": {
|
||||||
|
|||||||
@ -360,16 +360,16 @@
|
|||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"lyra": {
|
"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." },
|
"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 starten — ein kurzes Versprechen. Wir kennen dich nur unter einem Alias. Kein Klarname, keine Tracker, kein Werbe-Spam. Du bist sicher hier." },
|
"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." },
|
"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_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 check ihn für dich." },
|
"diga_code": { "body": "Tippe deinen Code ein — ich prüfe ihn für dich." },
|
||||||
"plan": { "body": "Schutz auf deinem Gerät kostet etwas — aber 14 Tage gratis. Welcher Plan passt zu dir?" },
|
"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 — Apple regelt das für dich." },
|
"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": { "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_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." },
|
"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." }
|
"done": { "body": "Geschafft. Tag 1 deiner neuen Streak — und du gehst nicht allein." }
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
@ -389,7 +389,11 @@
|
|||||||
"cta_primary": "Speichern",
|
"cta_primary": "Speichern",
|
||||||
"label": "DEIN ALIAS",
|
"label": "DEIN ALIAS",
|
||||||
"placeholder": "z.B. wanderer84",
|
"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": {
|
"diga_choice": {
|
||||||
"cta_yes": "Ja, ich habe einen Code",
|
"cta_yes": "Ja, ich habe einen Code",
|
||||||
@ -465,7 +469,8 @@
|
|||||||
"done": {
|
"done": {
|
||||||
"cta_primary": "In die App",
|
"cta_primary": "In die App",
|
||||||
"headline": "Du bist drin.",
|
"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}",
|
"step_progress": "Schritt %{current} von %{total}",
|
||||||
"block_spotlight": {
|
"block_spotlight": {
|
||||||
|
|||||||
@ -360,16 +360,16 @@
|
|||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"lyra": {
|
"lyra": {
|
||||||
"welcome": { "body": "Hi, I'm Lyra. Glad you took this step — we'll find your way out of gambling together." },
|
"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 quick promise. We only know you by your alias. No real name, no trackers, no ad spam. You're safe here." },
|
"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." },
|
"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." },
|
"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?" },
|
"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 — Apple handles that 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": { "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_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." },
|
"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." }
|
"done": { "body": "Done. Day 1 of your new streak — and you're not walking alone." }
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
@ -389,7 +389,11 @@
|
|||||||
"cta_primary": "Save",
|
"cta_primary": "Save",
|
||||||
"label": "YOUR ALIAS",
|
"label": "YOUR ALIAS",
|
||||||
"placeholder": "e.g. wanderer84",
|
"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": {
|
"diga_choice": {
|
||||||
"cta_yes": "Yes, I have a code",
|
"cta_yes": "Yes, I have a code",
|
||||||
@ -465,7 +469,8 @@
|
|||||||
"done": {
|
"done": {
|
||||||
"cta_primary": "Enter the app",
|
"cta_primary": "Enter the app",
|
||||||
"headline": "You're in.",
|
"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}",
|
"step_progress": "Step %{current} of %{total}",
|
||||||
"block_spotlight": {
|
"block_spotlight": {
|
||||||
|
|||||||
@ -358,17 +358,17 @@
|
|||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"lyra": {
|
"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." },
|
"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." },
|
"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 tu rentres directement." },
|
"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." },
|
"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 ?" },
|
"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 — Apple s'en occupe pour toi." },
|
"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·e ?" },
|
"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_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." },
|
"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·e." }
|
"done": { "body": "Voilà. Jour 1 de ta nouvelle série — et tu n'es pas seul." }
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"cta_primary": "On y va",
|
"cta_primary": "On y va",
|
||||||
@ -387,7 +387,11 @@
|
|||||||
"cta_primary": "Enregistrer",
|
"cta_primary": "Enregistrer",
|
||||||
"label": "TON ALIAS",
|
"label": "TON ALIAS",
|
||||||
"placeholder": "ex. wanderer84",
|
"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": {
|
"diga_choice": {
|
||||||
"cta_yes": "Oui, j'ai un code",
|
"cta_yes": "Oui, j'ai un code",
|
||||||
@ -463,7 +467,8 @@
|
|||||||
"done": {
|
"done": {
|
||||||
"cta_primary": "Entrer dans l'app",
|
"cta_primary": "Entrer dans l'app",
|
||||||
"headline": "Tu es dedans.",
|
"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}",
|
"step_progress": "Étape %{current} sur %{total}",
|
||||||
"block_spotlight": {
|
"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