feat(onboarding,protection): Duo-style flow + cooldown auto-disable fix + Family Controls live
## Duo-Style Onboarding (Foundation + alle Slides) Self-contained Onboarding-Flow mit Lyra-Mascot ersetzt das Spotlight-POC vom vorherigen Iteration. Slides leben unter `components/onboarding/slides/`. - Foundation: OnboardingShell (Progress + ScrollView + sticky CTABar), LyraBubble (Rive-Avatar + animierte Speech-Bubble), SlideProgress, CTABar - Slides: Welcome, Privacy (4 Versprechen), Nickname (inline + PATCH /me), DigaChoice (Ja/Nein-Branch), DigaCode (redeem-Endpoint + inline-Errors), Plan (Pro/Legend cards, monthly/yearly toggle, 2 Monate gratis, Härtefall- Mailto), Payment (RevenueCat-Dev-Stub bis Phase-0), Protection (activate + PermissionDeniedSheet-Wiring), Done (animierter Checkmark + Streak-Day-1) - State-Machine in app/onboarding/index.tsx: 9 Slides, DiGA-Branch, Resume- on-launch via slideFromStep(me.onboardingStep) - Routing-gate in (app)/_layout.tsx: step != 'done' → /onboarding - Backend Profile.onboardingStep enum extended: welcome | account | plan | pre_protection | done (+ legacy nickname/block) - Backend diga redeem: step='pre_protection' (NICHT 'done') — User muss noch durch Protection-Slide für NEFilter/VPN-Aktivierung - Locale-Keys (de/en/fr/ar): onboarding.lyra.<slide>.body, .cta_primary, Plan-Tier-Details (3,99/7,99 €/Mo, 39,90/79,90 €/Jahr mit 2 Monaten gratis), Härtefall-Link, DiGA-Code-Errors, Protection-Feat-Descriptions ## Cooldown Auto-Disable Race-Fix Bug: nach Cooldown-Ablauf bleib URL-Filter installiert (NEFilter in iOS- Settings sichtbar als "Läuft..."). Root-cause: `/api/cooldown/status` GET auto-resolved beim ersten expired-Hit; zweiter Call in applyCooldownDisableIfElapsed sah cooldownEndsAt=null → bail → forceDisable nie aufgerufen. - useProtectionState.fetchState: lokalen next.cooldown.endsAt state nutzen statt redundantem API-Call. Atomarer, race-frei. - AppState-Listener-Path unverändert (dort ist es der erste API-Call, kein Race). - lib/protection.forceDisable: console.log für Debug-Visibility. ## iOS NEFilter Robust-Disable (Native) `removeFromPreferences()` alleine ist auf iOS 18+ unzuverlässig — Settings- UI zeigt "Läuft..." obwohl Provider beendet sein sollte. 2-Step-Pattern: 1. loadFromPreferences 2. isEnabled = false + saveToPreferences (stoppt Filter-Daemon) 3. removeFromPreferences (Config-Eintrag aus Settings) Quelle: Apple-Developer-Forums + eigene Empirie. Pattern wird auch in PermissionDeniedSheet's resetUrlFilter genutzt (analog). ## Family Controls jetzt immer aktiv Apple-Entitlement seit 2026-05 für ReBreak approved (TestFlight-akzeptiert). `familyControlsEnabled: true` hart in app.config.ts (kein Env-Var-Gating mehr). "Bald verfügbar"-Placeholder in blocker.tsx entfernt — App-Lock-Toggle ist jetzt voll funktional auf iOS. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
ae92918449
commit
b23bd6d29f
@ -127,12 +127,12 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Spiegelt das Build-Flag aus eas.json (development.env.REBREAK_ENABLE_FAMILY_CONTROLS)
|
// Family Controls Entitlement (denyAppRemoval via ManagedSettings) ist seit
|
||||||
// in den JS-Bundle, damit die Blocker-Page weiß ob die App-Lock (denyAppRemoval via
|
// 2026-05 für ReBreak via Apple-Entitlement-Request approved und in TestFlight-
|
||||||
// FamilyControls) verfügbar ist. In TestFlight/production-Builds ist das (noch) false
|
// sowie production-Builds aktiv. Daher hart auf `true` — keine Build-Flag-Gating
|
||||||
// → dort UI: "Family Controls — coming soon" statt eines kaputten Toggles, der
|
// mehr nötig. Legacy `REBREAK_ENABLE_FAMILY_CONTROLS=1` aus dev-builds wird
|
||||||
// Schutz-Banner bleibt trotzdem positiv ("Schutz komplett", der URL-Filter trägt).
|
// ignoriert (das war Übergangs-Gating vor Apple-Approval).
|
||||||
familyControlsEnabled: process.env.REBREAK_ENABLE_FAMILY_CONTROLS === "1",
|
familyControlsEnabled: true,
|
||||||
apiUrl:
|
apiUrl:
|
||||||
process.env.EXPO_PUBLIC_API_URL ||
|
process.env.EXPO_PUBLIC_API_URL ||
|
||||||
process.env.API_URL ||
|
process.env.API_URL ||
|
||||||
|
|||||||
@ -105,7 +105,7 @@ export default function AppLayout() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session || !me) return;
|
if (!session || !me) return;
|
||||||
if (me.onboardingStep !== 'done') {
|
if (me.onboardingStep !== 'done') {
|
||||||
router.replace('/onboarding/welcome');
|
router.replace('/onboarding');
|
||||||
}
|
}
|
||||||
}, [session, me?.onboardingStep]);
|
}, [session, me?.onboardingStep]);
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import { useProtectionState } from '../../hooks/useProtectionState';
|
|||||||
import { useCustomDomains } from '../../hooks/useCustomDomains';
|
import { useCustomDomains } from '../../hooks/useCustomDomains';
|
||||||
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
|
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
|
||||||
import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime';
|
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';
|
import { useColors, type ColorScheme } from '../../lib/theme';
|
||||||
|
|
||||||
export default function BlockerScreen() {
|
export default function BlockerScreen() {
|
||||||
@ -250,20 +250,7 @@ export default function BlockerScreen() {
|
|||||||
active={urlFilterActive}
|
active={urlFilterActive}
|
||||||
onActivate={handleActivateUrlFilter}
|
onActivate={handleActivateUrlFilter}
|
||||||
/>
|
/>
|
||||||
{FAMILY_CONTROLS_AVAILABLE ? (
|
{Platform.OS === 'android' ? (
|
||||||
<LayerSwitchCard
|
|
||||||
icon="lock-closed-outline"
|
|
||||||
title={t('blocker.layers_app_lock_title')}
|
|
||||||
subtitle={
|
|
||||||
appDeletionLockActive
|
|
||||||
? t('blocker.layers_app_lock_subtitle_active')
|
|
||||||
: t('blocker.layers_app_lock_subtitle_inactive')
|
|
||||||
}
|
|
||||||
active={appDeletionLockActive}
|
|
||||||
onActivate={handleActivateFamilyControls}
|
|
||||||
warning={t('blocker.layers_app_lock_warning')}
|
|
||||||
/>
|
|
||||||
) : Platform.OS === 'android' ? (
|
|
||||||
<LayerSwitchCard
|
<LayerSwitchCard
|
||||||
icon="lock-closed-outline"
|
icon="lock-closed-outline"
|
||||||
title={t('blocker.layers_app_lock_title')}
|
title={t('blocker.layers_app_lock_title')}
|
||||||
@ -277,62 +264,18 @@ export default function BlockerScreen() {
|
|||||||
warning={t('blocker.layers_app_lock_warning')}
|
warning={t('blocker.layers_app_lock_warning')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View
|
<LayerSwitchCard
|
||||||
style={{
|
icon="lock-closed-outline"
|
||||||
backgroundColor: colors.surface,
|
title={t('blocker.layers_app_lock_title')}
|
||||||
borderWidth: 1,
|
subtitle={
|
||||||
borderColor: colors.border,
|
appDeletionLockActive
|
||||||
borderRadius: 16,
|
? t('blocker.layers_app_lock_subtitle_active')
|
||||||
padding: 14,
|
: t('blocker.layers_app_lock_subtitle_inactive')
|
||||||
flexDirection: 'row',
|
}
|
||||||
alignItems: 'center',
|
active={appDeletionLockActive}
|
||||||
gap: 12,
|
onActivate={handleActivateFamilyControls}
|
||||||
}}
|
warning={t('blocker.layers_app_lock_warning')}
|
||||||
>
|
/>
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 12,
|
|
||||||
backgroundColor: colors.surfaceElevated,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="lock-closed-outline" size={20} color={colors.textMuted} />
|
|
||||||
</View>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
|
||||||
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
|
||||||
{t('blocker.layers_app_lock_title')}
|
|
||||||
</Text>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
paddingHorizontal: 7,
|
|
||||||
paddingVertical: 2,
|
|
||||||
borderRadius: 999,
|
|
||||||
backgroundColor: colors.surfaceElevated,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.border,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ fontSize: 10, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
|
|
||||||
{t('blocker.app_lock_coming_soon_badge')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
color: colors.textMuted,
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('blocker.app_lock_coming_soon_desc')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -187,7 +187,7 @@ function RootLayoutInner() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="onboarding/welcome"
|
name="onboarding/index"
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
presentation: 'card',
|
presentation: 'card',
|
||||||
|
|||||||
176
apps/rebreak-native/app/onboarding/index.tsx
Normal file
176
apps/rebreak-native/app/onboarding/index.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useMe, invalidateMe, type OnboardingStep } from '../../hooks/useMe';
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
|
import { WelcomeSlide } from '../../components/onboarding/slides/WelcomeSlide';
|
||||||
|
import { PrivacySlide } from '../../components/onboarding/slides/PrivacySlide';
|
||||||
|
import { NicknameSlide } from '../../components/onboarding/slides/NicknameSlide';
|
||||||
|
import { DigaChoiceSlide } from '../../components/onboarding/slides/DigaChoiceSlide';
|
||||||
|
import { DigaCodeSlide } from '../../components/onboarding/slides/DigaCodeSlide';
|
||||||
|
import { PlanSlide } from '../../components/onboarding/slides/PlanSlide';
|
||||||
|
import { PaymentSlide } from '../../components/onboarding/slides/PaymentSlide';
|
||||||
|
import { ProtectionSlide } from '../../components/onboarding/slides/ProtectionSlide';
|
||||||
|
import { DoneSlide } from '../../components/onboarding/slides/DoneSlide';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duo-Style Onboarding — single route, state-machine intern.
|
||||||
|
*
|
||||||
|
* Linear-Flow:
|
||||||
|
* [1] welcome Lyra stellt sich vor
|
||||||
|
* [2] privacy DSGVO-Versprechen
|
||||||
|
* [3] nickname Inline-Input + PATCH /me + step='account'
|
||||||
|
* [4] diga_choice "Hast du Rezept-Code?" Ja/Nein
|
||||||
|
* │
|
||||||
|
* ├─ Ja → [4a] diga_code (Branch)
|
||||||
|
* │ redeem → step='pre_protection' → SKIP to [7]
|
||||||
|
* │
|
||||||
|
* └─ Nein →
|
||||||
|
* [5] plan Pro/Legend selection → step='plan'
|
||||||
|
* [6] payment DEV-Stub (RevenueCat-Phase pending) → step='pre_protection'
|
||||||
|
* [7] protection activateUrlFilter() → step='done'
|
||||||
|
* [8] done Geschafft-Screen → /(app)
|
||||||
|
*
|
||||||
|
* Resume: routing-gate (app/(app)/_layout.tsx) routet zu /onboarding wenn
|
||||||
|
* step != 'done'. Hier mappt slideFromStep den persistierten step zur Slide.
|
||||||
|
*/
|
||||||
|
type Slide =
|
||||||
|
| 'welcome'
|
||||||
|
| 'privacy'
|
||||||
|
| 'nickname'
|
||||||
|
| 'diga_choice'
|
||||||
|
| 'diga_code'
|
||||||
|
| 'plan'
|
||||||
|
| 'payment'
|
||||||
|
| 'protection'
|
||||||
|
| 'done';
|
||||||
|
|
||||||
|
// Linear-Order für Progress-Indicator + Default-Next-Navigation.
|
||||||
|
// diga_code ist NICHT in der Linear-Liste — wird via Branch erreicht.
|
||||||
|
const LINEAR_ORDER: Slide[] = [
|
||||||
|
'welcome',
|
||||||
|
'privacy',
|
||||||
|
'nickname',
|
||||||
|
'diga_choice',
|
||||||
|
'plan',
|
||||||
|
'payment',
|
||||||
|
'protection',
|
||||||
|
'done',
|
||||||
|
];
|
||||||
|
|
||||||
|
function slideFromStep(step: OnboardingStep): Slide {
|
||||||
|
switch (step) {
|
||||||
|
case 'welcome':
|
||||||
|
return 'welcome';
|
||||||
|
case 'account':
|
||||||
|
return 'diga_choice';
|
||||||
|
case 'plan':
|
||||||
|
return 'payment';
|
||||||
|
case 'pre_protection':
|
||||||
|
return 'protection';
|
||||||
|
case 'done':
|
||||||
|
return 'done';
|
||||||
|
case 'nickname': // legacy
|
||||||
|
return 'nickname';
|
||||||
|
case 'block': // legacy
|
||||||
|
return 'protection';
|
||||||
|
default:
|
||||||
|
return 'welcome';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OnboardingScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { me } = useMe();
|
||||||
|
const initialSlide = useMemo<Slide>(
|
||||||
|
() => (me ? slideFromStep(me.onboardingStep) : 'welcome'),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[me?.id],
|
||||||
|
);
|
||||||
|
const [slide, setSlide] = useState<Slide>(initialSlide);
|
||||||
|
|
||||||
|
function goToLinearNext() {
|
||||||
|
const idx = LINEAR_ORDER.indexOf(slide);
|
||||||
|
if (idx < 0 || idx === LINEAR_ORDER.length - 1) {
|
||||||
|
router.replace('/(app)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSlide(LINEAR_ORDER[idx + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitToApp() {
|
||||||
|
router.replace('/(app)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch-Handler ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function onDigaYes() {
|
||||||
|
setSlide('diga_code');
|
||||||
|
}
|
||||||
|
function onDigaNo() {
|
||||||
|
setSlide('plan');
|
||||||
|
}
|
||||||
|
function onDigaCodeSuccess() {
|
||||||
|
// Backend hat step='pre_protection' gesetzt → skip plan + payment
|
||||||
|
setSlide('protection');
|
||||||
|
}
|
||||||
|
async function onPlanChosen(_tier: 'pro' | 'legend', _billing: 'monthly' | 'yearly') {
|
||||||
|
// TODO: tier + billing an PaymentSlide weiterreichen sobald RevenueCat
|
||||||
|
// wired ist (Phase 0). Bis dahin nur step persistieren.
|
||||||
|
await apiFetch('/api/profile/me/onboarding-step', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { step: 'plan' },
|
||||||
|
}).catch(() => {});
|
||||||
|
invalidateMe();
|
||||||
|
setSlide('payment');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linear-Indizes für Progress (diga_code wird wie diga_choice gezählt) ──────
|
||||||
|
|
||||||
|
const linearIdx = (() => {
|
||||||
|
if (slide === 'diga_code') return LINEAR_ORDER.indexOf('diga_choice');
|
||||||
|
return LINEAR_ORDER.indexOf(slide);
|
||||||
|
})();
|
||||||
|
const current = Math.max(1, linearIdx + 1);
|
||||||
|
const total = LINEAR_ORDER.length;
|
||||||
|
|
||||||
|
// Slide-Dispatch ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
switch (slide) {
|
||||||
|
case 'welcome':
|
||||||
|
return <WelcomeSlide onNext={goToLinearNext} current={current} total={total} />;
|
||||||
|
case 'privacy':
|
||||||
|
return <PrivacySlide onNext={goToLinearNext} current={current} total={total} />;
|
||||||
|
case 'nickname':
|
||||||
|
return <NicknameSlide onNext={goToLinearNext} current={current} total={total} />;
|
||||||
|
case 'diga_choice':
|
||||||
|
return (
|
||||||
|
<DigaChoiceSlide
|
||||||
|
onYes={onDigaYes}
|
||||||
|
onNo={onDigaNo}
|
||||||
|
current={current}
|
||||||
|
total={total}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'diga_code':
|
||||||
|
return (
|
||||||
|
<DigaCodeSlide
|
||||||
|
onSuccess={onDigaCodeSuccess}
|
||||||
|
onBack={() => setSlide('diga_choice')}
|
||||||
|
current={current}
|
||||||
|
total={total}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'plan':
|
||||||
|
return <PlanSlide onChosen={onPlanChosen} current={current} total={total} />;
|
||||||
|
case 'payment':
|
||||||
|
return (
|
||||||
|
<PaymentSlide onCompleted={goToLinearNext} current={current} total={total} />
|
||||||
|
);
|
||||||
|
case 'protection':
|
||||||
|
return (
|
||||||
|
<ProtectionSlide onDone={goToLinearNext} current={current} total={total} />
|
||||||
|
);
|
||||||
|
case 'done':
|
||||||
|
return <DoneSlide onEnter={exitToApp} current={current} total={total} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,404 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Animated,
|
|
||||||
Dimensions,
|
|
||||||
Easing,
|
|
||||||
Text,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from 'react-native';
|
|
||||||
import Svg, { Defs, RadialGradient, Rect, Stop } from 'react-native-svg';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { apiFetch } from '../../lib/api';
|
|
||||||
import { invalidateMe } from '../../hooks/useMe';
|
|
||||||
|
|
||||||
const { height: SH } = Dimensions.get('window');
|
|
||||||
|
|
||||||
type Bullet = {
|
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
titleKey: string;
|
|
||||||
descKey: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const BULLETS: Bullet[] = [
|
|
||||||
{ icon: 'eye-off-outline', titleKey: 'onboarding.welcome.bullet_anon_title', descKey: 'onboarding.welcome.bullet_anon_desc' },
|
|
||||||
{ icon: 'shield-checkmark-outline', titleKey: 'onboarding.welcome.bullet_protect_title', descKey: 'onboarding.welcome.bullet_protect_desc' },
|
|
||||||
{ icon: 'people-outline', titleKey: 'onboarding.welcome.bullet_community_title', descKey: 'onboarding.welcome.bullet_community_desc' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function OnboardingWelcomeScreen() {
|
|
||||||
const router = useRouter();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
// Animation values — Spiegel zur LandingScreen-Choreografie für visuelle Kohärenz
|
|
||||||
const glowOpacity = useRef(new Animated.Value(0.5)).current;
|
|
||||||
const haloOpacity = useRef(new Animated.Value(0)).current;
|
|
||||||
const haloScale = useRef(new Animated.Value(0.6)).current;
|
|
||||||
|
|
||||||
const heroOpacity = useRef(new Animated.Value(0)).current;
|
|
||||||
const heroScale = useRef(new Animated.Value(0.85)).current;
|
|
||||||
const heroPulse = useRef(new Animated.Value(1)).current;
|
|
||||||
|
|
||||||
const headlineOpacity = useRef(new Animated.Value(0)).current;
|
|
||||||
const headlineTranslate = useRef(new Animated.Value(10)).current;
|
|
||||||
|
|
||||||
const bulletsOpacity = useRef([0, 0, 0].map(() => new Animated.Value(0))).current;
|
|
||||||
const bulletsTranslate = useRef([0, 0, 0].map(() => new Animated.Value(14))).current;
|
|
||||||
|
|
||||||
const privacyOpacity = useRef(new Animated.Value(0)).current;
|
|
||||||
const privacyTranslate = useRef(new Animated.Value(14)).current;
|
|
||||||
|
|
||||||
const ctaOpacity = useRef(new Animated.Value(0)).current;
|
|
||||||
const ctaTranslate = useRef(new Animated.Value(14)).current;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
Animated.loop(
|
|
||||||
Animated.sequence([
|
|
||||||
Animated.timing(glowOpacity, { toValue: 0.9, duration: 2200, useNativeDriver: true }),
|
|
||||||
Animated.timing(glowOpacity, { toValue: 0.5, duration: 2200, useNativeDriver: true }),
|
|
||||||
]),
|
|
||||||
).start();
|
|
||||||
|
|
||||||
const ease = (toValue: number, duration: number) => ({
|
|
||||||
toValue,
|
|
||||||
duration,
|
|
||||||
useNativeDriver: true,
|
|
||||||
easing: Easing.out(Easing.cubic),
|
|
||||||
});
|
|
||||||
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(haloOpacity, ease(1, 900)),
|
|
||||||
Animated.timing(haloScale, ease(1, 900)),
|
|
||||||
]).start();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(heroOpacity, ease(1, 650)),
|
|
||||||
Animated.spring(heroScale, { toValue: 1, useNativeDriver: true, friction: 6, tension: 80 }),
|
|
||||||
]).start();
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
Animated.loop(
|
|
||||||
Animated.sequence([
|
|
||||||
Animated.timing(heroPulse, { toValue: 1.06, duration: 1400, useNativeDriver: true }),
|
|
||||||
Animated.timing(heroPulse, { toValue: 1, duration: 1400, useNativeDriver: true }),
|
|
||||||
]),
|
|
||||||
).start();
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(headlineOpacity, ease(1, 600)),
|
|
||||||
Animated.timing(headlineTranslate, ease(0, 600)),
|
|
||||||
]).start();
|
|
||||||
}, 700);
|
|
||||||
|
|
||||||
BULLETS.forEach((_, i) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(bulletsOpacity[i], ease(1, 450)),
|
|
||||||
Animated.timing(bulletsTranslate[i], ease(0, 450)),
|
|
||||||
]).start();
|
|
||||||
}, 1100 + i * 180);
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(privacyOpacity, ease(1, 600)),
|
|
||||||
Animated.timing(privacyTranslate, ease(0, 600)),
|
|
||||||
]).start();
|
|
||||||
}, 1700);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(ctaOpacity, ease(1, 600)),
|
|
||||||
Animated.timing(ctaTranslate, ease(0, 600)),
|
|
||||||
]).start();
|
|
||||||
}, 1950);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function handleStart() {
|
|
||||||
if (submitting) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await apiFetch('/api/profile/me/onboarding-step', {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: { step: 'nickname' },
|
|
||||||
});
|
|
||||||
invalidateMe();
|
|
||||||
router.replace('/profile/edit');
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[onboarding/welcome] failed to advance step:', e);
|
|
||||||
// Sackgasse-Verhalten: Step bleibt 'welcome', User kann nochmal tappen.
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{ flex: 1, backgroundColor: '#0f172a', overflow: 'hidden' }}>
|
|
||||||
{/* Atmender Top-Glow */}
|
|
||||||
<Animated.View
|
|
||||||
pointerEvents="none"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: SH * 0.5,
|
|
||||||
opacity: glowOpacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Svg width="100%" height="100%">
|
|
||||||
<Defs>
|
|
||||||
<RadialGradient id="welcomeGlow" cx="50%" cy="0%" rx="70%" ry="100%" fx="50%" fy="0%">
|
|
||||||
<Stop offset="0%" stopColor="#1e3a8a" stopOpacity="1" />
|
|
||||||
<Stop offset="100%" stopColor="#1e3a8a" stopOpacity="0" />
|
|
||||||
</RadialGradient>
|
|
||||||
</Defs>
|
|
||||||
<Rect width="100%" height="100%" fill="url(#welcomeGlow)" />
|
|
||||||
</Svg>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Center indigo halo */}
|
|
||||||
<Animated.View
|
|
||||||
pointerEvents="none"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
opacity: haloOpacity,
|
|
||||||
transform: [{ scale: haloScale }],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Svg width="100%" height="100%">
|
|
||||||
<Defs>
|
|
||||||
<RadialGradient id="welcomeHalo" cx="50%" cy="30%" rx="55%" ry="55%">
|
|
||||||
<Stop offset="0%" stopColor="#6366f1" stopOpacity="0.22" />
|
|
||||||
<Stop offset="100%" stopColor="#6366f1" stopOpacity="0" />
|
|
||||||
</RadialGradient>
|
|
||||||
</Defs>
|
|
||||||
<Rect width="100%" height="100%" fill="url(#welcomeHalo)" />
|
|
||||||
</Svg>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
paddingTop: insets.top + 24,
|
|
||||||
paddingBottom: insets.bottom + 36,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Hero-Icon */}
|
|
||||||
<View style={{ alignItems: 'center', marginTop: 12 }}>
|
|
||||||
<Animated.View
|
|
||||||
style={{
|
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
borderRadius: 28,
|
|
||||||
backgroundColor: 'rgba(99,102,241,0.18)',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(99,102,241,0.35)',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
opacity: heroOpacity,
|
|
||||||
transform: [
|
|
||||||
{ scale: Animated.multiply(heroScale, heroPulse) as any },
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="shield-checkmark" size={66} color="#a5b4fc" />
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Headline + Subhead */}
|
|
||||||
<Animated.View
|
|
||||||
style={{
|
|
||||||
marginTop: 28,
|
|
||||||
opacity: headlineOpacity,
|
|
||||||
transform: [{ translateY: headlineTranslate }],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontFamily: 'Nunito_800ExtraBold',
|
|
||||||
fontSize: 28,
|
|
||||||
lineHeight: 34,
|
|
||||||
color: '#ffffff',
|
|
||||||
textAlign: 'center',
|
|
||||||
letterSpacing: -0.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('onboarding.welcome.headline')}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
fontSize: 15,
|
|
||||||
lineHeight: 22,
|
|
||||||
color: 'rgba(255,255,255,0.72)',
|
|
||||||
textAlign: 'center',
|
|
||||||
marginTop: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('onboarding.welcome.subhead')}
|
|
||||||
</Text>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Mission-Bullets */}
|
|
||||||
<View style={{ marginTop: 28, gap: 12 }}>
|
|
||||||
{BULLETS.map((b, i) => (
|
|
||||||
<Animated.View
|
|
||||||
key={b.titleKey}
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: 14,
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.08)',
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 14,
|
|
||||||
opacity: bulletsOpacity[i],
|
|
||||||
transform: [{ translateY: bulletsTranslate[i] }],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: 10,
|
|
||||||
backgroundColor: 'rgba(99,102,241,0.18)',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name={b.icon} size={20} color="#a5b4fc" />
|
|
||||||
</View>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontFamily: 'Nunito_700Bold',
|
|
||||||
fontSize: 15,
|
|
||||||
color: '#ffffff',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t(b.titleKey)}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
fontSize: 13,
|
|
||||||
lineHeight: 19,
|
|
||||||
color: 'rgba(255,255,255,0.65)',
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t(b.descKey)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* DSGVO-Box */}
|
|
||||||
<Animated.View
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: 12,
|
|
||||||
marginTop: 16,
|
|
||||||
paddingVertical: 14,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
borderRadius: 14,
|
|
||||||
backgroundColor: 'rgba(34,197,94,0.10)',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(34,197,94,0.30)',
|
|
||||||
opacity: privacyOpacity,
|
|
||||||
transform: [{ translateY: privacyTranslate }],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="lock-closed" size={18} color="#86efac" style={{ marginTop: 2 }} />
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontFamily: 'Nunito_700Bold',
|
|
||||||
fontSize: 13,
|
|
||||||
color: '#bbf7d0',
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('onboarding.welcome.privacy_label')}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
fontSize: 13,
|
|
||||||
lineHeight: 19,
|
|
||||||
color: 'rgba(255,255,255,0.82)',
|
|
||||||
marginTop: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('onboarding.welcome.privacy_body')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
<View style={{ flex: 1 }} />
|
|
||||||
|
|
||||||
{/* CTA */}
|
|
||||||
<Animated.View
|
|
||||||
style={{
|
|
||||||
opacity: ctaOpacity,
|
|
||||||
transform: [{ translateY: ctaTranslate }],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleStart}
|
|
||||||
disabled={submitting}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#6366f1',
|
|
||||||
borderRadius: 16,
|
|
||||||
paddingVertical: 16,
|
|
||||||
alignItems: 'center',
|
|
||||||
opacity: submitting ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontFamily: 'Nunito_700Bold',
|
|
||||||
fontSize: 16,
|
|
||||||
color: '#ffffff',
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{submitting ? t('onboarding.welcome.cta_loading') : t('onboarding.welcome.cta')}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
fontSize: 11,
|
|
||||||
lineHeight: 16,
|
|
||||||
color: 'rgba(255,255,255,0.45)',
|
|
||||||
textAlign: 'center',
|
|
||||||
marginTop: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('onboarding.welcome.next_hint')}
|
|
||||||
</Text>
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
87
apps/rebreak-native/components/onboarding/CTABar.tsx
Normal file
87
apps/rebreak-native/components/onboarding/CTABar.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sticky Bottom-Bar mit Primary-CTA (+ optional Secondary darüber).
|
||||||
|
* Layout-Pattern aus Duolingo: voll-breit, padding, SafeArea-aware.
|
||||||
|
*/
|
||||||
|
export function CTABar({
|
||||||
|
primaryLabel,
|
||||||
|
onPrimary,
|
||||||
|
primaryDisabled,
|
||||||
|
primaryLoading,
|
||||||
|
secondaryLabel,
|
||||||
|
onSecondary,
|
||||||
|
}: {
|
||||||
|
primaryLabel: string;
|
||||||
|
onPrimary: () => void;
|
||||||
|
primaryDisabled?: boolean;
|
||||||
|
primaryLoading?: boolean;
|
||||||
|
secondaryLabel?: string;
|
||||||
|
onSecondary?: () => void;
|
||||||
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const disabled = !!primaryDisabled || !!primaryLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: insets.bottom + 12,
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPrimary}
|
||||||
|
disabled={disabled}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.brandOrange,
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{primaryLoading ? (
|
||||||
|
<ActivityIndicator color="#ffffff" />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#ffffff',
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{primaryLabel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{secondaryLabel && onSecondary ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onSecondary}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
style={{ paddingVertical: 10, alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{secondaryLabel}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
apps/rebreak-native/components/onboarding/LyraBubble.tsx
Normal file
83
apps/rebreak-native/components/onboarding/LyraBubble.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Animated, Easing, Text, View } from 'react-native';
|
||||||
|
import { RiveAvatar, type Emotion } from '../RiveAvatar';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lyra-Mascot (animiertes Rive-Avatar) links + Speech-Bubble rechts.
|
||||||
|
* Fade+slide-in beim Mount und bei text-change (key-prop verwenden für Re-Animate).
|
||||||
|
*
|
||||||
|
* Layout entspricht Duolingo's Duo-Speech-Pattern: Avatar quadratisch links,
|
||||||
|
* Bubble organisch rechts mit "Tail" zum Avatar zeigend.
|
||||||
|
*/
|
||||||
|
export function LyraBubble({
|
||||||
|
text,
|
||||||
|
emotion = 'idle',
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
/** Lyra-Emotion fürs Rive (idle, happy, thinking, empathy). */
|
||||||
|
emotion?: Emotion;
|
||||||
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
|
const opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const translateX = useRef(new Animated.Value(-12)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
opacity.setValue(0);
|
||||||
|
translateX.setValue(-12);
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(opacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 400,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
}),
|
||||||
|
Animated.spring(translateX, {
|
||||||
|
toValue: 0,
|
||||||
|
useNativeDriver: true,
|
||||||
|
friction: 7,
|
||||||
|
tension: 80,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}, [text, opacity, translateX]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 12 }}>
|
||||||
|
<View style={{ marginTop: 4 }}>
|
||||||
|
<RiveAvatar emotion={emotion} size="md" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
opacity,
|
||||||
|
transform: [{ translateX }],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Speech-Bubble */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
// Tail würde via custom-path gerendert — hier reicht der reine
|
||||||
|
// Bubble-Look ohne Tail (cleaner auf RN, Duolingo macht's auf iOS
|
||||||
|
// auch ohne harten Pixel-Tail).
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 23,
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { ScrollView, View } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
import { SlideProgress } from './SlideProgress';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout-Wrapper für alle Onboarding-Slides.
|
||||||
|
*
|
||||||
|
* ┌──────────────────────────────┐
|
||||||
|
* │ [Top-Padding + SafeArea] │
|
||||||
|
* │ ────── Progress-Bar ───── │
|
||||||
|
* │ │
|
||||||
|
* │ <children — ScrollView> │
|
||||||
|
* │ │
|
||||||
|
* │ ───── CTABar (sticky) ───── │
|
||||||
|
* └──────────────────────────────┘
|
||||||
|
*
|
||||||
|
* `cta` wird IM Shell gerendert (sticky-bottom + SafeArea). Slides müssen
|
||||||
|
* ihre Inhalte als `children` reinpacken (= scrollbarer Content-Bereich).
|
||||||
|
*/
|
||||||
|
export function OnboardingShell({
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
children,
|
||||||
|
cta,
|
||||||
|
}: {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
children: ReactNode;
|
||||||
|
cta: ReactNode;
|
||||||
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingTop: insets.top + 12,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SlideProgress current={current} total={total} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
padding: 20,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: 24,
|
||||||
|
flexGrow: 1,
|
||||||
|
}}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{cta}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
apps/rebreak-native/components/onboarding/SlideProgress.tsx
Normal file
58
apps/rebreak-native/components/onboarding/SlideProgress.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Animated, Easing, View } from 'react-native';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duolingo-style schmaler Progress-Bar oben.
|
||||||
|
* Smooth animiert wenn `current` sich ändert.
|
||||||
|
*/
|
||||||
|
export function SlideProgress({
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
/** 1-basiert: aktuelle Slide-Nummer */
|
||||||
|
current: number;
|
||||||
|
/** Total Slide-Anzahl */
|
||||||
|
total: number;
|
||||||
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
|
const widthPct = useRef(new Animated.Value(clamp01(current / total))).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.timing(widthPct, {
|
||||||
|
toValue: clamp01(current / total),
|
||||||
|
duration: 350,
|
||||||
|
useNativeDriver: false,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
}).start();
|
||||||
|
}, [current, total, widthPct]);
|
||||||
|
|
||||||
|
const widthInterpolated = widthPct.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: ['0%', '100%'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: widthInterpolated,
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(v: number): number {
|
||||||
|
return Math.max(0, Math.min(1, v));
|
||||||
|
}
|
||||||
@ -0,0 +1,127 @@
|
|||||||
|
import { Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useColors } from '../../../lib/theme';
|
||||||
|
import { OnboardingShell } from '../OnboardingShell';
|
||||||
|
import { LyraBubble } from '../LyraBubble';
|
||||||
|
|
||||||
|
export function DigaChoiceSlide({
|
||||||
|
onYes,
|
||||||
|
onNo,
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
onYes: () => void;
|
||||||
|
onNo: () => void;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingShell
|
||||||
|
current={current}
|
||||||
|
total={total}
|
||||||
|
cta={
|
||||||
|
// Eigenes Layout statt CTABar — zwei gleichwertige Optionen
|
||||||
|
<ChoiceBar onYes={onYes} onNo={onNo} t={t} colors={colors} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LyraBubble text={t('onboarding.lyra.diga_choice.body')} emotion="thinking" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 24,
|
||||||
|
padding: 14,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="information-circle-outline" size={18} color={colors.brandOrange} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 19,
|
||||||
|
color: colors.textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('onboarding.diga_choice.hint')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChoiceBar({
|
||||||
|
onYes,
|
||||||
|
onNo,
|
||||||
|
t,
|
||||||
|
colors,
|
||||||
|
}: {
|
||||||
|
onYes: () => void;
|
||||||
|
onNo: () => void;
|
||||||
|
t: ReturnType<typeof useTranslation>['t'];
|
||||||
|
colors: import('../../../lib/theme').ColorScheme;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 24,
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onYes}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.brandOrange,
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#ffffff',
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('onboarding.diga_choice.cta_yes')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onNo}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('onboarding.diga_choice.cta_no')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,151 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Text, TextInput, View } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useColors } from '../../../lib/theme';
|
||||||
|
import { apiFetch } from '../../../lib/api';
|
||||||
|
import { invalidateMe } from '../../../hooks/useMe';
|
||||||
|
import { OnboardingShell } from '../OnboardingShell';
|
||||||
|
import { LyraBubble } from '../LyraBubble';
|
||||||
|
import { CTABar } from '../CTABar';
|
||||||
|
|
||||||
|
type RedeemError = 'not_found' | 'already_used' | 'expired' | 'invalid_input';
|
||||||
|
|
||||||
|
export function DigaCodeSlide({
|
||||||
|
onSuccess,
|
||||||
|
onBack,
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
/** Wird gerufen wenn der Code erfolgreich eingelöst wurde. Backend hat dann
|
||||||
|
* plan='legend' + onboarding_step='pre_protection' gesetzt. */
|
||||||
|
onSuccess: () => void;
|
||||||
|
/** Zurück zum DigaChoiceSlide (User hat sich's anders überlegt). */
|
||||||
|
onBack: () => void;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [errorKey, setErrorKey] = useState<RedeemError | null>(null);
|
||||||
|
|
||||||
|
const trimmed = code.trim();
|
||||||
|
const valid = trimmed.length >= 6;
|
||||||
|
|
||||||
|
async function redeem() {
|
||||||
|
if (!valid || submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setErrorKey(null);
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/onboarding/redeem-diga-code', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { code: trimmed },
|
||||||
|
});
|
||||||
|
invalidateMe();
|
||||||
|
onSuccess();
|
||||||
|
} catch (e: any) {
|
||||||
|
// apiFetch wirft Error mit `code` Feld bei strukturierten 4xx
|
||||||
|
const code = (e?.code ?? e?.data?.error) as RedeemError | undefined;
|
||||||
|
setErrorKey(code ?? 'not_found');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingShell
|
||||||
|
current={current}
|
||||||
|
total={total}
|
||||||
|
cta={
|
||||||
|
<CTABar
|
||||||
|
primaryLabel={t('onboarding.diga_code.cta_primary')}
|
||||||
|
onPrimary={redeem}
|
||||||
|
primaryDisabled={!valid}
|
||||||
|
primaryLoading={submitting}
|
||||||
|
secondaryLabel={t('onboarding.diga_code.cta_secondary')}
|
||||||
|
onSecondary={onBack}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LyraBubble text={t('onboarding.lyra.diga_code.body')} emotion="thinking" />
|
||||||
|
|
||||||
|
<View style={{ marginTop: 24 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('onboarding.diga_code.label')}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
autoFocus
|
||||||
|
value={code}
|
||||||
|
onChangeText={(v) => {
|
||||||
|
setCode(v.toUpperCase());
|
||||||
|
if (errorKey) setErrorKey(null);
|
||||||
|
}}
|
||||||
|
onSubmitEditing={redeem}
|
||||||
|
placeholder="REBREAK-XXXX-XXX"
|
||||||
|
placeholderTextColor="#a3a3a3"
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoCorrect={false}
|
||||||
|
maxLength={32}
|
||||||
|
returnKeyType="done"
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 22,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
letterSpacing: 1,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: errorKey ? colors.error : valid ? colors.brandOrange : 'transparent',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{errorKey ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 10,
|
||||||
|
paddingHorizontal: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="alert-circle" size={16} color={colors.error} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.error,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(`onboarding.diga_code.error_${errorKey}`)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('onboarding.diga_code.hint')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Animated, Easing, Text, View } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useColors } from '../../../lib/theme';
|
||||||
|
import { OnboardingShell } from '../OnboardingShell';
|
||||||
|
import { LyraBubble } from '../LyraBubble';
|
||||||
|
import { CTABar } from '../CTABar';
|
||||||
|
|
||||||
|
export function DoneSlide({
|
||||||
|
onEnter,
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
onEnter: () => void;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const scale = useRef(new Animated.Value(0.6)).current;
|
||||||
|
const opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.spring(scale, { toValue: 1, useNativeDriver: true, friction: 5, tension: 90 }),
|
||||||
|
Animated.timing(opacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 500,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingShell
|
||||||
|
current={current}
|
||||||
|
total={total}
|
||||||
|
cta={<CTABar primaryLabel={t('onboarding.done.cta_primary')} onPrimary={onEnter} />}
|
||||||
|
>
|
||||||
|
<LyraBubble text={t('onboarding.lyra.done.body')} emotion="happy" />
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
marginTop: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity,
|
||||||
|
transform: [{ scale }],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 60,
|
||||||
|
backgroundColor: 'rgba(34,197,94,0.14)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: 'rgba(34,197,94,0.40)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="checkmark" size={64} color={colors.success} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 24,
|
||||||
|
fontFamily: 'Nunito_800ExtraBold',
|
||||||
|
fontSize: 26,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: 'center',
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('onboarding.done.headline')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 22,
|
||||||
|
color: colors.textMuted,
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('onboarding.done.subhead')}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Alert, Text, TextInput, View } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useColors } from '../../../lib/theme';
|
||||||
|
import { apiFetch } from '../../../lib/api';
|
||||||
|
import { invalidateMe, useMe } from '../../../hooks/useMe';
|
||||||
|
import { OnboardingShell } from '../OnboardingShell';
|
||||||
|
import { LyraBubble } from '../LyraBubble';
|
||||||
|
import { CTABar } from '../CTABar';
|
||||||
|
|
||||||
|
export function NicknameSlide({
|
||||||
|
onNext,
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
onNext: () => void;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const { me } = useMe();
|
||||||
|
const [nickname, setNickname] = useState(me?.nickname ?? '');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const inputRef = useRef<TextInput | null>(null);
|
||||||
|
|
||||||
|
const trimmed = nickname.trim();
|
||||||
|
const valid = trimmed.length >= 2;
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!valid || saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/auth/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { nickname: trimmed },
|
||||||
|
});
|
||||||
|
await apiFetch('/api/profile/me/onboarding-step', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { step: 'account' },
|
||||||
|
}).catch(() => {});
|
||||||
|
invalidateMe();
|
||||||
|
onNext();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
Alert.alert(
|
||||||
|
t('common.error'),
|
||||||
|
e instanceof Error ? e.message : t('common.unknown_error'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingShell
|
||||||
|
current={current}
|
||||||
|
total={total}
|
||||||
|
cta={
|
||||||
|
<CTABar
|
||||||
|
primaryLabel={t('onboarding.nickname.cta_primary')}
|
||||||
|
onPrimary={save}
|
||||||
|
primaryDisabled={!valid}
|
||||||
|
primaryLoading={saving}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LyraBubble text={t('onboarding.lyra.nickname.body')} emotion="happy" />
|
||||||
|
|
||||||
|
<View style={{ marginTop: 24 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('onboarding.nickname.label')}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
autoFocus
|
||||||
|
value={nickname}
|
||||||
|
onChangeText={setNickname}
|
||||||
|
onSubmitEditing={save}
|
||||||
|
placeholder={t('onboarding.nickname.placeholder')}
|
||||||
|
placeholderTextColor="#a3a3a3"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
maxLength={32}
|
||||||
|
returnKeyType="done"
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 22,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: valid ? colors.brandOrange : 'transparent',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('onboarding.nickname.hint')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useColors } from '../../../lib/theme';
|
||||||
|
import { apiFetch } from '../../../lib/api';
|
||||||
|
import { invalidateMe } from '../../../hooks/useMe';
|
||||||
|
import { OnboardingShell } from '../OnboardingShell';
|
||||||
|
import { LyraBubble } from '../LyraBubble';
|
||||||
|
import { CTABar } from '../CTABar';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEV-STUB für Stage Payment.
|
||||||
|
*
|
||||||
|
* Production-Variante (Phase 0 vom Strategist-Plan):
|
||||||
|
* - iOS: RevenueCat-Purchase-Sheet öffnet sich für 14-Tage-Pro-Trial
|
||||||
|
* - Web/Android: Stripe-Checkout
|
||||||
|
*
|
||||||
|
* Aktueller Dev-Stub: zeigt "RevenueCat kommt"-Erklärung + Button der
|
||||||
|
* step='pre_protection' im Backend setzt + zum nächsten Slide weiterleitet.
|
||||||
|
* Sichtbar als Dev-Banner damit niemand denkt das wäre die finale UX.
|
||||||
|
*/
|
||||||
|
export function PaymentSlide({
|
||||||
|
onCompleted,
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
onCompleted: () => void;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
|
||||||
|
async function devSkipPayment() {
|
||||||
|
await apiFetch('/api/profile/me/onboarding-step', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { step: 'pre_protection' },
|
||||||
|
}).catch(() => {});
|
||||||
|
invalidateMe();
|
||||||
|
onCompleted();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingShell
|
||||||
|
current={current}
|
||||||
|
total={total}
|
||||||
|
cta={
|
||||||
|
<CTABar
|
||||||
|
primaryLabel={t('onboarding.payment.cta_dev_skip')}
|
||||||
|
onPrimary={devSkipPayment}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LyraBubble text={t('onboarding.lyra.payment.body')} emotion="thinking" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 24,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: 'rgba(245,158,11,0.08)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(245,158,11,0.30)',
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Ionicons name="construct-outline" size={18} color={colors.warning} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
fontSize: 12,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
color: colors.warning,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('onboarding.payment.dev_label')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 19,
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('onboarding.payment.dev_body')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
346
apps/rebreak-native/components/onboarding/slides/PlanSlide.tsx
Normal file
346
apps/rebreak-native/components/onboarding/slides/PlanSlide.tsx
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Linking, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useColors } from '../../../lib/theme';
|
||||||
|
import { OnboardingShell } from '../OnboardingShell';
|
||||||
|
import { LyraBubble } from '../LyraBubble';
|
||||||
|
import { CTABar } from '../CTABar';
|
||||||
|
|
||||||
|
type Tier = 'pro' | 'legend';
|
||||||
|
type Billing = 'monthly' | 'yearly';
|
||||||
|
|
||||||
|
const HARDSHIP_EMAIL = 'support@rebreak.org';
|
||||||
|
|
||||||
|
export function PlanSlide({
|
||||||
|
onChosen,
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
onChosen: (tier: Tier, billing: Billing) => void;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const [tier, setTier] = useState<Tier>('pro');
|
||||||
|
const [billing, setBilling] = useState<Billing>('yearly');
|
||||||
|
|
||||||
|
function openHardshipMail() {
|
||||||
|
const subject = encodeURIComponent('Härtefall — rebreak');
|
||||||
|
Linking.openURL(`mailto:${HARDSHIP_EMAIL}?subject=${subject}`).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingShell
|
||||||
|
current={current}
|
||||||
|
total={total}
|
||||||
|
cta={
|
||||||
|
<CTABar
|
||||||
|
primaryLabel={
|
||||||
|
tier === 'pro'
|
||||||
|
? t('onboarding.plan.cta_trial')
|
||||||
|
: t('onboarding.plan.cta_legend')
|
||||||
|
}
|
||||||
|
onPrimary={() => onChosen(tier, billing)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LyraBubble text={t('onboarding.lyra.plan.body')} emotion="happy" />
|
||||||
|
|
||||||
|
<BillingToggle billing={billing} setBilling={setBilling} t={t} colors={colors} />
|
||||||
|
|
||||||
|
<View style={{ marginTop: 14, gap: 12 }}>
|
||||||
|
<PlanCard
|
||||||
|
selected={tier === 'pro'}
|
||||||
|
onSelect={() => setTier('pro')}
|
||||||
|
colors={colors}
|
||||||
|
badge={t('onboarding.plan.tier_pro_badge')}
|
||||||
|
title="Pro"
|
||||||
|
price={
|
||||||
|
billing === 'yearly'
|
||||||
|
? t('onboarding.plan.tier_pro_price_yearly')
|
||||||
|
: t('onboarding.plan.tier_pro_price_monthly')
|
||||||
|
}
|
||||||
|
anchorPrice={billing === 'yearly' ? t('onboarding.plan.tier_pro_anchor_yearly') : null}
|
||||||
|
totalPrice={billing === 'yearly' ? t('onboarding.plan.tier_pro_total_yearly') : null}
|
||||||
|
subline={
|
||||||
|
billing === 'yearly'
|
||||||
|
? t('onboarding.plan.tier_pro_subline_yearly')
|
||||||
|
: t('onboarding.plan.tier_pro_subline_monthly')
|
||||||
|
}
|
||||||
|
features={[
|
||||||
|
t('onboarding.plan.feat_blocklist'),
|
||||||
|
t('onboarding.plan.feat_lyra'),
|
||||||
|
t('onboarding.plan.feat_mail'),
|
||||||
|
t('onboarding.plan.feat_community'),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<PlanCard
|
||||||
|
selected={tier === 'legend'}
|
||||||
|
onSelect={() => setTier('legend')}
|
||||||
|
colors={colors}
|
||||||
|
badge={null}
|
||||||
|
title="Legend"
|
||||||
|
price={
|
||||||
|
billing === 'yearly'
|
||||||
|
? t('onboarding.plan.tier_legend_price_yearly')
|
||||||
|
: t('onboarding.plan.tier_legend_price_monthly')
|
||||||
|
}
|
||||||
|
anchorPrice={billing === 'yearly' ? t('onboarding.plan.tier_legend_anchor_yearly') : null}
|
||||||
|
totalPrice={billing === 'yearly' ? t('onboarding.plan.tier_legend_total_yearly') : null}
|
||||||
|
subline={
|
||||||
|
billing === 'yearly'
|
||||||
|
? t('onboarding.plan.tier_legend_subline_yearly')
|
||||||
|
: t('onboarding.plan.tier_legend_subline_monthly')
|
||||||
|
}
|
||||||
|
features={[
|
||||||
|
t('onboarding.plan.feat_legend_all_pro'),
|
||||||
|
t('onboarding.plan.feat_legend_multi_device'),
|
||||||
|
t('onboarding.plan.feat_legend_voice'),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 18,
|
||||||
|
color: colors.textMuted,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('onboarding.plan.disclaimer')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Härtefall-Mailto — manuell, kein Auto-Sozial-Rabatt (Strategist-Verdict) */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={openHardshipMail}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={{ marginTop: 8, paddingVertical: 6, alignSelf: 'center' }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.brandOrange,
|
||||||
|
textDecorationLine: 'underline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('onboarding.plan.hardship_link')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Billing-Toggle ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function BillingToggle({
|
||||||
|
billing,
|
||||||
|
setBilling,
|
||||||
|
t,
|
||||||
|
colors,
|
||||||
|
}: {
|
||||||
|
billing: Billing;
|
||||||
|
setBilling: (b: Billing) => void;
|
||||||
|
t: ReturnType<typeof useTranslation>['t'];
|
||||||
|
colors: import('../../../lib/theme').ColorScheme;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 22,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 4,
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleButton
|
||||||
|
active={billing === 'monthly'}
|
||||||
|
onPress={() => setBilling('monthly')}
|
||||||
|
label={t('onboarding.plan.billing_monthly')}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
<ToggleButton
|
||||||
|
active={billing === 'yearly'}
|
||||||
|
onPress={() => setBilling('yearly')}
|
||||||
|
label={t('onboarding.plan.billing_yearly')}
|
||||||
|
savingsBadge={t('onboarding.plan.billing_savings')}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleButton({
|
||||||
|
active,
|
||||||
|
onPress,
|
||||||
|
label,
|
||||||
|
savingsBadge,
|
||||||
|
colors,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
savingsBadge?: string;
|
||||||
|
colors: import('../../../lib/theme').ColorScheme;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: active ? colors.bg : 'transparent',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
fontSize: 14,
|
||||||
|
color: active ? colors.text : colors.textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{savingsBadge ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
color: active ? colors.success : colors.textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{savingsBadge}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Plan-Card ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PlanCard({
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
colors,
|
||||||
|
badge,
|
||||||
|
title,
|
||||||
|
price,
|
||||||
|
anchorPrice,
|
||||||
|
totalPrice,
|
||||||
|
subline,
|
||||||
|
features,
|
||||||
|
}: {
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
colors: import('../../../lib/theme').ColorScheme;
|
||||||
|
badge: string | null;
|
||||||
|
title: string;
|
||||||
|
/** Hauptpreis (z.B. "3,99 € / Monat" oder "3,33 € / Monat"). */
|
||||||
|
price: string;
|
||||||
|
/** Durchgestrichener Original-Annual-Preis (z.B. "47,88 €"). Nur bei Yearly. */
|
||||||
|
anchorPrice: string | null;
|
||||||
|
/** Total-Annual-Preis als Zusatzzeile (z.B. "39,90 € / Jahr"). Nur bei Yearly. */
|
||||||
|
totalPrice: string | null;
|
||||||
|
subline: string;
|
||||||
|
features: string[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onSelect}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: selected ? colors.brandOrange : colors.border,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 22, color: colors.text }}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{badge ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: colors.brandOrange,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 10, color: '#ffffff', letterSpacing: 0.6 }}>
|
||||||
|
{badge.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: selected ? colors.brandOrange : colors.border,
|
||||||
|
backgroundColor: selected ? colors.brandOrange : 'transparent',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected ? <Ionicons name="checkmark" size={14} color="#ffffff" /> : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 10 }}>
|
||||||
|
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: colors.text }}>
|
||||||
|
{price}
|
||||||
|
</Text>
|
||||||
|
{anchorPrice ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.textMuted,
|
||||||
|
textDecorationLine: 'line-through',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{anchorPrice}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{totalPrice ? (
|
||||||
|
<Text style={{ fontFamily: 'Nunito_400Regular', fontSize: 12, color: colors.textMuted }}>
|
||||||
|
{totalPrice}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text style={{ fontFamily: 'Nunito_400Regular', fontSize: 12, color: colors.textMuted }}>
|
||||||
|
{subline}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={{ marginTop: 4, gap: 6 }}>
|
||||||
|
{features.map((f) => (
|
||||||
|
<View key={f} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Ionicons name="checkmark-circle" size={14} color={colors.success} />
|
||||||
|
<Text style={{ flex: 1, fontFamily: 'Nunito_400Regular', fontSize: 13, color: colors.text }}>
|
||||||
|
{f}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useColors } from '../../../lib/theme';
|
||||||
|
import { OnboardingShell } from '../OnboardingShell';
|
||||||
|
import { LyraBubble } from '../LyraBubble';
|
||||||
|
import { CTABar } from '../CTABar';
|
||||||
|
|
||||||
|
export function PrivacySlide({
|
||||||
|
onNext,
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
onNext: () => void;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingShell
|
||||||
|
current={current}
|
||||||
|
total={total}
|
||||||
|
cta={<CTABar primaryLabel={t('onboarding.privacy.cta_primary')} onPrimary={onNext} />}
|
||||||
|
>
|
||||||
|
<LyraBubble text={t('onboarding.lyra.privacy.body')} emotion="empathy" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 28,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PromiseRow
|
||||||
|
icon="eye-off-outline"
|
||||||
|
text={t('onboarding.privacy.promise_alias')}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
<PromiseRow
|
||||||
|
icon="lock-closed-outline"
|
||||||
|
text={t('onboarding.privacy.promise_minimal')}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
<PromiseRow
|
||||||
|
icon="ban-outline"
|
||||||
|
text={t('onboarding.privacy.promise_no_ads')}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
<PromiseRow
|
||||||
|
icon="server-outline"
|
||||||
|
text={t('onboarding.privacy.promise_germany')}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromiseRow({
|
||||||
|
icon,
|
||||||
|
text,
|
||||||
|
colors,
|
||||||
|
}: {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
text: string;
|
||||||
|
colors: import('../../../lib/theme').ColorScheme;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: 'rgba(34,197,94,0.12)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={18} color={colors.success} />
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,195 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Platform, Text, View } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useColors } from '../../../lib/theme';
|
||||||
|
import { apiFetch } from '../../../lib/api';
|
||||||
|
import { invalidateMe } from '../../../hooks/useMe';
|
||||||
|
import { protection } from '../../../lib/protection';
|
||||||
|
import { OnboardingShell } from '../OnboardingShell';
|
||||||
|
import { LyraBubble } from '../LyraBubble';
|
||||||
|
import { CTABar } from '../CTABar';
|
||||||
|
import { PermissionDeniedSheet } from '../../PermissionDeniedSheet';
|
||||||
|
|
||||||
|
export function ProtectionSlide({
|
||||||
|
onDone,
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
/** Wird gerufen wenn URL-Filter erfolgreich aktiviert wurde. */
|
||||||
|
onDone: () => void;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const [activating, setActivating] = useState(false);
|
||||||
|
const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false);
|
||||||
|
|
||||||
|
async function activate() {
|
||||||
|
if (activating) return;
|
||||||
|
setActivating(true);
|
||||||
|
try {
|
||||||
|
const res = await protection.activateUrlFilter();
|
||||||
|
if (!res.enabled) {
|
||||||
|
const isCodeFive =
|
||||||
|
Platform.OS === 'ios' &&
|
||||||
|
typeof res.error === 'string' &&
|
||||||
|
/NEFilterErrorDomain:\s*5/i.test(res.error);
|
||||||
|
if (isCodeFive) {
|
||||||
|
setPermissionDeniedOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Alert.alert(
|
||||||
|
t('onboarding.protection.error_title'),
|
||||||
|
res.error ?? t('onboarding.protection.error_unknown'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Schutz live → step='done'
|
||||||
|
await apiFetch('/api/profile/me/onboarding-step', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { step: 'done' },
|
||||||
|
}).catch(() => {});
|
||||||
|
invalidateMe();
|
||||||
|
onDone();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
Alert.alert(
|
||||||
|
t('common.error'),
|
||||||
|
e instanceof Error ? e.message : t('common.unknown_error'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setActivating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingShell
|
||||||
|
current={current}
|
||||||
|
total={total}
|
||||||
|
cta={
|
||||||
|
<CTABar
|
||||||
|
primaryLabel={t('onboarding.protection.cta_primary')}
|
||||||
|
onPrimary={activate}
|
||||||
|
primaryLoading={activating}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LyraBubble text={t('onboarding.lyra.protection.body')} emotion="empathy" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 24,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProtectionRow
|
||||||
|
icon="globe-outline"
|
||||||
|
title={t('onboarding.protection.feat_blocklist_title')}
|
||||||
|
desc={t('onboarding.protection.feat_blocklist_desc')}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
<ProtectionRow
|
||||||
|
icon={Platform.OS === 'ios' ? 'shield-checkmark-outline' : 'lock-closed-outline'}
|
||||||
|
title={t(
|
||||||
|
Platform.OS === 'ios'
|
||||||
|
? 'onboarding.protection.feat_ios_title'
|
||||||
|
: 'onboarding.protection.feat_android_title',
|
||||||
|
)}
|
||||||
|
desc={t(
|
||||||
|
Platform.OS === 'ios'
|
||||||
|
? 'onboarding.protection.feat_ios_desc'
|
||||||
|
: 'onboarding.protection.feat_android_desc',
|
||||||
|
)}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
<ProtectionRow
|
||||||
|
icon="time-outline"
|
||||||
|
title={t('onboarding.protection.feat_cooldown_title')}
|
||||||
|
desc={t('onboarding.protection.feat_cooldown_desc')}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 18,
|
||||||
|
color: colors.textMuted,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('onboarding.protection.permission_note')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<PermissionDeniedSheet
|
||||||
|
visible={permissionDeniedOpen}
|
||||||
|
onClose={() => setPermissionDeniedOpen(false)}
|
||||||
|
onRetry={async () => {
|
||||||
|
const res = await protection.resetUrlFilter();
|
||||||
|
if (res.enabled) {
|
||||||
|
await apiFetch('/api/profile/me/onboarding-step', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { step: 'done' },
|
||||||
|
}).catch(() => {});
|
||||||
|
invalidateMe();
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProtectionRow({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
desc,
|
||||||
|
colors,
|
||||||
|
}: {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
colors: import('../../../lib/theme').ColorScheme;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 12 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(0,122,255,0.12)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={20} color={colors.brandOrange} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 14, color: colors.text }}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 2,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
color: colors.textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{desc}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useColors } from '../../../lib/theme';
|
||||||
|
import { OnboardingShell } from '../OnboardingShell';
|
||||||
|
import { LyraBubble } from '../LyraBubble';
|
||||||
|
import { CTABar } from '../CTABar';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slide 1: Lyra stellt sich vor + erklärt die Mission in einem Satz.
|
||||||
|
* Hat den langen Empathie-Touch, weil's der erste Eindruck nach Signup ist.
|
||||||
|
*/
|
||||||
|
export function WelcomeSlide({
|
||||||
|
onNext,
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
onNext: () => void;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingShell
|
||||||
|
current={current}
|
||||||
|
total={total}
|
||||||
|
cta={<CTABar primaryLabel={t('onboarding.welcome.cta_primary')} onPrimary={onNext} />}
|
||||||
|
>
|
||||||
|
<LyraBubble text={t('onboarding.lyra.welcome.body')} emotion="happy" />
|
||||||
|
|
||||||
|
<View style={{ marginTop: 28, gap: 12 }}>
|
||||||
|
<BulletRow
|
||||||
|
icon="eye-off-outline"
|
||||||
|
text={t('onboarding.welcome.bullet_anon')}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
<BulletRow
|
||||||
|
icon="shield-checkmark-outline"
|
||||||
|
text={t('onboarding.welcome.bullet_protect')}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
<BulletRow
|
||||||
|
icon="people-outline"
|
||||||
|
text={t('onboarding.welcome.bullet_community')}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</OnboardingShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BulletRow({
|
||||||
|
icon,
|
||||||
|
text,
|
||||||
|
colors,
|
||||||
|
}: {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
text: string;
|
||||||
|
colors: import('../../../lib/theme').ColorScheme;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={20} color={colors.brandOrange} />
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 21,
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -17,7 +17,15 @@ export type Plan = 'free' | 'pro' | 'legend';
|
|||||||
* Stand (Avatar/Nickname/Plan werden via Profile-Edit-API geupdated, landen
|
* Stand (Avatar/Nickname/Plan werden via Profile-Edit-API geupdated, landen
|
||||||
* in der DB, NICHT zurück ins JWT-Claim).
|
* 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 = {
|
export type Me = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@ -81,18 +81,25 @@ export function useProtectionState(): UseProtectionStateReturn {
|
|||||||
const prevActive = prevCooldownActiveRef.current;
|
const prevActive = prevCooldownActiveRef.current;
|
||||||
prevCooldownActiveRef.current = next.cooldown.active;
|
prevCooldownActiveRef.current = next.cooldown.active;
|
||||||
|
|
||||||
// Cooldown ist gerade von active → inactive gekippt: Auto-Disable prüfen.
|
// Cooldown ist gerade von active → inactive gekippt: Auto-Disable.
|
||||||
if (prevActive === true && !next.cooldown.active) {
|
// Wir nutzen LOKAL den Cooldown-State aus dem eben gefetchten `next` —
|
||||||
const didDisable = await protection.applyCooldownDisableIfElapsed();
|
// KEIN redundanter API-Call zu /api/cooldown/status. Grund: der Backend-
|
||||||
if (didDisable) {
|
// GET resolved den Cooldown autom. beim ersten expired-Hit. Ein zweiter
|
||||||
showCooldownElapsedNotice();
|
// Call würde dann cooldownEndsAt=null returnen → false bail → Filter
|
||||||
// Nativer State hat sich geändert → ein weiterer Fetch für konsistenten State.
|
// bleibt installiert. Local-state-check ist atomar + race-frei.
|
||||||
const afterDisable = await protection.getCombinedState();
|
if (
|
||||||
setState(afterDisable);
|
prevActive === true &&
|
||||||
setTickSeconds(afterDisable.cooldown.remainingSeconds);
|
!next.cooldown.active &&
|
||||||
setError(null);
|
next.cooldown.endsAt !== null
|
||||||
return;
|
) {
|
||||||
}
|
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);
|
setState(next);
|
||||||
@ -137,10 +144,12 @@ export function useProtectionState(): UseProtectionStateReturn {
|
|||||||
};
|
};
|
||||||
}, [state?.cooldown.active]);
|
}, [state?.cooldown.active]);
|
||||||
|
|
||||||
// AppState-Listener: Refresh + Auto-Disable wenn Cooldown elapsed ist.
|
// AppState-Listener: Refresh + Auto-Disable wenn Cooldown elapsed während
|
||||||
// Guard in applyCooldownDisableIfElapsed: cooldownEndsAt muss gesetzt sein
|
// App backgrounded war. applyCooldownDisableIfElapsed macht hier den initialen
|
||||||
// (= es lief je ein Cooldown) und remainingSeconds <= 0. Verhindert
|
// API-Call (= keine Race-Condition mit anderem GET, weil das der erste post-
|
||||||
// False-Positives wenn canDisableProtection im Initial-State true ist.
|
// 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(() => {
|
useEffect(() => {
|
||||||
const sub = AppState.addEventListener('change', async (status: AppStateStatus) => {
|
const sub = AppState.addEventListener('change', async (status: AppStateStatus) => {
|
||||||
if (status !== 'active') return;
|
if (status !== 'active') return;
|
||||||
|
|||||||
@ -162,6 +162,7 @@ export const protection = {
|
|||||||
|
|
||||||
/** Schaltet alle Layer ab + disarmed den Tamper-Lock. NUR aufrufen wenn JS-Layer Cooldown verifiziert. */
|
/** Schaltet alle Layer ab + disarmed den Tamper-Lock. NUR aufrufen wenn JS-Layer Cooldown verifiziert. */
|
||||||
async forceDisable() {
|
async forceDisable() {
|
||||||
|
console.log("[protection] forceDisable() — disarm tamper + native disable");
|
||||||
// Tamper-Lock ZUERST disarmen — sonst setzt der AccessibilityService den Schutz
|
// 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
|
// 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
|
// System-Settings) → der User kommt nicht aus dem Schutz raus, obwohl der Cooldown
|
||||||
@ -171,7 +172,9 @@ export const protection = {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[protection] disarmTamperLock failed:", e);
|
console.warn("[protection] disarmTamperLock failed:", e);
|
||||||
}
|
}
|
||||||
return RebreakProtection.disable();
|
const res = await RebreakProtection.disable();
|
||||||
|
console.log("[protection] native disable returned:", res);
|
||||||
|
return res;
|
||||||
},
|
},
|
||||||
|
|
||||||
getDeviceState(): Promise<DeviceLayers> {
|
getDeviceState(): Promise<DeviceLayers> {
|
||||||
|
|||||||
@ -357,20 +357,103 @@
|
|||||||
"empty_mail": "لا توجد نطاقات بريد مخصصة. اضغط + لحجب عنوان أو نطاق بريد."
|
"empty_mail": "لا توجد نطاقات بريد مخصصة. اضغط + لحجب عنوان أو نطاق بريد."
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"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": {
|
"welcome": {
|
||||||
"headline": "مرحباً بك في ReBreak.",
|
"cta_primary": "هيا نبدأ",
|
||||||
"subhead": "طريقك للخروج من القمار — بشكل مجهول، محمي، ولست وحدك.",
|
"bullet_anon": "مجهول — بدون اسم حقيقي",
|
||||||
"bullet_anon_title": "تظل مجهولاً",
|
"bullet_protect": "مواقع القمار محجوبة",
|
||||||
"bullet_anon_desc": "تختار اسماً مستعاراً. لا أحد يرى اسمك الحقيقي — نحن أيضاً لا نعرفه.",
|
"bullet_community": "آخرون على نفس الطريق"
|
||||||
"bullet_protect_title": "جهازك محمي",
|
},
|
||||||
"bullet_protect_desc": "تُحجب مواقع وتطبيقات القمار. حتى في لحظات الضعف.",
|
"privacy": {
|
||||||
"bullet_community_title": "لست وحدك",
|
"cta_primary": "فهمت",
|
||||||
"bullet_community_desc": "آخرون على نفس الطريق. اكتب بشكل مجهول، شارك سلسلتك، اجد دعماً.",
|
"promise_alias": "فقط اسمك المستعار مرئي",
|
||||||
"privacy_label": "GDPR · تقليل البيانات",
|
"promise_minimal": "نخزّن أقل قدر ممكن",
|
||||||
"privacy_body": "نعالج أقل قدر ممكن من البيانات. تحتاج فقط إلى اسم مستعار. لا اسم حقيقي، لا تتبع، لا إعلانات.",
|
"promise_no_ads": "لا تتبع، لا إعلانات",
|
||||||
"cta": "هيا نبدأ",
|
"promise_germany": "خوادم في ألمانيا · متوافق مع GDPR"
|
||||||
"cta_loading": "لحظة من فضلك...",
|
},
|
||||||
"next_hint": "في الخطوة التالية تختار اسمك المستعار."
|
"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}",
|
"step_progress": "الخطوة %{current} من %{total}",
|
||||||
"block_spotlight": {
|
"block_spotlight": {
|
||||||
|
|||||||
@ -357,20 +357,103 @@
|
|||||||
"empty_mail": "Noch keine Mail-Domains. Tippe + um eine E-Mail-Adresse oder Domain zu blockieren."
|
"empty_mail": "Noch keine Mail-Domains. Tippe + um eine E-Mail-Adresse oder Domain zu blockieren."
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"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": {
|
"welcome": {
|
||||||
"headline": "Willkommen bei ReBreak.",
|
"cta_primary": "Los geht's",
|
||||||
"subhead": "Dein Weg raus aus dem Glücksspiel — anonym, geschützt, und nicht allein.",
|
"bullet_anon": "Anonym — kein echter Name nötig",
|
||||||
"bullet_anon_title": "Du bleibst anonym",
|
"bullet_protect": "Glücksspiel-Seiten werden blockiert",
|
||||||
"bullet_anon_desc": "Du wählst einen Alias. Niemand sieht deinen echten Namen — auch wir nicht.",
|
"bullet_community": "Andere auf dem gleichen Weg"
|
||||||
"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.",
|
"privacy": {
|
||||||
"bullet_community_title": "Du gehst nicht allein",
|
"cta_primary": "Verstanden",
|
||||||
"bullet_community_desc": "Andere auf dem gleichen Weg. Anonym schreiben, Streaks teilen, Halt finden.",
|
"promise_alias": "Nur dein Alias ist sichtbar",
|
||||||
"privacy_label": "DSGVO · Datenminimierung",
|
"promise_minimal": "Wir speichern so wenig wie möglich",
|
||||||
"privacy_body": "Wir verarbeiten so wenig wie möglich. Du brauchst nur einen Alias. Kein Klarname, keine Tracker, keine Werbung.",
|
"promise_no_ads": "Keine Tracker, keine Werbung",
|
||||||
"cta": "Los geht's",
|
"promise_germany": "Server in Deutschland · DSGVO-konform"
|
||||||
"cta_loading": "Einen Moment...",
|
},
|
||||||
"next_hint": "Im nächsten Schritt wählst du deinen Alias."
|
"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}",
|
"step_progress": "Schritt %{current} von %{total}",
|
||||||
"block_spotlight": {
|
"block_spotlight": {
|
||||||
|
|||||||
@ -357,20 +357,103 @@
|
|||||||
"empty_mail": "No mail domains yet. Tap + to block an email address or domain."
|
"empty_mail": "No mail domains yet. Tap + to block an email address or domain."
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"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": {
|
"welcome": {
|
||||||
"headline": "Welcome to ReBreak.",
|
"cta_primary": "Let's go",
|
||||||
"subhead": "Your way out of gambling — anonymous, protected, and not alone.",
|
"bullet_anon": "Anonymous — no real name needed",
|
||||||
"bullet_anon_title": "You stay anonymous",
|
"bullet_protect": "Gambling sites get blocked",
|
||||||
"bullet_anon_desc": "You pick an alias. No one sees your real name — not even us.",
|
"bullet_community": "Others walking the same path"
|
||||||
"bullet_protect_title": "Your device gets protected",
|
},
|
||||||
"bullet_protect_desc": "Gambling sites and apps are blocked for you. Even when the urge hits.",
|
"privacy": {
|
||||||
"bullet_community_title": "You're not alone",
|
"cta_primary": "Got it",
|
||||||
"bullet_community_desc": "Others walking the same path. Post anonymously, share streaks, find support.",
|
"promise_alias": "Only your alias is visible",
|
||||||
"privacy_label": "GDPR · Data minimization",
|
"promise_minimal": "We store as little as possible",
|
||||||
"privacy_body": "We process as little as possible. You only need an alias. No real name, no trackers, no ads.",
|
"promise_no_ads": "No trackers, no ads",
|
||||||
"cta": "Let's go",
|
"promise_germany": "Servers in Germany · GDPR-compliant"
|
||||||
"cta_loading": "One moment...",
|
},
|
||||||
"next_hint": "Next, you'll pick your alias."
|
"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}",
|
"step_progress": "Step %{current} of %{total}",
|
||||||
"block_spotlight": {
|
"block_spotlight": {
|
||||||
|
|||||||
@ -355,20 +355,103 @@
|
|||||||
"empty_mail": "Aucun domaine mail. Appuyez sur + pour bloquer une adresse ou un domaine."
|
"empty_mail": "Aucun domaine mail. Appuyez sur + pour bloquer une adresse ou un domaine."
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"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": {
|
"welcome": {
|
||||||
"headline": "Bienvenue sur ReBreak.",
|
"cta_primary": "On y va",
|
||||||
"subhead": "Ta voie hors du jeu — anonyme, protégée, et pas seul·e.",
|
"bullet_anon": "Anonyme — pas de vrai nom",
|
||||||
"bullet_anon_title": "Tu restes anonyme",
|
"bullet_protect": "Sites de jeu bloqués",
|
||||||
"bullet_anon_desc": "Tu choisis un alias. Personne ne voit ton vrai nom — nous non plus.",
|
"bullet_community": "D'autres sur le même chemin"
|
||||||
"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.",
|
"privacy": {
|
||||||
"bullet_community_title": "Tu n'es pas seul",
|
"cta_primary": "Compris",
|
||||||
"bullet_community_desc": "D'autres font le même chemin. Écris anonymement, partage tes streaks, trouve du soutien.",
|
"promise_alias": "Seul ton alias est visible",
|
||||||
"privacy_label": "RGPD · Minimisation des données",
|
"promise_minimal": "On stocke le minimum",
|
||||||
"privacy_body": "Nous traitons le strict minimum. Un alias suffit. Pas de vrai nom, pas de trackers, pas de pubs.",
|
"promise_no_ads": "Pas de trackers, pas de pub",
|
||||||
"cta": "On y va",
|
"promise_germany": "Serveurs en Allemagne · RGPD"
|
||||||
"cta_loading": "Un instant...",
|
},
|
||||||
"next_hint": "À l'étape suivante, tu choisiras ton alias."
|
"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}",
|
"step_progress": "Étape %{current} sur %{total}",
|
||||||
"block_spotlight": {
|
"block_spotlight": {
|
||||||
|
|||||||
@ -243,12 +243,31 @@ public class RebreakProtectionModule: Module {
|
|||||||
// ───────── disable: NUR aufrufen wenn JS-Cooldown abgelaufen! ─────────
|
// ───────── disable: NUR aufrufen wenn JS-Cooldown abgelaufen! ─────────
|
||||||
|
|
||||||
AsyncFunction("disable") { () async -> [String: Any] in
|
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 {
|
do {
|
||||||
let manager = NEFilterManager.shared()
|
let manager = NEFilterManager.shared()
|
||||||
try await manager.loadFromPreferences()
|
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()
|
try await manager.removeFromPreferences()
|
||||||
SharedLogStore.append("✅ NEFilter disabled")
|
SharedLogStore.append("✅ NEFilter disabled + removed from preferences")
|
||||||
} catch {
|
} catch {
|
||||||
SharedLogStore.append("⚠️ NEFilter disable: \(error.localizedDescription)")
|
SharedLogStore.append("⚠️ NEFilter disable: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,11 +49,13 @@ export async function redeemDigaCode(
|
|||||||
data: { usedAt: now, usedByProfileId: userId },
|
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({
|
await tx.profile.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
plan: found.grantsPlan,
|
plan: found.grantsPlan,
|
||||||
onboardingStep: "done",
|
onboardingStep: "pre_protection",
|
||||||
digaCodeRedeemedAt: now,
|
digaCodeRedeemedAt: now,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -24,7 +24,26 @@ export async function deleteProfile(userId: string) {
|
|||||||
|
|
||||||
// ─── Onboarding-Step ────────────────────────────────────────────────────────
|
// ─── 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 type OnboardingStep = (typeof ONBOARDING_STEPS)[number];
|
||||||
|
|
||||||
export function isOnboardingStep(value: unknown): value is OnboardingStep {
|
export function isOnboardingStep(value: unknown): value is OnboardingStep {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user