From b23bd6d29fe9a3a78a9ddf69ddff74cdfb822137 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sun, 17 May 2026 17:48:05 +0200 Subject: [PATCH] feat(onboarding,protection): Duo-style flow + cooldown auto-disable fix + Family Controls live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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..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 --- apps/rebreak-native/app.config.ts | 12 +- apps/rebreak-native/app/(app)/_layout.tsx | 2 +- apps/rebreak-native/app/(app)/blocker.tsx | 85 +--- apps/rebreak-native/app/_layout.tsx | 2 +- apps/rebreak-native/app/onboarding/index.tsx | 176 ++++++++ .../rebreak-native/app/onboarding/welcome.tsx | 404 ------------------ .../components/onboarding/CTABar.tsx | 87 ++++ .../components/onboarding/LyraBubble.tsx | 83 ++++ .../components/onboarding/OnboardingShell.tsx | 65 +++ .../components/onboarding/SlideProgress.tsx | 58 +++ .../onboarding/slides/DigaChoiceSlide.tsx | 127 ++++++ .../onboarding/slides/DigaCodeSlide.tsx | 151 +++++++ .../onboarding/slides/DoneSlide.tsx | 95 ++++ .../onboarding/slides/NicknameSlide.tsx | 119 ++++++ .../onboarding/slides/PaymentSlide.tsx | 94 ++++ .../onboarding/slides/PlanSlide.tsx | 346 +++++++++++++++ .../onboarding/slides/PrivacySlide.tsx | 101 +++++ .../onboarding/slides/ProtectionSlide.tsx | 195 +++++++++ .../onboarding/slides/WelcomeSlide.tsx | 90 ++++ apps/rebreak-native/hooks/useMe.ts | 10 +- .../hooks/useProtectionState.ts | 41 +- apps/rebreak-native/lib/protection.ts | 5 +- apps/rebreak-native/locales/ar.json | 109 ++++- apps/rebreak-native/locales/de.json | 109 ++++- apps/rebreak-native/locales/en.json | 109 ++++- apps/rebreak-native/locales/fr.json | 109 ++++- .../ios/RebreakProtectionModule.swift | 23 +- backend/server/db/diga.ts | 4 +- backend/server/db/profile.ts | 21 +- 29 files changed, 2275 insertions(+), 557 deletions(-) create mode 100644 apps/rebreak-native/app/onboarding/index.tsx delete mode 100644 apps/rebreak-native/app/onboarding/welcome.tsx create mode 100644 apps/rebreak-native/components/onboarding/CTABar.tsx create mode 100644 apps/rebreak-native/components/onboarding/LyraBubble.tsx create mode 100644 apps/rebreak-native/components/onboarding/OnboardingShell.tsx create mode 100644 apps/rebreak-native/components/onboarding/SlideProgress.tsx create mode 100644 apps/rebreak-native/components/onboarding/slides/DigaChoiceSlide.tsx create mode 100644 apps/rebreak-native/components/onboarding/slides/DigaCodeSlide.tsx create mode 100644 apps/rebreak-native/components/onboarding/slides/DoneSlide.tsx create mode 100644 apps/rebreak-native/components/onboarding/slides/NicknameSlide.tsx create mode 100644 apps/rebreak-native/components/onboarding/slides/PaymentSlide.tsx create mode 100644 apps/rebreak-native/components/onboarding/slides/PlanSlide.tsx create mode 100644 apps/rebreak-native/components/onboarding/slides/PrivacySlide.tsx create mode 100644 apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx create mode 100644 apps/rebreak-native/components/onboarding/slides/WelcomeSlide.tsx 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 {