From 22385d7d67f639baa17468231ce925490097fbba Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sun, 17 May 2026 20:51:11 +0200 Subject: [PATCH] feat(stripe,onboarding): tier-rename + TTS audio button in lyra bubble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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__ (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 --- .../components/onboarding/LyraBubble.tsx | 92 ++++++++++++-- .../onboarding/slides/ProtectionSlide.tsx | 8 +- apps/rebreak-native/lib/lyraSpeech.ts | 120 ++++++++++++++++++ apps/rebreak-native/locales/ar.json | 5 +- apps/rebreak-native/locales/de.json | 5 +- apps/rebreak-native/locales/en.json | 5 +- apps/rebreak-native/locales/fr.json | 5 +- backend/server/api/stripe/checkout.post.ts | 57 ++++++--- 8 files changed, 255 insertions(+), 42 deletions(-) create mode 100644 apps/rebreak-native/lib/lyraSpeech.ts diff --git a/apps/rebreak-native/components/onboarding/LyraBubble.tsx b/apps/rebreak-native/components/onboarding/LyraBubble.tsx index 107b9ab..2de8cb1 100644 --- a/apps/rebreak-native/components/onboarding/LyraBubble.tsx +++ b/apps/rebreak-native/components/onboarding/LyraBubble.tsx @@ -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('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 ( @@ -54,16 +94,14 @@ export function LyraBubble({ transform: [{ translateX }], }} > - {/* Speech-Bubble */} {text} + + {/* Audio-Button rechts oben — kompakt, dezent */} + + + diff --git a/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx b/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx index c06c3e3..0e17a61 100644 --- a/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx +++ b/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx @@ -222,18 +222,14 @@ function PreExplainer({ {t(titleKey)} - {/* 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. */} { + 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 { + 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'); + } +} diff --git a/apps/rebreak-native/locales/ar.json b/apps/rebreak-native/locales/ar.json index 2c5a954..e942ef7 100644 --- a/apps/rebreak-native/locales/ar.json +++ b/apps/rebreak-native/locales/ar.json @@ -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": "هيا نبدأ", diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index dd1c2c0..006e2d0 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -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", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index d03b71f..3ed9355 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -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", diff --git a/apps/rebreak-native/locales/fr.json b/apps/rebreak-native/locales/fr.json index e7e0c75..8f13cde 100644 --- a/apps/rebreak-native/locales/fr.json +++ b/apps/rebreak-native/locales/fr.json @@ -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", diff --git a/backend/server/api/stripe/checkout.post.ts b/backend/server/api/stripe/checkout.post.ts index fb21210..f5fbb4e 100644 --- a/backend/server/api/stripe/checkout.post.ts +++ b/backend/server/api/stripe/checkout.post.ts @@ -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> = { - 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__ 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, }, });