chahinebrini 1596a4ea7a feat(protection,onboarding): anti-auto-reactivation + protection pre-explainer + custom sheets
## Backend: Anti-Auto-Reactivation nach Cooldown

Bug: nach Cooldown-Ablauf wurde der URL-Filter automatisch wieder
reaktiviert (enforceProtection-Loop fängt 'recoveringFromBypass'-Phase ab).
Damit war der Cooldown-Schritt entwertet — User konnte nicht wirklich
abschalten, weil die App den Schutz sofort wieder hochfuhr.

Fix: Profile.protectionDisabledAt (DateTime nullable). Wird in
/api/cooldown/status auf cooldown-auto-resolve gesetzt. /api/protection/state
gibt dann protectionShouldBeActive=false zurück → Frontend macht KEINE
Auto-Reactivation. User muss explizit re-aktivieren (CTA in der App).

- Migration 20260517_protection_disabled_at
- Schema: Profile.protectionDisabledAt
- /api/cooldown/status: setzt das Feld auf expired+resolve
- /api/protection/state: includes profile.protectionDisabledAt in shouldBeActive-Berechnung
- /api/protection/mark-active (POST, NEU): clears das Feld, vom Frontend
  auto-aufgerufen nach erfolgreichem activateUrlFilter

Bypass-Recovery durch externe iOS-Settings-Disable (nicht cooldown-bezogen)
funktioniert weiter — protectionDisabledAt ist dann null, alte Logik greift.

## Frontend: ProtectionOffSheet (Custom-Sheet statt Alert.alert)

Bisheriges native Alert mit OK+Reactivate-Buttons hat keine visuelle
Hierarchy (iOS macht beide gleich). Ersetzt mit FormSheet:
 - Großer blauer Primary "Schutz wieder einschalten"
 - Ghost-Link "Später"
 - Swipe-down / Backdrop-Tap zum Schließen

## Frontend: ProtectionSlide mit Pre-Explainer (Screenshot + Pulse-Marker)

User-Request: vor dem iOS-Permission-Dialog ein Erklärungs-Screen zeigen
damit der User weiß wo er tappen muss (Apple's "Don't Allow" ist groß+
blau = Trap, "Allow" ist der unscheinbare Button unten).

- components/onboarding/ScreenshotPointer.tsx — Reanimated pulsing red
  ring, positionierbar via {xPercent, yPercent}
- lib/onboardingAssets.ts — locale-aware require()-Map für Screenshot-
  Assets mit de-Fallback
- assets/onboarding/de/ — 4 iOS-Screenshots vom User (url_filter +
  screen_time permission dialogs + 2 confirm screens)
- ProtectionSlide refactored: internal phase state preexplain_url →
  preexplain_lock → done. Jede Phase zeigt Screenshot + Pulse-Marker auf
  korrekten Button + Lyra-Bubble + activate-CTA.

## Locale-Keys

- onboarding.lyra.protection_url.body, onboarding.lyra.protection_lock.body
- onboarding.protection.url_title, .lock_title, .tap_marker_hint
- onboarding.protection.applock_failed_*, applock_skip
- blocker.protection_off_later, reactivate_btn (refined)

## Bugfix: de.json JSON-syntax

Smart-quote-typo: schließendes "" nach „Erlauben" und „Fortfahren" war
ein plain ASCII " (U+0022) statt U+201D, was den JSON-String früh
terminiert hat. Metro+Hermes warfen "unrecognized Unicode —".
Fix: escapte \" verwendet — JSON-safe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 19:05:37 +02:00

255 lines
8.1 KiB
TypeScript

