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

154 lines
4.2 KiB
TypeScript

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.
*
* 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;
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);
translateX.setValue(-12);
Animated.parallel([
Animated.timing(opacity, {
toValue: 1,
duration: 400,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
}),
Animated.spring(translateX, {
toValue: 0,
useNativeDriver: true,
friction: 7,
tension: 80,
}),
]).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 }}>
<RiveAvatar emotion={emotion} size="md" />
</View>
<Animated.View
style={{
flex: 1,
opacity,
transform: [{ translateX }],
}}
>
<View
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 16,
paddingVertical: 14,
paddingLeft: 16,
paddingRight: 50, // Platz für den Audio-Button rechts
position: 'relative',
}}
>
<Text
style={{
fontFamily: 'Nunito_600SemiBold',
fontSize: 16,
lineHeight: 23,
color: colors.text,
}}
>
{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>
);
}