feat(onboarding,protection): Duo-style flow + cooldown auto-disable fix + Family Controls live

## Duo-Style Onboarding (Foundation + alle Slides)

Self-contained Onboarding-Flow mit Lyra-Mascot ersetzt das Spotlight-POC vom
vorherigen Iteration. Slides leben unter `components/onboarding/slides/`.

- Foundation: OnboardingShell (Progress + ScrollView + sticky CTABar),
  LyraBubble (Rive-Avatar + animierte Speech-Bubble), SlideProgress, CTABar
- Slides: Welcome, Privacy (4 Versprechen), Nickname (inline + PATCH /me),
  DigaChoice (Ja/Nein-Branch), DigaCode (redeem-Endpoint + inline-Errors),
  Plan (Pro/Legend cards, monthly/yearly toggle, 2 Monate gratis, Härtefall-
  Mailto), Payment (RevenueCat-Dev-Stub bis Phase-0), Protection (activate +
  PermissionDeniedSheet-Wiring), Done (animierter Checkmark + Streak-Day-1)
- State-Machine in app/onboarding/index.tsx: 9 Slides, DiGA-Branch, Resume-
  on-launch via slideFromStep(me.onboardingStep)
- Routing-gate in (app)/_layout.tsx: step != 'done' → /onboarding
- Backend Profile.onboardingStep enum extended:
  welcome | account | plan | pre_protection | done (+ legacy nickname/block)
- Backend diga redeem: step='pre_protection' (NICHT 'done') — User muss noch
  durch Protection-Slide für NEFilter/VPN-Aktivierung
- Locale-Keys (de/en/fr/ar): onboarding.lyra.<slide>.body, .cta_primary,
  Plan-Tier-Details (3,99/7,99 €/Mo, 39,90/79,90 €/Jahr mit 2 Monaten gratis),
  Härtefall-Link, DiGA-Code-Errors, Protection-Feat-Descriptions

## Cooldown Auto-Disable Race-Fix

Bug: nach Cooldown-Ablauf bleib URL-Filter installiert (NEFilter in iOS-
Settings sichtbar als "Läuft..."). Root-cause: `/api/cooldown/status` GET
auto-resolved beim ersten expired-Hit; zweiter Call in
applyCooldownDisableIfElapsed sah cooldownEndsAt=null → bail → forceDisable
nie aufgerufen.

- useProtectionState.fetchState: lokalen next.cooldown.endsAt state nutzen
  statt redundantem API-Call. Atomarer, race-frei.
- AppState-Listener-Path unverändert (dort ist es der erste API-Call, kein
  Race).
- lib/protection.forceDisable: console.log für Debug-Visibility.

## iOS NEFilter Robust-Disable (Native)

`removeFromPreferences()` alleine ist auf iOS 18+ unzuverlässig — Settings-
UI zeigt "Läuft..." obwohl Provider beendet sein sollte. 2-Step-Pattern:

  1. loadFromPreferences
  2. isEnabled = false + saveToPreferences (stoppt Filter-Daemon)
  3. removeFromPreferences (Config-Eintrag aus Settings)

Quelle: Apple-Developer-Forums + eigene Empirie. Pattern wird auch in
PermissionDeniedSheet's resetUrlFilter genutzt (analog).

## Family Controls jetzt immer aktiv

Apple-Entitlement seit 2026-05 für ReBreak approved (TestFlight-akzeptiert).
`familyControlsEnabled: true` hart in app.config.ts (kein Env-Var-Gating mehr).
"Bald verfügbar"-Placeholder in blocker.tsx entfernt — App-Lock-Toggle ist
jetzt voll funktional auf iOS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-17 17:48:05 +02:00
parent ae92918449
commit b23bd6d29f
29 changed files with 2275 additions and 557 deletions

View File

@ -127,12 +127,12 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
},
},
},
// Spiegelt das Build-Flag aus eas.json (development.env.REBREAK_ENABLE_FAMILY_CONTROLS)
// in den JS-Bundle, damit die Blocker-Page weiß ob die App-Lock (denyAppRemoval via
// FamilyControls) verfügbar ist. In TestFlight/production-Builds ist das (noch) false
// → dort UI: "Family Controls — coming soon" statt eines kaputten Toggles, der
// Schutz-Banner bleibt trotzdem positiv ("Schutz komplett", der URL-Filter trägt).
familyControlsEnabled: process.env.REBREAK_ENABLE_FAMILY_CONTROLS === "1",
// Family Controls Entitlement (denyAppRemoval via ManagedSettings) ist seit
// 2026-05 für ReBreak via Apple-Entitlement-Request approved und in TestFlight-
// sowie production-Builds aktiv. Daher hart auf `true` — keine Build-Flag-Gating
// mehr nötig. Legacy `REBREAK_ENABLE_FAMILY_CONTROLS=1` aus dev-builds wird
// ignoriert (das war Übergangs-Gating vor Apple-Approval).
familyControlsEnabled: true,
apiUrl:
process.env.EXPO_PUBLIC_API_URL ||
process.env.API_URL ||

View File

