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

102 lines
2.8 KiB
TypeScript

import { useEffect, useState } from 'react';
import { apiFetch } from '../lib/api';
export type Plan = 'free' | 'pro' | 'legend';
/**
* Single source of truth für den eingeloggten User. /api/auth/me joint
* `auth.users` mit `rebreak.profiles` server-side — wir bekommen alles in
* einem Request: plan, avatar, nickname, streak.
*
* Live-Update-Pattern (siehe RECOVERY_LOG): nach Profile-Edit (PATCH /api/auth/me)
* MUSS `invalidateMe()` aufgerufen werden — alle useMe-Konsumenten (AppHeader,
* PostCard, ComposeCard, etc.) re-fetchen automatisch via Listener-Subscribe.
*
* WICHTIG: nicht aus `supabase.auth.getUser().user_metadata` lesen — das
* sind nur die JWT-Claims vom Signup-Zeitpunkt, NICHT der aktuelle Profile-
* Stand (Avatar/Nickname/Plan werden via Profile-Edit-API geupdated, landen
* in der DB, NICHT zurück ins JWT-Claim).
*/
export type OnboardingStep =
| 'welcome'
| 'account'
| 'plan'
| 'pre_protection'
| 'done'
// legacy (alte Builds könnten das im Profile haben — wird im neuen Flow nicht gesetzt)
| 'nickname'
| 'block';
export type Me = {
id: string;
email: string;
username: string;
nickname: string | null;
avatar: string | null;
plan: Plan;
streak: number;
lyraVoiceId: string | null;
onboardingStep: OnboardingStep;
created_at?: string;
};
let cachedMe: Me | null = null;
const listeners = new Set<() => void>();
/**
* Lädt /api/auth/me neu und benachrichtigt ALLE useMe-Konsumenten in der App.
* Nach jedem PATCH /api/auth/me aufrufen — sonst sehen Konsumenten alten Cache.
*/
export function invalidateMe(): void {
cachedMe = null;
for (const cb of listeners) cb();
}
export function useMe(): { me: Me | null; loading: boolean; reload: () => void } {
const [me, setMe] = useState<Me | null>(cachedMe);
const [loading, setLoading] = useState(cachedMe === null);
const [version, setVersion] = useState(0);
// Auf globale Invalidierung lauschen (Avatar-/Nickname-Update aus Profile-Edit)
useEffect(() => {
const cb = () => setVersion((v) => v + 1);
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
// Falls cache schon frisch ist (von anderem Konsumenten gerade geladen): nutzen
if (cachedMe !== null) {
setMe(cachedMe);
setLoading(false);
return;
}
try {
const res = await apiFetch<Me>('/api/auth/me');
if (cancelled) return;
cachedMe = res;
setMe(res);
} catch (e) {
console.warn('[useMe] fetch failed:', e);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [version]);
return {
me,
loading,
reload: () => {
invalidateMe();
},
};
}