## Stripe Checkout Rename
Alte Legacy-Tier-Namen 'standard/pro' (von alter Tier-Struktur) waren
irreführend — heute heißt es 'pro/legend'. Cleanup:
- ENV-Var-Namen: STRIPE_PRICE_<PLAN>_<BILLING> (computed) statt
hardcoded STANDARD/PRO Mapping. Erwartet:
STRIPE_PRICE_PRO_MONTHLY
STRIPE_PRICE_PRO_YEARLY
STRIPE_PRICE_LEGEND_MONTHLY
STRIPE_PRICE_LEGEND_YEARLY
- 'quarterly' billing entfernt (Strategist-Verdict: nur monthly + yearly,
'2 Monate gratis' bei yearly).
- metadata enthält jetzt billing zusätzlich zu plan.
Webhook-Audit: bereits korrekt (mapped session.metadata.plan → pro/legend/free
via simple switch).
User-Action benötigt (Stripe Test-Dashboard):
- 4 Products + Prices anlegen mit 14-Tage-Trial
- Pricing pro Strategist: Pro 3,99/Mo + 39,90/Yr (2mo gratis),
Legend 7,99/Mo + 79,90/Yr
- Webhook-Endpoint: https://staging.rebreak.org/api/stripe/webhook
(Events: checkout.session.completed, customer.subscription.{updated,deleted})
- ENV-Vars (incl. STRIPE_WEBHOOK_SECRET) in Infisical pflegen
## TTS Audio-Button in LyraBubble
DiGA-Accessibility: Screen-Reader-Alternative + Lese-Hürden-Mitigation.
- lib/lyraSpeech.ts: one-shot TTS-Helper (vereinfacht aus SosTtsQueue)
- Fetch /api/coach/speak mit Auth-Token
- Bytes → Base64 → temp-file → expo-av Audio.Sound
- Stop-fn: abortet in-flight fetch + unloaded sound
- Status-callback: idle | loading | playing
- LyraBubble: Audio-Button rechts oben (orange Pill, 34×34)
- Icon: volume-medium / hourglass / stop je nach status
- Auto-stop bei text-change (Slide-Switch) + unmount
- A11y-Labels in 4 Sprachen (audio_play / audio_loading / audio_stop)
Bubble-paddingRight erhöht auf 50 für Button-Platz.
## Locales
de/en/fr/ar: onboarding.lyra.audio_play / audio_loading / audio_stop
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
262 lines
8.4 KiB
TypeScript
262 lines
8.4 KiB
TypeScript
import { useState } from 'react';
|
|
import { Alert, 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 { 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 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 { height: screenH } = useWindowDimensions();
|
|
const lang = i18n.language || 'de';
|
|
const screenshot = getPermissionScreenshot(dialog, lang);
|
|
const buttonLabelKey =
|
|
dialog === 'url_filter'
|
|
? 'onboarding.protection.dialog_button_allow'
|
|
: 'onboarding.protection.dialog_button_continue';
|
|
|
|
// 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>
|
|
|
|
{/* Screenshot — Modal hat eigene runde Ecken im Bild, kein extra Container-
|
|
Radius (sonst Double-Round-Look). Nur padding für Atmungsraum. */}
|
|
<View
|
|
style={{
|
|
marginTop: 8,
|
|
alignSelf: 'center',
|
|
height: screenshotHeight,
|
|
aspectRatio: 0.9,
|
|
}}
|
|
>
|
|
<Image
|
|
source={screenshot}
|
|
style={{ width: '100%', height: '100%' }}
|
|
resizeMode="contain"
|
|
/>
|
|
</View>
|
|
|
|
{/* Animierter Pointer UNTER dem Screenshot — Dimensions-agnostic. */}
|
|
<ScreenshotPointer buttonLabel={t(buttonLabelKey)} />
|
|
|
|
<Text
|
|
style={{
|
|
marginTop: 10,
|
|
fontFamily: 'Nunito_400Regular',
|
|
fontSize: 12,
|
|
lineHeight: 17,
|
|
color: colors.textMuted,
|
|
textAlign: 'center',
|
|
paddingHorizontal: 8,
|
|
}}
|
|
>
|
|
{t('onboarding.protection.tap_marker_hint')}
|
|
</Text>
|
|
{children}
|
|
</OnboardingShell>
|
|
);
|
|
}
|