@ -105,7 +105,7 @@ export default function AppLayout() {
useEffect(() => {
if (!session || !me) return;
if (me.onboardingStep !== 'done') {
router.replace('/onboarding/welcome');
router.replace('/onboarding');
}
}, [session, me?.onboardingStep]);

View File

@ -17,7 +17,7 @@ import { useProtectionState } from '../../hooks/useProtectionState';
import { useCustomDomains } from '../../hooks/useCustomDomains';
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime';
import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection';
import { protection } from '../../lib/protection';
import { useColors, type ColorScheme } from '../../lib/theme';
export default function BlockerScreen() {
@ -250,20 +250,7 @@ export default function BlockerScreen() {
active={urlFilterActive}
onActivate={handleActivateUrlFilter}
/>
{FAMILY_CONTROLS_AVAILABLE ? (
<LayerSwitchCard
icon="lock-closed-outline"
title={t('blocker.layers_app_lock_title')}
subtitle={
appDeletionLockActive
? t('blocker.layers_app_lock_subtitle_active')
: t('blocker.layers_app_lock_subtitle_inactive')
}
active={appDeletionLockActive}
onActivate={handleActivateFamilyControls}
warning={t('blocker.layers_app_lock_warning')}
/>
) : Platform.OS === 'android' ? (
{Platform.OS === 'android' ? (
<LayerSwitchCard
icon="lock-closed-outline"
title={t('blocker.layers_app_lock_title')}
@ -277,62 +264,18 @@ export default function BlockerScreen() {
warning={t('blocker.layers_app_lock_warning')}
/>
) : (
<View
style={{
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 16,
padding: 14,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
}}
>
<View
style={{
width: 40,
height: 40,
borderRadius: 12,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="lock-closed-outline" size={20} color={colors.textMuted} />
</View>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('blocker.layers_app_lock_title')}
</Text>
<View
style={{
paddingHorizontal: 7,
paddingVertical: 2,
borderRadius: 999,
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
}}
>
<Text style={{ fontSize: 10, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{t('blocker.app_lock_coming_soon_badge')}
</Text>
</View>
</View>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
marginTop: 2,
}}
>
{t('blocker.app_lock_coming_soon_desc')}
</Text>
</View>
</View>
<LayerSwitchCard
icon="lock-closed-outline"
title={t('blocker.layers_app_lock_title')}
subtitle={
appDeletionLockActive
? t('blocker.layers_app_lock_subtitle_active')
: t('blocker.layers_app_lock_subtitle_inactive')
}
active={appDeletionLockActive}
onActivate={handleActivateFamilyControls}
warning={t('blocker.layers_app_lock_warning')}
/>
)}
</View>
)}

View File

@ -187,7 +187,7 @@ function RootLayoutInner() {
}}
/>
<Stack.Screen
name="onboarding/welcome"
name="onboarding/index"
options={{
headerShown: false,
presentation: 'card',

View File

@ -0,0 +1,176 @@
import { useMemo, useState } from 'react';
import { useRouter } from 'expo-router';
import { useMe, invalidateMe, type OnboardingStep } from '../../hooks/useMe';
import { apiFetch } from '../../lib/api';
import { WelcomeSlide } from '../../components/onboarding/slides/WelcomeSlide';
import { PrivacySlide } from '../../components/onboarding/slides/PrivacySlide';
import { NicknameSlide } from '../../components/onboarding/slides/NicknameSlide';
import { DigaChoiceSlide } from '../../components/onboarding/slides/DigaChoiceSlide';
import { DigaCodeSlide } from '../../components/onboarding/slides/DigaCodeSlide';
import { PlanSlide } from '../../components/onboarding/slides/PlanSlide';
import { PaymentSlide } from '../../components/onboarding/slides/PaymentSlide';
import { ProtectionSlide } from '../../components/onboarding/slides/ProtectionSlide';
import { DoneSlide } from '../../components/onboarding/slides/DoneSlide';
/**
* Duo-Style Onboarding single route, state-machine intern.
*
* Linear-Flow:
* [1] welcome Lyra stellt sich vor
* [2] privacy DSGVO-Versprechen
* [3] nickname Inline-Input + PATCH /me + step='account'
* [4] diga_choice "Hast du Rezept-Code?" Ja/Nein
*
* Ja [4a] diga_code (Branch)
* redeem step='pre_protection' SKIP to [7]
*
* Nein
* [5] plan Pro/Legend selection step='plan'
* [6] payment DEV-Stub (RevenueCat-Phase pending) step='pre_protection'
* [7] protection activateUrlFilter() step='done'
* [8] done Geschafft-Screen /(app)
*
* Resume: routing-gate (app/(app)/_layout.tsx) routet zu /onboarding wenn
* step != 'done'. Hier mappt slideFromStep den persistierten step zur Slide.
*/
type Slide =
| 'welcome'
| 'privacy'
| 'nickname'
| 'diga_choice'
| 'diga_code'
| 'plan'
| 'payment'
| 'protection'
| 'done';
// Linear-Order für Progress-Indicator + Default-Next-Navigation.
// diga_code ist NICHT in der Linear-Liste — wird via Branch erreicht.
const LINEAR_ORDER: Slide[] = [
'welcome',
'privacy',
'nickname',
'diga_choice',
'plan',
'payment',
'protection',
'done',
];
function slideFromStep(step: OnboardingStep): Slide {
switch (step) {
case 'welcome':
return 'welcome';
case 'account':
return 'diga_choice';
case 'plan':
return 'payment';
case 'pre_protection':
return 'protection';
case 'done':
return 'done';
case 'nickname': // legacy
return 'nickname';
case 'block': // legacy
return 'protection';
default:
return 'welcome';
}
}
export default function OnboardingScreen() {
const router = useRouter();
const { me } = useMe();
const initialSlide = useMemo<Slide>(
() => (me ? slideFromStep(me.onboardingStep) : 'welcome'),
// eslint-disable-next-line react-hooks/exhaustive-deps
[me?.id],
);
const [slide, setSlide] = useState<Slide>(initialSlide);
function goToLinearNext() {
const idx = LINEAR_ORDER.indexOf(slide);
if (idx < 0 || idx === LINEAR_ORDER.length - 1) {
router.replace('/(app)');
return;
}
setSlide(LINEAR_ORDER[idx + 1]);
}
function exitToApp() {
router.replace('/(app)');
}
// Branch-Handler ─────────────────────────────────────────────────────────────
function onDigaYes() {
setSlide('diga_code');
}
function onDigaNo() {
setSlide('plan');
}
function onDigaCodeSuccess() {
// Backend hat step='pre_protection' gesetzt → skip plan + payment
setSlide('protection');
}
async function onPlanChosen(_tier: 'pro' | 'legend', _billing: 'monthly' | 'yearly') {
// TODO: tier + billing an PaymentSlide weiterreichen sobald RevenueCat
// wired ist (Phase 0). Bis dahin nur step persistieren.
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step: 'plan' },
}).catch(() => {});
invalidateMe();
setSlide('payment');
}
// Linear-Indizes für Progress (diga_code wird wie diga_choice gezählt) ──────
const linearIdx = (() => {
if (slide === 'diga_code') return LINEAR_ORDER.indexOf('diga_choice');
return LINEAR_ORDER.indexOf(slide);
})();
const current = Math.max(1, linearIdx + 1);
const total = LINEAR_ORDER.length;
// Slide-Dispatch ────────────────────────────────────────────────────────────
switch (slide) {
case 'welcome':
return <WelcomeSlide onNext={goToLinearNext} current={current} total={total} />;
case 'privacy':
return <PrivacySlide onNext={goToLinearNext} current={current} total={total} />;
case 'nickname':
return <NicknameSlide onNext={goToLinearNext} current={current} total={total} />;
case 'diga_choice':
return (
<DigaChoiceSlide
onYes={onDigaYes}
onNo={onDigaNo}
current={current}
total={total}
/>
);
case 'diga_code':
return (
<DigaCodeSlide
onSuccess={onDigaCodeSuccess}
onBack={() => setSlide('diga_choice')}
current={current}
total={total}
/>
);
case 'plan':
return <PlanSlide onChosen={onPlanChosen} current={current} total={total} />;
case 'payment':
return (
<PaymentSlide onCompleted={goToLinearNext} current={current} total={total} />
);
case 'protection':
return (
<ProtectionSlide onDone={goToLinearNext} current={current} total={total} />
);
case 'done':
return <DoneSlide onEnter={exitToApp} current={current} total={total} />;
}
}

View File

@ -1,404 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import {
Animated,
Dimensions,
Easing,
Text,
TouchableOpacity,
View,
} from 'react-native';
import Svg, { Defs, RadialGradient, Rect, Stop } from 'react-native-svg';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../../lib/api';
import { invalidateMe } from '../../hooks/useMe';
const { height: SH } = Dimensions.get('window');
type Bullet = {
icon: keyof typeof Ionicons.glyphMap;
titleKey: string;
descKey: string;
};
const BULLETS: Bullet[] = [
{ icon: 'eye-off-outline', titleKey: 'onboarding.welcome.bullet_anon_title', descKey: 'onboarding.welcome.bullet_anon_desc' },
{ icon: 'shield-checkmark-outline', titleKey: 'onboarding.welcome.bullet_protect_title', descKey: 'onboarding.welcome.bullet_protect_desc' },
{ icon: 'people-outline', titleKey: 'onboarding.welcome.bullet_community_title', descKey: 'onboarding.welcome.bullet_community_desc' },
];
export default function OnboardingWelcomeScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [submitting, setSubmitting] = useState(false);
// Animation values — Spiegel zur LandingScreen-Choreografie für visuelle Kohärenz
const glowOpacity = useRef(new Animated.Value(0.5)).current;
const haloOpacity = useRef(new Animated.Value(0)).current;
const haloScale = useRef(new Animated.Value(0.6)).current;
const heroOpacity = useRef(new Animated.Value(0)).current;
const heroScale = useRef(new Animated.Value(0.85)).current;
const heroPulse = useRef(new Animated.Value(1)).current;
const headlineOpacity = useRef(new Animated.Value(0)).current;
const headlineTranslate = useRef(new Animated.Value(10)).current;
const bulletsOpacity = useRef([0, 0, 0].map(() => new Animated.Value(0))).current;
const bulletsTranslate = useRef([0, 0, 0].map(() => new Animated.Value(14))).current;
const privacyOpacity = useRef(new Animated.Value(0)).current;
const privacyTranslate = useRef(new Animated.Value(14)).current;
const ctaOpacity = useRef(new Animated.Value(0)).current;
const ctaTranslate = useRef(new Animated.Value(14)).current;
useEffect(() => {
Animated.loop(
Animated.sequence([
Animated.timing(glowOpacity, { toValue: 0.9, duration: 2200, useNativeDriver: true }),
Animated.timing(glowOpacity, { toValue: 0.5, duration: 2200, useNativeDriver: true }),
]),
).start();
const ease = (toValue: number, duration: number) => ({
toValue,
duration,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
});
Animated.parallel([
Animated.timing(haloOpacity, ease(1, 900)),
Animated.timing(haloScale, ease(1, 900)),
]).start();
setTimeout(() => {
Animated.parallel([
Animated.timing(heroOpacity, ease(1, 650)),
Animated.spring(heroScale, { toValue: 1, useNativeDriver: true, friction: 6, tension: 80 }),
]).start();
}, 250);
setTimeout(() => {
Animated.loop(
Animated.sequence([
Animated.timing(heroPulse, { toValue: 1.06, duration: 1400, useNativeDriver: true }),
Animated.timing(heroPulse, { toValue: 1, duration: 1400, useNativeDriver: true }),
]),
).start();
}, 1000);
setTimeout(() => {
Animated.parallel([
Animated.timing(headlineOpacity, ease(1, 600)),
Animated.timing(headlineTranslate, ease(0, 600)),
]).start();
}, 700);
BULLETS.forEach((_, i) => {
setTimeout(() => {
Animated.parallel([
Animated.timing(bulletsOpacity[i], ease(1, 450)),
Animated.timing(bulletsTranslate[i], ease(0, 450)),
]).start();
}, 1100 + i * 180);
});
setTimeout(() => {
Animated.parallel([
Animated.timing(privacyOpacity, ease(1, 600)),
Animated.timing(privacyTranslate, ease(0, 600)),
]).start();
}, 1700);
setTimeout(() => {
Animated.parallel([
Animated.timing(ctaOpacity, ease(1, 600)),
Animated.timing(ctaTranslate, ease(0, 600)),
]).start();
}, 1950);
}, []);
async function handleStart() {
if (submitting) return;
setSubmitting(true);
try {
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step: 'nickname' },
});
invalidateMe();
router.replace('/profile/edit');
} catch (e) {
console.warn('[onboarding/welcome] failed to advance step:', e);
// Sackgasse-Verhalten: Step bleibt 'welcome', User kann nochmal tappen.
setSubmitting(false);
}
}
return (
<View style={{ flex: 1, backgroundColor: '#0f172a', overflow: 'hidden' }}>
{/* Atmender Top-Glow */}
<Animated.View
pointerEvents="none"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: SH * 0.5,
opacity: glowOpacity,
}}
>
<Svg width="100%" height="100%">
<Defs>
<RadialGradient id="welcomeGlow" cx="50%" cy="0%" rx="70%" ry="100%" fx="50%" fy="0%">
<Stop offset="0%" stopColor="#1e3a8a" stopOpacity="1" />
<Stop offset="100%" stopColor="#1e3a8a" stopOpacity="0" />
</RadialGradient>
</Defs>
<Rect width="100%" height="100%" fill="url(#welcomeGlow)" />
</Svg>
</Animated.View>
{/* Center indigo halo */}
<Animated.View
pointerEvents="none"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
opacity: haloOpacity,
transform: [{ scale: haloScale }],
}}
>
<Svg width="100%" height="100%">
<Defs>
<RadialGradient id="welcomeHalo" cx="50%" cy="30%" rx="55%" ry="55%">
<Stop offset="0%" stopColor="#6366f1" stopOpacity="0.22" />
<Stop offset="100%" stopColor="#6366f1" stopOpacity="0" />
</RadialGradient>
</Defs>
<Rect width="100%" height="100%" fill="url(#welcomeHalo)" />
</Svg>
</Animated.View>
<View
style={{
flex: 1,
paddingHorizontal: 24,
paddingTop: insets.top + 24,
paddingBottom: insets.bottom + 36,
}}
>
{/* Hero-Icon */}
<View style={{ alignItems: 'center', marginTop: 12 }}>
<Animated.View
style={{
width: 120,
height: 120,
borderRadius: 28,
backgroundColor: 'rgba(99,102,241,0.18)',
borderWidth: 1,
borderColor: 'rgba(99,102,241,0.35)',
alignItems: 'center',
justifyContent: 'center',
opacity: heroOpacity,
transform: [
{ scale: Animated.multiply(heroScale, heroPulse) as any },
],
}}
>
<Ionicons name="shield-checkmark" size={66} color="#a5b4fc" />
</Animated.View>
</View>
{/* Headline + Subhead */}
<Animated.View
style={{
marginTop: 28,
opacity: headlineOpacity,
transform: [{ translateY: headlineTranslate }],
}}
>
<Text
style={{
fontFamily: 'Nunito_800ExtraBold',
fontSize: 28,
lineHeight: 34,
color: '#ffffff',
textAlign: 'center',
letterSpacing: -0.4,
}}
>
{t('onboarding.welcome.headline')}
</Text>
<Text
style={{
fontFamily: 'Nunito_400Regular',
fontSize: 15,
lineHeight: 22,
color: 'rgba(255,255,255,0.72)',
textAlign: 'center',
marginTop: 10,
}}
>
{t('onboarding.welcome.subhead')}
</Text>
</Animated.View>
{/* Mission-Bullets */}
<View style={{ marginTop: 28, gap: 12 }}>
{BULLETS.map((b, i) => (
<Animated.View
key={b.titleKey}
style={{
flexDirection: 'row',
alignItems: 'flex-start',
gap: 14,
backgroundColor: 'rgba(255,255,255,0.05)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
borderRadius: 16,
padding: 14,
opacity: bulletsOpacity[i],
transform: [{ translateY: bulletsTranslate[i] }],
}}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 10,
backgroundColor: 'rgba(99,102,241,0.18)',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={b.icon} size={20} color="#a5b4fc" />
</View>
<View style={{ flex: 1 }}>
<Text
style={{
fontFamily: 'Nunito_700Bold',
fontSize: 15,
color: '#ffffff',
}}
>
{t(b.titleKey)}
</Text>
<Text
style={{
fontFamily: 'Nunito_400Regular',
fontSize: 13,
lineHeight: 19,
color: 'rgba(255,255,255,0.65)',
marginTop: 2,
}}
>
{t(b.descKey)}
</Text>
</View>
</Animated.View>
))}
</View>
{/* DSGVO-Box */}
<Animated.View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
marginTop: 16,
paddingVertical: 14,
paddingHorizontal: 14,
borderRadius: 14,
backgroundColor: 'rgba(34,197,94,0.10)',
borderWidth: 1,
borderColor: 'rgba(34,197,94,0.30)',
opacity: privacyOpacity,
transform: [{ translateY: privacyTranslate }],
}}
>
<Ionicons name="lock-closed" size={18} color="#86efac" style={{ marginTop: 2 }} />
<View style={{ flex: 1 }}>
<Text
style={{
fontFamily: 'Nunito_700Bold',
fontSize: 13,
color: '#bbf7d0',
letterSpacing: 0.2,
textTransform: 'uppercase',
}}
>
{t('onboarding.welcome.privacy_label')}
</Text>
<Text
style={{
fontFamily: 'Nunito_400Regular',
fontSize: 13,
lineHeight: 19,
color: 'rgba(255,255,255,0.82)',
marginTop: 4,
}}
>
{t('onboarding.welcome.privacy_body')}
</Text>
</View>
</Animated.View>
<View style={{ flex: 1 }} />
{/* CTA */}
<Animated.View
style={{
opacity: ctaOpacity,
transform: [{ translateY: ctaTranslate }],
}}
>
<TouchableOpacity
onPress={handleStart}
disabled={submitting}
activeOpacity={0.85}
style={{
backgroundColor: '#6366f1',
borderRadius: 16,
paddingVertical: 16,
alignItems: 'center',
opacity: submitting ? 0.6 : 1,
}}
>
<Text
style={{
fontFamily: 'Nunito_700Bold',
fontSize: 16,
color: '#ffffff',
letterSpacing: 0.2,
}}
>
{submitting ? t('onboarding.welcome.cta_loading') : t('onboarding.welcome.cta')}
</Text>
</TouchableOpacity>
<Text
style={{
fontFamily: 'Nunito_400Regular',
fontSize: 11,
lineHeight: 16,
color: 'rgba(255,255,255,0.45)',
textAlign: 'center',
marginTop: 10,
}}
>
{t('onboarding.welcome.next_hint')}
</Text>
</Animated.View>
</View>
</View>
);
}

