FAQ-Bug-Fix + Component-Extraction:
- DoneSlide nutzte qkey.replace('q','a') → 'faq_q1'.replace('q','a')='faa_q1'
weil .replace nur das ERSTE q matched (in "fa**q**"), nicht das in "q1".
→ Antworten resolved gegen non-existent key, raw key gerendert.
- Fix: explizite ID-Array [1,2,4,5,8] mit `help.faq_q\${id}` / `help.faq_a\${id}`.
- Shared FaqAccordion-Component extrahiert (components/FaqAccordion.tsx)
mit 2 Varianten: 'card' (help/faq.tsx) + 'pills' (DoneSlide inline).
- app/help/faq.tsx + DoneSlide nutzen jetzt beide den shared component.
ScreenshotPointer-Alignment für iOS Screen-Time-Permission:
- iOS Family-Controls-Dialog: "Continue/Continuer/Fortfahren" ist LINKS-grau,
"Don't Allow" ist RECHTS-blau (Apple platziert decline prominent, accept
zurückhaltend bei Screen-Time-Permission). Pointer muss daher nach LINKS,
nicht zentriert wie beim NEFilter-Dialog.
- ScreenshotPointer: neuer alignment-Prop ('left'|'center'|'right') →
translateX (-80|0|+80 dp).
- ProtectionSlide iOS Phase B: pointerAlignment="left" durchgereicht.
- Phase A (url_filter) + alle Android-Phasen bleiben center.
Release-Prep (zied):
- CHANGELOG.md v0.3.0-Block erweitert (TTS, Stripe-Pricing, Keyboard-Fix,
Single-Banner, FAQ-Extraktion, i18n-Status, Backend-Pending-Migration).
- version 0.3.0 + buildNumber 10 + versionCode 10 schon vorher gesetzt.
- eas.json production-Profil ready; Android-serviceAccountKeyPath bleibt
TODO (User-Action: Google-Cloud-Service-Account anlegen).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
466 lines
13 KiB
TypeScript
466 lines
13 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Alert, AppState, Image, Platform, Text, useWindowDimensions, 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 RebreakProtection from '../../../modules/rebreak-protection';
|
|
import { getPermissionScreenshot } 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-Schutz-Step.
|
|
*
|
|
* Platform.OS-Dispatch:
|
|
* iOS → IosProtectionSlide (NEFilter + Family-Controls)
|
|
* Android → AndroidProtectionSlide (VpnService + Accessibility-Tamper-Lock)
|
|
*
|
|
* Beide haben den gleichen Eltern-Vertrag (current/total/onDone) und nutzen
|
|
* den gleichen Pre-Explainer + Lyra-Bubble + CTA-Pattern — die Innereien
|
|
* unterscheiden sich nur in (a) welche Permission-Dialoge geöffnet werden
|
|
* und (b) welche Screenshots gezeigt werden.
|
|
*/
|
|
|
|
export function ProtectionSlide(props: {
|
|
onDone: () => void;
|
|
current: number;
|
|
total: number;
|
|
}) {
|
|
if (Platform.OS === 'android') {
|
|
return <AndroidProtectionSlide {...props} />;
|
|
}
|
|
return <IosProtectionSlide {...props} />;
|
|
}
|
|
|
|
// ─── iOS ────────────────────────────────────────────────────────────────────
|
|
|
|
type IosPhase = 'preexplain_url' | 'preexplain_lock' | 'done';
|
|
|
|
function IosProtectionSlide({
|
|
onDone,
|
|
current,
|
|
total,
|
|
}: {
|
|
onDone: () => void;
|
|
current: number;
|
|
total: number;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [phase, setPhase] = useState<IosPhase>('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 =
|
|
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;
|
|
}
|
|
setPhase('preexplain_lock');
|
|
} finally {
|
|
setActivating(false);
|
|
}
|
|
}
|
|
|
|
async function activateAppLock() {
|
|
if (activating) return;
|
|
setActivating(true);
|
|
try {
|
|
const res = await protection.activateFamilyControls();
|
|
if (!res.enabled) {
|
|
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();
|
|
}
|
|
|
|
if (phase === 'preexplain_url') {
|
|
return (
|
|
<PreExplainer
|
|
key="ios-url"
|
|
dialog="url_filter"
|
|
lyraBodyKey="onboarding.lyra.protection_url.body"
|
|
titleKey="onboarding.protection.url_title"
|
|
ctaKey="onboarding.protection.cta_primary"
|
|
buttonLabelKey="onboarding.protection.dialog_button_allow"
|
|
markerHintKey="onboarding.protection.tap_marker_hint"
|
|
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>
|
|
);
|
|
}
|
|
if (phase === 'preexplain_lock') {
|
|
return (
|
|
<PreExplainer
|
|
key="ios-lock"
|
|
dialog="screen_time"
|
|
lyraBodyKey="onboarding.lyra.protection_lock.body"
|
|
titleKey="onboarding.protection.lock_title"
|
|
ctaKey="onboarding.protection.cta_primary"
|
|
buttonLabelKey="onboarding.protection.dialog_button_continue"
|
|
markerHintKey="onboarding.protection.tap_marker_hint"
|
|
pointerAlignment="left"
|
|
activating={activating}
|
|
onActivate={activateAppLock}
|
|
current={current}
|
|
total={total}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ─── Android ────────────────────────────────────────────────────────────────
|
|
|
|
type AndroidPhase =
|
|
| 'preexplain_vpn'
|
|
| 'preexplain_a11y'
|
|
| 'a11y_pending'
|
|
| 'done';
|
|
|
|
function AndroidProtectionSlide({
|
|
onDone,
|
|
current,
|
|
total,
|
|
}: {
|
|
onDone: () => void;
|
|
current: number;
|
|
total: number;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [phase, setPhase] = useState<AndroidPhase>('preexplain_vpn');
|
|
const [activating, setActivating] = useState(false);
|
|
// True wenn wir auf Settings-Rückkehr warten. AppState-Listener pollt dann
|
|
// a11y-State + advanced automatisch wenn ReBreak-Schalter live ist.
|
|
const awaitingReturnRef = useRef(false);
|
|
const appStateRef = useRef(AppState.currentState);
|
|
|
|
async function finishProtectionStep() {
|
|
await apiFetch('/api/profile/me/onboarding-step', {
|
|
method: 'PATCH',
|
|
body: { step: 'done' },
|
|
}).catch(() => {});
|
|
invalidateMe();
|
|
setPhase('done');
|
|
onDone();
|
|
}
|
|
|
|
async function activateVpn() {
|
|
if (activating) return;
|
|
setActivating(true);
|
|
try {
|
|
const res = await protection.activateUrlFilter();
|
|
if (!res.enabled) {
|
|
Alert.alert(
|
|
t('onboarding.protection.error_title'),
|
|
res.error ?? t('onboarding.protection.error_unknown'),
|
|
);
|
|
return;
|
|
}
|
|
setPhase('preexplain_a11y');
|
|
} finally {
|
|
setActivating(false);
|
|
}
|
|
}
|
|
|
|
async function activateA11y() {
|
|
if (activating) return;
|
|
setActivating(true);
|
|
try {
|
|
const res = await protection.activateFamilyControls();
|
|
if (res.enabled) {
|
|
// Selten: User hatte a11y schon manuell aktiviert → Lock direkt armed.
|
|
finishProtectionStep();
|
|
return;
|
|
}
|
|
if (res.error === 'accessibility_pending') {
|
|
// Native hat Settings geöffnet; warte auf Rückkehr + poll.
|
|
awaitingReturnRef.current = true;
|
|
setPhase('a11y_pending');
|
|
return;
|
|
}
|
|
Alert.alert(
|
|
t('onboarding.protection.error_title'),
|
|
res.error ?? t('onboarding.protection.error_unknown'),
|
|
);
|
|
} finally {
|
|
setActivating(false);
|
|
}
|
|
}
|
|
|
|
// Auto-Check beim Foreground-Return: wenn a11y jetzt aktiv → Lock armen + done.
|
|
useEffect(() => {
|
|
const sub = AppState.addEventListener('change', async (next) => {
|
|
const prev = appStateRef.current;
|
|
appStateRef.current = next;
|
|
if (!awaitingReturnRef.current) return;
|
|
if (prev.match(/inactive|background/) && next === 'active') {
|
|
try {
|
|
const a11y = await RebreakProtection.isAccessibilityEnabled();
|
|
if (a11y.enabled) {
|
|
// ReBreak-Service ist live → Tamper-Lock armen + finish.
|
|
const res = await protection.activateFamilyControls();
|
|
if (res.enabled) {
|
|
awaitingReturnRef.current = false;
|
|
finishProtectionStep();
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignorieren — User kann manuell auf "Ich habe ReBreak aktiviert" tippen.
|
|
}
|
|
}
|
|
});
|
|
return () => sub.remove();
|
|
}, []);
|
|
|
|
if (phase === 'preexplain_vpn') {
|
|
return (
|
|
<PreExplainer
|
|
key="android-vpn"
|
|
dialog="android_vpn"
|
|
lyraBodyKey="onboarding.lyra.protection_url_android.body"
|
|
titleKey="onboarding.protection.url_title_android"
|
|
ctaKey="onboarding.protection.cta_primary"
|
|
buttonLabelKey="onboarding.protection.dialog_button_vpn_ok"
|
|
markerHintKey="onboarding.protection.tap_marker_hint_android_vpn"
|
|
activating={activating}
|
|
onActivate={activateVpn}
|
|
current={current}
|
|
total={total}
|
|
/>
|
|
);
|
|
}
|
|
if (phase === 'preexplain_a11y') {
|
|
return (
|
|
<PreExplainer
|
|
key="android-a11y"
|
|
dialog="android_a11y"
|
|
lyraBodyKey="onboarding.lyra.protection_lock_android.body"
|
|
titleKey="onboarding.protection.lock_title_android"
|
|
ctaKey="onboarding.protection.cta_open_a11y"
|
|
buttonLabelKey="onboarding.protection.dialog_button_a11y_toggle"
|
|
markerHintKey="onboarding.protection.tap_marker_hint_android_a11y"
|
|
activating={activating}
|
|
onActivate={activateA11y}
|
|
current={current}
|
|
total={total}
|
|
/>
|
|
);
|
|
}
|
|
if (phase === 'a11y_pending') {
|
|
return (
|
|
<A11yPendingView
|
|
current={current}
|
|
total={total}
|
|
activating={activating}
|
|
onRetry={activateA11y}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function A11yPendingView({
|
|
current,
|
|
total,
|
|
activating,
|
|
onRetry,
|
|
}: {
|
|
current: number;
|
|
total: number;
|
|
activating: boolean;
|
|
onRetry: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const colors = useColors();
|
|
return (
|
|
<OnboardingShell
|
|
current={current}
|
|
total={total}
|
|
cta={
|
|
<CTABar
|
|
primaryLabel={t('onboarding.protection.cta_check_a11y')}
|
|
onPrimary={onRetry}
|
|
primaryLoading={activating}
|
|
/>
|
|
}
|
|
>
|
|
<LyraBubble
|
|
text={t('onboarding.protection.android_a11y_pending_body')}
|
|
emotion="empathy"
|
|
/>
|
|
<Text
|
|
style={{
|
|
marginTop: 14,
|
|
fontFamily: 'Nunito_700Bold',
|
|
fontSize: 12,
|
|
letterSpacing: 0.6,
|
|
color: colors.textMuted,
|
|
textTransform: 'uppercase',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
{t('onboarding.protection.android_a11y_pending_title')}
|
|
</Text>
|
|
</OnboardingShell>
|
|
);
|
|
}
|
|
|
|
// ─── PreExplainer (shared) ───────────────────────────────────────────────────
|
|
|
|
function PreExplainer({
|
|
dialog,
|
|
lyraBodyKey,
|
|
titleKey,
|
|
ctaKey,
|
|
buttonLabelKey,
|
|
markerHintKey,
|
|
pointerAlignment = 'center',
|
|
activating,
|
|
onActivate,
|
|
current,
|
|
total,
|
|
children,
|
|
}: {
|
|
dialog: 'url_filter' | 'screen_time' | 'android_vpn' | 'android_a11y';
|
|
lyraBodyKey: string;
|
|
titleKey: string;
|
|
ctaKey: string;
|
|
buttonLabelKey: string;
|
|
markerHintKey: string;
|
|
pointerAlignment?: 'left' | 'center' | 'right';
|
|
activating: boolean;
|
|
onActivate: () => void;
|
|
current: number;
|
|
total: number;
|
|
children?: React.ReactNode;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const colors = useColors();
|
|
const { height: screenH } = useWindowDimensions();
|
|
const lang = i18n.language || 'de';
|
|
const screenshot = getPermissionScreenshot(dialog, lang);
|
|
|
|
// Dynamische Screenshot-Höhe: Auf kleinen Phones (SE/mini ~667-844 pt)
|
|
// capped damit alles + CTA-Bar ohne Scroll passt. Auf großen Phones/iPad
|
|
// skaliert es mit. Min 200, Max 320.
|
|
const screenshotHeight = Math.min(320, Math.max(200, screenH * 0.32));
|
|
|
|
return (
|
|
<OnboardingShell
|
|
current={current}
|
|
total={total}
|
|
cta={
|
|
<CTABar
|
|
primaryLabel={t(ctaKey)}
|
|
onPrimary={onActivate}
|
|
primaryLoading={activating}
|
|
/>
|
|
}
|
|
>
|
|
<LyraBubble text={t(lyraBodyKey)} emotion="empathy" />
|
|
|
|
<Text
|
|
style={{
|
|
marginTop: 14,
|
|
fontFamily: 'Nunito_700Bold',
|
|
fontSize: 12,
|
|
letterSpacing: 0.6,
|
|
color: colors.textMuted,
|
|
textTransform: 'uppercase',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
{t(titleKey)}
|
|
</Text>
|
|
|
|
<View
|
|
style={{
|
|
marginTop: 8,
|
|
alignSelf: 'center',
|
|
height: screenshotHeight,
|
|
aspectRatio: 0.9,
|
|
}}
|
|
>
|
|
<Image
|
|
source={screenshot}
|
|
style={{ width: '100%', height: '100%' }}
|
|
resizeMode="contain"
|
|
/>
|
|
</View>
|
|
|
|
<ScreenshotPointer buttonLabel={t(buttonLabelKey)} alignment={pointerAlignment} />
|
|
|
|
<Text
|
|
style={{
|
|
marginTop: 10,
|
|
fontFamily: 'Nunito_400Regular',
|
|
fontSize: 12,
|
|
lineHeight: 17,
|
|
color: colors.textMuted,
|
|
textAlign: 'center',
|
|
paddingHorizontal: 8,
|
|
}}
|
|
>
|
|
{t(markerHintKey)}
|
|
</Text>
|
|
{children}
|
|
</OnboardingShell>
|
|
);
|
|
}
|