chahinebrini 22385d7d67 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>
2026-05-17 20:51:11 +02:00

121 lines
3.5 KiB
TypeScript

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');
}
}