## 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>
196 lines
5.3 KiB
TypeScript
196 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}
|