diff --git a/apps/rebreak-native/app.config.ts b/apps/rebreak-native/app.config.ts
index fd85973..2b6c4a3 100644
--- a/apps/rebreak-native/app.config.ts
+++ b/apps/rebreak-native/app.config.ts
@@ -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 ||
diff --git a/apps/rebreak-native/app/(app)/_layout.tsx b/apps/rebreak-native/app/(app)/_layout.tsx
index 3357b7c..7781040 100644
--- a/apps/rebreak-native/app/(app)/_layout.tsx
+++ b/apps/rebreak-native/app/(app)/_layout.tsx
@@ -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]);
diff --git a/apps/rebreak-native/app/(app)/blocker.tsx b/apps/rebreak-native/app/(app)/blocker.tsx
index b31acb4..1578c10 100644
--- a/apps/rebreak-native/app/(app)/blocker.tsx
+++ b/apps/rebreak-native/app/(app)/blocker.tsx
@@ -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 ? (
-
- ) : Platform.OS === 'android' ? (
+ {Platform.OS === 'android' ? (
) : (
-
-
-
-
-
-
-
- {t('blocker.layers_app_lock_title')}
-
-
-
- {t('blocker.app_lock_coming_soon_badge')}
-
-
-
-
- {t('blocker.app_lock_coming_soon_desc')}
-
-
-
+
)}
)}
diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx
index 01e14cb..951f71c 100644
--- a/apps/rebreak-native/app/_layout.tsx
+++ b/apps/rebreak-native/app/_layout.tsx
@@ -187,7 +187,7 @@ function RootLayoutInner() {
}}
/>
(
+ () => (me ? slideFromStep(me.onboardingStep) : 'welcome'),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [me?.id],
+ );
+ const [slide, setSlide] = useState(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 ;
+ case 'privacy':
+ return ;
+ case 'nickname':
+ return ;
+ case 'diga_choice':
+ return (
+
+ );
+ case 'diga_code':
+ return (
+ setSlide('diga_choice')}
+ current={current}
+ total={total}
+ />
+ );
+ case 'plan':
+ return ;
+ case 'payment':
+ return (
+
+ );
+ case 'protection':
+ return (
+
+ );
+ case 'done':
+ return ;
+ }
+}
diff --git a/apps/rebreak-native/app/onboarding/welcome.tsx b/apps/rebreak-native/app/onboarding/welcome.tsx
deleted file mode 100644
index 158e07b..0000000
--- a/apps/rebreak-native/app/onboarding/welcome.tsx
+++ /dev/null
@@ -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 (
-
- {/* Atmender Top-Glow */}
-
-
-
-
- {/* Center indigo halo */}
-
-
-
-
-
- {/* Hero-Icon */}
-
-
-
-
-
-
- {/* Headline + Subhead */}
-
-
- {t('onboarding.welcome.headline')}
-
-
- {t('onboarding.welcome.subhead')}
-
-
-
- {/* Mission-Bullets */}
-
- {BULLETS.map((b, i) => (
-
-
-
-
-
-
- {t(b.titleKey)}
-
-
- {t(b.descKey)}
-
-
-
- ))}
-
-
- {/* DSGVO-Box */}
-
-
-
-
- {t('onboarding.welcome.privacy_label')}
-
-
- {t('onboarding.welcome.privacy_body')}
-
-
-
-
-
-
- {/* CTA */}
-
-
-
- {submitting ? t('onboarding.welcome.cta_loading') : t('onboarding.welcome.cta')}
-
-
-
- {t('onboarding.welcome.next_hint')}
-
-
-
-
- );
-}
diff --git a/apps/rebreak-native/components/onboarding/CTABar.tsx b/apps/rebreak-native/components/onboarding/CTABar.tsx
new file mode 100644
index 0000000..dd7b08c
--- /dev/null
+++ b/apps/rebreak-native/components/onboarding/CTABar.tsx
@@ -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 (
+
+
+ {primaryLoading ? (
+
+ ) : (
+
+ {primaryLabel}
+
+ )}
+
+
+ {secondaryLabel && onSecondary ? (
+
+
+ {secondaryLabel}
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/rebreak-native/components/onboarding/LyraBubble.tsx b/apps/rebreak-native/components/onboarding/LyraBubble.tsx
new file mode 100644
index 0000000..107b9ab
--- /dev/null
+++ b/apps/rebreak-native/components/onboarding/LyraBubble.tsx
@@ -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 (
+
+
+
+
+
+
+ {/* Speech-Bubble */}
+
+
+ {text}
+
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/onboarding/OnboardingShell.tsx b/apps/rebreak-native/components/onboarding/OnboardingShell.tsx
new file mode 100644
index 0000000..a6f864a
--- /dev/null
+++ b/apps/rebreak-native/components/onboarding/OnboardingShell.tsx
@@ -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 ───── │
+ * │ │
+ * │ │
+ * │ │
+ * │ ───── 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 (
+
+
+
+
+
+
+ {children}
+
+
+ {cta}
+
+ );
+}
diff --git a/apps/rebreak-native/components/onboarding/SlideProgress.tsx b/apps/rebreak-native/components/onboarding/SlideProgress.tsx
new file mode 100644
index 0000000..928e89a
--- /dev/null
+++ b/apps/rebreak-native/components/onboarding/SlideProgress.tsx
@@ -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 (
+
+
+
+ );
+}
+
+function clamp01(v: number): number {
+ return Math.max(0, Math.min(1, v));
+}
diff --git a/apps/rebreak-native/components/onboarding/slides/DigaChoiceSlide.tsx b/apps/rebreak-native/components/onboarding/slides/DigaChoiceSlide.tsx
new file mode 100644
index 0000000..a063e2d
--- /dev/null
+++ b/apps/rebreak-native/components/onboarding/slides/DigaChoiceSlide.tsx
@@ -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 (
+
+ }
+ >
+
+
+
+
+
+ {t('onboarding.diga_choice.hint')}
+
+
+
+ );
+}
+
+function ChoiceBar({
+ onYes,
+ onNo,
+ t,
+ colors,
+}: {
+ onYes: () => void;
+ onNo: () => void;
+ t: ReturnType['t'];
+ colors: import('../../../lib/theme').ColorScheme;
+}) {
+ return (
+
+
+
+ {t('onboarding.diga_choice.cta_yes')}
+
+
+
+
+ {t('onboarding.diga_choice.cta_no')}
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/onboarding/slides/DigaCodeSlide.tsx b/apps/rebreak-native/components/onboarding/slides/DigaCodeSlide.tsx
new file mode 100644
index 0000000..e36e7c1
--- /dev/null
+++ b/apps/rebreak-native/components/onboarding/slides/DigaCodeSlide.tsx
@@ -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(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 (
+
+ }
+ >
+
+
+
+
+ {t('onboarding.diga_code.label')}
+
+ {
+ 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 ? (
+
+
+
+ {t(`onboarding.diga_code.error_${errorKey}`)}
+
+
+ ) : (
+
+ {t('onboarding.diga_code.hint')}
+
+ )}
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/onboarding/slides/DoneSlide.tsx b/apps/rebreak-native/components/onboarding/slides/DoneSlide.tsx
new file mode 100644
index 0000000..f40f097
--- /dev/null
+++ b/apps/rebreak-native/components/onboarding/slides/DoneSlide.tsx
@@ -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 (
+ }
+ >
+
+
+
+
+
+
+
+
+ {t('onboarding.done.headline')}
+
+
+ {t('onboarding.done.subhead')}
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/onboarding/slides/NicknameSlide.tsx b/apps/rebreak-native/components/onboarding/slides/NicknameSlide.tsx
new file mode 100644
index 0000000..722cda6
--- /dev/null
+++ b/apps/rebreak-native/components/onboarding/slides/NicknameSlide.tsx
@@ -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(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 (
+
+ }
+ >
+
+
+
+
+ {t('onboarding.nickname.label')}
+
+
+
+ {t('onboarding.nickname.hint')}
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/onboarding/slides/PaymentSlide.tsx b/apps/rebreak-native/components/onboarding/slides/PaymentSlide.tsx
new file mode 100644
index 0000000..e833b4a
--- /dev/null
+++ b/apps/rebreak-native/components/onboarding/slides/PaymentSlide.tsx
@@ -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 (
+
+ }
+ >
+
+
+
+
+
+
+ {t('onboarding.payment.dev_label')}
+
+
+
+ {t('onboarding.payment.dev_body')}
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/onboarding/slides/PlanSlide.tsx b/apps/rebreak-native/components/onboarding/slides/PlanSlide.tsx
new file mode 100644
index 0000000..17a0efd
--- /dev/null
+++ b/apps/rebreak-native/components/onboarding/slides/PlanSlide.tsx
@@ -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('pro');
+ const [billing, setBilling] = useState('yearly');
+
+ function openHardshipMail() {
+ const subject = encodeURIComponent('Härtefall — rebreak');
+ Linking.openURL(`mailto:${HARDSHIP_EMAIL}?subject=${subject}`).catch(() => {});
+ }
+
+ return (
+ onChosen(tier, billing)}
+ />
+ }
+ >
+
+
+
+
+
+ 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'),
+ ]}
+ />
+ 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'),
+ ]}
+ />
+
+
+
+ {t('onboarding.plan.disclaimer')}
+
+
+ {/* Härtefall-Mailto — manuell, kein Auto-Sozial-Rabatt (Strategist-Verdict) */}
+
+
+ {t('onboarding.plan.hardship_link')}
+
+
+
+ );
+}
+
+// ─── Billing-Toggle ──────────────────────────────────────────────────────────
+
+function BillingToggle({
+ billing,
+ setBilling,
+ t,
+ colors,
+}: {
+ billing: Billing;
+ setBilling: (b: Billing) => void;
+ t: ReturnType['t'];
+ colors: import('../../../lib/theme').ColorScheme;
+}) {
+ return (
+
+ setBilling('monthly')}
+ label={t('onboarding.plan.billing_monthly')}
+ colors={colors}
+ />
+ setBilling('yearly')}
+ label={t('onboarding.plan.billing_yearly')}
+ savingsBadge={t('onboarding.plan.billing_savings')}
+ colors={colors}
+ />
+
+ );
+}
+
+function ToggleButton({
+ active,
+ onPress,
+ label,
+ savingsBadge,
+ colors,
+}: {
+ active: boolean;
+ onPress: () => void;
+ label: string;
+ savingsBadge?: string;
+ colors: import('../../../lib/theme').ColorScheme;
+}) {
+ return (
+
+
+ {label}
+
+ {savingsBadge ? (
+
+ {savingsBadge}
+
+ ) : null}
+
+ );
+}
+
+// ─── 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 (
+
+
+
+
+ {title}
+
+ {badge ? (
+
+
+ {badge.toUpperCase()}
+
+
+ ) : null}
+
+
+ {selected ? : null}
+
+
+
+
+
+ {price}
+
+ {anchorPrice ? (
+
+ {anchorPrice}
+
+ ) : null}
+
+ {totalPrice ? (
+
+ {totalPrice}
+
+ ) : null}
+
+ {subline}
+
+
+
+ {features.map((f) => (
+
+
+
+ {f}
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/onboarding/slides/PrivacySlide.tsx b/apps/rebreak-native/components/onboarding/slides/PrivacySlide.tsx
new file mode 100644
index 0000000..5d5811e
--- /dev/null
+++ b/apps/rebreak-native/components/onboarding/slides/PrivacySlide.tsx
@@ -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 (
+ }
+ >
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function PromiseRow({
+ icon,
+ text,
+ colors,
+}: {
+ icon: keyof typeof Ionicons.glyphMap;
+ text: string;
+ colors: import('../../../lib/theme').ColorScheme;
+}) {
+ return (
+
+
+
+
+
+ {text}
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx b/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx
new file mode 100644
index 0000000..7a6e637
--- /dev/null
+++ b/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx
@@ -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 (
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+ {t('onboarding.protection.permission_note')}
+
+
+ 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;
+ }}
+ />
+
+ );
+}
+
+function ProtectionRow({
+ icon,
+ title,
+ desc,
+ colors,
+}: {
+ icon: keyof typeof Ionicons.glyphMap;
+ title: string;
+ desc: string;
+ colors: import('../../../lib/theme').ColorScheme;
+}) {
+ return (
+
+
+
+
+
+
+ {title}
+
+
+ {desc}
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/onboarding/slides/WelcomeSlide.tsx b/apps/rebreak-native/components/onboarding/slides/WelcomeSlide.tsx
new file mode 100644
index 0000000..aa99a65
--- /dev/null
+++ b/apps/rebreak-native/components/onboarding/slides/WelcomeSlide.tsx
@@ -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 (
+ }
+ >
+
+
+
+
+
+
+
+
+ );
+}
+
+function BulletRow({
+ icon,
+ text,
+ colors,
+}: {
+ icon: keyof typeof Ionicons.glyphMap;
+ text: string;
+ colors: import('../../../lib/theme').ColorScheme;
+}) {
+ return (
+
+
+
+
+
+ {text}
+
+
+ );
+}
diff --git a/apps/rebreak-native/hooks/useMe.ts b/apps/rebreak-native/hooks/useMe.ts
index 79de037..26efb6d 100644
--- a/apps/rebreak-native/hooks/useMe.ts
+++ b/apps/rebreak-native/hooks/useMe.ts
@@ -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;
diff --git a/apps/rebreak-native/hooks/useProtectionState.ts b/apps/rebreak-native/hooks/useProtectionState.ts
index f6186cd..43ded40 100644
--- a/apps/rebreak-native/hooks/useProtectionState.ts
+++ b/apps/rebreak-native/hooks/useProtectionState.ts
@@ -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;
diff --git a/apps/rebreak-native/lib/protection.ts b/apps/rebreak-native/lib/protection.ts
index 33eafcd..d820fd1 100644
--- a/apps/rebreak-native/lib/protection.ts
+++ b/apps/rebreak-native/lib/protection.ts
@@ -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 {
diff --git a/apps/rebreak-native/locales/ar.json b/apps/rebreak-native/locales/ar.json
index 6050488..041554c 100644
--- a/apps/rebreak-native/locales/ar.json
+++ b/apps/rebreak-native/locales/ar.json
@@ -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": {
diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json
index fcc65f3..69fa91e 100644
--- a/apps/rebreak-native/locales/de.json
+++ b/apps/rebreak-native/locales/de.json
@@ -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": "2–32 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": {
diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json
index d629aef..844acf4 100644
--- a/apps/rebreak-native/locales/en.json
+++ b/apps/rebreak-native/locales/en.json
@@ -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": "2–32 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": {
diff --git a/apps/rebreak-native/locales/fr.json b/apps/rebreak-native/locales/fr.json
index 5f75067..89ce79c 100644
--- a/apps/rebreak-native/locales/fr.json
+++ b/apps/rebreak-native/locales/fr.json
@@ -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": {
diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift
index 95e92b8..f63e7b0 100644
--- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift
+++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift
@@ -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)")
}
diff --git a/backend/server/db/diga.ts b/backend/server/db/diga.ts
index 0cb3e0d..645074e 100644
--- a/backend/server/db/diga.ts
+++ b/backend/server/db/diga.ts
@@ -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,
},
});
diff --git a/backend/server/db/profile.ts b/backend/server/db/profile.ts
index 3a5b8ce..ca7655a 100644
--- a/backend/server/db/profile.ts
+++ b/backend/server/db/profile.ts
@@ -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 {