import { useState } from 'react';
import { Alert, Image, Platform, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../../lib/theme';
import { apiFetch } from '../../../lib/api';
import { invalidateMe } from '../../../hooks/useMe';
import { protection } from '../../../lib/protection';
import { getPermissionScreenshot, getPointerPosition } from '../../../lib/onboardingAssets';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
import { CTABar } from '../CTABar';
import { ScreenshotPointer } from '../ScreenshotPointer';
import { PermissionDeniedSheet } from '../../PermissionDeniedSheet';
import i18n from '../../../lib/i18n';
/**
* Onboarding Step Protection — 2 Phasen, beide mit Pre-Explainer-Modal das
* den iOS-Permission-Dialog vorzeigt + Pulse-Marker auf den "Erlauben"-Button.
*
* ┌──────────────────────────────────────────────────────────────┐
* │ Phase A: preexplain_url │
* │ Lyra: "Gleich kommt iOS-Dialog. Tippe ERLAUBEN." │
* │ Screenshot vom NEFilter-Dialog + roter Pulse auf "Erlauben" │
* │ CTA "Aktivieren" → triggers protection.activateUrlFilter() │
* │ │
* │ Phase B: preexplain_lock │
* │ Lyra: "Jetzt App-Schutz. Tippe FORTFAHREN." │
* │ Screenshot vom Screen-Time-Dialog + roter Pulse │
* │ CTA "Aktivieren" → triggers protection.activateFamilyControls │
* │ │
* │ Phase C: done → onDone() │
* └──────────────────────────────────────────────────────────────┘
*
* Wenn URL-Filter fehlschlägt mit code 5 → PermissionDeniedSheet öffnet sich
* (Retry-Pfad via resetUrlFilter()). Family-Controls hat keinen analogen
* Recovery-Sheet — User muss in Settings → Bildschirmzeit den App-Zugriff
* gewähren.
*/
type Phase = 'preexplain_url' | 'preexplain_lock' | 'done';
export function ProtectionSlide({
onDone,
current,
total,
}: {
onDone: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const colors = useColors();
const [phase, setPhase] = useState<Phase>('preexplain_url');
const [activating, setActivating] = useState(false);
const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false);
async function activateUrlFilter() {
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;
}
// Filter live → weiter zur Phase B (App-Lock)
setPhase('preexplain_lock');
} finally {
setActivating(false);
}
}
async function activateAppLock() {
if (activating) return;
setActivating(true);
try {
const res = await protection.activateFamilyControls();
if (!res.enabled) {
// Family Controls fehlgeschlagen → User Info aber Tour-Done (URL-Filter
// läuft schon, das ist der Hauptschutz; App-Lock ist optional)
Alert.alert(
t('onboarding.protection.applock_failed_title'),
res.error ?? t('onboarding.protection.applock_failed_msg'),
[
{
text: t('onboarding.protection.applock_skip'),
style: 'cancel',
onPress: () => finishProtectionStep(),
},
{ text: t('common.retry'), onPress: activateAppLock },
],
);
return;
}
finishProtectionStep();
} finally {
setActivating(false);
}
}
async function finishProtectionStep() {
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step: 'done' },
}).catch(() => {});
invalidateMe();
setPhase('done');
onDone();
}
return phase === 'preexplain_url' ? (
<PreExplainer
key="url"
dialog="url_filter"
lyraBodyKey="onboarding.lyra.protection_url.body"
titleKey="onboarding.protection.url_title"
ctaKey="onboarding.protection.cta_primary"
activating={activating}
onActivate={activateUrlFilter}
current={current}
total={total}
>
<PermissionDeniedSheet
visible={permissionDeniedOpen}
onClose={() => setPermissionDeniedOpen(false)}
onRetry={async () => {
const res = await protection.resetUrlFilter();
if (res.enabled) setPhase('preexplain_lock');
return res;
}}
/>
</PreExplainer>
) : phase === 'preexplain_lock' ? (
<PreExplainer
key="lock"
dialog="screen_time"
lyraBodyKey="onboarding.lyra.protection_lock.body"
titleKey="onboarding.protection.lock_title"
ctaKey="onboarding.protection.cta_primary"
activating={activating}
onActivate={activateAppLock}
current={current}
total={total}
/>
) : null;
}
// ─── PreExplainer (shared) ───────────────────────────────────────────────────
function PreExplainer({
dialog,
lyraBodyKey,
titleKey,
ctaKey,
activating,
onActivate,
current,
total,
children,
}: {
dialog: 'url_filter' | 'screen_time';
lyraBodyKey: string;
titleKey: string;
ctaKey: string;
activating: boolean;
onActivate: () => void;
current: number;
total: number;
children?: React.ReactNode;
}) {
const { t } = useTranslation();
const colors = useColors();
const lang = i18n.language || 'de';
const screenshot = getPermissionScreenshot(dialog, lang);
const pointer = getPointerPosition(dialog);
return (
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={t(ctaKey)}
onPrimary={onActivate}
primaryLoading={activating}
/>
}
>
<LyraBubble text={t(lyraBodyKey)} emotion="empathy" />
<Text
style={{
marginTop: 22,
fontFamily: 'Nunito_700Bold',
fontSize: 14,
letterSpacing: 0.5,
color: colors.textMuted,
textTransform: 'uppercase',
textAlign: 'center',
}}
>
{t(titleKey)}
</Text>
{/* Screenshot mit Pulse-Pointer auf den korrekten Button */}
<View
style={{
marginTop: 12,
alignSelf: 'center',
width: '92%',
aspectRatio: 0.9, // Screenshots sind etwa quadratisch / leicht hochkant
borderRadius: 18,
overflow: 'hidden',
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
}}
>
<Image
source={screenshot}
style={{ width: '100%', height: '100%' }}
resizeMode="contain"
/>
<ScreenshotPointer xPercent={pointer.xPercent} yPercent={pointer.yPercent} />
</View>
<Text
style={{
marginTop: 16,
fontFamily: 'Nunito_400Regular',
fontSize: 13,
lineHeight: 19,
color: colors.textMuted,
textAlign: 'center',
paddingHorizontal: 8,
}}
>
{t('onboarding.protection.tap_marker_hint')}
</Text>
{children}
</OnboardingShell>
);
}