fix(onboarding): FAQ-answers + iOS-screen-time pointer alignment
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>
This commit is contained in:
parent
2e409efaf0
commit
534f978b4e
@ -14,19 +14,35 @@ Versioning: `version` follows SemVer, `versionCode` is monotonically increasing.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Duo-style Onboarding (9 Slides)**: Vollständiger Onboarding-Flow — Welcome → Privacy → Nickname → DiGA-Choice → DiGA-Code → Plan → Payment → Protection → Done. Lyra-Bubble mit TTS-Audio-Button auf jedem Slide. Pre-Explainer-Screenshots vor iOS-Permission-Dialogen. Confetti-Animation + Top-5-FAQ-Accordion auf Done-Screen.
|
- **Duo-style Onboarding (9 Slides)**: Vollständiger Onboarding-Flow — Welcome → Privacy → Nickname → DiGA-Choice → DiGA-Code → Plan → Payment → Protection → Done. Lyra-Bubble mit TTS-Audio-Button auf jedem Slide. Pre-Explainer-Screenshots vor iOS-Permission-Dialogen. Confetti-Animation + Top-5-FAQ-Accordion auf Done-Screen.
|
||||||
- **DiGA-Redeem-Endpoint + 10 Test-Codes**: Backend-Endpoint für DiGA-Code-Einlösung. 10 vordefinierte Test-Codes (REBREAK-TEST-001 bis REBREAK-TEST-010) für QA und Reviewer.
|
- **DiGA-Redeem-Endpoint + 100 Test-Codes**: Backend-Endpoint für DiGA-Code-Einlösung. 100 vordefinierte Test-Codes (REBREAK-TEST-001 bis REBREAK-TEST-100) für QA und Reviewer.
|
||||||
|
- **Lyra Voice TTS Auto-Play + Voice-Button in Bubble**: TTS startet automatisch beim Slide-Einblenden; Voice-Button in der Lyra-Bubble startet/stoppt Audio on-demand. DiGA-Accessibility-Feature.
|
||||||
|
- **Stripe Web-Checkout-Integration**: Pro 3,99 EUR/Monat, Legend 7,99 EUR/Monat, je 14-Tage-Trial. Checkout-Redirect via `expo-web-browser`, Webhook bestätigt Plan-Aktivierung serverseitig.
|
||||||
- **Arabisch (Arabic) + RTL-Support**: Vollständige arabische Lokalisation mit automatischem RTL-Layout-Switching.
|
- **Arabisch (Arabic) + RTL-Support**: Vollständige arabische Lokalisation mit automatischem RTL-Layout-Switching.
|
||||||
- **NEFilter Robust Disable**: 2-step Apple-Pattern für zuverlässiges Deaktivieren des URL-Filters. `resetUrlFilter` als Recovery-Pfad bei code-5-Fehlern. `ProtectionOffSheet` ersetzt bisherigen Alert.
|
- **NEFilter Robust Disable**: 2-step Apple-Pattern für zuverlässiges Deaktivieren des URL-Filters. `resetUrlFilter` als Recovery-Pfad bei code-5-Fehlern. `ProtectionOffSheet` ersetzt bisherigen Alert.
|
||||||
- **Family Controls always-on**: Kein "Bald"-Placeholder mehr — FamilyControls-Entitlement ist vollständig aktiv.
|
- **Family Controls always-on**: Kein "Bald"-Placeholder mehr — FamilyControls-Entitlement (Distribution-approved) ist vollständig aktiv.
|
||||||
- **Stripe Tier-Rename + Checkout-Refactor**: Pläne heißen jetzt konsistent `pro` / `legend` (statt alter Naming-Varianten). Checkout-Endpoint neu strukturiert.
|
- **Stripe Tier-Rename + Checkout-Refactor**: Pläne heißen jetzt konsistent `pro` / `legend` (statt alter Naming-Varianten). Checkout-Endpoint neu strukturiert.
|
||||||
- **protectionDisabledAt (Backend)**: Server-seitige Timestamps verhindern Auto-Reaktivierung nach manuellem Deaktivieren.
|
- **protectionDisabledAt (Backend)**: Server-seitige Timestamps verhindern Auto-Reaktivierung nach manuellem Deaktivieren durch den User.
|
||||||
|
- **FaqAccordion-Komponente (shared)**: FAQ-Accordion extrahiert als `components/FaqAccordion` — geteilt zwischen DoneSlide und `help/faq`-Seite.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **ProtectionSlide: Platform.OS-Dispatch**: iOS-Pfad aktiviert NEFilter + FamilyControls. Android-Pfad aktiviert VPN-Permission + a11y-TamperLock (VPN-Permission-Dialog → a11y-Pre-Explainer → a11y-Settings-Open → AppState-Auto-Detect-Return → tamperLock armed → finished).
|
||||||
|
- **Blocker Single-Banner-Logik**: `lockedIn` ist nur `true` wenn BEIDE Schutz-Ebenen aktiv sind (iOS: urlFilter && appDeletionLock; Android: VPN && a11y). Kein falsches "Locked In" mehr bei Teilschutz.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Android Keyboard-Covers-Input (Chat/DM)**: `react-native-keyboard-controller` ersetzt bisherigen KeyboardAvoidingView-Workaround — Input-Feld bleibt beim Tippen korrekt sichtbar.
|
||||||
- **Protection Cooldown Auto-Disable Race**: Stale-State nach Cooldown-Ablauf korrigiert — kein falscher "aktiv"-Zustand mehr.
|
- **Protection Cooldown Auto-Disable Race**: Stale-State nach Cooldown-Ablauf korrigiert — kein falscher "aktiv"-Zustand mehr.
|
||||||
- **Blocker: lockedIn requires both layers**: Grüner "Locked In"-Banner erscheint nur noch, wenn beide Schutz-Ebenen (VPN + a11y) aktiv sind.
|
|
||||||
- **Nickname Validation + Duplicate-Check**: Echtzeit-Prüfung auf bereits vergebene Nicknames im Onboarding.
|
- **Nickname Validation + Duplicate-Check**: Echtzeit-Prüfung auf bereits vergebene Nicknames im Onboarding.
|
||||||
- **DiGA-Code Auto-Format**: Code-Eingabe formatiert automatisch (Großbuchstaben, Bindestriche).
|
- **DiGA-Code Auto-Format**: Code-Eingabe formatiert automatisch (Großbuchstaben, Bindestriche).
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
- Französisch (fr) — 3. Sprache (war bereits in v0.2.x als Beta eingeführt, jetzt vollständig).
|
||||||
|
- Arabisch (ar) + RTL — neu in v0.3.0.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `protectionDisabledAt`-Timestamp auf User-Profil — verhindert Auto-Reactivation nach manuellem Disable.
|
||||||
|
- DiGA-Code-Redemption-Endpoint (`POST /api/diga/redeem`) — 100 Test-Codes, Plan-Hochstufung auf `legend`.
|
||||||
|
- Migration `20260517_add_lyra_post_i18n_key`: `i18n_key TEXT` auf `community_posts` (nullable, non-blocking). **Pending Deploy auf Hetzner** — Feature-Flag `USE_TEMPLATE_CATALOG=false` (default) haelt dieses Feature deaktiviert bis Migration deployed ist.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [0.2.1] — versionCode 9 — 2026-05-16
|
## [0.2.1] — versionCode 9 — 2026-05-16
|
||||||
|
|||||||
@ -1,68 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { ScrollView, View } from 'react-native';
|
||||||
import { ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
import { AppHeader } from '../../components/AppHeader';
|
import { AppHeader } from '../../components/AppHeader';
|
||||||
|
import { FaqAccordion, type FaqItem } from '../../components/FaqAccordion';
|
||||||
type FaqItem = { q: string; a: string };
|
|
||||||
|
|
||||||
function FaqRow({ q, a, colors }: FaqItem & { colors: ReturnType<typeof useColors> }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: colors.border,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setOpen((v) => !v)}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 14,
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 15,
|
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
|
||||||
color: colors.text,
|
|
||||||
lineHeight: 21,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{q}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name={open ? 'chevron-up' : 'chevron-down'}
|
|
||||||
size={18}
|
|
||||||
color={colors.textMuted}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{open ? (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingBottom: 16,
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
color: colors.textMuted,
|
|
||||||
lineHeight: 21,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{a}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FaqScreen() {
|
export default function FaqScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -93,27 +34,7 @@ export default function FaqScreen() {
|
|||||||
}}
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<View
|
<FaqAccordion items={items} />
|
||||||
style={{
|
|
||||||
backgroundColor: colors.card,
|
|
||||||
borderRadius: 14,
|
|
||||||
overflow: 'hidden',
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
shadowOpacity: 0.04,
|
|
||||||
shadowRadius: 3,
|
|
||||||
elevation: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{items.map((item, i) => (
|
|
||||||
<FaqRow
|
|
||||||
key={i}
|
|
||||||
q={item.q}
|
|
||||||
a={item.a}
|
|
||||||
colors={colors}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
178
apps/rebreak-native/components/FaqAccordion.tsx
Normal file
178
apps/rebreak-native/components/FaqAccordion.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useColors } from '../lib/theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared FAQ Accordion-Komponente.
|
||||||
|
*
|
||||||
|
* Nutzung:
|
||||||
|
* <FaqAccordion items={[{ q: t('help.faq_q1'), a: t('help.faq_a1') }, ...]} />
|
||||||
|
*
|
||||||
|
* Varianten:
|
||||||
|
* - 'card' (default) — gruppiert in einer Card mit Trennlinien zwischen Zeilen,
|
||||||
|
* so wie auf der Settings → FAQ-Seite (iOS-Listen-Pattern).
|
||||||
|
* - 'pills' — jede Frage als eigene pill-Card, kompakt für inline-Embeds
|
||||||
|
* (z.B. End-of-Onboarding Top-5-Block).
|
||||||
|
*
|
||||||
|
* Eltern hat die Verantwortung für padding/marginHorizontal/ScrollView-Wrap. Die
|
||||||
|
* Komponente kümmert sich NUR um Rendering der Zeilen + Expand/Collapse-State.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FaqItem = { q: string; a: string };
|
||||||
|
|
||||||
|
type Variant = 'card' | 'pills';
|
||||||
|
|
||||||
|
export function FaqAccordion({
|
||||||
|
items,
|
||||||
|
variant = 'card',
|
||||||
|
}: {
|
||||||
|
items: FaqItem[];
|
||||||
|
variant?: Variant;
|
||||||
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
|
|
||||||
|
if (variant === 'pills') {
|
||||||
|
return (
|
||||||
|
<View style={{ gap: 8 }}>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<PillRow key={i} q={item.q} a={item.a} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: 14,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.04,
|
||||||
|
shadowRadius: 3,
|
||||||
|
elevation: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<CardRow
|
||||||
|
key={i}
|
||||||
|
q={item.q}
|
||||||
|
a={item.a}
|
||||||
|
isLast={i === items.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardRow({ q, a, isLast }: { q: string; a: string; isLast: boolean }) {
|
||||||
|
const colors = useColors();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderBottomWidth: isLast ? 0 : 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setOpen((v) => !v)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
color: colors.text,
|
||||||
|
lineHeight: 21,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{q}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={open ? 'chevron-up' : 'chevron-down'}
|
||||||
|
size={18}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{open ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 16,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: colors.textMuted,
|
||||||
|
lineHeight: 21,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{a}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PillRow({ q, a }: { q: string; a: string }) {
|
||||||
|
const colors = useColors();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setOpen((v) => !v)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 19,
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{q}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={open ? 'chevron-up' : 'chevron-down'}
|
||||||
|
size={16}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{open ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: 'rgba(0,0,0,0.06)',
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 19,
|
||||||
|
color: colors.textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{a}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -27,11 +27,25 @@ import { useColors } from '../../lib/theme';
|
|||||||
*/
|
*/
|
||||||
export function ScreenshotPointer({
|
export function ScreenshotPointer({
|
||||||
buttonLabel,
|
buttonLabel,
|
||||||
|
alignment = 'center',
|
||||||
}: {
|
}: {
|
||||||
/** Der Text auf dem korrekten Button im iOS-Dialog. Wird ins Label übernommen. */
|
/** Der Text auf dem korrekten Button im iOS-Dialog. Wird ins Label übernommen. */
|
||||||
buttonLabel: string;
|
buttonLabel: string;
|
||||||
|
/**
|
||||||
|
* Horizontale Position des Pointer-Pakets relativ zum Screenshot.
|
||||||
|
* 'center' = unter zentrierten Buttons (z.B. iOS NEFilter "Erlauben" unten).
|
||||||
|
* 'left' = unter links-positioniertem Button (z.B. iOS Family Controls
|
||||||
|
* "Fortfahren"/"Continuer" — Apple platziert die Zustimmung
|
||||||
|
* links-grau, Decline rechts-blau bei Screen-Time-Permission).
|
||||||
|
* 'right' = unter rechts-positioniertem Button (symmetrisch).
|
||||||
|
*/
|
||||||
|
alignment?: 'left' | 'center' | 'right';
|
||||||
}) {
|
}) {
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
|
// Fixed Offset in dp — auf einem 390pt-iPhone shiftet das ~80pt vom Mittel-
|
||||||
|
// punkt weg, das matched die Button-Position innerhalb des ~80%-breiten
|
||||||
|
// System-Dialog-Modals gut genug ohne pixel-genaue Kalibrierung.
|
||||||
|
const offsetX = alignment === 'left' ? -80 : alignment === 'right' ? 80 : 0;
|
||||||
const bounce = useRef(new Animated.Value(0)).current;
|
const bounce = useRef(new Animated.Value(0)).current;
|
||||||
const pulse = useRef(new Animated.Value(0)).current;
|
const pulse = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
@ -72,11 +86,11 @@ export function ScreenshotPointer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ alignItems: 'center', marginTop: 8 }}>
|
<View style={{ alignItems: 'center', marginTop: 8 }}>
|
||||||
{/* Animated Up-Arrow zeigt auf den Screenshot (auf dessen unteren Button) */}
|
{/* Animated Up-Arrow zeigt auf den Screenshot (auf dessen relevanten Button) */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
opacity: arrowOpacity,
|
opacity: arrowOpacity,
|
||||||
transform: [{ translateY }, { scale: arrowScale }],
|
transform: [{ translateX: offsetX }, { translateY }, { scale: arrowScale }],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="caret-up" size={28} color={colors.brandOrange} />
|
<Ionicons name="caret-up" size={28} color={colors.brandOrange} />
|
||||||
@ -93,7 +107,7 @@ export function ScreenshotPointer({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
transform: [{ translateY }],
|
transform: [{ translateX: offsetX }, { translateY }],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: 16 }}>👆</Text>
|
<Text style={{ fontSize: 16 }}>👆</Text>
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Animated, Easing, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
|
import { Animated, Easing, Text, useWindowDimensions, View } from 'react-native';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useColors } from '../../../lib/theme';
|
import { useColors } from '../../../lib/theme';
|
||||||
import { OnboardingShell } from '../OnboardingShell';
|
import { OnboardingShell } from '../OnboardingShell';
|
||||||
import { LyraBubble } from '../LyraBubble';
|
import { LyraBubble } from '../LyraBubble';
|
||||||
import { CTABar } from '../CTABar';
|
import { CTABar } from '../CTABar';
|
||||||
|
import { FaqAccordion, type FaqItem } from '../../FaqAccordion';
|
||||||
|
|
||||||
const FAQ_KEYS = ['faq_q1', 'faq_q2', 'faq_q3', 'faq_q4', 'faq_q5'] as const;
|
// Top-5 (kuratiert für Onboarding-Ende) — alle 8 sind unter app/help/faq.tsx.
|
||||||
|
const ONBOARDING_FAQ_IDS = [1, 2, 4, 5, 8] as const;
|
||||||
|
|
||||||
export function DoneSlide({
|
export function DoneSlide({
|
||||||
onEnter,
|
onEnter,
|
||||||
@ -23,6 +25,11 @@ export function DoneSlide({
|
|||||||
const scale = useRef(new Animated.Value(0.6)).current;
|
const scale = useRef(new Animated.Value(0.6)).current;
|
||||||
const opacity = useRef(new Animated.Value(0)).current;
|
const opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
const faqItems: FaqItem[] = ONBOARDING_FAQ_IDS.map((id) => ({
|
||||||
|
q: t(`help.faq_q${id}`),
|
||||||
|
a: t(`help.faq_a${id}`),
|
||||||
|
}));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.spring(scale, { toValue: 1, useNativeDriver: true, friction: 5, tension: 90 }),
|
Animated.spring(scale, { toValue: 1, useNativeDriver: true, friction: 5, tension: 90 }),
|
||||||
@ -95,7 +102,7 @@ export function DoneSlide({
|
|||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Inline Top-5-FAQ Accordion */}
|
{/* Inline Top-5-FAQ Accordion (pills-Variante) */}
|
||||||
<View style={{ marginTop: 28 }}>
|
<View style={{ marginTop: 28 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
@ -109,16 +116,7 @@ export function DoneSlide({
|
|||||||
>
|
>
|
||||||
{t('onboarding.done.faq_section_title')}
|
{t('onboarding.done.faq_section_title')}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ gap: 8 }}>
|
<FaqAccordion items={faqItems} variant="pills" />
|
||||||
{FAQ_KEYS.map((qkey) => (
|
|
||||||
<FaqRow
|
|
||||||
key={qkey}
|
|
||||||
question={t(`help.${qkey}`)}
|
|
||||||
answer={t(`help.${qkey.replace('q', 'a')}`)}
|
|
||||||
colors={colors}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</OnboardingShell>
|
</OnboardingShell>
|
||||||
);
|
);
|
||||||
@ -195,65 +193,3 @@ function ConfettiOverlay() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── FAQ Accordion-Row ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function FaqRow({
|
|
||||||
question,
|
|
||||||
answer,
|
|
||||||
colors,
|
|
||||||
}: {
|
|
||||||
question: string;
|
|
||||||
answer: string;
|
|
||||||
colors: import('../../../lib/theme').ColorScheme;
|
|
||||||
}) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
backgroundColor: colors.surfaceElevated,
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingVertical: 12,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setExpanded((v) => !v)}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
|
||||||
fontSize: 13,
|
|
||||||
lineHeight: 19,
|
|
||||||
color: colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{question}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name={expanded ? 'chevron-up' : 'chevron-down'}
|
|
||||||
size={16}
|
|
||||||
color={colors.textMuted}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{expanded ? (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
marginTop: 8,
|
|
||||||
paddingTop: 8,
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: 'rgba(0,0,0,0.06)',
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
fontSize: 13,
|
|
||||||
lineHeight: 19,
|
|
||||||
color: colors.textMuted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{answer}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -154,6 +154,7 @@ function IosProtectionSlide({
|
|||||||
ctaKey="onboarding.protection.cta_primary"
|
ctaKey="onboarding.protection.cta_primary"
|
||||||
buttonLabelKey="onboarding.protection.dialog_button_continue"
|
buttonLabelKey="onboarding.protection.dialog_button_continue"
|
||||||
markerHintKey="onboarding.protection.tap_marker_hint"
|
markerHintKey="onboarding.protection.tap_marker_hint"
|
||||||
|
pointerAlignment="left"
|
||||||
activating={activating}
|
activating={activating}
|
||||||
onActivate={activateAppLock}
|
onActivate={activateAppLock}
|
||||||
current={current}
|
current={current}
|
||||||
@ -369,6 +370,7 @@ function PreExplainer({
|
|||||||
ctaKey,
|
ctaKey,
|
||||||
buttonLabelKey,
|
buttonLabelKey,
|
||||||
markerHintKey,
|
markerHintKey,
|
||||||
|
pointerAlignment = 'center',
|
||||||
activating,
|
activating,
|
||||||
onActivate,
|
onActivate,
|
||||||
current,
|
current,
|
||||||
@ -381,6 +383,7 @@ function PreExplainer({
|
|||||||
ctaKey: string;
|
ctaKey: string;
|
||||||
buttonLabelKey: string;
|
buttonLabelKey: string;
|
||||||
markerHintKey: string;
|
markerHintKey: string;
|
||||||
|
pointerAlignment?: 'left' | 'center' | 'right';
|
||||||
activating: boolean;
|
activating: boolean;
|
||||||
onActivate: () => void;
|
onActivate: () => void;
|
||||||
current: number;
|
current: number;
|
||||||
@ -441,7 +444,7 @@ function PreExplainer({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScreenshotPointer buttonLabel={t(buttonLabelKey)} />
|
<ScreenshotPointer buttonLabel={t(buttonLabelKey)} alignment={pointerAlignment} />
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user