View File

@ -0,0 +1,87 @@
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useColors } from '../../lib/theme';
/**
* Sticky Bottom-Bar mit Primary-CTA (+ optional Secondary darüber).
* Layout-Pattern aus Duolingo: voll-breit, padding, SafeArea-aware.
*/
export function CTABar({
primaryLabel,
onPrimary,
primaryDisabled,
primaryLoading,
secondaryLabel,
onSecondary,
}: {
primaryLabel: string;
onPrimary: () => void;
primaryDisabled?: boolean;
primaryLoading?: boolean;
secondaryLabel?: string;
onSecondary?: () => void;
}) {
const colors = useColors();
const insets = useSafeAreaInsets();
const disabled = !!primaryDisabled || !!primaryLoading;
return (
<View
style={{
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: insets.bottom + 12,
backgroundColor: colors.bg,
borderTopWidth: 1,
borderTopColor: colors.border,
gap: 8,
}}
>
<TouchableOpacity
onPress={onPrimary}
disabled={disabled}
activeOpacity={0.85}
style={{
backgroundColor: colors.brandOrange,
borderRadius: 14,
paddingVertical: 16,
alignItems: 'center',
opacity: disabled ? 0.5 : 1,
}}
>
{primaryLoading ? (
<ActivityIndicator color="#ffffff" />
) : (
<Text
style={{
fontFamily: 'Nunito_700Bold',
fontSize: 16,
color: '#ffffff',
letterSpacing: 0.2,
}}
>
{primaryLabel}
</Text>
)}
</TouchableOpacity>
{secondaryLabel && onSecondary ? (
<TouchableOpacity
onPress={onSecondary}
activeOpacity={0.6}
style={{ paddingVertical: 10, alignItems: 'center' }}
>
<Text
style={{
fontFamily: 'Nunito_600SemiBold',
fontSize: 14,
color: colors.textMuted,
}}
>
{secondaryLabel}
</Text>
</TouchableOpacity>
) : null}
</View>
);
}

View File

@ -0,0 +1,83 @@
import { useEffect, useRef } from 'react';
import { Animated, Easing, Text, View } from 'react-native';
import { RiveAvatar, type Emotion } from '../RiveAvatar';
import { useColors } from '../../lib/theme';
/**
* Lyra-Mascot (animiertes Rive-Avatar) links + Speech-Bubble rechts.
* Fade+slide-in beim Mount und bei text-change (key-prop verwenden für Re-Animate).
*
* Layout entspricht Duolingo's Duo-Speech-Pattern: Avatar quadratisch links,
* Bubble organisch rechts mit "Tail" zum Avatar zeigend.
*/
export function LyraBubble({
text,
emotion = 'idle',
}: {
text: string;
/** Lyra-Emotion fürs Rive (idle, happy, thinking, empathy). */
emotion?: Emotion;
}) {
const colors = useColors();
const opacity = useRef(new Animated.Value(0)).current;
const translateX = useRef(new Animated.Value(-12)).current;
useEffect(() => {
opacity.setValue(0);
translateX.setValue(-12);
Animated.parallel([
Animated.timing(opacity, {
toValue: 1,
duration: 400,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
}),
Animated.spring(translateX, {
toValue: 0,
useNativeDriver: true,
friction: 7,
tension: 80,
}),
]).start();
}, [text, opacity, translateX]);
return (
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 12 }}>
<View style={{ marginTop: 4 }}>
<RiveAvatar emotion={emotion} size="md" />
</View>
<Animated.View
style={{
flex: 1,
opacity,
transform: [{ translateX }],
}}
>
{/* Speech-Bubble */}
<View
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 16,
paddingVertical: 14,
paddingHorizontal: 16,
// Tail würde via custom-path gerendert — hier reicht der reine
// Bubble-Look ohne Tail (cleaner auf RN, Duolingo macht's auf iOS
// auch ohne harten Pixel-Tail).
}}
>
<Text
style={{
fontFamily: 'Nunito_600SemiBold',
fontSize: 16,
lineHeight: 23,
color: colors.text,
}}
>
{text}
</Text>
</View>
</Animated.View>
</View>
);
}

View File

@ -0,0 +1,65 @@
import { ReactNode } from 'react';
import { ScrollView, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useColors } from '../../lib/theme';
import { SlideProgress } from './SlideProgress';
/**
* Layout-Wrapper für alle Onboarding-Slides.
*
*
* [Top-Padding + SafeArea]
* Progress-Bar
*
* <children ScrollView>
*
* CTABar (sticky)
*
*
* `cta` wird IM Shell gerendert (sticky-bottom + SafeArea). Slides müssen
* ihre Inhalte als `children` reinpacken (= scrollbarer Content-Bereich).
*/
export function OnboardingShell({
current,
total,
children,
cta,
}: {
current: number;
total: number;
children: ReactNode;
cta: ReactNode;
}) {
const colors = useColors();
const insets = useSafeAreaInsets();
return (
<View style={{ flex: 1, backgroundColor: colors.bg }}>
<View
style={{
paddingTop: insets.top + 12,
paddingHorizontal: 20,
paddingBottom: 8,
}}
>
<SlideProgress current={current} total={total} />
</View>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
padding: 20,
paddingTop: 16,
paddingBottom: 24,
flexGrow: 1,
}}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
{cta}
</View>
);
}

View File

@ -0,0 +1,58 @@
import { useEffect, useRef } from 'react';
import { Animated, Easing, View } from 'react-native';
import { useColors } from '../../lib/theme';
/**
* Duolingo-style schmaler Progress-Bar oben.
* Smooth animiert wenn `current` sich ändert.
*/
export function SlideProgress({
current,
total,
}: {
/** 1-basiert: aktuelle Slide-Nummer */
current: number;
/** Total Slide-Anzahl */
total: number;
}) {
const colors = useColors();
const widthPct = useRef(new Animated.Value(clamp01(current / total))).current;
useEffect(() => {
Animated.timing(widthPct, {
toValue: clamp01(current / total),
duration: 350,
useNativeDriver: false,
easing: Easing.out(Easing.cubic),
}).start();
}, [current, total, widthPct]);
const widthInterpolated = widthPct.interpolate({
inputRange: [0, 1],
outputRange: ['0%', '100%'],
});
return (
<View
style={{
height: 8,
borderRadius: 4,
backgroundColor: colors.surfaceElevated,
overflow: 'hidden',
}}
>
<Animated.View
style={{
height: '100%',
width: widthInterpolated,
backgroundColor: colors.success,
borderRadius: 4,
}}
/>
</View>
);
}
function clamp01(v: number): number {
return Math.max(0, Math.min(1, v));
}

View File

@ -0,0 +1,127 @@
import { Text, TouchableOpacity, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Ionicons } from '@expo/vector-icons';
import { useColors } from '../../../lib/theme';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
export function DigaChoiceSlide({
onYes,
onNo,
current,
total,
}: {
onYes: () => void;
onNo: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const colors = useColors();
return (
<OnboardingShell
current={current}
total={total}
cta={
// Eigenes Layout statt CTABar — zwei gleichwertige Optionen
<ChoiceBar onYes={onYes} onNo={onNo} t={t} colors={colors} />
}
>
<LyraBubble text={t('onboarding.lyra.diga_choice.body')} emotion="thinking" />
<View
style={{
marginTop: 24,
padding: 14,
borderRadius: 12,
backgroundColor: colors.surfaceElevated,
flexDirection: 'row',
alignItems: 'center',
gap: 10,
}}
>
<Ionicons name="information-circle-outline" size={18} color={colors.brandOrange} />
<Text
style={{
flex: 1,
fontFamily: 'Nunito_400Regular',
fontSize: 13,
lineHeight: 19,
color: colors.textMuted,
}}
>
{t('onboarding.diga_choice.hint')}
</Text>
</View>
</OnboardingShell>
);
}
function ChoiceBar({
onYes,
onNo,
t,
colors,
}: {
onYes: () => void;
onNo: () => void;
t: ReturnType<typeof useTranslation>['t'];
colors: import('../../../lib/theme').ColorScheme;
}) {
return (
<View
style={{
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 24,
backgroundColor: colors.bg,
borderTopWidth: 1,
borderTopColor: colors.border,
gap: 10,
}}
>
<TouchableOpacity
onPress={onYes}
activeOpacity={0.85}
style={{
backgroundColor: colors.brandOrange,
borderRadius: 14,
paddingVertical: 16,
alignItems: 'center',
}}
>
<Text
style={{
fontFamily: 'Nunito_700Bold',
fontSize: 16,
color: '#ffffff',
letterSpacing: 0.2,
}}
>
{t('onboarding.diga_choice.cta_yes')}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onNo}
activeOpacity={0.7}
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 14,
paddingVertical: 16,
alignItems: 'center',
}}
>
<Text
style={{
fontFamily: 'Nunito_600SemiBold',
fontSize: 15,
color: colors.text,
}}
>
{t('onboarding.diga_choice.cta_no')}
</Text>
</TouchableOpacity>
</View>
);
}

View File

