diff --git a/apps/rebreak-native/assets/onboarding/ar/android-a11y-confirm-dialog-001.png b/apps/rebreak-native/assets/onboarding/ar/android-a11y-confirm-dialog-001.png new file mode 100644 index 0000000..dff2fc9 Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/ar/android-a11y-confirm-dialog-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/ar/android-a11y-overview-001.png b/apps/rebreak-native/assets/onboarding/ar/android-a11y-overview-001.png new file mode 100644 index 0000000..d770366 Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/ar/android-a11y-overview-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/ar/android-a11y-rebreak-row-001.png b/apps/rebreak-native/assets/onboarding/ar/android-a11y-rebreak-row-001.png new file mode 100644 index 0000000..bf5818e Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/ar/android-a11y-rebreak-row-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/ar/android-a11y-toggle-on-001.png b/apps/rebreak-native/assets/onboarding/ar/android-a11y-toggle-on-001.png new file mode 100644 index 0000000..e51ccc2 Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/ar/android-a11y-toggle-on-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/ar/android-vpn-permission-001.png b/apps/rebreak-native/assets/onboarding/ar/android-vpn-permission-001.png new file mode 100644 index 0000000..b98fea6 Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/ar/android-vpn-permission-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/de/android-a11y-confirm-dialog-001.png b/apps/rebreak-native/assets/onboarding/de/android-a11y-confirm-dialog-001.png new file mode 100644 index 0000000..117cf15 Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/de/android-a11y-confirm-dialog-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/de/android-a11y-overview-001.png b/apps/rebreak-native/assets/onboarding/de/android-a11y-overview-001.png new file mode 100644 index 0000000..4d1adc0 Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/de/android-a11y-overview-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/de/android-a11y-rebreak-row-001.png b/apps/rebreak-native/assets/onboarding/de/android-a11y-rebreak-row-001.png new file mode 100644 index 0000000..9ca7cfa Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/de/android-a11y-rebreak-row-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/de/android-a11y-toggle-on-001.png b/apps/rebreak-native/assets/onboarding/de/android-a11y-toggle-on-001.png new file mode 100644 index 0000000..94e1f47 Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/de/android-a11y-toggle-on-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/de/android-vpn-permission-001.png b/apps/rebreak-native/assets/onboarding/de/android-vpn-permission-001.png new file mode 100644 index 0000000..864469c Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/de/android-vpn-permission-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/en/android-a11y-confirm-dialog-001.png b/apps/rebreak-native/assets/onboarding/en/android-a11y-confirm-dialog-001.png new file mode 100644 index 0000000..10cc27f Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/en/android-a11y-confirm-dialog-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/en/android-a11y-overview-001.png b/apps/rebreak-native/assets/onboarding/en/android-a11y-overview-001.png new file mode 100644 index 0000000..d0ed225 Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/en/android-a11y-overview-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/en/android-a11y-rebreak-row-001.png b/apps/rebreak-native/assets/onboarding/en/android-a11y-rebreak-row-001.png new file mode 100644 index 0000000..b1c2c63 Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/en/android-a11y-rebreak-row-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/en/android-a11y-toggle-on-001.png b/apps/rebreak-native/assets/onboarding/en/android-a11y-toggle-on-001.png new file mode 100644 index 0000000..f66eb28 Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/en/android-a11y-toggle-on-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/en/android-vpn-permission-001.png b/apps/rebreak-native/assets/onboarding/en/android-vpn-permission-001.png new file mode 100644 index 0000000..562e742 Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/en/android-vpn-permission-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/fr/android-a11y-confirm-dialog-001.png b/apps/rebreak-native/assets/onboarding/fr/android-a11y-confirm-dialog-001.png new file mode 100644 index 0000000..b3802e4 Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/fr/android-a11y-confirm-dialog-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/fr/android-a11y-overview-001.png b/apps/rebreak-native/assets/onboarding/fr/android-a11y-overview-001.png new file mode 100644 index 0000000..8b24268 Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/fr/android-a11y-overview-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/fr/android-a11y-rebreak-row-001.png b/apps/rebreak-native/assets/onboarding/fr/android-a11y-rebreak-row-001.png new file mode 100644 index 0000000..58b15bf Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/fr/android-a11y-rebreak-row-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/fr/android-a11y-toggle-on-001.png b/apps/rebreak-native/assets/onboarding/fr/android-a11y-toggle-on-001.png new file mode 100644 index 0000000..ed68e66 Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/fr/android-a11y-toggle-on-001.png differ diff --git a/apps/rebreak-native/assets/onboarding/fr/android-vpn-permission-001.png b/apps/rebreak-native/assets/onboarding/fr/android-vpn-permission-001.png new file mode 100644 index 0000000..2e1c600 Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/fr/android-vpn-permission-001.png differ diff --git a/apps/rebreak-native/components/PostCard.tsx b/apps/rebreak-native/components/PostCard.tsx index 6031889..ae83ac8 100644 --- a/apps/rebreak-native/components/PostCard.tsx +++ b/apps/rebreak-native/components/PostCard.tsx @@ -75,7 +75,11 @@ function PostCardImpl({ post, onCommentPress }: Props) { }, [heartScale]); const displayAuthor = post.repostOf ? post.repostOf.author : post.author; - const displayContent = post.repostOf ? post.repostOf.content : post.content; + const rawContent = post.repostOf ? post.repostOf.content : post.content; + // i18n-aware content: wenn i18nKey gesetzt → übersetzten Text nehmen, + // sonst rawContent (Legacy-Verhalten unverändert). + const i18nKey = post.repostOf ? undefined : post.i18nKey; + const displayContent = i18nKey ? t(`lyra_posts.${i18nKey}`) : rawContent; const displayImage = post.repostOf ? post.repostOf.imageUrl : post.imageUrl; // Image aspect-ratio: ermittelt aus onLoad event.source.{width,height}. diff --git a/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx b/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx index 0e17a61..7420dd3 100644 --- a/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx +++ b/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx @@ -1,10 +1,11 @@ -import { useState } from 'react'; -import { Alert, Image, Platform, Text, useWindowDimensions, View } from 'react-native'; +import { useEffect, useRef, useState } from 'react'; +import { Alert, AppState, Image, Platform, Text, useWindowDimensions, View } from 'react-native'; import { useTranslation } from 'react-i18next'; import { useColors } from '../../../lib/theme'; import { apiFetch } from '../../../lib/api'; import { invalidateMe } from '../../../hooks/useMe'; import { protection } from '../../../lib/protection'; +import RebreakProtection from '../../../modules/rebreak-protection'; import { getPermissionScreenshot } from '../../../lib/onboardingAssets'; import { OnboardingShell } from '../OnboardingShell'; import { LyraBubble } from '../LyraBubble'; @@ -14,32 +15,34 @@ import { PermissionDeniedSheet } from '../../PermissionDeniedSheet'; import i18n from '../../../lib/i18n'; /** - * Onboarding Step Protection — 2 Phasen, beide mit Pre-Explainer-Modal das - * den iOS-Permission-Dialog vorzeigt + Pulse-Marker auf den "Erlauben"-Button. + * Onboarding-Schutz-Step. * - * ┌──────────────────────────────────────────────────────────────┐ - * │ Phase A: preexplain_url │ - * │ Lyra: "Gleich kommt iOS-Dialog. Tippe ERLAUBEN." │ - * │ Screenshot vom NEFilter-Dialog + roter Pulse auf "Erlauben" │ - * │ CTA "Aktivieren" → triggers protection.activateUrlFilter() │ - * │ │ - * │ Phase B: preexplain_lock │ - * │ Lyra: "Jetzt App-Schutz. Tippe FORTFAHREN." │ - * │ Screenshot vom Screen-Time-Dialog + roter Pulse │ - * │ CTA "Aktivieren" → triggers protection.activateFamilyControls │ - * │ │ - * │ Phase C: done → onDone() │ - * └──────────────────────────────────────────────────────────────┘ + * Platform.OS-Dispatch: + * iOS → IosProtectionSlide (NEFilter + Family-Controls) + * Android → AndroidProtectionSlide (VpnService + Accessibility-Tamper-Lock) * - * Wenn URL-Filter fehlschlägt mit code 5 → PermissionDeniedSheet öffnet sich - * (Retry-Pfad via resetUrlFilter()). Family-Controls hat keinen analogen - * Recovery-Sheet — User muss in Settings → Bildschirmzeit den App-Zugriff - * gewähren. + * Beide haben den gleichen Eltern-Vertrag (current/total/onDone) und nutzen + * den gleichen Pre-Explainer + Lyra-Bubble + CTA-Pattern — die Innereien + * unterscheiden sich nur in (a) welche Permission-Dialoge geöffnet werden + * und (b) welche Screenshots gezeigt werden. */ -type Phase = 'preexplain_url' | 'preexplain_lock' | 'done'; +export function ProtectionSlide(props: { + onDone: () => void; + current: number; + total: number; +}) { + if (Platform.OS === 'android') { + return ; + } + return ; +} -export function ProtectionSlide({ +// ─── iOS ──────────────────────────────────────────────────────────────────── + +type IosPhase = 'preexplain_url' | 'preexplain_lock' | 'done'; + +function IosProtectionSlide({ onDone, current, total, @@ -49,8 +52,7 @@ export function ProtectionSlide({ total: number; }) { const { t } = useTranslation(); - const colors = useColors(); - const [phase, setPhase] = useState('preexplain_url'); + const [phase, setPhase] = useState('preexplain_url'); const [activating, setActivating] = useState(false); const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false); @@ -61,7 +63,6 @@ export function ProtectionSlide({ const res = await protection.activateUrlFilter(); if (!res.enabled) { const isCodeFive = - Platform.OS === 'ios' && typeof res.error === 'string' && /NEFilterErrorDomain:\s*5/i.test(res.error); if (isCodeFive) { @@ -74,7 +75,6 @@ export function ProtectionSlide({ ); return; } - // Filter live → weiter zur Phase B (App-Lock) setPhase('preexplain_lock'); } finally { setActivating(false); @@ -87,8 +87,6 @@ export function ProtectionSlide({ try { const res = await protection.activateFamilyControls(); if (!res.enabled) { - // Family Controls fehlgeschlagen → User Info aber Tour-Done (URL-Filter - // läuft schon, das ist der Hauptschutz; App-Lock ist optional) Alert.alert( t('onboarding.protection.applock_failed_title'), res.error ?? t('onboarding.protection.applock_failed_msg'), @@ -119,41 +117,247 @@ export function ProtectionSlide({ onDone(); } - return phase === 'preexplain_url' ? ( - - setPermissionDeniedOpen(false)} - onRetry={async () => { - const res = await protection.resetUrlFilter(); - if (res.enabled) setPhase('preexplain_lock'); - return res; - }} + if (phase === 'preexplain_url') { + return ( + + setPermissionDeniedOpen(false)} + onRetry={async () => { + const res = await protection.resetUrlFilter(); + if (res.enabled) setPhase('preexplain_lock'); + return res; + }} + /> + + ); + } + if (phase === 'preexplain_lock') { + return ( + - - ) : phase === 'preexplain_lock' ? ( - void; + current: number; + total: number; +}) { + const { t } = useTranslation(); + const [phase, setPhase] = useState('preexplain_vpn'); + const [activating, setActivating] = useState(false); + // True wenn wir auf Settings-Rückkehr warten. AppState-Listener pollt dann + // a11y-State + advanced automatisch wenn ReBreak-Schalter live ist. + const awaitingReturnRef = useRef(false); + const appStateRef = useRef(AppState.currentState); + + async function finishProtectionStep() { + await apiFetch('/api/profile/me/onboarding-step', { + method: 'PATCH', + body: { step: 'done' }, + }).catch(() => {}); + invalidateMe(); + setPhase('done'); + onDone(); + } + + async function activateVpn() { + if (activating) return; + setActivating(true); + try { + const res = await protection.activateUrlFilter(); + if (!res.enabled) { + Alert.alert( + t('onboarding.protection.error_title'), + res.error ?? t('onboarding.protection.error_unknown'), + ); + return; + } + setPhase('preexplain_a11y'); + } finally { + setActivating(false); + } + } + + async function activateA11y() { + if (activating) return; + setActivating(true); + try { + const res = await protection.activateFamilyControls(); + if (res.enabled) { + // Selten: User hatte a11y schon manuell aktiviert → Lock direkt armed. + finishProtectionStep(); + return; + } + if (res.error === 'accessibility_pending') { + // Native hat Settings geöffnet; warte auf Rückkehr + poll. + awaitingReturnRef.current = true; + setPhase('a11y_pending'); + return; + } + Alert.alert( + t('onboarding.protection.error_title'), + res.error ?? t('onboarding.protection.error_unknown'), + ); + } finally { + setActivating(false); + } + } + + // Auto-Check beim Foreground-Return: wenn a11y jetzt aktiv → Lock armen + done. + useEffect(() => { + const sub = AppState.addEventListener('change', async (next) => { + const prev = appStateRef.current; + appStateRef.current = next; + if (!awaitingReturnRef.current) return; + if (prev.match(/inactive|background/) && next === 'active') { + try { + const a11y = await RebreakProtection.isAccessibilityEnabled(); + if (a11y.enabled) { + // ReBreak-Service ist live → Tamper-Lock armen + finish. + const res = await protection.activateFamilyControls(); + if (res.enabled) { + awaitingReturnRef.current = false; + finishProtectionStep(); + } + } + } catch { + // Ignorieren — User kann manuell auf "Ich habe ReBreak aktiviert" tippen. + } + } + }); + return () => sub.remove(); + }, []); + + if (phase === 'preexplain_vpn') { + return ( + + ); + } + if (phase === 'preexplain_a11y') { + return ( + + ); + } + if (phase === 'a11y_pending') { + return ( + + ); + } + return null; +} + +function A11yPendingView({ + current, + total, + activating, + onRetry, +}: { + current: number; + total: number; + activating: boolean; + onRetry: () => void; +}) { + const { t } = useTranslation(); + const colors = useColors(); + return ( + - ) : null; + cta={ + + } + > + + + {t('onboarding.protection.android_a11y_pending_title')} + + + ); } // ─── PreExplainer (shared) ─────────────────────────────────────────────────── @@ -163,16 +367,20 @@ function PreExplainer({ lyraBodyKey, titleKey, ctaKey, + buttonLabelKey, + markerHintKey, activating, onActivate, current, total, children, }: { - dialog: 'url_filter' | 'screen_time'; + dialog: 'url_filter' | 'screen_time' | 'android_vpn' | 'android_a11y'; lyraBodyKey: string; titleKey: string; ctaKey: string; + buttonLabelKey: string; + markerHintKey: string; activating: boolean; onActivate: () => void; current: number; @@ -184,10 +392,6 @@ function PreExplainer({ const { height: screenH } = useWindowDimensions(); const lang = i18n.language || 'de'; const screenshot = getPermissionScreenshot(dialog, lang); - const buttonLabelKey = - dialog === 'url_filter' - ? 'onboarding.protection.dialog_button_allow' - : 'onboarding.protection.dialog_button_continue'; // Dynamische Screenshot-Höhe: Auf kleinen Phones (SE/mini ~667-844 pt) // capped damit alles + CTA-Bar ohne Scroll passt. Auf großen Phones/iPad @@ -222,8 +426,6 @@ function PreExplainer({ {t(titleKey)} - {/* Screenshot — Modal hat eigene runde Ecken im Bild, kein extra Container- - Radius (sonst Double-Round-Look). Nur padding für Atmungsraum. */} - {/* Animierter Pointer UNTER dem Screenshot — Dimensions-agnostic. */} - {t('onboarding.protection.tap_marker_hint')} + {t(markerHintKey)} {children} diff --git a/apps/rebreak-native/lib/onboardingAssets.ts b/apps/rebreak-native/lib/onboardingAssets.ts index 25a97d3..7000a27 100644 --- a/apps/rebreak-native/lib/onboardingAssets.ts +++ b/apps/rebreak-native/lib/onboardingAssets.ts @@ -1,15 +1,23 @@ /** - * iOS Permission-Dialog-Screenshots für den Onboarding-Pre-Explainer. + * Permission-Dialog-Screenshots für den Onboarding-Pre-Explainer. * * Pro Sprache liegen die Screenshots in `assets/onboarding//`. Falls * eine Sprache nicht (oder noch nicht) verfügbar ist, fällt der Resolver auf * `de` zurück. * + * Dialog-Typen: + * iOS: + * - `url_filter` → NEFilter-System-Dialog ("Erlauben"-Button) + * - `screen_time` → Family-Controls-/Screen-Time-Dialog ("Fortfahren") + * Android: + * - `android_vpn` → VpnService-System-Dialog ("OK"-Button) + * - `android_a11y` → Bedienungshilfen-Settings mit ReBreak-Eintrag + * * Diese Maps explizit mit `require(...)` deklarieren — RN/Metro kann keine * dynamischen Pfade auflösen. */ -type Dialog = 'url_filter' | 'screen_time'; +type Dialog = 'url_filter' | 'screen_time' | 'android_vpn' | 'android_a11y'; type Lang = 'de' | 'en' | 'fr' | 'ar'; /* eslint-disable @typescript-eslint/no-require-imports */ @@ -22,6 +30,18 @@ const SCREEN_TIME_DE = require('../assets/onboarding/de/screen_time_permission.j const SCREEN_TIME_EN = require('../assets/onboarding/en/screen_time_permission.jpeg'); const SCREEN_TIME_FR = require('../assets/onboarding/fr/screen_time_permission.jpeg'); const SCREEN_TIME_AR = require('../assets/onboarding/ar/screen_time_permission.jpeg'); + +// Android — VpnService-Permission-Dialog ("Verbindungsanforderung") +const ANDROID_VPN_DE = require('../assets/onboarding/de/android-vpn-permission-001.png'); +const ANDROID_VPN_EN = require('../assets/onboarding/en/android-vpn-permission-001.png'); +const ANDROID_VPN_FR = require('../assets/onboarding/fr/android-vpn-permission-001.png'); +const ANDROID_VPN_AR = require('../assets/onboarding/ar/android-vpn-permission-001.png'); + +// Android — Accessibility-Settings, ReBreak-Row sichtbar +const ANDROID_A11Y_DE = require('../assets/onboarding/de/android-a11y-rebreak-row-001.png'); +const ANDROID_A11Y_EN = require('../assets/onboarding/en/android-a11y-rebreak-row-001.png'); +const ANDROID_A11Y_FR = require('../assets/onboarding/fr/android-a11y-rebreak-row-001.png'); +const ANDROID_A11Y_AR = require('../assets/onboarding/ar/android-a11y-rebreak-row-001.png'); /* eslint-enable @typescript-eslint/no-require-imports */ const SCREENSHOTS: Record>> = { @@ -37,6 +57,18 @@ const SCREENSHOTS: Record>> = { fr: SCREEN_TIME_FR, ar: SCREEN_TIME_AR, }, + android_vpn: { + de: ANDROID_VPN_DE, + en: ANDROID_VPN_EN, + fr: ANDROID_VPN_FR, + ar: ANDROID_VPN_AR, + }, + android_a11y: { + de: ANDROID_A11Y_DE, + en: ANDROID_A11Y_EN, + fr: ANDROID_A11Y_FR, + ar: ANDROID_A11Y_AR, + }, }; /** @@ -50,8 +82,3 @@ export function getPermissionScreenshot(dialog: Dialog, lang: string): number { const map = SCREENSHOTS[dialog]; return map[normalized] ?? map.de!; } - -// (Deprecated) getPointerPosition entfernt — der Pointer wird jetzt extern -// UNTER dem Screenshot gerendert (ScreenshotPointer-Komponente), nicht mehr -// per-percent overlayed. Damit entfällt die Notwendigkeit pixel-genaue -// Positionen pro Locale + Dialog zu pflegen — siehe ScreenshotPointer.tsx. diff --git a/apps/rebreak-native/locales/ar.json b/apps/rebreak-native/locales/ar.json index 77173e5..0dd2e12 100644 --- a/apps/rebreak-native/locales/ar.json +++ b/apps/rebreak-native/locales/ar.json @@ -370,6 +370,8 @@ "protection": { "body": "الآن الجزء الأهم — الحماية على جهازك. هل أنت مستعد؟" }, "protection_url": { "body": "ستظهر نافذة iOS. اضغط «السماح» — الزر السفلي (وليس الأزرق الكبير في الأعلى — هذا هو الفخ)." }, "protection_lock": { "body": "الآن قفل التطبيق. iOS يطلب الوصول إلى مدة استخدام الجهاز — اضغط «متابعة»، مرة أخرى الزر السفلي (وليس الأزرق)." }, + "protection_url_android": { "body": "سيطلب Android إذن VPN. اضغط «موافق» — هذا ليس VPN حقيقي، الفلتر يعمل محلياً على جهازك." }, + "protection_lock_android": { "body": "الخطوة الأخيرة: سأفتح إعدادات إمكانية الوصول. ابحث عن «ReBreak» وفعّل المفتاح — ثم ارجع إلى التطبيق." }, "done": { "body": "تم. اليوم الأول من سلسلتك — ولست وحدك." }, "audio_play": "تفعيل الصوت", "audio_loading": "جاري تحميل الصوت...", @@ -450,11 +452,21 @@ }, "protection": { "cta_primary": "فعّل الحماية", + "cta_open_a11y": "افتح إعدادات إمكانية الوصول", + "cta_check_a11y": "لقد فعّلت ReBreak", "url_title": "الخطوة 1 من 2 — فلتر المحتوى", "lock_title": "الخطوة 2 من 2 — قفل التطبيق", + "url_title_android": "الخطوة 1 من 2 — فلتر DNS", + "lock_title_android": "الخطوة 2 من 2 — حماية من التلاعب", "tap_marker_hint": "Apple يضع الزر الأزرق الكبير في الأعلى («عدم السماح») — اضغط الزر السفلي، وليس العلوي.", + "tap_marker_hint_android_vpn": "سيعرض Android طلب إذن VPN. اضغط «موافق» — نحن نستخدم واجهة VPN محلياً فقط كفلتر DNS، ولا تغادر أي بيانات جهازك.", + "tap_marker_hint_android_a11y": "بمجرد تفعيل مفتاح ReBreak، تحمي الخدمة إعداداتك من التعطيل غير المقصود. ثم ارجع إلى التطبيق.", + "android_a11y_pending_title": "بانتظار تفعيل إمكانية الوصول", + "android_a11y_pending_body": "إذا لم تفعّل إمكانية الوصول، افتحها مجدداً وفعّل ReBreak.", "dialog_button_allow": "اضغط «السماح»", "dialog_button_continue": "اضغط «متابعة»", + "dialog_button_vpn_ok": "اضغط «موافق»", + "dialog_button_a11y_toggle": "تفعيل المفتاح", "applock_failed_title": "فشل قفل التطبيق", "applock_failed_msg": "يمكنك المحاولة مرة أخرى أو تخطي هذه الخطوة — فلتر URL يعمل بالفعل.", "applock_skip": "تخطّي", diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 59864b8..e853b86 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -370,6 +370,8 @@ "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)." }, + "protection_url_android": { "body": "Gleich fragt Android nach VPN-Erlaubnis. Tippe \"OK\" — das ist kein echtes VPN, der Filter läuft lokal auf deinem Gerät." }, + "protection_lock_android": { "body": "Letzter Schritt: Ich öffne gleich die Bedienungshilfen. Such dort \"ReBreak\" und schalte den Schalter an — komm dann einfach wieder zurück." }, "done": { "body": "Geschafft. Tag 1 deiner neuen Streak — und du gehst nicht allein." }, "audio_play": "Stimme einschalten", "audio_loading": "Lade Stimme...", @@ -450,11 +452,21 @@ }, "protection": { "cta_primary": "Schutz aktivieren", + "cta_open_a11y": "Bedienungshilfen öffnen", + "cta_check_a11y": "Ich habe ReBreak aktiviert", "url_title": "Schritt 1 von 2 — Inhaltsfilter", "lock_title": "Schritt 2 von 2 — App-Schutz", + "url_title_android": "Schritt 1 von 2 — DNS-Filter", + "lock_title_android": "Schritt 2 von 2 — Tamper-Schutz", "tap_marker_hint": "Apple platziert den großen blauen Button oben (\"Nicht erlauben\") — bitte den UNTEREN Button tippen, nicht den oberen.", + "tap_marker_hint_android_vpn": "Android zeigt gleich eine VPN-Erlaubnis-Anfrage. Tippe \"OK\" — wir nutzen die VPN-API nur lokal als DNS-Filter, kein Traffic verlässt dein Gerät.", + "tap_marker_hint_android_a11y": "Sobald du den ReBreak-Schalter anschaltest, schützt der Service deine Einstellungen vor versehentlicher Deaktivierung. Komm dann zurück zur App.", + "android_a11y_pending_title": "Warte auf Bedienungshilfen-Aktivierung", + "android_a11y_pending_body": "Falls du die Bedienungshilfen nicht aktiviert hast, öffne sie nochmal und schalte ReBreak an.", "dialog_button_allow": "Tippe \"Erlauben\"", "dialog_button_continue": "Tippe \"Fortfahren\"", + "dialog_button_vpn_ok": "Tippe \"OK\"", + "dialog_button_a11y_toggle": "Schalter aktivieren", "applock_failed_title": "App-Schutz fehlgeschlagen", "applock_failed_msg": "Du kannst es nochmal versuchen oder den Schritt überspringen — der URL-Filter läuft schon.", "applock_skip": "Überspringen", @@ -1251,5 +1263,22 @@ "crisis_emergency_desc": "Wenn du oder jemand in deiner Nähe in akuter Gefahr ist, ruf sofort den Notruf an.", "crisis_emergency_cta": "112 — Notruf", "crisis_disclaimer": "Diese Stellen sind unabhängig von Rebreak. Wir verweisen weiter, beraten aber nicht selbst." + }, + "lyra_posts": { + "motivation_quiet_01": "Manchmal ist ein Tag, an dem man einfach nicht gespielt hat, schon ein stiller Sieg. Kein Applaus nötig — du weißt, was du heute getan hast.", + "motivation_quiet_02": "Der Drang kommt in Wellen. Er fühlt sich endlos an — ist er aber nicht. Die meisten Wellen dauern unter 20 Minuten. Einfach warten.", + "motivation_quiet_03": "Niemand erwartet, dass du jeden Tag stark bist. Manche Tage reicht es, einfach da zu sein und nicht nachzugeben.", + "motivation_distance_01": "Zwischen dem Impuls und der Entscheidung liegt ein Moment. In diesem Moment bist du frei. Das ist kein Zufall — das ist Übung.", + "motivation_distance_02": "Fortschritt sieht selten dramatisch aus. Meistens ist es ein Tag ohne Rückfall, still und unbemerkt von allen außer dir.", + "tipp_breath_01": "Wenn der Drang stark ist: 4 Sekunden einatmen, 7 halten, 8 ausatmen. Das aktiviert dein parasympathisches Nervensystem und bremst den Impuls messbar.", + "tipp_urge_surf_01": "Urge Surfing: Beobachte den Drang wie eine Welle — ohne ihn zu bekämpfen. Benenne ihn laut: 'Ich spüre gerade Verlangen.' Das schafft Distanz zwischen dir und dem Impuls.", + "tipp_habit_replace_01": "Das Gehirn hasst Lücken. Wenn du ein Verhalten stoppst, brauchst du ein Ersatzverhalten für denselben Auslöser. Langweilig auf der Couch? Genau dann ist der SOS-Atem nützlich.", + "tipp_sos_reminder_01": "ReBreak hat eine SOS-Funktion für akute Momente — Atemübungen, Ablenkungsspiele, direkter Lyra-Chat. Sie ist genau für diesen Moment gebaut.", + "zitat_stoic_01": "\"Zwischen Reiz und Reaktion liegt ein Raum. In diesem Raum liegt unsere Freiheit.\" — Viktor Frankl. Kein großer Satz. Nur ein kleiner Raum, jeden Tag ein bisschen breiter.", + "zitat_psychology_01": "\"Gewohnheiten sind nie wirklich gelöscht, nur überschrieben.\" Das klingt erst entmutigend. Bedeutet aber: Jede neue Entscheidung schreibt mit.", + "witzig_impulse_01": "Mein Gehirn um 23 Uhr: 'Nur mal kurz schauen.' Mein Präfrontaler Kortex: 'Ich bin schon schlafen gegangen, macht euch selbst.' — Genau für diesen Moment gibt es Blocker.", + "witzig_distraction_01": "Impulskontrolle ist eigentlich nur eine fancy Art zu sagen: du hast dein Zukunfts-Ich angerufen, bevor dein Jetzt-Ich Mist bauen konnte.", + "news_push_tactics_01": "Casinos verschicken Push-Nachrichten bevorzugt freitags ab 18 Uhr und sonntags morgens — gezielt wenn Struktur wegfällt. Der ReBreak-Mailfilter fängt auch die digitale Variante ab.", + "feature_sos_01": "Übrigens: Der SOS-Bereich hat jetzt Minispiele als Ablenkung — Memory, Snake, Tetris. Nicht als Spaß-Feature, sondern weil kurze kognitive Aufgaben den Drang-Loop unterbrechen." } } diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index ce3b78d..3108b2c 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -370,6 +370,8 @@ "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)." }, + "protection_url_android": { "body": "Android will ask for VPN permission. Tap \"OK\" — this isn't a real VPN; the filter runs locally on your device." }, + "protection_lock_android": { "body": "Last step: I'll open Accessibility settings now. Find \"ReBreak\" there and flip the switch on — then come right back." }, "done": { "body": "Done. Day 1 of your new streak — and you're not walking alone." }, "audio_play": "Enable voice", "audio_loading": "Loading voice...", @@ -450,11 +452,21 @@ }, "protection": { "cta_primary": "Activate protection", + "cta_open_a11y": "Open Accessibility settings", + "cta_check_a11y": "I've enabled ReBreak", "url_title": "Step 1 of 2 — Content filter", "lock_title": "Step 2 of 2 — App lock", + "url_title_android": "Step 1 of 2 — DNS filter", + "lock_title_android": "Step 2 of 2 — Tamper protection", "tap_marker_hint": "Apple puts the big blue button on top (\"Don't Allow\") — please tap the BOTTOM button, not the top one.", + "tap_marker_hint_android_vpn": "Android will show a VPN permission request. Tap \"OK\" — we only use the VPN API locally as a DNS filter; no traffic leaves your device.", + "tap_marker_hint_android_a11y": "Once you flip the ReBreak switch on, the service protects your settings from accidental disabling. Then come back to the app.", + "android_a11y_pending_title": "Waiting for Accessibility activation", + "android_a11y_pending_body": "If you didn't enable Accessibility, open it again and turn ReBreak on.", "dialog_button_allow": "Tap \"Allow\"", "dialog_button_continue": "Tap \"Continue\"", + "dialog_button_vpn_ok": "Tap \"OK\"", + "dialog_button_a11y_toggle": "Toggle the switch", "applock_failed_title": "App lock failed", "applock_failed_msg": "You can try again or skip this step — the URL filter is already running.", "applock_skip": "Skip", diff --git a/apps/rebreak-native/locales/fr.json b/apps/rebreak-native/locales/fr.json index 093602a..01fa76a 100644 --- a/apps/rebreak-native/locales/fr.json +++ b/apps/rebreak-native/locales/fr.json @@ -368,6 +368,8 @@ "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)." }, + "protection_url_android": { "body": "Android va demander la permission VPN. Touche « OK » — ce n'est pas un vrai VPN, le filtre tourne localement sur ton téléphone." }, + "protection_lock_android": { "body": "Dernière étape : j'ouvre les paramètres d'Accessibilité. Trouve « ReBreak » et active l'interrupteur — puis reviens dans l'app." }, "done": { "body": "Voilà. Jour 1 de ta nouvelle série — et tu n'es pas seul." }, "audio_play": "Activer la voix", "audio_loading": "Chargement de la voix...", @@ -448,11 +450,21 @@ }, "protection": { "cta_primary": "Activer la protection", + "cta_open_a11y": "Ouvrir les paramètres d'Accessibilité", + "cta_check_a11y": "J'ai activé ReBreak", "url_title": "Étape 1 sur 2 — Filtre de contenu", "lock_title": "Étape 2 sur 2 — Verrou d'app", + "url_title_android": "Étape 1 sur 2 — Filtre DNS", + "lock_title_android": "Étape 2 sur 2 — Protection anti-altération", "tap_marker_hint": "Apple place le grand bouton bleu en haut (« Refuser ») — touche le bouton du BAS, pas celui du haut.", + "tap_marker_hint_android_vpn": "Android va afficher une demande de permission VPN. Touche « OK » — nous utilisons l'API VPN uniquement localement comme filtre DNS, aucun trafic ne quitte ton appareil.", + "tap_marker_hint_android_a11y": "Une fois l'interrupteur ReBreak activé, le service protège tes paramètres contre toute désactivation accidentelle. Puis reviens dans l'app.", + "android_a11y_pending_title": "En attente de l'activation Accessibilité", + "android_a11y_pending_body": "Si tu n'as pas activé l'Accessibilité, rouvre-la et active ReBreak.", "dialog_button_allow": "Touche « Autoriser »", "dialog_button_continue": "Touche « Continuer »", + "dialog_button_vpn_ok": "Touche « OK »", + "dialog_button_a11y_toggle": "Activer l'interrupteur", "applock_failed_title": "Échec du verrou d'app", "applock_failed_msg": "Tu peux réessayer ou ignorer cette étape — le filtre URL est déjà actif.", "applock_skip": "Ignorer", diff --git a/apps/rebreak-native/scripts/grab-onboarding-screenshot.sh b/apps/rebreak-native/scripts/grab-onboarding-screenshot.sh new file mode 100755 index 0000000..13fa060 --- /dev/null +++ b/apps/rebreak-native/scripts/grab-onboarding-screenshot.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# grab-onboarding-screenshot.sh +# +# Zieht Android-Screenshots via adb + speichert mit Auto-Numbering damit +# nichts überschrieben wird. Pro /-Combo wird die nächste +# freie Nummer XYZ gewählt → -001.png, -002.png, ... +# +# So kann man mehrere Steps der gleichen Flow capturen (z.B. VPN-Dialog +# vorher / nachher, A11y-Settings-Liste / -Toggle / -Confirm) und später +# in Ruhe entscheiden welche Files wir tatsächlich im Pre-Explainer zeigen. +# +# Usage: +# ./grab-onboarding-screenshot.sh +# = de | en | fr | ar (oder beliebig) +# = freier Name (z.B. android-vpn-permission, android-a11y-step1, +# android-a11y-toggle, ios-permission, etc.) +# +# Beispiele: +# ./grab-onboarding-screenshot.sh de android-vpn-permission +# ./grab-onboarding-screenshot.sh de android-vpn-permission # → -002.png +# ./grab-onboarding-screenshot.sh de android-a11y-overview +# ./grab-onboarding-screenshot.sh en android-a11y-rebreak-toggle + +set -euo pipefail + +LANG="${1:-}" +DIALOG="${2:-}" + +if [[ -z "$LANG" || -z "$DIALOG" ]]; then + echo "Usage: $0 " + echo " lang: de | en | fr | ar (oder anderer Folder-Name)" + echo " dialog: freier Name (z.B. android-vpn-permission, android-a11y-step1)" + exit 1 +fi + +# Validate lang nicht hart — User kann auch debug/misc/etc anlegen wenn nötig. +# Nur Sanity-Check: alphanum + dash + underscore. +if [[ ! "$LANG" =~ ^[a-zA-Z0-9_-]+$ ]]; then + echo "Invalid lang/folder name: $LANG"; exit 1 +fi +if [[ ! "$DIALOG" =~ ^[a-zA-Z0-9_-]+$ ]]; then + echo "Invalid dialog name: $DIALOG"; exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET_DIR="$SCRIPT_DIR/../assets/onboarding/$LANG" +mkdir -p "$TARGET_DIR" + +# Auto-Numbering: finde nächste freie ${DIALOG}-NNN.png +NEXT_NUM=1 +while [[ -f "$TARGET_DIR/${DIALOG}-$(printf "%03d" $NEXT_NUM).png" ]]; do + NEXT_NUM=$((NEXT_NUM + 1)) +done +TARGET_FILE="$TARGET_DIR/${DIALOG}-$(printf "%03d" $NEXT_NUM).png" + +if ! command -v adb >/dev/null 2>&1; then + echo "adb nicht installiert. Brew: brew install --cask android-platform-tools" + exit 1 +fi + +DEVICE_COUNT=$(adb devices | grep -c "^[a-zA-Z0-9].*device$" || true) +if [[ "$DEVICE_COUNT" -lt 1 ]]; then + echo "Kein Android-Device verbunden. USB-Debugging an + adb devices checken." + exit 1 +fi + +echo "→ pull screenshot to $TARGET_FILE" +adb exec-out screencap -p > "$TARGET_FILE" + +if [[ -s "$TARGET_FILE" ]]; then + SIZE=$(du -h "$TARGET_FILE" | cut -f1) + COUNT=$(ls -1 "$TARGET_DIR/${DIALOG}-"*.png 2>/dev/null | wc -l | tr -d ' ') + echo "✓ saved $SIZE → ${DIALOG}-$(printf "%03d" $NEXT_NUM).png ($COUNT total for this dialog)" +else + echo "✗ screenshot empty — adb issue?" + rm -f "$TARGET_FILE" + exit 1 +fi diff --git a/apps/rebreak-native/stores/community.ts b/apps/rebreak-native/stores/community.ts index 1584404..d7bdca4 100644 --- a/apps/rebreak-native/stores/community.ts +++ b/apps/rebreak-native/stores/community.ts @@ -30,6 +30,7 @@ export interface CommunityPost { challengeStatus?: 'OPEN' | 'ACTIVE' | 'FINISHED' | 'CANCELLED' | null; opponentName?: string | null; isLive?: boolean; + i18nKey?: string | null; userVote?: 'yes' | 'no' | null; submission?: { id: string; diff --git a/backend/prisma/migrations/20260517_add_lyra_post_i18n_key/migration.sql b/backend/prisma/migrations/20260517_add_lyra_post_i18n_key/migration.sql new file mode 100644 index 0000000..9bc9fdd --- /dev/null +++ b/backend/prisma/migrations/20260517_add_lyra_post_i18n_key/migration.sql @@ -0,0 +1,5 @@ +-- Migration: add i18n_key column to community_posts +-- Nullable: legacy posts remain untouched (no backfill). +-- New Lyra-posts generated via template catalog will have this set. + +ALTER TABLE rebreak.community_posts ADD COLUMN i18n_key TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 80694a1..976d728 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -223,6 +223,10 @@ model CommunityPost { gameName String? @map("game_name") repostOfId String? @map("repost_of_id") @db.Uuid challengeId String? @map("challenge_id") @db.Uuid + /// Template-ID aus dem Lyra-Post-Catalog (z.B. "motivation_quiet_01"). + /// Nullable: Legacy-Posts ohne Template-Key nutzen content direkt. + /// Frontend rendert t('lyra_posts.') wenn gesetzt. + i18nKey String? @map("i18n_key") createdAt DateTime @default(now()) @map("created_at") author Profile? @relation(fields: [userId], references: [id]) diff --git a/backend/server/api/community/posts.get.ts b/backend/server/api/community/posts.get.ts index 47162b1..4553ad2 100644 --- a/backend/server/api/community/posts.get.ts +++ b/backend/server/api/community/posts.get.ts @@ -88,6 +88,7 @@ export default defineEventHandler(async (event) => { } : null, userVote: userDomainVotes[p.id] ?? null, + i18nKey: (p as any).i18nKey ?? null, repostOfId: (p as any).repostOfId ?? null, repostOf: (p as any).repostOf ? { diff --git a/backend/server/api/cron/lyra-post.ts b/backend/server/api/cron/lyra-post.ts index 97e23a3..033fda6 100644 --- a/backend/server/api/cron/lyra-post.ts +++ b/backend/server/api/cron/lyra-post.ts @@ -1,5 +1,9 @@ import { createPost } from "../../db/community"; import { usePrisma } from "../../utils/prisma"; +import { + pickRandomTemplate, + LYRA_POST_CATALOG, +} from "../../lib/lyraPostCatalog"; /** * POST /api/cron/lyra-post @@ -11,10 +15,14 @@ import { usePrisma } from "../../utils/prisma"; * 0 10 * * 1,3,5 curl -X POST https://rebreak.org/api/cron/lyra-post \ * -H "x-cron-secret: $NUXT_CRON_SECRET" * + * Feature-Flag: + * USE_TEMPLATE_CATALOG=true → Template-Catalog (i18n-fähig, kein LLM) + * USE_TEMPLATE_CATALOG=false → LLM-Path via OpenRouter (Legacy, Default) + * * Infisical Secrets: * NUXT_LYRA_BOT_USER_ID – UUID des Lyra-Profils in der DB * NUXT_CRON_SECRET – zufälliger langer Token - * NUXT_OPENROUTER_API_KEY – bereits vorhanden + * NUXT_OPENROUTER_API_KEY – bereits vorhanden (nur LLM-Path) * * Einmalig auf Server einrichten: * Registriere einen Account mit Username "lyra" in der App, @@ -64,10 +72,6 @@ export default defineEventHandler(async (event) => { }); } - if (!config.openrouterApiKey) { - throw createError({ statusCode: 500, message: "OpenRouter API Key fehlt" }); - } - // Max 3x pro Woche: letzten Lyra-Post prüfen const db = usePrisma(); const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); @@ -86,6 +90,56 @@ export default defineEventHandler(async (event) => { }; } + // Feature-flag: USE_TEMPLATE_CATALOG=true → template-path, false → LLM-path + const useTemplateCatalog = + process.env.USE_TEMPLATE_CATALOG === "true"; + + if (useTemplateCatalog) { + return await postFromCatalog(db, lyraBotUserId); + } else { + return await postFromLLM(db, lyraBotUserId, config); + } +}); + +// ── Template-Catalog Path ──────────────────────────────────────────────────── + +async function postFromCatalog(db: ReturnType, lyraBotUserId: string) { + // Collect recently used template IDs (last 30 posts) to avoid repeats + const recentPosts = await db.communityPost.findMany({ + where: { userId: lyraBotUserId }, + orderBy: { createdAt: "desc" }, + take: LYRA_POST_CATALOG.length, + select: { i18nKey: true }, + }); + const usedIds = recentPosts + .map((p) => p.i18nKey) + .filter((k): k is string => !!k); + + const template = pickRandomTemplate(usedIds); + + // content = DE-fallback text so the DB column is never empty. + // Frontend will prefer the i18nKey translation when available. + // NOTE: DE fallback text is fetched from locale at runtime in the future; + // for now we store the template ID as a sentinel so legacy fallback still + // works. Production should have DE locale populated before enabling flag. + const fallbackContent = `[lyra:${template.id}]`; + + const post = await createPost(lyraBotUserId, "community", fallbackContent, undefined, null, template.id); + + return { success: true, postId: post.id, topic: template.topic, i18nKey: template.id, path: "catalog" }; +} + +// ── LLM Path (Legacy) ──────────────────────────────────────────────────────── + +async function postFromLLM( + _db: ReturnType, + lyraBotUserId: string, + config: ReturnType, +) { + if (!config.openrouterApiKey) { + throw createError({ statusCode: 500, message: "OpenRouter API Key fehlt" }); + } + // Zufälliges Thema const topic = TOPICS[Math.floor(Math.random() * TOPICS.length)]; @@ -129,5 +183,5 @@ export default defineEventHandler(async (event) => { const post = await createPost(lyraBotUserId, "community", content); - return { success: true, postId: post.id, topic }; -}); + return { success: true, postId: post.id, topic, path: "llm" }; +} diff --git a/backend/server/db/community.ts b/backend/server/db/community.ts index 85887c6..8a01d5a 100644 --- a/backend/server/db/community.ts +++ b/backend/server/db/community.ts @@ -257,6 +257,7 @@ export async function createPost( content: string, imageUrl?: string, gameName?: string | null, + i18nKey?: string | null, ) { const db = usePrisma(); return db.communityPost.create({ @@ -266,6 +267,7 @@ export async function createPost( content, imageUrl: imageUrl || null, gameName: gameName ?? null, + i18nKey: i18nKey ?? null, isAnonymous: false, isModerated: false, }, diff --git a/backend/server/lib/lyraPostCatalog.ts b/backend/server/lib/lyraPostCatalog.ts new file mode 100644 index 0000000..435e7e0 --- /dev/null +++ b/backend/server/lib/lyraPostCatalog.ts @@ -0,0 +1,60 @@ +/** + * Lyra Community Post Template Catalog + * + * Each entry has a stable ID that maps to a locale key in: + * apps/rebreak-native/locales/{de,en,fr,ar}.json → lyra_posts. + * + * The `topic` field mirrors the existing TOPICS enum in lyra-post.ts and is + * used for throttle-logic and potential future category filtering. + * + * Texts live in locale files — this file is locale-agnostic. + * EN/FR/AR texts are curated by lyra-persona agent (pending). + * + * Target: ~60 templates (15 per topic). Currently: 15 scaffolded. + */ + +export type LyraPostTopic = 'motivation' | 'tipp' | 'zitat' | 'witzig' | 'news' | 'feature'; + +export interface LyraPostTemplate { + id: string; + topic: LyraPostTopic; +} + +export const LYRA_POST_CATALOG: LyraPostTemplate[] = [ + // ── motivation (5) ────────────────────────────────────────────────────── + { id: 'motivation_quiet_01', topic: 'motivation' }, + { id: 'motivation_quiet_02', topic: 'motivation' }, + { id: 'motivation_quiet_03', topic: 'motivation' }, + { id: 'motivation_distance_01', topic: 'motivation' }, + { id: 'motivation_distance_02', topic: 'motivation' }, + + // ── tipp (4) ───────────────────────────────────────────────────────────── + { id: 'tipp_breath_01', topic: 'tipp' }, + { id: 'tipp_urge_surf_01', topic: 'tipp' }, + { id: 'tipp_habit_replace_01', topic: 'tipp' }, + { id: 'tipp_sos_reminder_01', topic: 'tipp' }, + + // ── zitat (2) ──────────────────────────────────────────────────────────── + { id: 'zitat_stoic_01', topic: 'zitat' }, + { id: 'zitat_psychology_01', topic: 'zitat' }, + + // ── witzig (2) ─────────────────────────────────────────────────────────── + { id: 'witzig_impulse_01', topic: 'witzig' }, + { id: 'witzig_distraction_01', topic: 'witzig' }, + + // ── news (1) ───────────────────────────────────────────────────────────── + { id: 'news_push_tactics_01', topic: 'news' }, + + // ── feature (1) ────────────────────────────────────────────────────────── + { id: 'feature_sos_01', topic: 'feature' }, +]; + +/** + * Returns a random template from the catalog. + * Optional: pass `excludeIds` (e.g. recently used) to avoid repeats. + */ +export function pickRandomTemplate(excludeIds: string[] = []): LyraPostTemplate { + const pool = LYRA_POST_CATALOG.filter((t) => !excludeIds.includes(t.id)); + const source = pool.length > 0 ? pool : LYRA_POST_CATALOG; + return source[Math.floor(Math.random() * source.length)]; +}