chahinebrini b23bd6d29f 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>
2026-05-17 17:48:05 +02:00

177 lines
5.8 KiB
TypeScript

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} />;
}
}