@ -0,0 +1,151 @@
import { useState } from 'react';
import { 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 } from '../../../hooks/useMe';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
import { CTABar } from '../CTABar';
type RedeemError = 'not_found' | 'already_used' | 'expired' | 'invalid_input';
export function DigaCodeSlide({
onSuccess,
onBack,
current,
total,
}: {
/** Wird gerufen wenn der Code erfolgreich eingelöst wurde. Backend hat dann
* plan='legend' + onboarding_step='pre_protection' gesetzt. */
onSuccess: () => void;
/** Zurück zum DigaChoiceSlide (User hat sich's anders überlegt). */
onBack: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const colors = useColors();
const [code, setCode] = useState('');
const [submitting, setSubmitting] = useState(false);
const [errorKey, setErrorKey] = useState<RedeemError | null>(null);
const trimmed = code.trim();
const valid = trimmed.length >= 6;
async function redeem() {
if (!valid || submitting) return;
setSubmitting(true);
setErrorKey(null);
try {
await apiFetch('/api/onboarding/redeem-diga-code', {
method: 'POST',
body: { code: trimmed },
});
invalidateMe();
onSuccess();
} catch (e: any) {
// apiFetch wirft Error mit `code` Feld bei strukturierten 4xx
const code = (e?.code ?? e?.data?.error) as RedeemError | undefined;
setErrorKey(code ?? 'not_found');
} finally {
setSubmitting(false);
}
}
return (
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={t('onboarding.diga_code.cta_primary')}
onPrimary={redeem}
primaryDisabled={!valid}
primaryLoading={submitting}
secondaryLabel={t('onboarding.diga_code.cta_secondary')}
onSecondary={onBack}
/>
}
>
<LyraBubble text={t('onboarding.lyra.diga_code.body')} emotion="thinking" />
<View style={{ marginTop: 24 }}>
<Text
style={{
fontFamily: 'Nunito_700Bold',
fontSize: 12,
color: colors.textMuted,
letterSpacing: 0.8,
marginBottom: 8,
}}
>
{t('onboarding.diga_code.label')}
</Text>
<TextInput
autoFocus
value={code}
onChangeText={(v) => {
setCode(v.toUpperCase());
if (errorKey) setErrorKey(null);
}}
onSubmitEditing={redeem}
placeholder="REBREAK-XXXX-XXX"
placeholderTextColor="#a3a3a3"
autoCapitalize="characters"
autoCorrect={false}
maxLength={32}
returnKeyType="done"
style={{
fontSize: 16,
lineHeight: 22,
paddingVertical: 14,
paddingHorizontal: 16,
color: colors.text,
fontFamily: 'Nunito_700Bold',
letterSpacing: 1,
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
borderWidth: 2,
borderColor: errorKey ? colors.error : valid ? colors.brandOrange : 'transparent',
}}
/>
{errorKey ? (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginTop: 10,
paddingHorizontal: 2,
}}
>
<Ionicons name="alert-circle" size={16} color={colors.error} />
<Text
style={{
flex: 1,
fontFamily: 'Nunito_600SemiBold',
fontSize: 13,
color: colors.error,
}}
>
{t(`onboarding.diga_code.error_${errorKey}`)}
</Text>
</View>
) : (
<Text
style={{
marginTop: 8,
fontFamily: 'Nunito_400Regular',
fontSize: 12,
color: colors.textMuted,
}}
>
{t('onboarding.diga_code.hint')}
</Text>
)}
</View>
</OnboardingShell>
);
}

View File

@ -0,0 +1,95 @@
import { useEffect, useRef } from 'react';
import { Animated, Easing, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Ionicons } from '@expo/vector-icons';
import { useColors } from '../../../lib/theme';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
import { CTABar } from '../CTABar';
export function DoneSlide({
onEnter,
current,
total,
}: {
onEnter: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const colors = useColors();
const scale = useRef(new Animated.Value(0.6)).current;
const opacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.parallel([
Animated.spring(scale, { toValue: 1, useNativeDriver: true, friction: 5, tension: 90 }),
Animated.timing(opacity, {
toValue: 1,
duration: 500,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
}),
]).start();
}, []);
return (
<OnboardingShell
current={current}
total={total}
cta={<CTABar primaryLabel={t('onboarding.done.cta_primary')} onPrimary={onEnter} />}
>
<LyraBubble text={t('onboarding.lyra.done.body')} emotion="happy" />
<Animated.View
style={{
marginTop: 40,
alignItems: 'center',
opacity,
transform: [{ scale }],
}}
>
<View
style={{
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: 'rgba(34,197,94,0.14)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: 'rgba(34,197,94,0.40)',
}}
>
<Ionicons name="checkmark" size={64} color={colors.success} />
</View>
<Text
style={{
marginTop: 24,
fontFamily: 'Nunito_800ExtraBold',
fontSize: 26,
color: colors.text,
textAlign: 'center',
letterSpacing: -0.3,
}}
>
{t('onboarding.done.headline')}
</Text>
<Text
style={{
marginTop: 8,
fontFamily: 'Nunito_400Regular',
fontSize: 15,
lineHeight: 22,
color: colors.textMuted,
textAlign: 'center',
paddingHorizontal: 16,
}}
>
{t('onboarding.done.subhead')}
</Text>
</Animated.View>
</OnboardingShell>
);
}

View File

@ -0,0 +1,119 @@
import { useRef, useState } from 'react';
import { Alert, Text, TextInput, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../../lib/theme';
import { apiFetch } from '../../../lib/api';
import { invalidateMe, useMe } from '../../../hooks/useMe';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
import { CTABar } from '../CTABar';
export function NicknameSlide({
onNext,
current,
total,
}: {
onNext: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const colors = useColors();
const { me } = useMe();
const [nickname, setNickname] = useState(me?.nickname ?? '');
const [saving, setSaving] = useState(false);
const inputRef = useRef<TextInput | null>(null);
const trimmed = nickname.trim();
const valid = trimmed.length >= 2;
async function save() {
if (!valid || saving) return;
setSaving(true);
try {
await apiFetch('/api/auth/me', {
method: 'PATCH',
body: { nickname: trimmed },
});
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step: 'account' },
}).catch(() => {});
invalidateMe();
onNext();
} catch (e: unknown) {
Alert.alert(
t('common.error'),
e instanceof Error ? e.message : t('common.unknown_error'),
);
} finally {
setSaving(false);
}
}
return (
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={t('onboarding.nickname.cta_primary')}
onPrimary={save}
primaryDisabled={!valid}
primaryLoading={saving}
/>
}
>
<LyraBubble text={t('onboarding.lyra.nickname.body')} emotion="happy" />
<View style={{ marginTop: 24 }}>
<Text
style={{
fontFamily: 'Nunito_700Bold',
fontSize: 12,
color: colors.textMuted,
letterSpacing: 0.8,
marginBottom: 8,
}}
>
{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',
}}
/>
<Text
style={{
marginTop: 8,
fontFamily: 'Nunito_400Regular',
fontSize: 12,
color: colors.textMuted,
}}
>
{t('onboarding.nickname.hint')}
</Text>
</View>
</OnboardingShell>
);
}

View File

@ -0,0 +1,94 @@
import { Text, 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 } from '../../../hooks/useMe';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
import { CTABar } from '../CTABar';
/**
* DEV-STUB für Stage Payment.
*
* Production-Variante (Phase 0 vom Strategist-Plan):
* - iOS: RevenueCat-Purchase-Sheet öffnet sich für 14-Tage-Pro-Trial
* - Web/Android: Stripe-Checkout
*
* Aktueller Dev-Stub: zeigt "RevenueCat kommt"-Erklärung + Button der
* step='pre_protection' im Backend setzt + zum nächsten Slide weiterleitet.
* Sichtbar als Dev-Banner damit niemand denkt das wäre die finale UX.
*/
export function PaymentSlide({
onCompleted,
current,
total,
}: {
onCompleted: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const colors = useColors();
async function devSkipPayment() {
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step: 'pre_protection' },
}).catch(() => {});
invalidateMe();
onCompleted();
}
return (
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={t('onboarding.payment.cta_dev_skip')}
onPrimary={devSkipPayment}
/>
}
>
<LyraBubble text={t('onboarding.lyra.payment.body')} emotion="thinking" />
<View
style={{
marginTop: 24,
padding: 16,
borderRadius: 14,
backgroundColor: 'rgba(245,158,11,0.08)',
borderWidth: 1,
borderColor: 'rgba(245,158,11,0.30)',
gap: 10,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Ionicons name="construct-outline" size={18} color={colors.warning} />
<Text
style={{
fontFamily: 'Nunito_700Bold',
fontSize: 12,
letterSpacing: 0.8,
color: colors.warning,
textTransform: 'uppercase',
}}
>
{t('onboarding.payment.dev_label')}
</Text>
</View>
<Text
style={{
fontFamily: 'Nunito_400Regular',
fontSize: 13,
lineHeight: 19,
color: colors.text,
}}
>
{t('onboarding.payment.dev_body')}
</Text>
</View>
</OnboardingShell>
);
}

View File

