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:
parent
3c5c9ebfba
commit
22385d7d67
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
120
apps/rebreak-native/lib/lyraSpeech.ts
Normal file
120
apps/rebreak-native/lib/lyraSpeech.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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": "هيا نبدأ",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user