## 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>
347 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|