feat(stripe,onboarding): tier-rename + TTS audio button in lyra bubble

## 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>
This commit is contained in:
chahinebrini 2026-05-17 20:51:11 +02:00
parent 3c5c9ebfba
commit 22385d7d67
8 changed files with 255 additions and 42 deletions

View File

@ -1,26 +1,37 @@
import { useEffect, useRef } from 'react';
import { Animated, Easing, Text, View } from 'react-native';
import { useEffect, useRef, useState } from 'react';
import { Animated, Easing, Text, TouchableOpacity, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import i18n from '../../lib/i18n';
import { useTranslation } from 'react-i18next';
import { RiveAvatar, type Emotion } from '../RiveAvatar';
import { useColors } from '../../lib/theme';
import {
playLyraSpeech,
stopLyraSpeech,
type LyraSpeechStatus,
} from '../../lib/lyraSpeech';
/**
* Lyra-Mascot (animiertes Rive-Avatar) links + Speech-Bubble rechts.
* Fade+slide-in beim Mount und bei text-change (key-prop verwenden für Re-Animate).
*
* Layout entspricht Duolingo's Duo-Speech-Pattern: Avatar quadratisch links,
* Bubble organisch rechts mit "Tail" zum Avatar zeigend.
* Plus: kleiner Audio-Button rechts oben in der Bubble User kann den Lyra-
* Text via TTS hören lassen. DiGA-Accessibility-relevant (Screen-Reader-
* Alternative + Lese-Hürden-Mitigation).
*
* Fade+slide-in beim Mount und bei text-change (key-prop verwenden für Re-Animate).
*/
export function LyraBubble({
text,
emotion = 'idle',
}: {
text: string;
/** Lyra-Emotion fürs Rive (idle, happy, thinking, empathy). */
emotion?: Emotion;
}) {
const { t } = useTranslation();
const colors = useColors();
const opacity = useRef(new Animated.Value(0)).current;
const translateX = useRef(new Animated.Value(-12)).current;
const [speech, setSpeech] = useState<LyraSpeechStatus>('idle');
useEffect(() => {
opacity.setValue(0);
@ -41,6 +52,35 @@ export function LyraBubble({
]).start();
}, [text, opacity, translateX]);
// Cleanup beim Unmount — sonst spielt der Sound weiter wenn User Slide wechselt
useEffect(() => {
return () => {
void stopLyraSpeech();
};
}, []);
// Stoppt auch wenn der Bubble-Text wechselt (= neuer Slide angezeigt)
useEffect(() => {
setSpeech('idle');
void stopLyraSpeech();
}, [text]);
async function togglePlayback() {
if (speech === 'playing' || speech === 'loading') {
await stopLyraSpeech();
setSpeech('idle');
return;
}
await playLyraSpeech(text, i18n.language || 'de', setSpeech);
}
const a11yLabel =
speech === 'playing'
? t('onboarding.lyra.audio_stop')
: speech === 'loading'
? t('onboarding.lyra.audio_loading')
: t('onboarding.lyra.audio_play');
return (
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 12 }}>
<View style={{ marginTop: 4 }}>
@ -54,16 +94,14 @@ export function LyraBubble({
transform: [{ translateX }],
}}
>
{/* Speech-Bubble */}
<View
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 16,
paddingVertical: 14,
paddingHorizontal: 16,
// Tail würde via custom-path gerendert — hier reicht der reine
// Bubble-Look ohne Tail (cleaner auf RN, Duolingo macht's auf iOS
// auch ohne harten Pixel-Tail).
paddingLeft: 16,
paddingRight: 50, // Platz für den Audio-Button rechts
position: 'relative',
}}
>
<Text
@ -76,6 +114,38 @@ export function LyraBubble({
>
{text}
</Text>
{/* Audio-Button rechts oben — kompakt, dezent */}
<TouchableOpacity
onPress={togglePlayback}
hitSlop={8}
activeOpacity={0.7}
accessibilityLabel={a11yLabel}
accessibilityRole="button"
style={{
position: 'absolute',
top: 8,
right: 8,
width: 34,
height: 34,
borderRadius: 17,
backgroundColor: colors.brandOrange,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons
name={
speech === 'playing'
? 'stop'
: speech === 'loading'
? 'hourglass'
: 'volume-medium'
}
size={16}
color="#ffffff"
/>
</TouchableOpacity>
</View>
</Animated.View>
</View>

View File

@ -222,18 +222,14 @@ function PreExplainer({
{t(titleKey)}
</Text>
{/* Screenshot — sauber ohne Overlay. Dynamisch dimensioniert. */}
{/* 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,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
}}
>
<Image

View File

@ -0,0 +1,120 @@
import { Audio } from 'expo-av';
import * as FileSystem from 'expo-file-system/legacy';
import Constants from 'expo-constants';
import { supabase } from './supabase';
/**
* One-shot Lyra-TTS für Onboarding/Bubble-Playback.
*
* Vereinfachte Version des SosTtsQueue für Single-Sentence-Playback:
* - Kein Pre-Fetch, keine Queue, kein Streaming
* - Bei neuem play(): vorheriger Sound + Fetch wird abgebrochen
* - status-Callback für UI-Feedback (idle/loading/playing)
*
* Genutzt für Lyra-Bubble Accessibility (DiGA-relevant): User tappt Audio-
* Button neben der Bubble Text wird per /api/coach/speak gesprochen.
*
* Endpoint /api/coach/speak ist der Default-Provider Backend pickt selbst
* (Cartesia/ElevenLabs/Deepgram je nach Plan + Voice-Picker-Setting).
*/
const apiUrl = Constants.expoConfig?.extra?.apiUrl as string;
let currentSound: Audio.Sound | null = null;
let currentAbort: AbortController | null = null;
export type LyraSpeechStatus = 'idle' | 'loading' | 'playing';
export async function stopLyraSpeech(): Promise<void> {
if (currentAbort) {
currentAbort.abort();
currentAbort = null;
}
if (currentSound) {
try {
await currentSound.unloadAsync();
} catch {
// ignore — sound might already be released
}
currentSound = null;
}
}
export async function playLyraSpeech(
text: string,
locale: string,
onStatus?: (status: LyraSpeechStatus) => void,
): Promise<void> {
await stopLyraSpeech();
onStatus?.('loading');
const controller = new AbortController();
currentAbort = controller;
try {
const session = (await supabase.auth.getSession()).data.session;
if (!session?.access_token) {
throw new Error('not_authenticated');
}
const res = await fetch(`${apiUrl}/api/coach/speak`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({ text, locale, mode: 'coach' }),
signal: controller.signal,
});
if (!res.ok || controller.signal.aborted) {
onStatus?.('idle');
return;
}
// Default-Provider liefert audio/mpeg als raw bytes
const buffer = await res.arrayBuffer();
if (controller.signal.aborted || buffer.byteLength === 0) {
onStatus?.('idle');
return;
}
// Bytes → Base64 (chunked damit String.fromCharCode nicht overflowt)
const bytes = new Uint8Array(buffer);
const chunks: string[] = [];
const cs = 0x8000;
for (let i = 0; i < bytes.length; i += cs) {
chunks.push(
String.fromCharCode(...bytes.subarray(i, Math.min(i + cs, bytes.length))),
);
}
const base64 = btoa(chunks.join(''));
const tmpPath = `${FileSystem.cacheDirectory}lyra-speech-${Date.now()}.mp3`;
await FileSystem.writeAsStringAsync(tmpPath, base64, {
encoding: FileSystem.EncodingType.Base64,
});
if (controller.signal.aborted) {
onStatus?.('idle');
return;
}
const { sound } = await Audio.Sound.createAsync({ uri: tmpPath });
currentSound = sound;
sound.setOnPlaybackStatusUpdate((status) => {
if (!status.isLoaded) return;
if (status.didJustFinish) {
onStatus?.('idle');
stopLyraSpeech().catch(() => {});
}
});
await sound.playAsync();
onStatus?.('playing');
} catch (e) {
if ((e as Error).name === 'AbortError') return;
console.warn('[lyraSpeech] failed:', e);
onStatus?.('idle');
}
}

View File

@ -370,7 +370,10 @@
"protection": { "body": "الآن الجزء الأهم — الحماية على جهازك. هل أنت مستعد؟" },
"protection_url": { "body": "ستظهر نافذة iOS. اضغط «السماح» — الزر السفلي (وليس الأزرق الكبير في الأعلى — هذا هو الفخ)." },
"protection_lock": { "body": "الآن قفل التطبيق. iOS يطلب الوصول إلى مدة استخدام الجهاز — اضغط «متابعة»، مرة أخرى الزر السفلي (وليس الأزرق)." },
"done": { "body": "تم. اليوم الأول من سلسلتك — ولست وحدك." }
"done": { "body": "تم. اليوم الأول من سلسلتك — ولست وحدك." },
"audio_play": "اقرأ النص بصوت عالٍ",
"audio_loading": "جاري تحميل الصوت...",
"audio_stop": "إيقاف التشغيل"
},
"welcome": {
"cta_primary": "هيا نبدأ",

View File

@ -370,7 +370,10 @@
"protection": { "body": "Jetzt der wichtigste Teil — der Schutz auf deinem Gerät. Bereit?" },
"protection_url": { "body": "Gleich kommt ein iOS-Dialog. Tippe \"Erlauben\" — den unteren Button (nicht den großen blauen oben — das ist die Falle)." },
"protection_lock": { "body": "Jetzt der App-Schutz. iOS fragt nach Bildschirmzeit-Zugriff — tippe \"Fortfahren\", wieder den unteren Button (nicht den blauen)." },
"done": { "body": "Geschafft. Tag 1 deiner neuen Streak — und du gehst nicht allein." }
"done": { "body": "Geschafft. Tag 1 deiner neuen Streak — und du gehst nicht allein." },
"audio_play": "Text vorlesen",
"audio_loading": "Lade Stimme...",
"audio_stop": "Wiedergabe stoppen"
},
"welcome": {
"cta_primary": "Los geht's",

View File

@ -370,7 +370,10 @@
"protection": { "body": "Now the important part — the protection on your device. Ready?" },
"protection_url": { "body": "An iOS dialog is coming. Tap \"Allow\" — the bottom button (not the big blue one on top — that's the trap)." },
"protection_lock": { "body": "Now the app lock. iOS asks for Screen Time access — tap \"Continue\", again the bottom button (not the blue one)." },
"done": { "body": "Done. Day 1 of your new streak — and you're not walking alone." }
"done": { "body": "Done. Day 1 of your new streak — and you're not walking alone." },
"audio_play": "Read text aloud",
"audio_loading": "Loading voice...",
"audio_stop": "Stop playback"
},
"welcome": {
"cta_primary": "Let's go",

View File

@ -368,7 +368,10 @@
"protection": { "body": "Maintenant la partie importante — la protection sur ton appareil. Prêt ?" },
"protection_url": { "body": "Une fenêtre iOS va apparaître. Touche « Autoriser » — le bouton du bas (pas le grand bleu en haut — c'est le piège)." },
"protection_lock": { "body": "Maintenant le verrou d'app. iOS demande l'accès à Temps d'écran — touche « Continuer », encore le bouton du bas (pas le bleu)." },
"done": { "body": "Voilà. Jour 1 de ta nouvelle série — et tu n'es pas seul." }
"done": { "body": "Voilà. Jour 1 de ta nouvelle série — et tu n'es pas seul." },
"audio_play": "Lire le texte",
"audio_loading": "Chargement de la voix...",
"audio_stop": "Arrêter la lecture"
},
"welcome": {
"cta_primary": "On y va",

View File

@ -1,12 +1,37 @@
import Stripe from "stripe";
/**
* POST /api/stripe/checkout
*
* Erstellt eine Stripe-Checkout-Session für Pro oder Legend Subscription.
* Frontend (Web) öffnet die zurückgegebene URL User landet im Stripe-Hosted
* Checkout, gibt Zahlungsmethode ein, wird zu success_url umgeleitet.
*
* iOS muss via RevenueCat/Apple-IAP Stripe ist Web-only-Path (Apple-Guideline
* 3.1.1 verbietet externe Bezahlwege für digitale Subs in iOS-Apps).
*
* Body: { plan: 'pro' | 'legend', billing: 'monthly' | 'yearly' }
*
* ENV-Vars (in Infisical):
* STRIPE_PRICE_PRO_MONTHLY Test/Live Stripe price_xxx ID
* STRIPE_PRICE_PRO_YEARLY
* STRIPE_PRICE_LEGEND_MONTHLY
* STRIPE_PRICE_LEGEND_YEARLY
*
* Tier-Mapping (post Free-Drop Pivot, 2026-05):
* Pro 3,99 /mo · 39,90 /yr (2 Monate gratis)
* Legend 7,99 /mo · 79,90 /yr (Multi-Device-Hardening)
*
* 14-Tage-Trial wird im Stripe-Dashboard pro Price konfiguriert (Trial-Period-
* Days) der Endpoint passt sich automatisch an wenn der Price die Trial hat.
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
if (!config.stripeSecretKey) {
throw createError({
statusCode: 500,
message: "Stripe nicht konfiguriert NUXT_STRIPE_SECRET_KEY fehlt",
message: "Stripe nicht konfiguriert STRIPE_SECRET_KEY fehlt",
});
}
@ -17,38 +42,27 @@ export default defineEventHandler(async (event) => {
const plan = body?.plan as string;
const billing = (body?.billing as string) || "monthly";
// Aktive Pläne: free (kein Checkout), pro, legend (legend noch nicht aktiv TODO: Stripe-Preise hinzufügen)
const activePlans = ["pro", "legend"];
if (!plan || !activePlans.includes(plan)) {
throw createError({ statusCode: 400, message: "Ungültiger Plan" });
if (!plan || !["pro", "legend"].includes(plan)) {
throw createError({
statusCode: 400,
message: "Ungültiger Plan (erwartet: 'pro' oder 'legend')",
});
}
if (!["monthly", "yearly"].includes(billing)) {
throw createError({
statusCode: 400,
message: "Ungültiger Billing-Zyklus",
message: "Ungültiger Billing-Zyklus (erwartet: 'monthly' oder 'yearly')",
});
}
const priceEnvMap: Record<string, Record<string, string>> = {
pro: {
monthly: "STRIPE_PRICE_STANDARD_MONTHLY",
quarterly: "STRIPE_PRICE_STANDARD_QUARTERLY",
yearly: "STRIPE_PRICE_STANDARD_YEARLY",
},
legend: {
monthly: "STRIPE_PRICE_PRO_MONTHLY",
quarterly: "STRIPE_PRICE_PRO_QUARTERLY",
yearly: "STRIPE_PRICE_PRO_YEARLY",
},
};
const envKey = priceEnvMap[plan][billing];
// ENV-Var-Naming-Pattern: STRIPE_PRICE_<PLAN>_<BILLING> in Großbuchstaben
const envKey = `STRIPE_PRICE_${plan.toUpperCase()}_${billing.toUpperCase()}`;
const priceId = process.env[envKey];
if (!priceId || !priceId.startsWith("price_")) {
throw createError({
statusCode: 503,
message: `Dieser Plan ist noch nicht verfügbar. (${envKey} nicht gesetzt)`,
message: `Dieser Plan ist noch nicht verfügbar. (${envKey} nicht in Infisical gesetzt)`,
});
}
@ -63,6 +77,7 @@ export default defineEventHandler(async (event) => {
metadata: {
user_id: user.id,
plan,
billing,
},
});