@ -0,0 +1,346 @@
import { useState } from 'react';
import { Linking, Text, TouchableOpacity, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Ionicons } from '@expo/vector-icons';
import { useColors } from '../../../lib/theme';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
import { CTABar } from '../CTABar';
type Tier = 'pro' | 'legend';
type Billing = 'monthly' | 'yearly';
const HARDSHIP_EMAIL = 'support@rebreak.org';
export function PlanSlide({
onChosen,
current,
total,
}: {
onChosen: (tier: Tier, billing: Billing) => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const colors = useColors();
const [tier, setTier] = useState<Tier>('pro');
const [billing, setBilling] = useState<Billing>('yearly');
function openHardshipMail() {
const subject = encodeURIComponent('Härtefall — rebreak');
Linking.openURL(`mailto:${HARDSHIP_EMAIL}?subject=${subject}`).catch(() => {});
}
return (
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={
tier === 'pro'
? t('onboarding.plan.cta_trial')
: t('onboarding.plan.cta_legend')
}
onPrimary={() => onChosen(tier, billing)}
/>
}
>
<LyraBubble text={t('onboarding.lyra.plan.body')} emotion="happy" />
<BillingToggle billing={billing} setBilling={setBilling} t={t} colors={colors} />
<View style={{ marginTop: 14, gap: 12 }}>
<PlanCard
selected={tier === 'pro'}
onSelect={() => setTier('pro')}
colors={colors}
badge={t('onboarding.plan.tier_pro_badge')}
title="Pro"
price={
billing === 'yearly'
? t('onboarding.plan.tier_pro_price_yearly')
: t('onboarding.plan.tier_pro_price_monthly')
}
anchorPrice={billing === 'yearly' ? t('onboarding.plan.tier_pro_anchor_yearly') : null}
totalPrice={billing === 'yearly' ? t('onboarding.plan.tier_pro_total_yearly') : null}
subline={
billing === 'yearly'
? t('onboarding.plan.tier_pro_subline_yearly')
: t('onboarding.plan.tier_pro_subline_monthly')
}
features={[
t('onboarding.plan.feat_blocklist'),
t('onboarding.plan.feat_lyra'),
t('onboarding.plan.feat_mail'),
t('onboarding.plan.feat_community'),
]}
/>
<PlanCard
selected={tier === 'legend'}
onSelect={() => setTier('legend')}
colors={colors}
badge={null}
title="Legend"
price={
billing === 'yearly'
? t('onboarding.plan.tier_legend_price_yearly')
: t('onboarding.plan.tier_legend_price_monthly')
}
anchorPrice={billing === 'yearly' ? t('onboarding.plan.tier_legend_anchor_yearly') : null}
totalPrice={billing === 'yearly' ? t('onboarding.plan.tier_legend_total_yearly') : null}
subline={
billing === 'yearly'
? t('onboarding.plan.tier_legend_subline_yearly')
: t('onboarding.plan.tier_legend_subline_monthly')
}
features={[
t('onboarding.plan.feat_legend_all_pro'),
t('onboarding.plan.feat_legend_multi_device'),
t('onboarding.plan.feat_legend_voice'),
]}
/>
</View>
<Text
style={{
marginTop: 16,
fontFamily: 'Nunito_400Regular',
fontSize: 12,
lineHeight: 18,
color: colors.textMuted,
textAlign: 'center',
}}
>
{t('onboarding.plan.disclaimer')}
</Text>
{/* Härtefall-Mailto — manuell, kein Auto-Sozial-Rabatt (Strategist-Verdict) */}
<TouchableOpacity
onPress={openHardshipMail}
activeOpacity={0.7}
style={{ marginTop: 8, paddingVertical: 6, alignSelf: 'center' }}
>
<Text
style={{
fontFamily: 'Nunito_600SemiBold',
fontSize: 12,
color: colors.brandOrange,
textDecorationLine: 'underline',
}}
>
{t('onboarding.plan.hardship_link')}
</Text>
</TouchableOpacity>
</OnboardingShell>
);
}
// ─── Billing-Toggle ──────────────────────────────────────────────────────────
function BillingToggle({
billing,
setBilling,
t,
colors,
}: {
billing: Billing;
setBilling: (b: Billing) => void;
t: ReturnType<typeof useTranslation>['t'];
colors: import('../../../lib/theme').ColorScheme;
}) {
return (
<View
style={{
marginTop: 22,
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
padding: 4,
flexDirection: 'row',
gap: 4,
}}
>
<ToggleButton
active={billing === 'monthly'}
onPress={() => setBilling('monthly')}
label={t('onboarding.plan.billing_monthly')}
colors={colors}
/>
<ToggleButton
active={billing === 'yearly'}
onPress={() => setBilling('yearly')}
label={t('onboarding.plan.billing_yearly')}
savingsBadge={t('onboarding.plan.billing_savings')}
colors={colors}
/>
</View>
);
}
function ToggleButton({
active,
onPress,
label,
savingsBadge,
colors,
}: {
active: boolean;
onPress: () => void;
label: string;
savingsBadge?: string;
colors: import('../../../lib/theme').ColorScheme;
}) {
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.85}
style={{
flex: 1,
paddingVertical: 10,
borderRadius: 9,
backgroundColor: active ? colors.bg : 'transparent',
alignItems: 'center',
gap: 2,
}}
>
<Text
style={{
fontFamily: 'Nunito_700Bold',
fontSize: 14,
color: active ? colors.text : colors.textMuted,
}}
>
{label}
</Text>
{savingsBadge ? (
<Text
style={{
fontFamily: 'Nunito_600SemiBold',
fontSize: 10,
letterSpacing: 0.3,
color: active ? colors.success : colors.textMuted,
}}
>
{savingsBadge}
</Text>
) : null}
</TouchableOpacity>
);
}
// ─── Plan-Card ───────────────────────────────────────────────────────────────
function PlanCard({
selected,
onSelect,
colors,
badge,
title,
price,
anchorPrice,
totalPrice,
subline,
features,
}: {
selected: boolean;
onSelect: () => void;
colors: import('../../../lib/theme').ColorScheme;
badge: string | null;
title: string;
/** Hauptpreis (z.B. "3,99 € / Monat" oder "3,33 € / Monat"). */
price: string;
/** Durchgestrichener Original-Annual-Preis (z.B. "47,88 €"). Nur bei Yearly. */
anchorPrice: string | null;
/** Total-Annual-Preis als Zusatzzeile (z.B. "39,90 € / Jahr"). Nur bei Yearly. */
totalPrice: string | null;
subline: string;
features: string[];
}) {
return (
<TouchableOpacity
onPress={onSelect}
activeOpacity={0.85}
style={{
backgroundColor: colors.surface,
borderRadius: 16,
padding: 16,
borderWidth: 2,
borderColor: selected ? colors.brandOrange : colors.border,
gap: 8,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 22, color: colors.text }}>
{title}
</Text>
{badge ? (
<View
style={{
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
backgroundColor: colors.brandOrange,
}}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 10, color: '#ffffff', letterSpacing: 0.6 }}>
{badge.toUpperCase()}
</Text>
</View>
) : null}
</View>
<View
style={{
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: selected ? colors.brandOrange : colors.border,
backgroundColor: selected ? colors.brandOrange : 'transparent',
alignItems: 'center',
justifyContent: 'center',
}}
>
{selected ? <Ionicons name="checkmark" size={14} color="#ffffff" /> : null}
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 10 }}>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: colors.text }}>
{price}
</Text>
{anchorPrice ? (
<Text
style={{
fontFamily: 'Nunito_400Regular',
fontSize: 13,
color: colors.textMuted,
textDecorationLine: 'line-through',
}}
>
{anchorPrice}
</Text>
) : null}
</View>
{totalPrice ? (
<Text style={{ fontFamily: 'Nunito_400Regular', fontSize: 12, color: colors.textMuted }}>
{totalPrice}
</Text>
) : null}
<Text style={{ fontFamily: 'Nunito_400Regular', fontSize: 12, color: colors.textMuted }}>
{subline}
</Text>
<View style={{ marginTop: 4, gap: 6 }}>
{features.map((f) => (
<View key={f} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Ionicons name="checkmark-circle" size={14} color={colors.success} />
<Text style={{ flex: 1, fontFamily: 'Nunito_400Regular', fontSize: 13, color: colors.text }}>
{f}
</Text>
</View>
))}
</View>
</TouchableOpacity>
);
}

View File

@ -0,0 +1,101 @@
import { Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Ionicons } from '@expo/vector-icons';
import { useColors } from '../../../lib/theme';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
import { CTABar } from '../CTABar';
export function PrivacySlide({
onNext,
current,
total,
}: {
onNext: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const colors = useColors();
return (
<OnboardingShell
current={current}
total={total}
cta={<CTABar primaryLabel={t('onboarding.privacy.cta_primary')} onPrimary={onNext} />}
>
<LyraBubble text={t('onboarding.lyra.privacy.body')} emotion="empathy" />
<View
style={{
marginTop: 28,
padding: 16,
borderRadius: 14,
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
gap: 14,
}}
>
<PromiseRow
icon="eye-off-outline"
text={t('onboarding.privacy.promise_alias')}
colors={colors}
/>
<PromiseRow
icon="lock-closed-outline"
text={t('onboarding.privacy.promise_minimal')}
colors={colors}
/>
<PromiseRow
icon="ban-outline"
text={t('onboarding.privacy.promise_no_ads')}
colors={colors}
/>
<PromiseRow
icon="server-outline"
text={t('onboarding.privacy.promise_germany')}
colors={colors}
/>
</View>
</OnboardingShell>
);
}
function PromiseRow({
icon,
text,
colors,
}: {
icon: keyof typeof Ionicons.glyphMap;
text: string;
colors: import('../../../lib/theme').ColorScheme;
}) {
return (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<View
style={{
width: 32,
height: 32,
borderRadius: 10,
backgroundColor: 'rgba(34,197,94,0.12)',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={icon} size={18} color={colors.success} />
</View>
<Text
style={{
flex: 1,
fontFamily: 'Nunito_600SemiBold',
fontSize: 14,
lineHeight: 20,
color: colors.text,
}}
>
{text}
</Text>
</View>
);
}

View File

@ -0,0 +1,195 @@
import { useState } from 'react';
import { Alert, Platform, Text, 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 } from '../../../hooks/useMe';
import { protection } from '../../../lib/protection';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
import { CTABar } from '../CTABar';
import { PermissionDeniedSheet } from '../../PermissionDeniedSheet';
export function ProtectionSlide({
onDone,
current,
total,
}: {
/** Wird gerufen wenn URL-Filter erfolgreich aktiviert wurde. */
onDone: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const colors = useColors();
const [activating, setActivating] = useState(false);
const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false);
async function activate() {
if (activating) return;
setActivating(true);
try {
const res = await protection.activateUrlFilter();
if (!res.enabled) {
const isCodeFive =
Platform.OS === 'ios' &&
typeof res.error === 'string' &&
/NEFilterErrorDomain:\s*5/i.test(res.error);
if (isCodeFive) {
setPermissionDeniedOpen(true);
return;
}
Alert.alert(
t('onboarding.protection.error_title'),
res.error ?? t('onboarding.protection.error_unknown'),
);
return;
}
// Schutz live → step='done'
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step: 'done' },
}).catch(() => {});
invalidateMe();
onDone();
} catch (e: unknown) {
Alert.alert(
t('common.error'),
e instanceof Error ? e.message : t('common.unknown_error'),
);
} finally {
setActivating(false);
}
}
return (
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={t('onboarding.protection.cta_primary')}
onPrimary={activate}
primaryLoading={activating}
/>
}
>
<LyraBubble text={t('onboarding.lyra.protection.body')} emotion="empathy" />
<View
style={{
marginTop: 24,
padding: 16,
borderRadius: 14,
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
gap: 12,
}}
>
<ProtectionRow
icon="globe-outline"
title={t('onboarding.protection.feat_blocklist_title')}
desc={t('onboarding.protection.feat_blocklist_desc')}
colors={colors}
/>
<ProtectionRow
icon={Platform.OS === 'ios' ? 'shield-checkmark-outline' : 'lock-closed-outline'}
title={t(
Platform.OS === 'ios'
? 'onboarding.protection.feat_ios_title'
: 'onboarding.protection.feat_android_title',
)}
desc={t(
Platform.OS === 'ios'
? 'onboarding.protection.feat_ios_desc'
: 'onboarding.protection.feat_android_desc',
)}
colors={colors}
/>
<ProtectionRow
icon="time-outline"
title={t('onboarding.protection.feat_cooldown_title')}
desc={t('onboarding.protection.feat_cooldown_desc')}
colors={colors}
/>
</View>
<Text
style={{
marginTop: 16,
fontFamily: 'Nunito_400Regular',
fontSize: 12,
lineHeight: 18,
color: colors.textMuted,
textAlign: 'center',
}}
>
{t('onboarding.protection.permission_note')}
</Text>
<PermissionDeniedSheet
visible={permissionDeniedOpen}
onClose={() => setPermissionDeniedOpen(false)}
onRetry={async () => {
const res = await protection.resetUrlFilter();
if (res.enabled) {
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step: 'done' },
}).catch(() => {});
invalidateMe();
onDone();
}
return res;
}}
/>
</OnboardingShell>
);
}
function ProtectionRow({
icon,
title,
desc,
colors,
}: {
icon: keyof typeof Ionicons.glyphMap;
title: string;
desc: string;
colors: import('../../../lib/theme').ColorScheme;
}) {
return (
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 12 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: 'rgba(0,122,255,0.12)',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={icon} size={20} color={colors.brandOrange} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 14, color: colors.text }}>
{title}
</Text>
<Text
style={{
marginTop: 2,
fontFamily: 'Nunito_400Regular',
fontSize: 13,
lineHeight: 18,
color: colors.textMuted,
}}
>
{desc}
</Text>
</View>
</View>
);
}

