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

347 lines
10 KiB
TypeScript

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