View File

@ -0,0 +1,90 @@
import { Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Ionicons } from '@expo/vector-icons';
import { useColors } from '../../../lib/theme';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
import { CTABar } from '../CTABar';
/**
* Slide 1: Lyra stellt sich vor + erklärt die Mission in einem Satz.
* Hat den langen Empathie-Touch, weil's der erste Eindruck nach Signup ist.
*/
export function WelcomeSlide({
onNext,
current,
total,
}: {
onNext: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const colors = useColors();
return (
<OnboardingShell
current={current}
total={total}
cta={<CTABar primaryLabel={t('onboarding.welcome.cta_primary')} onPrimary={onNext} />}
>
<LyraBubble text={t('onboarding.lyra.welcome.body')} emotion="happy" />
<View style={{ marginTop: 28, gap: 12 }}>
<BulletRow
icon="eye-off-outline"
text={t('onboarding.welcome.bullet_anon')}
colors={colors}
/>
<BulletRow
icon="shield-checkmark-outline"
text={t('onboarding.welcome.bullet_protect')}
colors={colors}
/>
<BulletRow
icon="people-outline"
text={t('onboarding.welcome.bullet_community')}
colors={colors}
/>
</View>
</OnboardingShell>
);
}
function BulletRow({
icon,
text,
colors,
}: {
icon: keyof typeof Ionicons.glyphMap;
text: string;
colors: import('../../../lib/theme').ColorScheme;
}) {
return (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={icon} size={20} color={colors.brandOrange} />
</View>
<Text
style={{
flex: 1,
fontFamily: 'Nunito_600SemiBold',
fontSize: 15,
lineHeight: 21,
color: colors.text,
}}
>
{text}
</Text>
</View>
);
}

View File

@ -17,7 +17,15 @@ export type Plan = 'free' | 'pro' | 'legend';
* Stand (Avatar/Nickname/Plan werden via Profile-Edit-API geupdated, landen
* in der DB, NICHT zurück ins JWT-Claim).
*/
export type OnboardingStep = 'welcome' | 'nickname' | 'block' | 'done';
export type OnboardingStep =
| 'welcome'
| 'account'
| 'plan'
| 'pre_protection'
| 'done'
// legacy (alte Builds könnten das im Profile haben — wird im neuen Flow nicht gesetzt)
| 'nickname'
| 'block';
export type Me = {
id: string;

View File

@ -81,18 +81,25 @@ export function useProtectionState(): UseProtectionStateReturn {
const prevActive = prevCooldownActiveRef.current;
prevCooldownActiveRef.current = next.cooldown.active;
// Cooldown ist gerade von active → inactive gekippt: Auto-Disable prüfen.
if (prevActive === true && !next.cooldown.active) {
const didDisable = await protection.applyCooldownDisableIfElapsed();
if (didDisable) {
showCooldownElapsedNotice();
// Nativer State hat sich geändert → ein weiterer Fetch für konsistenten State.
const afterDisable = await protection.getCombinedState();
setState(afterDisable);
setTickSeconds(afterDisable.cooldown.remainingSeconds);
setError(null);
return;
}
// Cooldown ist gerade von active → inactive gekippt: Auto-Disable.
// Wir nutzen LOKAL den Cooldown-State aus dem eben gefetchten `next` —
// KEIN redundanter API-Call zu /api/cooldown/status. Grund: der Backend-
// GET resolved den Cooldown autom. beim ersten expired-Hit. Ein zweiter
// Call würde dann cooldownEndsAt=null returnen → false bail → Filter
// bleibt installiert. Local-state-check ist atomar + race-frei.
if (
prevActive === true &&
!next.cooldown.active &&
next.cooldown.endsAt !== null
) {
await protection.forceDisable();
showCooldownElapsedNotice();
// Nativer State hat sich geändert → ein weiterer Fetch für konsistenten State.
const afterDisable = await protection.getCombinedState();
setState(afterDisable);
setTickSeconds(afterDisable.cooldown.remainingSeconds);
setError(null);
return;
}
setState(next);
@ -137,10 +144,12 @@ export function useProtectionState(): UseProtectionStateReturn {
};
}, [state?.cooldown.active]);
// AppState-Listener: Refresh + Auto-Disable wenn Cooldown elapsed ist.
// Guard in applyCooldownDisableIfElapsed: cooldownEndsAt muss gesetzt sein
// (= es lief je ein Cooldown) und remainingSeconds <= 0. Verhindert
// False-Positives wenn canDisableProtection im Initial-State true ist.
// AppState-Listener: Refresh + Auto-Disable wenn Cooldown elapsed während
// App backgrounded war. applyCooldownDisableIfElapsed macht hier den initialen
// API-Call (= keine Race-Condition mit anderem GET, weil das der erste post-
// background Call ist). fetchState danach räumt den State auf — der neue
// Guard im fetchState (`next.cooldown.endsAt !== null`) verhindert ein
// doppeltes forceDisable wenn der AppState-Listener schon disabled hat.
useEffect(() => {
const sub = AppState.addEventListener('change', async (status: AppStateStatus) => {
if (status !== 'active') return;

View File

@ -162,6 +162,7 @@ export const protection = {
/** Schaltet alle Layer ab + disarmed den Tamper-Lock. NUR aufrufen wenn JS-Layer Cooldown verifiziert. */
async forceDisable() {
console.log("[protection] forceDisable() — disarm tamper + native disable");
// Tamper-Lock ZUERST disarmen — sonst setzt der AccessibilityService den Schutz
// nach dem Cooldown weiter durch (blockt z.B. das Ausschalten des a11y-Service in den
// System-Settings) → der User kommt nicht aus dem Schutz raus, obwohl der Cooldown
@ -171,7 +172,9 @@ export const protection = {
} catch (e) {
console.warn("[protection] disarmTamperLock failed:", e);
}
return RebreakProtection.disable();
const res = await RebreakProtection.disable();
console.log("[protection] native disable returned:", res);
return res;
},
getDeviceState(): Promise<DeviceLayers> {

View File

@ -357,20 +357,103 @@
"empty_mail": "لا توجد نطاقات بريد مخصصة. اضغط + لحجب عنوان أو نطاق بريد."
},
"onboarding": {
"lyra": {
"welcome": { "body": "أهلاً، أنا Lyra. سعيدة أنك خطوت هذه الخطوة — سنجد طريق الخروج من القمار معاً." },
"privacy": { "body": "قبل أن نبدأ — وعد سريع. نعرفك فقط باسمك المستعار. لا اسم حقيقي، لا تتبع، لا إعلانات. أنت في أمان هنا." },
"nickname": { "body": "بم أناديك؟ اختر اسماً مستعاراً — فقط المجتمع يراه، لا حاجة لاسم حقيقي." },
"diga_choice": { "body": "هل لديك رمز وصفة طبية من تأمينك الصحي؟ إذن تدخل مباشرة." },
"diga_code": { "body": "اكتب رمزك — سأتحقق منه لك." },
"plan": { "body": "حماية جهازك تكلف بعض الشيء — لكن 14 يوماً مجاناً. أي خطة تناسبك؟" },
"payment": { "body": "خطوة قصيرة: أكّد تجربتك. يمكنك الإلغاء في أي وقت — Apple يتولى ذلك لك." },
"protection": { "body": "الآن الجزء الأهم — الحماية على جهازك. مستعد؟" },
"done": { "body": "تم. اليوم الأول من سلسلتك — ولست وحدك." }
},
"welcome": {
"headline": "مرحباً بك في ReBreak.",
"subhead": "طريقك للخروج من القمار — بشكل مجهول، محمي، ولست وحدك.",
"bullet_anon_title": "تظل مجهولاً",
"bullet_anon_desc": "تختار اسماً مستعاراً. لا أحد يرى اسمك الحقيقي — نحن أيضاً لا نعرفه.",
"bullet_protect_title": "جهازك محمي",
"bullet_protect_desc": "تُحجب مواقع وتطبيقات القمار. حتى في لحظات الضعف.",
"bullet_community_title": "لست وحدك",
"bullet_community_desc": "آخرون على نفس الطريق. اكتب بشكل مجهول، شارك سلسلتك، اجد دعماً.",
"privacy_label": "GDPR · تقليل البيانات",
"privacy_body": "نعالج أقل قدر ممكن من البيانات. تحتاج فقط إلى اسم مستعار. لا اسم حقيقي، لا تتبع، لا إعلانات.",
"cta": "هيا نبدأ",
"cta_loading": "لحظة من فضلك...",
"next_hint": "في الخطوة التالية تختار اسمك المستعار."
"cta_primary": "هيا نبدأ",
"bullet_anon": "مجهول — بدون اسم حقيقي",
"bullet_protect": "مواقع القمار محجوبة",
"bullet_community": "آخرون على نفس الطريق"
},
"privacy": {
"cta_primary": "فهمت",
"promise_alias": "فقط اسمك المستعار مرئي",
"promise_minimal": "نخزّن أقل قدر ممكن",
"promise_no_ads": "لا تتبع، لا إعلانات",
"promise_germany": "خوادم في ألمانيا · متوافق مع GDPR"
},
"nickname": {
"cta_primary": "حفظ",
"label": "اسمك المستعار",
"placeholder": "مثلاً wanderer84",
"hint": "2 إلى 32 حرف. قابل للتغيير في أي وقت."
},
"diga_choice": {
"cta_yes": "نعم، لدي رمز",
"cta_no": "لا، أرني الخطط",
"hint": "رمز DiGA يصدره تأمينك الصحي ويمنحك الوصول الكامل بدون دفع."
},
"diga_code": {
"cta_primary": "استخدام",
"cta_secondary": "بدون رمز — رجوع",
"label": "رمز الوصفة",
"hint": "رموز اختبار داخلية: REBREAK-TEST-001 إلى -010",
"error_not_found": "هذا الرمز غير موجود. الرجاء التحقق من الإملاء.",
"error_already_used": "تم استخدام هذا الرمز مسبقاً.",
"error_expired": "انتهت صلاحية هذا الرمز.",
"error_invalid_input": "الرجاء إدخال رمز صالح."
},
"plan": {
"cta_trial": "ابدأ 14 يوماً مجاناً",
"cta_legend": "اختر Legend",
"billing_monthly": "شهري",
"billing_yearly": "سنوي",
"billing_savings": "شهران مجاناً",
"tier_pro_badge": "موصى به",
"tier_pro_price_monthly": "3,99 € / شهر",
"tier_pro_price_yearly": "3,33 € / شهر",
"tier_pro_anchor_yearly": "47,88 €",
"tier_pro_total_yearly": "39,90 € / سنة",
"tier_pro_subline_monthly": "أول 14 يوماً مجاناً",
"tier_pro_subline_yearly": "14 يوماً مجاناً + شهران هدية",
"tier_legend_price_monthly": "7,99 € / شهر",
"tier_legend_price_yearly": "6,66 € / شهر",
"tier_legend_anchor_yearly": "95,88 €",
"tier_legend_total_yearly": "79,90 € / سنة",
"tier_legend_subline_monthly": "للحماية على أجهزة متعددة",
"tier_legend_subline_yearly": "شهران هدية · أجهزة متعددة",
"feat_blocklist": "أكثر من 208000 نطاق قمار محجوب",
"feat_lyra": "Lyra غير محدود",
"feat_mail": "فلتر البريد لرسائل الكازينو",
"feat_community": "المجتمع + السلاسل",
"feat_legend_all_pro": "كل ما في Pro",
"feat_legend_multi_device": "حماية على Mac + Windows",
"feat_legend_voice": "صوت Lyra المميز",
"disclaimer": "تجديد تلقائي. ألغِ في أي وقت من إعدادات iOS.",
"hardship_link": "ميزانيتك ضيقة؟ راسلنا."
},
"payment": {
"cta_dev_skip": "متابعة (تخطي تطويري)",
"dev_label": "نسخة تطوير",
"dev_body": "ورقة الدفع الحقيقية (RevenueCat / StoreKit) ستأتي في المرحلة القادمة. الآن نضبط step='pre_protection' ونتابع."
},
"protection": {
"cta_primary": "فعّل الحماية",
"error_title": "تعذّر تفعيل الحماية",
"error_unknown": "خطأ غير معروف. حاول مرة أخرى.",
"feat_blocklist_title": "فلتر شامل",
"feat_blocklist_desc": "نطاقات القمار محجوبة في المتصفحات والتطبيقات.",
"feat_ios_title": "iOS NEFilter",
"feat_ios_desc": "Network Extension من Apple — آمن وعميق.",
"feat_android_title": "Android VPN فلتر",
"feat_android_desc": "فلتر DNS محلي — بدون خادم خارجي.",
"feat_cooldown_title": "حماية بفترة انتظار",
"feat_cooldown_desc": "24 ساعة قبل أن تستطيع تعطيل الحماية.",
"permission_note": "في نافذة iOS / Android القادمة: اضغط \"السماح\"."
},
"done": {
"cta_primary": "ادخل التطبيق",
"headline": "أنت معنا.",
"subhead": "اليوم الأول من سلسلتك. لست وحدك — المجتمع هنا، وLyra أيضاً."
},
"step_progress": "الخطوة %{current} من %{total}",
"block_spotlight": {

View File

@ -357,20 +357,103 @@
"empty_mail": "Noch keine Mail-Domains. Tippe + um eine E-Mail-Adresse oder Domain zu blockieren."
},
"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." },
"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." },
"protection": { "body": "Jetzt der wichtigste Teil — der Schutz auf deinem Gerät. Bereit?" },
"done": { "body": "Geschafft. Tag 1 deiner neuen Streak — und du gehst nicht allein." }
},
"welcome": {
"headline": "Willkommen bei ReBreak.",
"subhead": "Dein Weg raus aus dem Glücksspiel — anonym, geschützt, und nicht allein.",
"bullet_anon_title": "Du bleibst anonym",
"bullet_anon_desc": "Du wählst einen Alias. Niemand sieht deinen echten Namen — auch wir nicht.",
"bullet_protect_title": "Dein Gerät wird geschützt",
"bullet_protect_desc": "Glücksspiel-Seiten und -Apps werden für dich blockiert. Auch wenn der Drang kommt.",
"bullet_community_title": "Du gehst nicht allein",
"bullet_community_desc": "Andere auf dem gleichen Weg. Anonym schreiben, Streaks teilen, Halt finden.",
"privacy_label": "DSGVO · Datenminimierung",
"privacy_body": "Wir verarbeiten so wenig wie möglich. Du brauchst nur einen Alias. Kein Klarname, keine Tracker, keine Werbung.",
"cta": "Los geht's",
"cta_loading": "Einen Moment...",
"next_hint": "Im nächsten Schritt wählst du deinen Alias."
"cta_primary": "Los geht's",
"bullet_anon": "Anonym — kein echter Name nötig",
"bullet_protect": "Glücksspiel-Seiten werden blockiert",
"bullet_community": "Andere auf dem gleichen Weg"
},
"privacy": {
"cta_primary": "Verstanden",
"promise_alias": "Nur dein Alias ist sichtbar",
"promise_minimal": "Wir speichern so wenig wie möglich",
"promise_no_ads": "Keine Tracker, keine Werbung",
"promise_germany": "Server in Deutschland · DSGVO-konform"
},
"nickname": {
"cta_primary": "Speichern",
"label": "DEIN ALIAS",
"placeholder": "z.B. wanderer84",
"hint": "232 Zeichen. Kannst du jederzeit ändern."
},
"diga_choice": {
"cta_yes": "Ja, ich habe einen Code",
"cta_no": "Nein, weiter zum Plan",
"hint": "Ein DiGA-Code wird von deiner Krankenkasse ausgestellt und gibt dir vollen Schutz ohne Bezahlung."
},
"diga_code": {
"cta_primary": "Einlösen",
"cta_secondary": "Doch kein Code — zurück",
"label": "REZEPT-CODE",
"hint": "Test-Codes für interne Tester: REBREAK-TEST-001 bis -010",
"error_not_found": "Dieser Code existiert nicht. Bitte prüfe die Schreibweise.",
"error_already_used": "Dieser Code wurde bereits eingelöst.",
"error_expired": "Dieser Code ist abgelaufen.",
"error_invalid_input": "Bitte gib einen gültigen Code ein."
},
"plan": {
"cta_trial": "14 Tage gratis starten",
"cta_legend": "Legend wählen",
"billing_monthly": "Monatlich",
"billing_yearly": "Jährlich",
"billing_savings": "2 Monate gratis",
"tier_pro_badge": "Empfohlen",
"tier_pro_price_monthly": "3,99 € / Monat",
"tier_pro_price_yearly": "3,33 € / Monat",
"tier_pro_anchor_yearly": "47,88 €",
"tier_pro_total_yearly": "39,90 € / Jahr",
"tier_pro_subline_monthly": "Erste 14 Tage gratis",
"tier_pro_subline_yearly": "14 Tage gratis + 2 Monate geschenkt",
"tier_legend_price_monthly": "7,99 € / Monat",
"tier_legend_price_yearly": "6,66 € / Monat",
"tier_legend_anchor_yearly": "95,88 €",
"tier_legend_total_yearly": "79,90 € / Jahr",
"tier_legend_subline_monthly": "Für Multi-Device-Schutz",
"tier_legend_subline_yearly": "2 Monate geschenkt · Multi-Device",
"feat_blocklist": "208 000+ Glücksspiel-Domains blockiert",
"feat_lyra": "Lyra-Coach unbegrenzt",
"feat_mail": "Mail-Filter für Casino-Spam",
"feat_community": "Community + Streaks",
"feat_legend_all_pro": "Alles in Pro enthalten",
"feat_legend_multi_device": "Schutz auf Mac + Windows",
"feat_legend_voice": "Premium Lyra-Stimme",
"disclaimer": "Auto-Renew. Du kannst jederzeit kündigen — in den iOS-Einstellungen.",
"hardship_link": "Knapp bei Kasse? Schreib uns."
},
"payment": {
"cta_dev_skip": "Weiter (Dev-Skip)",
"dev_label": "Dev-Stub",
"dev_body": "Die echte Zahlungs-Sheet (RevenueCat / StoreKit) kommt in der nächsten Phase. Für jetzt setzen wir step='pre_protection' und gehen weiter zum Schutz."
},
"protection": {
"cta_primary": "Schutz aktivieren",
"error_title": "Schutz konnte nicht aktiviert werden",
"error_unknown": "Unbekannter Fehler. Bitte nochmal versuchen.",
"feat_blocklist_title": "Globaler Filter",
"feat_blocklist_desc": "Glücksspiel-Domains werden in Browser + Apps blockiert.",
"feat_ios_title": "iOS NEFilter",
"feat_ios_desc": "Apple's Network Extension — sicher und tief im System.",
"feat_android_title": "Android VPN-Filter",
"feat_android_desc": "Lokaler DNS-Filter — kein externer Server.",
"feat_cooldown_title": "Cooldown-Schutz",
"feat_cooldown_desc": "24h-Reibung bevor du den Schutz deaktivieren kannst.",
"permission_note": "Im nächsten Dialog von iOS / Android: bitte „Erlauben\" wählen."
},
"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."
},
"step_progress": "Schritt %{current} von %{total}",
"block_spotlight": {

View File

@ -357,20 +357,103 @@
"empty_mail": "No mail domains yet. Tap + to block an email address or domain."
},
"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." },
"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_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." },
"protection": { "body": "Now the important part — the protection on your device. Ready?" },
"done": { "body": "Done. Day 1 of your new streak — and you're not walking alone." }
},
"welcome": {
"headline": "Welcome to ReBreak.",
"subhead": "Your way out of gambling — anonymous, protected, and not alone.",
"bullet_anon_title": "You stay anonymous",
"bullet_anon_desc": "You pick an alias. No one sees your real name — not even us.",
"bullet_protect_title": "Your device gets protected",
"bullet_protect_desc": "Gambling sites and apps are blocked for you. Even when the urge hits.",
"bullet_community_title": "You're not alone",
"bullet_community_desc": "Others walking the same path. Post anonymously, share streaks, find support.",
"privacy_label": "GDPR · Data minimization",
"privacy_body": "We process as little as possible. You only need an alias. No real name, no trackers, no ads.",
"cta": "Let's go",
"cta_loading": "One moment...",
"next_hint": "Next, you'll pick your alias."
"cta_primary": "Let's go",
"bullet_anon": "Anonymous — no real name needed",
"bullet_protect": "Gambling sites get blocked",
"bullet_community": "Others walking the same path"
},
"privacy": {
"cta_primary": "Got it",
"promise_alias": "Only your alias is visible",
"promise_minimal": "We store as little as possible",
"promise_no_ads": "No trackers, no ads",
"promise_germany": "Servers in Germany · GDPR-compliant"
},
"nickname": {
"cta_primary": "Save",
"label": "YOUR ALIAS",
"placeholder": "e.g. wanderer84",
"hint": "232 characters. Changeable anytime."
},
"diga_choice": {
"cta_yes": "Yes, I have a code",
"cta_no": "No, show me plans",
"hint": "A DiGA code is issued by your health insurance and gives you full access without payment."
},
"diga_code": {
"cta_primary": "Redeem",
"cta_secondary": "No code after all — back",
"label": "PRESCRIPTION CODE",
"hint": "Internal test codes: REBREAK-TEST-001 to -010",
"error_not_found": "This code doesn't exist. Check the spelling please.",
"error_already_used": "This code has already been redeemed.",
"error_expired": "This code has expired.",
"error_invalid_input": "Please enter a valid code."
},
"plan": {
"cta_trial": "Start 14 days free",
"cta_legend": "Choose Legend",
"billing_monthly": "Monthly",
"billing_yearly": "Yearly",
"billing_savings": "2 months free",
"tier_pro_badge": "Recommended",
"tier_pro_price_monthly": "€3.99 / month",
"tier_pro_price_yearly": "€3.33 / month",
"tier_pro_anchor_yearly": "€47.88",
"tier_pro_total_yearly": "€39.90 / year",
"tier_pro_subline_monthly": "First 14 days free",
"tier_pro_subline_yearly": "14 days free + 2 months gift",
"tier_legend_price_monthly": "€7.99 / month",
"tier_legend_price_yearly": "€6.66 / month",
"tier_legend_anchor_yearly": "€95.88",
"tier_legend_total_yearly": "€79.90 / year",
"tier_legend_subline_monthly": "For multi-device protection",
"tier_legend_subline_yearly": "2 months gift · multi-device",
"feat_blocklist": "208,000+ gambling domains blocked",
"feat_lyra": "Lyra coach unlimited",
"feat_mail": "Mail filter for casino spam",
"feat_community": "Community + streaks",
"feat_legend_all_pro": "Everything in Pro",
"feat_legend_multi_device": "Protection on Mac + Windows",
"feat_legend_voice": "Premium Lyra voice",
"disclaimer": "Auto-renew. Cancel anytime in iOS Settings.",
"hardship_link": "Tight on money? Write to us."
},
"payment": {
"cta_dev_skip": "Continue (dev skip)",
"dev_label": "Dev stub",
"dev_body": "The real payment sheet (RevenueCat / StoreKit) is coming in the next phase. For now we set step='pre_protection' and move on to protection setup."
},
"protection": {
"cta_primary": "Activate protection",
"error_title": "Protection couldn't be activated",
"error_unknown": "Unknown error. Please try again.",
"feat_blocklist_title": "Global filter",
"feat_blocklist_desc": "Gambling domains blocked in browsers + apps.",
"feat_ios_title": "iOS NEFilter",
"feat_ios_desc": "Apple's Network Extension — secure and deep in the system.",
"feat_android_title": "Android VPN filter",
"feat_android_desc": "Local DNS filter — no external server.",
"feat_cooldown_title": "Cooldown protection",
"feat_cooldown_desc": "24h friction before you can disable protection.",
"permission_note": "In the next iOS / Android dialog: please tap \"Allow\"."
},
"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."
},
"step_progress": "Step %{current} of %{total}",
"block_spotlight": {

View File

@ -355,20 +355,103 @@
"empty_mail": "Aucun domaine mail. Appuyez sur + pour bloquer une adresse ou un domaine."
},
"onboarding": {
"lyra": {
"welcome": { "body": "Salut, je suis Lyra. Content·e que tu aies franchi ce pas — on trouve ta voie hors du jeu ensemble." },
"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." },
"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 ?" },
"done": { "body": "Voilà. Jour 1 de ta nouvelle série — et tu n'es pas seul·e." }
},
"welcome": {
"headline": "Bienvenue sur ReBreak.",
"subhead": "Ta voie hors du jeu — anonyme, protégée, et pas seul·e.",
"bullet_anon_title": "Tu restes anonyme",
"bullet_anon_desc": "Tu choisis un alias. Personne ne voit ton vrai nom — nous non plus.",
"bullet_protect_title": "Ton appareil est protégé",
"bullet_protect_desc": "Les sites et apps de jeu sont bloqués pour toi. Même quand l'envie revient.",
"bullet_community_title": "Tu n'es pas seul",
"bullet_community_desc": "D'autres font le même chemin. Écris anonymement, partage tes streaks, trouve du soutien.",
"privacy_label": "RGPD · Minimisation des données",
"privacy_body": "Nous traitons le strict minimum. Un alias suffit. Pas de vrai nom, pas de trackers, pas de pubs.",
"cta": "On y va",
"cta_loading": "Un instant...",
"next_hint": "À l'étape suivante, tu choisiras ton alias."
"cta_primary": "On y va",
"bullet_anon": "Anonyme — pas de vrai nom",
"bullet_protect": "Sites de jeu bloqués",
"bullet_community": "D'autres sur le même chemin"
},
"privacy": {
"cta_primary": "Compris",
"promise_alias": "Seul ton alias est visible",
"promise_minimal": "On stocke le minimum",
"promise_no_ads": "Pas de trackers, pas de pub",
"promise_germany": "Serveurs en Allemagne · RGPD"
},
"nickname": {
"cta_primary": "Enregistrer",
"label": "TON ALIAS",
"placeholder": "ex. wanderer84",
"hint": "2 à 32 caractères. Modifiable à tout moment."
},
"diga_choice": {
"cta_yes": "Oui, j'ai un code",
"cta_no": "Non, montre les plans",
"hint": "Un code DiGA est émis par ta caisse d'assurance et te donne l'accès complet sans paiement."
},
"diga_code": {
"cta_primary": "Valider",
"cta_secondary": "Pas de code finalement — retour",
"label": "CODE D'ORDONNANCE",
"hint": "Codes de test internes : REBREAK-TEST-001 à -010",
"error_not_found": "Ce code n'existe pas. Vérifie l'orthographe.",
"error_already_used": "Ce code a déjà été utilisé.",
"error_expired": "Ce code a expiré.",
"error_invalid_input": "Entre un code valide."
},
"plan": {
"cta_trial": "14 jours gratuits",
"cta_legend": "Choisir Legend",
"billing_monthly": "Mensuel",
"billing_yearly": "Annuel",
"billing_savings": "2 mois offerts",
"tier_pro_badge": "Recommandé",
"tier_pro_price_monthly": "3,99 € / mois",
"tier_pro_price_yearly": "3,33 € / mois",
"tier_pro_anchor_yearly": "47,88 €",
"tier_pro_total_yearly": "39,90 € / an",
"tier_pro_subline_monthly": "14 premiers jours gratuits",
"tier_pro_subline_yearly": "14 jours gratuits + 2 mois offerts",
"tier_legend_price_monthly": "7,99 € / mois",
"tier_legend_price_yearly": "6,66 € / mois",
"tier_legend_anchor_yearly": "95,88 €",
"tier_legend_total_yearly": "79,90 € / an",
"tier_legend_subline_monthly": "Pour protection multi-appareils",
"tier_legend_subline_yearly": "2 mois offerts · multi-appareils",
"feat_blocklist": "208 000+ domaines de jeu bloqués",
"feat_lyra": "Coach Lyra illimité",
"feat_mail": "Filtre mail anti-spam casino",
"feat_community": "Communauté + séries",
"feat_legend_all_pro": "Tout Pro inclus",
"feat_legend_multi_device": "Protection sur Mac + Windows",
"feat_legend_voice": "Voix premium de Lyra",
"disclaimer": "Renouvellement auto. Annulation à tout moment dans Réglages iOS.",
"hardship_link": "Budget serré ? Écris-nous."
},
"payment": {
"cta_dev_skip": "Continuer (dev skip)",
"dev_label": "Dev stub",
"dev_body": "La vraie feuille de paiement (RevenueCat / StoreKit) arrive à la prochaine phase. Pour l'instant on passe step='pre_protection' et on continue."
},
"protection": {
"cta_primary": "Activer la protection",
"error_title": "Impossible d'activer la protection",
"error_unknown": "Erreur inconnue. Réessaie.",
"feat_blocklist_title": "Filtre global",
"feat_blocklist_desc": "Domaines de jeu bloqués dans navigateurs + apps.",
"feat_ios_title": "iOS NEFilter",
"feat_ios_desc": "Network Extension d'Apple — sûr et profond.",
"feat_android_title": "Filtre VPN Android",
"feat_android_desc": "Filtre DNS local — pas de serveur externe.",
"feat_cooldown_title": "Cooldown 24h",
"feat_cooldown_desc": "24h de friction avant de pouvoir désactiver.",
"permission_note": "Dans la fenêtre iOS / Android : touche \"Autoriser\"."
},
"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."
},
"step_progress": "Étape %{current} sur %{total}",
"block_spotlight": {

View File

@ -243,12 +243,31 @@ public class RebreakProtectionModule: Module {
// disable: NUR aufrufen wenn JS-Cooldown abgelaufen!
AsyncFunction("disable") { () async -> [String: Any] in
// NEFilter
// NEFilter robuster Disable-Path:
// 1. loadFromPreferences (current config + isEnabled state lesen)
// 2. isEnabled = false + saveToPreferences (Filter-Daemon stoppen +
// Settings.app-UI flippt sofort auf "deaktiviert")
// 3. removeFromPreferences (Config-Eintrag aus Settings entfernen)
//
// Warum 2-Step: removeFromPreferences ALLEIN ist auf manchen iOS-Versionen
// (insb. iOS 18+) unzuverlässig Settings-UI zeigt "Läuft..." obwohl der
// Provider beendet sein sollte. Erst isEnabled=false + save bringt das
// System dazu, den Filter-Daemon sauber zu beenden bevor wir die Config
// löschen. Pattern aus Apple-Developer-Forums + eigene Empirie.
do {
let manager = NEFilterManager.shared()
try await manager.loadFromPreferences()
if manager.isEnabled {
manager.isEnabled = false
do {
try await manager.saveToPreferences()
SharedLogStore.append("⏸ NEFilter isEnabled=false saved (daemon stop)")
} catch {
SharedLogStore.append("⚠️ saveToPreferences(disabled) failed: \(error.localizedDescription)")
}
}
try await manager.removeFromPreferences()
SharedLogStore.append("✅ NEFilter disabled")
SharedLogStore.append("✅ NEFilter disabled + removed from preferences")
} catch {
SharedLogStore.append("⚠️ NEFilter disable: \(error.localizedDescription)")
}

View File

@ -49,11 +49,13 @@ export async function redeemDigaCode(
data: { usedAt: now, usedByProfileId: userId },
});
// step → 'pre_protection' (NICHT 'done') — User muss noch durch den
// Protection-Slide (NEFilter/VPN-Aktivierung auf dem Device).
await tx.profile.update({
where: { id: userId },
data: {
plan: found.grantsPlan,
onboardingStep: "done",
onboardingStep: "pre_protection",
digaCodeRedeemedAt: now,
},
});

View File

@ -24,7 +24,26 @@ export async function deleteProfile(userId: string) {
// ─── Onboarding-Step ────────────────────────────────────────────────────────
export const ONBOARDING_STEPS = ["welcome", "nickname", "block", "done"] as const;
// Onboarding-Milestones im Duo-Style-Flow (siehe app/onboarding/index.tsx):
// welcome → Flow noch nicht angefangen (Default für neue Profile)
// account → Nickname gesetzt
// plan → Trial/Sub gewählt (vor Payment)
// pre_protection → Payment confirmed, Protection-Slide noch offen
// done → komplett abgeschlossen
//
// Legacy-Werte 'nickname' und 'block' werden im Backend noch akzeptiert für
// Backwards-Compat (alte Builds in TestFlight), aber im neuen Flow nicht mehr
// geschrieben. Können nach allen-User-Force-Update entfernt werden.
export const ONBOARDING_STEPS = [
"welcome",
"account",
"plan",
"pre_protection",
"done",
// legacy (kept readable to not break old clients):
"nickname",
"block",
] as const;
export type OnboardingStep = (typeof ONBOARDING_STEPS)[number];
export function isOnboardingStep(value: unknown): value is OnboardingStep {