wip: KeyboardAwareSheet migrations + Snake/Tetris UI + iron.png + useMe live-update
Sheets via neuer KeyboardAwareSheet-Composable (in Modal pattern, auto-grow mit Tastatur, paddingBottom-Lift): EditMail, AddDomain, CreateRoom, ConnectMail. GameOverScreen behält Spring-Slide-In, nutzt RN Keyboard.addListener für Lift. - KeyboardAwareSheet.tsx — universal modal with sheet-grow + keyboard-padding - react-native-keyboard-controller installiert + KeyboardProvider in Root - Snake: time + ScoreProgressBar + useSnakeSounds (haptic, audio TODO) - Tetris: title weg, Buttons zentriert, kein Pressable mit style-fn - DPad-Buttons 60→48, more bg, no scale - useMe: pub-sub listener pattern für app-weite avatar/nickname-Updates - dm.tsx: resolveAvatar wrap (iron.png-Warning) - Mail-error-humanizer + locales Recovery-Doc-Update in docs/internal/RECOVERY_LOG_2026-05-10.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
53
apps/marketing/app/assets/css/main.css
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "@nuxt/ui";
|
||||||
|
|
||||||
|
@theme static {
|
||||||
|
--font-sans: 'Nunito', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth Scrolling */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iOS Zoom-Fix: 16px verhindert Auto-Zoom bei Input-Fokus */
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verhindert Double-Tap-Zoom auf Buttons und interaktiven Elementen */
|
||||||
|
button, a, [role="button"] {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════
|
||||||
|
PAGE TRANSITIONS – iOS-style slide
|
||||||
|
═══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.slide-left-enter-active,
|
||||||
|
.slide-left-leave-active,
|
||||||
|
.slide-right-enter-active,
|
||||||
|
.slide-right-leave-active {
|
||||||
|
transition: opacity 200ms ease, transform 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-left-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
.slide-left-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-right-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
.slide-right-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
57
apps/marketing/app/components/AnimatedCounter.vue
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<span ref="spanEl">{{ display }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* AnimatedCounter – zählt von 0 auf einen Zielwert hoch.
|
||||||
|
* Verwendet requestAnimationFrame mit Ease-out-Cubic.
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* target – Zielwert (required)
|
||||||
|
* duration – Animationsdauer in ms (default 1800)
|
||||||
|
* delay – Startverzögerung in ms (default 0)
|
||||||
|
* decimals – Nachkommastellen (default 0)
|
||||||
|
*/
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
target: number;
|
||||||
|
duration?: number;
|
||||||
|
delay?: number;
|
||||||
|
decimals?: number;
|
||||||
|
}>(), {
|
||||||
|
duration: 1800,
|
||||||
|
delay: 0,
|
||||||
|
decimals: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const display = ref(props.decimals > 0 ? (0).toFixed(props.decimals) : '0');
|
||||||
|
const spanEl = ref<HTMLElement | null>(null);
|
||||||
|
let started = false;
|
||||||
|
|
||||||
|
function runAnimation() {
|
||||||
|
const startTime = performance.now();
|
||||||
|
const update = (now: number) => {
|
||||||
|
const elapsed = now - startTime;
|
||||||
|
const progress = Math.min(elapsed / props.duration, 1);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
|
||||||
|
const current = eased * props.target;
|
||||||
|
display.value = props.decimals > 0
|
||||||
|
? current.toFixed(props.decimals)
|
||||||
|
: Math.round(current).toString();
|
||||||
|
if (progress < 1) requestAnimationFrame(update);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const observer = new IntersectionObserver(([entry]) => {
|
||||||
|
if (entry.isIntersecting && !started) {
|
||||||
|
started = true;
|
||||||
|
observer.disconnect();
|
||||||
|
setTimeout(runAnimation, props.delay);
|
||||||
|
}
|
||||||
|
}, { threshold: 0.3 });
|
||||||
|
if (spanEl.value) observer.observe(spanEl.value);
|
||||||
|
onUnmounted(() => observer.disconnect());
|
||||||
|
});
|
||||||
|
</script>
|
||||||
26
apps/marketing/app/components/FeatureCard.vue
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rounded-2xl border border-default p-6" :class="bg">
|
||||||
|
<div class="flex items-start justify-between mb-3">
|
||||||
|
<div class="w-10 h-10 rounded-xl flex items-center justify-center" :class="bg.replace('/30', '/50')">
|
||||||
|
<UIcon :name="icon" :class="[color, 'text-xl']" />
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-bold px-2 py-1 rounded-full"
|
||||||
|
:class="badge === 'Premium' ? 'bg-primary-900/60 text-primary-300' : 'bg-green-900/60 text-green-300'">
|
||||||
|
{{ badge }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-highlighted mb-2">{{ title }}</h3>
|
||||||
|
<p class="text-sm text-muted leading-relaxed">{{ desc }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
bg: string;
|
||||||
|
badge: string;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
111
apps/marketing/app/components/charts/BlocklistGrowth.vue
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
<template>
|
||||||
|
<ClientOnly>
|
||||||
|
<div class="w-full" :style="{ height: `${height}px` }">
|
||||||
|
<Line :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
<template #fallback>
|
||||||
|
<div :style="{ height: `${height}px` }" class="animate-pulse bg-muted rounded-xl" />
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Filler,
|
||||||
|
Tooltip,
|
||||||
|
type ChartData,
|
||||||
|
type ChartOptions,
|
||||||
|
} from "chart.js";
|
||||||
|
import { Line } from "vue-chartjs";
|
||||||
|
|
||||||
|
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler, Tooltip);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: { label: string; count: number }[];
|
||||||
|
height?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const height = computed(() => props.height ?? 180);
|
||||||
|
|
||||||
|
function resolvePrimaryRgb(): string {
|
||||||
|
if (typeof window === "undefined") return "rgb(14,165,233)";
|
||||||
|
const raw = getComputedStyle(document.documentElement).getPropertyValue("--ui-primary").trim();
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 1; canvas.height = 1;
|
||||||
|
const ctx2 = canvas.getContext("2d");
|
||||||
|
if (!ctx2) return "rgb(14,165,233)";
|
||||||
|
ctx2.fillStyle = raw;
|
||||||
|
ctx2.fillRect(0, 0, 1, 1);
|
||||||
|
const [r, g, b] = ctx2.getImageData(0, 0, 1, 1).data;
|
||||||
|
return `rgb(${r},${g},${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryRgb = ref("rgb(14,165,233)");
|
||||||
|
onMounted(() => { primaryRgb.value = resolvePrimaryRgb(); });
|
||||||
|
|
||||||
|
function rgba(alpha: number) {
|
||||||
|
return primaryRgb.value.replace("rgb(", "rgba(").replace(")", `,${alpha})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = computed<ChartData<"line">>(() => ({
|
||||||
|
labels: props.data.map((d) => d.label),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Domains blockiert",
|
||||||
|
data: props.data.map((d) => d.count),
|
||||||
|
borderColor: primaryRgb.value,
|
||||||
|
backgroundColor: (ctx: any) => {
|
||||||
|
const chart = ctx.chart;
|
||||||
|
const { ctx: c, chartArea } = chart;
|
||||||
|
if (!chartArea) return rgba(0.3);
|
||||||
|
const grad = c.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
|
||||||
|
grad.addColorStop(0, rgba(0.35));
|
||||||
|
grad.addColorStop(1, rgba(0));
|
||||||
|
return grad;
|
||||||
|
},
|
||||||
|
fill: true,
|
||||||
|
cubicInterpolationMode: "monotone" as any,
|
||||||
|
pointRadius: 0,
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const chartOptions = computed<ChartOptions<"line">>(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: { duration: 600, easing: "easeOutExpo" } as any,
|
||||||
|
interaction: { intersect: false, mode: "index" },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => ` ${(ctx.parsed?.y ?? 0).toLocaleString("de-DE")} Domains`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
border: { display: false },
|
||||||
|
ticks: { color: "#6b7280", font: { size: 11 }, maxTicksLimit: 6, maxRotation: 0 },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: "rgba(255,255,255,0.04)" },
|
||||||
|
border: { display: false },
|
||||||
|
ticks: {
|
||||||
|
color: "#6b7280",
|
||||||
|
font: { size: 11 },
|
||||||
|
maxTicksLimit: 4,
|
||||||
|
callback: (v: any) => (v >= 1000 ? `${Math.round(v / 1000)}k` : v),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
18
apps/marketing/app/composables/useViewportHeight.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Reactive viewport height.
|
||||||
|
* Vereinfachte Version für Marketing-Site (kein Capacitor/WKWebView-Keyboard-Handling nötig).
|
||||||
|
*/
|
||||||
|
export function useViewportHeight() {
|
||||||
|
const height = ref(globalThis.innerHeight || 800);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const update = () => {
|
||||||
|
height.value = window.innerHeight;
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", update);
|
||||||
|
update();
|
||||||
|
onUnmounted(() => window.removeEventListener("resize", update));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { height };
|
||||||
|
}
|
||||||
95
apps/marketing/app/layouts/default.vue
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div data-layout="default" class="flex flex-col overflow-hidden bg-default text-highlighted"
|
||||||
|
:style="{ height: vpHeight + 'px' }">
|
||||||
|
<!-- Header sticky, innerhalb des Flex-Flows -->
|
||||||
|
<header class="shrink-0 h-16 border-b border-default bg-default/95 backdrop-blur-md">
|
||||||
|
<div class="max-w-6xl mx-auto px-4 sm:px-6 h-full flex items-center justify-between">
|
||||||
|
<NuxtLink to="/" class="flex items-center gap-2">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||||
|
<UIcon name="i-heroicons-shield-check" class="text-white" />
|
||||||
|
</div>
|
||||||
|
<span class="font-bold text-lg text-highlighted">ReBreak</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<nav class="hidden sm:flex items-center gap-6 text-sm text-muted">
|
||||||
|
<NuxtLink to="/pricing" class="hover:text-highlighted transition-colors">{{ $t("nav.pricing") }}</NuxtLink>
|
||||||
|
<NuxtLink to="/resources" class="hover:text-highlighted transition-colors">{{ $t("nav.resources") }}</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- App-Download CTA statt Login (Marketing hat keine Auth) -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="https://apps.apple.com/app/rebreak" target="_blank" rel="noopener">
|
||||||
|
<UButton size="sm" color="primary">{{ $t("nav.download_app") }}</UButton>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Scrollbarer Inhalt -->
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<!-- DSGVO-Pflicht: Datenschutz + Impressum public erreichbar -->
|
||||||
|
<footer class="border-t border-default mt-12 pb-24 md:pb-6 pt-6 px-4">
|
||||||
|
<div
|
||||||
|
class="max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-muted">
|
||||||
|
<p>© {{ new Date().getFullYear() }} Rebreak</p>
|
||||||
|
<nav class="flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||||
|
<NuxtLink to="/datenschutz" class="hover:text-primary-400 transition-colors">Datenschutz</NuxtLink>
|
||||||
|
<NuxtLink to="/impressum" class="hover:text-primary-400 transition-colors">Impressum</NuxtLink>
|
||||||
|
<NuxtLink to="/nutzungsbedingungen" class="hover:text-primary-400 transition-colors">
|
||||||
|
Nutzungsbedingungen
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating Pill Tab-Bar (Mobile) -->
|
||||||
|
<div class="fixed bottom-3 left-4 right-4 z-50 pointer-events-none">
|
||||||
|
<nav
|
||||||
|
class="pointer-events-auto max-w-lg mx-auto bg-white/80 dark:bg-elevated backdrop-blur-md rounded-4xl shadow-[0_4px_24px_rgba(0,0,0,0.12)] dark:shadow-[0_4px_24px_rgba(0,0,0,0.3)] ring-1 ring-black/10 dark:ring-white/10 px-2 py-1.5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<NuxtLink v-for="tab in tabs" :key="tab.to" :to="tab.to"
|
||||||
|
class="relative flex-1 flex flex-col items-center gap-0.5 px-1 py-1.5 rounded-3xl transition-colors"
|
||||||
|
:class="isActive(tab.to) ? 'text-primary-400' : 'text-muted'">
|
||||||
|
<UIcon :name="isActive(tab.to) ? tab.iconActive : tab.icon" class="size-6 shrink-0" />
|
||||||
|
<span class="text-[10px] leading-none whitespace-nowrap font-semibold">{{ tab.label }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { height: vpHeight } = useViewportHeight();
|
||||||
|
const route = useRoute();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const tabs = computed(() => [
|
||||||
|
{
|
||||||
|
to: "/",
|
||||||
|
icon: "i-heroicons-home",
|
||||||
|
iconActive: "i-heroicons-home-solid",
|
||||||
|
label: t('pricing.footer_home'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/pricing",
|
||||||
|
icon: "i-heroicons-credit-card",
|
||||||
|
iconActive: "i-heroicons-credit-card-solid",
|
||||||
|
label: t('pricing.footer_pricing'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/resources",
|
||||||
|
icon: "i-heroicons-book-open",
|
||||||
|
iconActive: "i-heroicons-book-open-solid",
|
||||||
|
label: t('pricing.footer_resources'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isActive(to: string) {
|
||||||
|
if (to === "/") return route.path === "/";
|
||||||
|
return route.path.startsWith(to);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
230
apps/marketing/app/locales/de.json
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"pricing": "Preise",
|
||||||
|
"resources": "Hilfe",
|
||||||
|
"login": "Einloggen",
|
||||||
|
"download_app": "App laden"
|
||||||
|
},
|
||||||
|
"landing": {
|
||||||
|
"hero_badge": "Gemeinsam gegen die Gambling-Industrie",
|
||||||
|
"hero_title": "Millionen kämpfen still.",
|
||||||
|
"hero_subtitle": "Du musst das nicht allein tun!",
|
||||||
|
"hero_text": "Gemeinsam sind wir Stark!",
|
||||||
|
"cta_start": "Jetzt kostenlos starten",
|
||||||
|
"stat_affected": "Menschen in DE betroffen",
|
||||||
|
"stat_blocked": "Domains geblockt",
|
||||||
|
"stat_free": "Zum Starten",
|
||||||
|
"more_info": "Mehr erfahren",
|
||||||
|
"blocker_badge": "Gambling Blocker",
|
||||||
|
"blocker_title_domains": "Domains.",
|
||||||
|
"blocker_title_activated": "Einmal aktiviert.",
|
||||||
|
"blocker_desc": "Die umfangreichste Gambling-Blocklist. Täglich aktualisiert. Für alle Plattformen. Ein Cooldown verhindert schwache Momente.",
|
||||||
|
"blocker_feat_platforms": "Für macOS, iOS, Android & Pi-hole",
|
||||||
|
"blocker_feat_updated": "Täglich aktualisierte Liste",
|
||||||
|
"blocker_feat_custom": "Eigene Domains hinzufügen",
|
||||||
|
"blocker_feat_cooldown": "Cooldown-Schutz vor Rückfällen",
|
||||||
|
"oasis_badge": "Warum OASIS allein nicht reicht",
|
||||||
|
"oasis_title": "Täglich neue Casinos –",
|
||||||
|
"oasis_subtitle": "ohne Lizenz, ohne Sperre.",
|
||||||
|
"oasis_desc": "Der OASIS-Selbstausschluss sperrt dich nur bei lizenzierten Anbietern. Doch täglich gehen neue Casino-Seiten online – viele ohne Lizenz, viele offshore. Diese Seiten kennen OASIS nicht. ReBreak schützt dich auch dort: mit einer täglich aktualisierten Datenbank von über 208.000 Domains.",
|
||||||
|
"oasis_new_domains": "neue Gambling-Domains täglich",
|
||||||
|
"oasis_offshore": "Casinos ohne Lizenz umgehen OASIS komplett",
|
||||||
|
"oasis_updated": "Domains täglich aktualisiert durch ReBreak",
|
||||||
|
"streak_badge": "Streak & Ersparnisse",
|
||||||
|
"streak_title": "Jeden Tag zählt.",
|
||||||
|
"streak_subtitle": "Sichtbarer Fortschritt.",
|
||||||
|
"streak_desc": "Sieh wie viele Tage du gewonnen hast – und wie viel Geld du nicht verloren hast. Meilenstein-Badges motivieren weiter.",
|
||||||
|
"streak_days_free": "Tage frei",
|
||||||
|
"streak_saved": "gespart",
|
||||||
|
"crisis_badge": "Krisenmomente meistern",
|
||||||
|
"crisis_title": "Der Drang kommt.",
|
||||||
|
"crisis_subtitle": "Du bist vorbereitet.",
|
||||||
|
"sos_title": "SOS – Sofort-Hilfe",
|
||||||
|
"sos_subtitle": "Ein Klick. Sofort.",
|
||||||
|
"sos_desc": "Der Drang dauert im Schnitt nur 15–20 Minuten. ReBreak führt dich Schritt für Schritt durch diesen Moment – bis er vorüber ist.",
|
||||||
|
"sos_angry": "Wütend",
|
||||||
|
"sos_sad": "Niedergedrückt",
|
||||||
|
"sos_stressed": "Gestresst",
|
||||||
|
"sos_empty": "Leer",
|
||||||
|
"breathing_title": "4-7-8 Atemübung",
|
||||||
|
"breathing_subtitle": "Puls senken in 60 Sekunden",
|
||||||
|
"breathing_desc": "Wissenschaftlich belegt: 4 Sekunden einatmen, 7 halten, 8 ausatmen – der Körper schaltet automatisch in den Ruhemodus.",
|
||||||
|
"breathing_breathe": "Atme",
|
||||||
|
"breathing_inhale": "4s einatmen",
|
||||||
|
"breathing_hold": "7s halten",
|
||||||
|
"breathing_exhale": "8s ausatmen",
|
||||||
|
"coach_badge": "Wenn SOS nicht reicht",
|
||||||
|
"coach_title": "Coach & Community.",
|
||||||
|
"coach_subtitle": "Immer auf Abruf.",
|
||||||
|
"coach_desc": "Ein KI-Coach, der dich wirklich kennt – personalisiert, CBT-basiert, ohne Urteil. Und eine echte Community aus Menschen, die verstehen was du durchmachst.",
|
||||||
|
"coach_label": "KI-Coach",
|
||||||
|
"founding_badge": "Gründungsmitglied",
|
||||||
|
"founding_desc": "Die ersten {count} Mitglieder bekommen 1 Monat Standard gratis – automatisch, kein Code nötig.",
|
||||||
|
"founding_slots": "{current} / {total} Plätze",
|
||||||
|
"founding_cta": "Jetzt Platz sichern – kostenlos",
|
||||||
|
"mail_badge": "Mail-Bereinigung",
|
||||||
|
"mail_title": "Bonus-Mails?",
|
||||||
|
"mail_subtitle": "Nie gesehen.",
|
||||||
|
"mail_desc": "Casinos bombardieren dich täglich mit Angeboten und Rabatten. ReBreak verbindet sich mit deinem Postfach und verschiebt diese Mails in den Papierkorb – bevor du sie siehst.",
|
||||||
|
"mail_feat_providers": "Gmail, GMX, Outlook – alle großen Anbieter",
|
||||||
|
"mail_feat_intervals": "Echtzeit, stündlich oder alle 4 Stunden",
|
||||||
|
"mail_feat_privacy": "Keine Mail wird gelesen – nur analysiert",
|
||||||
|
"mail_mock_blocked": "Blockiert",
|
||||||
|
"mail_mock_scanned": "Gescannt",
|
||||||
|
"mail_mock_rate": "Treffer",
|
||||||
|
"mail_mock_accounts": "Verbundene Konten",
|
||||||
|
"mail_mock_rhythm": "Automatischer Scan-Rhythmus",
|
||||||
|
"final_title": "Fang jetzt an.",
|
||||||
|
"final_desc": "Du bist nicht kaputt. Das System ist manipulativ. Wir helfen dir zurück.",
|
||||||
|
"final_cta": "Jetzt starten – kostenlos & anonym",
|
||||||
|
"chat_msg_1": "Ich spüre den Drang wieder stark...",
|
||||||
|
"chat_msg_2": "Ich verstehe. Was triggert dich gerade? Lass uns das durchgehen.",
|
||||||
|
"chat_msg_3": "Stress bei der Arbeit.",
|
||||||
|
"chat_msg_4": "Das ist ein bekanntes Muster. Probier erst die 4-7-8 Übung."
|
||||||
|
},
|
||||||
|
"blocked": {
|
||||||
|
"lyra": "Lyra",
|
||||||
|
"title": "Diese Seite ist blockiert",
|
||||||
|
"message": "ReBreak hat diese Seite für dich gesperrt. Du hast dich entschieden, stark zu sein – und das hier ist der Beweis.",
|
||||||
|
"day": "Tag",
|
||||||
|
"days": "Tage",
|
||||||
|
"clean": "clean",
|
||||||
|
"streak_running": "Dein Streak läuft. Gib ihn nicht auf.",
|
||||||
|
"talk_lyra": "Mit Lyra reden",
|
||||||
|
"start_breathing": "Atemübung starten",
|
||||||
|
"back_to_app": "Zurück zur App",
|
||||||
|
"quote_1": "Jede blockierte Seite ist ein Beweis deiner Stärke.",
|
||||||
|
"quote_2": "Der Drang geht vorbei. Dein Fortschritt bleibt.",
|
||||||
|
"quote_3": "Du hast diese Seite nicht gebraucht – und du brauchst sie nicht.",
|
||||||
|
"quote_4": "Stark sein bedeutet, in diesem Moment Nein zu sagen.",
|
||||||
|
"quote_5": "Das hier ist dein Schutzwall. Du hast ihn aufgebaut."
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"blocklist_title": "Community-Blocklist",
|
||||||
|
"blocklist_desc": "Wächst täglich – von der Community, für die Community. Aktuell {count} Domains blockiert.",
|
||||||
|
"chart_label": "Blockierte Domains – letzten 12 Monate",
|
||||||
|
"hotlines_title": "Sofort-Hilfe & Hotlines",
|
||||||
|
"hotlines_desc": "Kostenlos, anonym, rund um die Uhr erreichbar.",
|
||||||
|
"tips_title": "Was jetzt hilft",
|
||||||
|
"tips_desc": "Bewährte Strategien aus der kognitiven Verhaltenstherapie (CBT).",
|
||||||
|
"not_weak_title": "Du bist nicht schwach",
|
||||||
|
"not_weak_desc": "Das System ist darauf ausgelegt. Hier ist warum.",
|
||||||
|
"cta_title": "Bereit für den ersten Schritt?",
|
||||||
|
"cta_button": "App herunterladen",
|
||||||
|
"hotline_de": "Deutschland",
|
||||||
|
"hotline_at": "Österreich",
|
||||||
|
"hotline_ch": "Schweiz",
|
||||||
|
"tip_breathing": "4-7-8 Atemübung bei akutem Drang",
|
||||||
|
"tip_breathing_desc": "4 Sek. einatmen, 7 halten, 8 ausatmen. Aktiviert das parasympathische Nervensystem und bricht den Impulsdrang.",
|
||||||
|
"tip_15min": "Die 15-Minuten-Regel",
|
||||||
|
"tip_15min_desc": "Warte 15 Minuten bevor du eine Entscheidung triffst. Gambling-Drang ist eine Welle – sie kommt und geht.",
|
||||||
|
"tip_move": "Raus und bewegen",
|
||||||
|
"tip_move_desc": "Ein 10-minütiger Spaziergang setzt Endorphine frei und unterbricht automatisch den Drang-Kreislauf.",
|
||||||
|
"tip_triggers": "Trigger kennen",
|
||||||
|
"tip_triggers_desc": "Stress, Langeweile, Abend allein? Wer seine Muster kennt, kann gegensteuern bevor der Drang überwältigt.",
|
||||||
|
"fact1_title": "Variable Belohnungen aktivieren denselben Kreislauf wie Drogen",
|
||||||
|
"fact1_text": "Das Nicht-Wissen, ob man gewinnt, schüttet mehr Dopamin aus als ein sicherer Gewinn. Design, kein Zufall.",
|
||||||
|
"fact2_title": "Online-Casinos sind 24/7 verfügbar – kein natürlicher Stopper",
|
||||||
|
"fact2_text": "Früher war das Casino physisch. Heute ist es das Handy. Kein Schließtag, keine Scham durch andere.",
|
||||||
|
"fact3_title": "Virtuelle Währungen verschleiern echten Geldverlust",
|
||||||
|
"fact3_text": "Chips, Coins, Credits – das Gehirn verarbeitet diese nicht wie Bargeld. Das ist kein Fehler im System.",
|
||||||
|
"fact4_title": "Die Quote gewinnt immer – mathematisch",
|
||||||
|
"fact4_text": "Jedes legale Casino hat eingebaute Marge. Langfristig verlieren 100 % der Spieler Geld. Keine Pechsträhne."
|
||||||
|
},
|
||||||
|
"pricing": {
|
||||||
|
"founding_banner": "Founding Member – Die ersten 100 bekommen 3 Monate Legend gratis",
|
||||||
|
"title": "Dein Weg, dein Tempo",
|
||||||
|
"subtitle_start": "Jetzt starten –",
|
||||||
|
"subtitle_end": "wähle deinen Plan.",
|
||||||
|
"pro_meaning_title": "Was bedeutet Pro wirklich?",
|
||||||
|
"pro_meaning_desc": "Mit Pro trägst du aktiv dazu bei, dass die ReBreak Blocklist für alle wächst. Du kannst Domains direkt hinzufügen und Einreichungen anderer Nutzer prüfen. Du leitest Gruppen, hast keinen KI-Gedächtnisverlust – und stehst an der Spitze für alle, die noch kämpfen.",
|
||||||
|
"comparison_title": "Was ist inklusive?",
|
||||||
|
"comparison_subtitle": "Vollständiger Vergleich aller Pläne",
|
||||||
|
"feature": "Feature",
|
||||||
|
"free": "Kostenlos",
|
||||||
|
"quotes_title": "Gedanken die helfen",
|
||||||
|
"quotes_subtitle": "Von Psychologen und Denkern über Selbstschutz und Veränderung",
|
||||||
|
"faq_title": "Häufige Fragen",
|
||||||
|
"cta_title": "Bereit anzufangen?",
|
||||||
|
"cta_desc": "Kostenlos starten, jederzeit upgraden.",
|
||||||
|
"cta_button": "App herunterladen",
|
||||||
|
"footer_home": "Home",
|
||||||
|
"footer_pricing": "Preise",
|
||||||
|
"footer_resources": "Ressourcen",
|
||||||
|
"footer_login": "Anmelden",
|
||||||
|
"billing_monthly": "Monatlich",
|
||||||
|
"billing_yearly": "Jährlich",
|
||||||
|
"billing_save_pct": "Spare 39%",
|
||||||
|
"billing_forever": "für immer",
|
||||||
|
"billing_per_month": "/ Monat",
|
||||||
|
"billing_per_year": "/ Monat, jährlich",
|
||||||
|
"plan_free_title": "Kostenlos",
|
||||||
|
"plan_free_desc": "Einstieg ohne Risiko – für immer gratis.",
|
||||||
|
"plan_free_btn": "App herunterladen",
|
||||||
|
"plan_pro_title": "Pro",
|
||||||
|
"plan_pro_desc": "Vollständiger Schutz und alle Tools für deinen Alltag.",
|
||||||
|
"plan_pro_btn": "Pro starten",
|
||||||
|
"plan_legend_title": "Legend",
|
||||||
|
"plan_legend_desc": "Für die, die stark genug sind – um anderen den Weg zu ebnen.",
|
||||||
|
"plan_legend_btn": "Legend starten",
|
||||||
|
"plan_loading": "Wird geladen...",
|
||||||
|
"plan_recommended": "Empfohlen",
|
||||||
|
"feat_free_domains": "5 eigene Domains",
|
||||||
|
"feat_free_mail": "1 Mail-Agent (Scan alle 4h)",
|
||||||
|
"feat_coach_basic": "KI-Coach Basis",
|
||||||
|
"feat_streak": "Streak & Ersparnisse Tracker",
|
||||||
|
"feat_urge": "Urge Tracker + Atemübung",
|
||||||
|
"feat_sos": "SOS-Button (Sofort-Hilfe)",
|
||||||
|
"feat_community": "Gemeinschaft erleben",
|
||||||
|
"feat_all_free": "Alles aus Kostenlos",
|
||||||
|
"feat_blocklist": "ReBreak Blocklist (208k+ Domains)",
|
||||||
|
"feat_pro_domains": "5 eigene Domains (rückfüllbar)",
|
||||||
|
"feat_pro_mail": "3 Mail-Agenten (Intervall: 1h / 4h / 8h)",
|
||||||
|
"feat_community_post": "Community posten",
|
||||||
|
"feat_buddy": "Buddy System",
|
||||||
|
"feat_coach_pro": "KI-Coach (besser)",
|
||||||
|
"feat_urge_stats": "Urge-Statistiken & Muster",
|
||||||
|
"feat_all_pro": "Alles aus Pro",
|
||||||
|
"feat_legend_domains": "Unbegrenzte eigene Domains (rückfüllbar)",
|
||||||
|
"feat_legend_mail": "Unbegrenzte Mail-Agenten (Echtzeit)",
|
||||||
|
"feat_legend_add": "Domains direkt zur ReBreak Blocklist hinzufügen",
|
||||||
|
"feat_legend_validate": "Community-Domains validieren",
|
||||||
|
"feat_legend_groups": "Gruppen gründen & leiten",
|
||||||
|
"feat_coach_legend": "Top KI-Coach mit Gedächtnis",
|
||||||
|
"comp_domains": "Eigene Domains",
|
||||||
|
"comp_mail": "Mail-Agent",
|
||||||
|
"comp_coach": "KI-Coach",
|
||||||
|
"comp_streak": "Streak & Ersparnisse Tracker",
|
||||||
|
"comp_urge": "Urge Tracker + Atemübung",
|
||||||
|
"comp_sos": "SOS-Button (Sofort-Hilfe)",
|
||||||
|
"comp_community": "Gemeinschaft erleben",
|
||||||
|
"comp_blocklist": "ReBreak Blocklist (208k+ Domains)",
|
||||||
|
"comp_post": "Community posten",
|
||||||
|
"comp_buddy": "Buddy System",
|
||||||
|
"comp_urge_stats": "Urge-Statistiken & Muster",
|
||||||
|
"comp_add_domain": "Domains zur Blocklist hinzufügen",
|
||||||
|
"comp_validate": "Community-Domains validieren",
|
||||||
|
"comp_groups": "Gruppen gründen & leiten",
|
||||||
|
"comp_free_domains": "5",
|
||||||
|
"comp_pro_domains": "5 (rückfüllbar)",
|
||||||
|
"comp_legend_domains": "Unbegrenzt (rückfüllbar)",
|
||||||
|
"comp_free_mail_val": "1 (4h)",
|
||||||
|
"comp_pro_mail_val": "3 (1h / 4h / 8h)",
|
||||||
|
"comp_legend_mail_val": "Echtzeit",
|
||||||
|
"comp_free_coach_val": "Basis",
|
||||||
|
"comp_pro_coach_val": "Besser",
|
||||||
|
"comp_legend_coach_val": "Top + Gedächtnis",
|
||||||
|
"faq1_q": "Muss ich eine E-Mail-Adresse angeben?",
|
||||||
|
"faq1_a": "Ja, für die Registrierung wird eine E-Mail-Adresse benötigt. Deine Daten werden ausschließlich auf deutschen Servern gespeichert und verarbeitet – vollständig anonym, nach strengen DSGVO-Standards. Kein Name, kein Standort, kein Nutzungsverhalten wird an Dritte weitergegeben.",
|
||||||
|
"faq2_q": "Was ist der Unterschied zwischen Pro und Legend?",
|
||||||
|
"faq2_a": "Pro gibt dir vollständigen Schutz: ReBreak Blocklist (208k+ Domains), 3 Mail-Agenten, KI-Coach und Community. Legend geht weiter: unbegrenzte Domains und Agenten, direktes Hinzufügen zur Blocklist, Validierung von Community-Domains, Gruppen leiten und Top KI-Coach mit Gedächtnis.",
|
||||||
|
"faq3_q": "Welche Zahlungszyklen gibt es?",
|
||||||
|
"faq3_a": "Monatlich (voller Preis) oder jährlich (Spare 39%). Du kannst jederzeit wechseln.",
|
||||||
|
"faq4_q": "Kann ich jederzeit kündigen?",
|
||||||
|
"faq4_a": "Ja, du kannst dein Abo jederzeit kündigen. Du behältst den Zugang bis zum Ende der bezahlten Periode.",
|
||||||
|
"faq5_q": "Was passiert mit meinen Daten wenn ich kündige?",
|
||||||
|
"faq5_a": "Dein Account und alle Daten bleiben erhalten. Dein Streak und alle Tracker gehören dir – für immer.",
|
||||||
|
"faq6_q": "Ist ReBreak ein Ersatz für professionelle Hilfe?",
|
||||||
|
"faq6_a": "Nein. ReBreak ist ein Selbsthilfe-Tool. Bei Krisen: BZgA (0800 1372700) oder Arzt aufsuchen."
|
||||||
|
}
|
||||||
|
}
|
||||||
230
apps/marketing/app/locales/en.json
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"pricing": "Pricing",
|
||||||
|
"resources": "Help",
|
||||||
|
"login": "Login",
|
||||||
|
"download_app": "Get the App"
|
||||||
|
},
|
||||||
|
"landing": {
|
||||||
|
"hero_badge": "Together against the gambling industry",
|
||||||
|
"hero_title": "Millions fight in silence.",
|
||||||
|
"hero_subtitle": "You don't have to do it alone!",
|
||||||
|
"hero_text": "Together we are strong!",
|
||||||
|
"cta_start": "Start free now",
|
||||||
|
"stat_affected": "People in DE affected",
|
||||||
|
"stat_blocked": "Domains blocked",
|
||||||
|
"stat_free": "To start",
|
||||||
|
"more_info": "Learn more",
|
||||||
|
"blocker_badge": "Gambling Blocker",
|
||||||
|
"blocker_title_domains": "Domains.",
|
||||||
|
"blocker_title_activated": "Once activated.",
|
||||||
|
"blocker_desc": "The most comprehensive gambling blocklist. Updated daily. For all platforms. A cooldown prevents weak moments.",
|
||||||
|
"blocker_feat_platforms": "For macOS, iOS, Android & Pi-hole",
|
||||||
|
"blocker_feat_updated": "Daily updated list",
|
||||||
|
"blocker_feat_custom": "Add custom domains",
|
||||||
|
"blocker_feat_cooldown": "Cooldown protection against relapses",
|
||||||
|
"oasis_badge": "Why OASIS alone isn't enough",
|
||||||
|
"oasis_title": "New casinos daily –",
|
||||||
|
"oasis_subtitle": "without license, without ban.",
|
||||||
|
"oasis_desc": "The OASIS self-exclusion only blocks you at licensed providers. But new casino sites go online daily – many without a license, many offshore. These sites don't know OASIS. ReBreak protects you there too: with a daily updated database of over 208,000 domains.",
|
||||||
|
"oasis_new_domains": "new gambling domains daily",
|
||||||
|
"oasis_offshore": "Casinos without license bypass OASIS completely",
|
||||||
|
"oasis_updated": "Domains updated daily by ReBreak",
|
||||||
|
"streak_badge": "Streak & Savings",
|
||||||
|
"streak_title": "Every day counts.",
|
||||||
|
"streak_subtitle": "Visible progress.",
|
||||||
|
"streak_desc": "See how many days you've won – and how much money you haven't lost. Milestone badges keep you motivated.",
|
||||||
|
"streak_days_free": "Days free",
|
||||||
|
"streak_saved": "saved",
|
||||||
|
"crisis_badge": "Mastering crisis moments",
|
||||||
|
"crisis_title": "The urge comes.",
|
||||||
|
"crisis_subtitle": "You are prepared.",
|
||||||
|
"sos_title": "SOS – Instant Help",
|
||||||
|
"sos_subtitle": "One click. Instant.",
|
||||||
|
"sos_desc": "The urge lasts on average only 15–20 minutes. ReBreak guides you step by step through this moment – until it passes.",
|
||||||
|
"sos_angry": "Angry",
|
||||||
|
"sos_sad": "Depressed",
|
||||||
|
"sos_stressed": "Stressed",
|
||||||
|
"sos_empty": "Empty",
|
||||||
|
"breathing_title": "4-7-8 Breathing Exercise",
|
||||||
|
"breathing_subtitle": "Lower pulse in 60 seconds",
|
||||||
|
"breathing_desc": "Scientifically proven: breathe in for 4 seconds, hold for 7, breathe out for 8 – the body automatically switches to rest mode.",
|
||||||
|
"breathing_breathe": "Breathe",
|
||||||
|
"breathing_inhale": "4s inhale",
|
||||||
|
"breathing_hold": "7s hold",
|
||||||
|
"breathing_exhale": "8s exhale",
|
||||||
|
"coach_badge": "When SOS isn't enough",
|
||||||
|
"coach_title": "Coach & Community.",
|
||||||
|
"coach_subtitle": "Always on call.",
|
||||||
|
"coach_desc": "An AI coach that truly knows you – personalized, CBT-based, without judgment. And a real community of people who understand what you're going through.",
|
||||||
|
"coach_label": "AI Coach",
|
||||||
|
"founding_badge": "Founding Member",
|
||||||
|
"founding_desc": "The first {count} members get 1 month Standard free – automatically, no code needed.",
|
||||||
|
"founding_slots": "{current} / {total} Spots",
|
||||||
|
"founding_cta": "Secure your spot – free",
|
||||||
|
"mail_badge": "Mail Cleanup",
|
||||||
|
"mail_title": "Bonus emails?",
|
||||||
|
"mail_subtitle": "Never seen.",
|
||||||
|
"mail_desc": "Casinos bombard you daily with offers and discounts. ReBreak connects to your inbox and moves these emails to trash – before you see them.",
|
||||||
|
"mail_feat_providers": "Gmail, GMX, Outlook – all major providers",
|
||||||
|
"mail_feat_intervals": "Real-time, hourly or every 4 hours",
|
||||||
|
"mail_feat_privacy": "No email is read – only analyzed",
|
||||||
|
"mail_mock_blocked": "Blocked",
|
||||||
|
"mail_mock_scanned": "Scanned",
|
||||||
|
"mail_mock_rate": "Hit rate",
|
||||||
|
"mail_mock_accounts": "Connected accounts",
|
||||||
|
"mail_mock_rhythm": "Automatic scan rhythm",
|
||||||
|
"final_title": "Start now.",
|
||||||
|
"final_desc": "You're not broken. The system is manipulative. We help you back.",
|
||||||
|
"final_cta": "Start now – free & anonymous",
|
||||||
|
"chat_msg_1": "I feel the urge strongly again...",
|
||||||
|
"chat_msg_2": "I understand. What's triggering you right now? Let's go through this.",
|
||||||
|
"chat_msg_3": "Stress at work.",
|
||||||
|
"chat_msg_4": "That's a known pattern. Try the 4-7-8 exercise first."
|
||||||
|
},
|
||||||
|
"blocked": {
|
||||||
|
"lyra": "Lyra",
|
||||||
|
"title": "This site is blocked",
|
||||||
|
"message": "ReBreak blocked this site for you. You chose to be strong – and this is the proof.",
|
||||||
|
"day": "Day",
|
||||||
|
"days": "Days",
|
||||||
|
"clean": "clean",
|
||||||
|
"streak_running": "Your streak is running. Don't give it up.",
|
||||||
|
"talk_lyra": "Talk to Lyra",
|
||||||
|
"start_breathing": "Start breathing exercise",
|
||||||
|
"back_to_app": "Back to app",
|
||||||
|
"quote_1": "Every blocked site is proof of your strength.",
|
||||||
|
"quote_2": "The urge passes. Your progress stays.",
|
||||||
|
"quote_3": "You didn't need this site – and you don't need it.",
|
||||||
|
"quote_4": "Being strong means saying no in this moment.",
|
||||||
|
"quote_5": "This is your wall of protection. You built it."
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"blocklist_title": "Community Blocklist",
|
||||||
|
"blocklist_desc": "Growing daily – by the community, for the community. Currently {count} domains blocked.",
|
||||||
|
"chart_label": "Blocked domains – last 12 months",
|
||||||
|
"hotlines_title": "Instant Help & Hotlines",
|
||||||
|
"hotlines_desc": "Free, anonymous, available 24/7.",
|
||||||
|
"tips_title": "What helps now",
|
||||||
|
"tips_desc": "Proven strategies from cognitive behavioral therapy (CBT).",
|
||||||
|
"not_weak_title": "You are not weak",
|
||||||
|
"not_weak_desc": "The system is designed this way. Here's why.",
|
||||||
|
"cta_title": "Ready for the first step?",
|
||||||
|
"cta_button": "Download the App",
|
||||||
|
"hotline_de": "Germany",
|
||||||
|
"hotline_at": "Austria",
|
||||||
|
"hotline_ch": "Switzerland",
|
||||||
|
"tip_breathing": "4-7-8 breathing exercise for acute urges",
|
||||||
|
"tip_breathing_desc": "Inhale 4 sec, hold 7, exhale 8. Activates the parasympathetic nervous system and breaks the impulse.",
|
||||||
|
"tip_15min": "The 15-minute rule",
|
||||||
|
"tip_15min_desc": "Wait 15 minutes before making a decision. Gambling urge is a wave – it comes and goes.",
|
||||||
|
"tip_move": "Get out and move",
|
||||||
|
"tip_move_desc": "A 10-minute walk releases endorphins and automatically interrupts the urge cycle.",
|
||||||
|
"tip_triggers": "Know your triggers",
|
||||||
|
"tip_triggers_desc": "Stress, boredom, evening alone? Those who know their patterns can counteract before the urge overwhelms.",
|
||||||
|
"fact1_title": "Variable rewards activate the same circuit as drugs",
|
||||||
|
"fact1_text": "Not knowing if you'll win releases more dopamine than a certain win. Design, not accident.",
|
||||||
|
"fact2_title": "Online casinos are available 24/7 – no natural stopper",
|
||||||
|
"fact2_text": "The casino used to be physical. Today it's your phone. No closing day, no shame from others.",
|
||||||
|
"fact3_title": "Virtual currencies obscure real money loss",
|
||||||
|
"fact3_text": "Chips, coins, credits – the brain doesn't process these like cash. That's not a bug in the system.",
|
||||||
|
"fact4_title": "The house always wins – mathematically",
|
||||||
|
"fact4_text": "Every legal casino has a built-in margin. Long-term, 100% of players lose money. No bad luck streak."
|
||||||
|
},
|
||||||
|
"pricing": {
|
||||||
|
"founding_banner": "Founding Member – First 100 get 3 months Legend free",
|
||||||
|
"title": "Your path, your pace",
|
||||||
|
"subtitle_start": "Start now –",
|
||||||
|
"subtitle_end": "choose your plan.",
|
||||||
|
"pro_meaning_title": "What does Pro really mean?",
|
||||||
|
"pro_meaning_desc": "With Pro you actively contribute to growing the ReBreak blocklist for everyone. You can add domains directly and review submissions from other users. You lead groups, have no AI memory loss – and stand at the forefront for everyone still fighting.",
|
||||||
|
"comparison_title": "What's included?",
|
||||||
|
"comparison_subtitle": "Complete comparison of all plans",
|
||||||
|
"feature": "Feature",
|
||||||
|
"free": "Free",
|
||||||
|
"quotes_title": "Thoughts that help",
|
||||||
|
"quotes_subtitle": "From psychologists and thinkers on self-protection and change",
|
||||||
|
"faq_title": "Frequently Asked Questions",
|
||||||
|
"cta_title": "Ready to start?",
|
||||||
|
"cta_desc": "Start free, upgrade anytime.",
|
||||||
|
"cta_button": "Download the App",
|
||||||
|
"footer_home": "Home",
|
||||||
|
"footer_pricing": "Pricing",
|
||||||
|
"footer_resources": "Resources",
|
||||||
|
"footer_login": "Login",
|
||||||
|
"billing_monthly": "Monthly",
|
||||||
|
"billing_yearly": "Yearly",
|
||||||
|
"billing_save_pct": "Save 39%",
|
||||||
|
"billing_forever": "forever",
|
||||||
|
"billing_per_month": "/ month",
|
||||||
|
"billing_per_year": "/ month, billed yearly",
|
||||||
|
"plan_free_title": "Free",
|
||||||
|
"plan_free_desc": "Get started with no risk – free forever.",
|
||||||
|
"plan_free_btn": "Download App",
|
||||||
|
"plan_pro_title": "Pro",
|
||||||
|
"plan_pro_desc": "Full protection and all tools for your daily life.",
|
||||||
|
"plan_pro_btn": "Start Pro",
|
||||||
|
"plan_legend_title": "Legend",
|
||||||
|
"plan_legend_desc": "For those strong enough to light the way for others.",
|
||||||
|
"plan_legend_btn": "Start Legend",
|
||||||
|
"plan_loading": "Loading...",
|
||||||
|
"plan_recommended": "Recommended",
|
||||||
|
"feat_free_domains": "5 custom domains",
|
||||||
|
"feat_free_mail": "1 mail agent (scan every 4h)",
|
||||||
|
"feat_coach_basic": "AI Coach Basic",
|
||||||
|
"feat_streak": "Streak & Savings Tracker",
|
||||||
|
"feat_urge": "Urge Tracker + Breathing Exercise",
|
||||||
|
"feat_sos": "SOS Button (Instant Help)",
|
||||||
|
"feat_community": "Experience the community",
|
||||||
|
"feat_all_free": "Everything in Free",
|
||||||
|
"feat_blocklist": "ReBreak Blocklist (208k+ domains)",
|
||||||
|
"feat_pro_domains": "5 custom domains (refillable)",
|
||||||
|
"feat_pro_mail": "3 mail agents (interval: 1h / 4h / 8h)",
|
||||||
|
"feat_community_post": "Post in community",
|
||||||
|
"feat_buddy": "Buddy System",
|
||||||
|
"feat_coach_pro": "AI Coach (Better)",
|
||||||
|
"feat_urge_stats": "Urge statistics & patterns",
|
||||||
|
"feat_all_pro": "Everything in Pro",
|
||||||
|
"feat_legend_domains": "Unlimited custom domains (refillable)",
|
||||||
|
"feat_legend_mail": "Unlimited mail agents (real-time)",
|
||||||
|
"feat_legend_add": "Add domains directly to the ReBreak Blocklist",
|
||||||
|
"feat_legend_validate": "Validate community domains",
|
||||||
|
"feat_legend_groups": "Create & lead groups",
|
||||||
|
"feat_coach_legend": "Top AI Coach with memory",
|
||||||
|
"comp_domains": "Custom Domains",
|
||||||
|
"comp_mail": "Mail Agent",
|
||||||
|
"comp_coach": "AI Coach",
|
||||||
|
"comp_streak": "Streak & Savings Tracker",
|
||||||
|
"comp_urge": "Urge Tracker + Breathing",
|
||||||
|
"comp_sos": "SOS Button (Instant Help)",
|
||||||
|
"comp_community": "Experience community",
|
||||||
|
"comp_blocklist": "ReBreak Blocklist (208k+ domains)",
|
||||||
|
"comp_post": "Post in community",
|
||||||
|
"comp_buddy": "Buddy System",
|
||||||
|
"comp_urge_stats": "Urge statistics & patterns",
|
||||||
|
"comp_add_domain": "Add domains to blocklist",
|
||||||
|
"comp_validate": "Validate community domains",
|
||||||
|
"comp_groups": "Create & lead groups",
|
||||||
|
"comp_free_domains": "5",
|
||||||
|
"comp_pro_domains": "5 (refillable)",
|
||||||
|
"comp_legend_domains": "Unlimited (refillable)",
|
||||||
|
"comp_free_mail_val": "1 (4h)",
|
||||||
|
"comp_pro_mail_val": "3 (1h / 4h / 8h)",
|
||||||
|
"comp_legend_mail_val": "Real-time",
|
||||||
|
"comp_free_coach_val": "Basic",
|
||||||
|
"comp_pro_coach_val": "Better",
|
||||||
|
"comp_legend_coach_val": "Top + Memory",
|
||||||
|
"faq1_q": "Do I need to provide an email address?",
|
||||||
|
"faq1_a": "Yes, an email address is required for registration. Your data is stored and processed exclusively on German servers – fully anonymously, according to strict GDPR standards.",
|
||||||
|
"faq2_q": "What's the difference between Pro and Legend?",
|
||||||
|
"faq2_a": "Pro gives you full protection: ReBreak Blocklist (208k+ domains), 3 mail agents, AI Coach and community. Legend goes further: unlimited domains, direct blocklist additions, domain validation, group leadership and top AI Coach with memory.",
|
||||||
|
"faq3_q": "What billing cycles are available?",
|
||||||
|
"faq3_a": "Monthly (full price) or yearly (save 39%). You can switch at any time.",
|
||||||
|
"faq4_q": "Can I cancel at any time?",
|
||||||
|
"faq4_a": "Yes, you can cancel your subscription at any time. You keep access until the end of the paid period.",
|
||||||
|
"faq5_q": "What happens to my data when I cancel?",
|
||||||
|
"faq5_a": "Your account and all data remain intact. Your streak and all trackers belong to you – forever.",
|
||||||
|
"faq6_q": "Is ReBreak a substitute for professional help?",
|
||||||
|
"faq6_a": "No. ReBreak is a self-help tool. In crises: contact a professional or call a helpline."
|
||||||
|
}
|
||||||
|
}
|
||||||
120
apps/marketing/app/pages/account-loeschen.vue
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-default font-sans pb-16 md:pb-0">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="pt-10 pb-8 text-center px-4">
|
||||||
|
<div class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-4 py-1.5 text-sm text-primary-300 mb-6">
|
||||||
|
<UIcon name="i-heroicons-trash" class="text-primary-400" />
|
||||||
|
Konto-Löschung
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl md:text-5xl font-extrabold text-highlighted mb-3">
|
||||||
|
Konto und Daten löschen
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted text-sm">
|
||||||
|
Du hast jederzeit das Recht, dein Konto und alle zugehörigen Daten löschen zu lassen (Art. 17 DSGVO).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<article class="px-4 max-w-3xl mx-auto pb-24 text-sm text-muted leading-relaxed space-y-8">
|
||||||
|
|
||||||
|
<!-- Methode 1: In-App -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">Variante 1: In der App löschen (empfohlen)</h2>
|
||||||
|
<ol class="list-decimal list-inside space-y-2">
|
||||||
|
<li>Öffne die ReBreak-App auf deinem Gerät</li>
|
||||||
|
<li>Tippe auf das Profil-Icon oben rechts</li>
|
||||||
|
<li>Wähle <strong>Einstellungen</strong></li>
|
||||||
|
<li>Scrolle zu <strong>Konto</strong> und tippe auf <strong>Konto löschen</strong></li>
|
||||||
|
<li>Bestätige die Löschung — alle Daten werden innerhalb von 30 Tagen unwiderruflich entfernt</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Methode 2: Email -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">Variante 2: Per E-Mail anfordern</h2>
|
||||||
|
<p class="mb-3">Falls du keinen Zugriff mehr auf die App hast, schreibe uns:</p>
|
||||||
|
<div class="bg-elevated border border-default rounded-xl p-4 not-prose">
|
||||||
|
<p>E-Mail: <a href="mailto:datenschutz@rebreak.org" class="text-primary-400 hover:underline">datenschutz@rebreak.org</a></p>
|
||||||
|
<p class="mt-2 text-xs">Betreff: <em>„Konto-Löschung — ReBreak"</em></p>
|
||||||
|
<p class="mt-2 text-xs">Bitte gib die mit deinem Konto verknüpfte E-Mail-Adresse an, damit wir dein Konto eindeutig identifizieren können.</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs italic mt-3">Deine Anfrage wird innerhalb von 30 Tagen bearbeitet. Wir senden dir eine Bestätigung sobald die Löschung abgeschlossen ist.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Was wird gelöscht -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">Was wird gelöscht?</h2>
|
||||||
|
<ul class="list-disc list-inside space-y-1.5">
|
||||||
|
<li>Dein ReBreak-Konto inklusive E-Mail, Nickname und Avatar</li>
|
||||||
|
<li>Alle Streak-, Trigger- und Recovery-Daten</li>
|
||||||
|
<li>Lyra-KI-Coach Chat-Verlauf und Memories</li>
|
||||||
|
<li>Community-Posts und Kommentare</li>
|
||||||
|
<li>Mail-Schutz-Verbindungen und gespeicherte IMAP-Tokens</li>
|
||||||
|
<li>Custom-Domain-Listen</li>
|
||||||
|
<li>Geräte-Registrierungen</li>
|
||||||
|
<li>Demographische Profil-Angaben</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Was wird aufbewahrt -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">Was wird aufbewahrt?</h2>
|
||||||
|
<p class="mb-3">Aus rechtlichen Gründen werden folgende Daten weiter gespeichert:</p>
|
||||||
|
<ul class="list-disc list-inside space-y-1.5">
|
||||||
|
<li><strong>Rechnungsdaten</strong>: 10 Jahre gemäß § 147 AO und § 257 HGB (Abgabenordnung / Handelsgesetzbuch)</li>
|
||||||
|
<li><strong>Aggregierte, anonymisierte Statistiken</strong>: Daten ohne jeden Personenbezug, die wir zur Produkt-Verbesserung nutzen (z.B. „durchschnittlich gesperrte Mails pro Tag")</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Teil-Löschung -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">Nur Teile deiner Daten löschen?</h2>
|
||||||
|
<p>Du kannst auch einzelne Daten-Kategorien löschen, ohne dein gesamtes Konto zu schließen:</p>
|
||||||
|
<ul class="list-disc list-inside space-y-1.5 mt-3">
|
||||||
|
<li>Lyra-Chat-Verlauf: in den App-Einstellungen unter „Lyra → Verlauf löschen"</li>
|
||||||
|
<li>Mail-Schutz-Verbindungen: Mail-Tab → Postfach aufklappen → „Trennen"</li>
|
||||||
|
<li>Demographische Daten: Profil-Bearbeiten → einzelne Felder leeren + Speichern</li>
|
||||||
|
<li>Custom-Domains: Blocker-Tab → Domain auswählen → „Entfernen"</li>
|
||||||
|
<li>Geräte: Profil → Geräteverwaltung → „Trennen"</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<section class="pt-6 border-t border-default text-xs">
|
||||||
|
<p class="mb-1"><strong>Verantwortlicher:</strong> Chahine Brini, Lärchenweg 17, 38368 Grasleben</p>
|
||||||
|
<p class="mb-1"><strong>Datenschutz:</strong> <a href="mailto:datenschutz@rebreak.org" class="text-primary-400 hover:underline">datenschutz@rebreak.org</a></p>
|
||||||
|
<p class="mb-1"><strong>Datenschutzerklärung:</strong> <a href="/datenschutz" class="text-primary-400 hover:underline">staging.rebreak.org/datenschutz</a></p>
|
||||||
|
<p class="mt-2"><strong>Stand:</strong> 10. Mai 2026</p>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Mobile Bottom Navigation (gleich wie datenschutz.vue) -->
|
||||||
|
<nav class="md:hidden fixed bottom-0 left-0 right-0 z-50 flex border-t border-default bg-default/95 backdrop-blur-md pb-safe">
|
||||||
|
<NuxtLink to="/" class="flex-1 flex flex-col items-center justify-center py-2.5 gap-0.5 text-[11px] text-muted">
|
||||||
|
<UIcon name="i-heroicons-home" class="size-5" /><span>Start</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/pricing" class="flex-1 flex flex-col items-center justify-center py-2.5 gap-0.5 text-[11px] text-muted">
|
||||||
|
<UIcon name="i-heroicons-credit-card" class="size-5" /><span>Preise</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/resources" class="flex-1 flex flex-col items-center justify-center py-2.5 gap-0.5 text-[11px] text-muted">
|
||||||
|
<UIcon name="i-heroicons-book-open" class="size-5" /><span>Ressourcen</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/auth/login" class="flex-1 flex flex-col items-center justify-center py-2.5 gap-0.5 text-[11px] text-muted">
|
||||||
|
<UIcon name="i-heroicons-user-circle" class="size-5" /><span>Login</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
useHead({
|
||||||
|
title: 'Konto löschen – ReBreak',
|
||||||
|
meta: [
|
||||||
|
{ name: 'description', content: 'Anleitung zum Löschen deines ReBreak-Kontos und aller zugehörigen Daten gemäß Art. 17 DSGVO.' },
|
||||||
|
{ name: 'robots', content: 'index,follow' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
definePageMeta({
|
||||||
|
// public — kein auth nötig
|
||||||
|
});
|
||||||
|
</script>
|
||||||
81
apps/marketing/app/pages/blocked.vue
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-default flex flex-col items-center justify-center px-6 py-12 text-center"
|
||||||
|
style="padding-top: max(3rem, env(safe-area-inset-top)); padding-bottom: max(3rem, env(safe-area-inset-bottom))">
|
||||||
|
|
||||||
|
<!-- Animated Shield Icon -->
|
||||||
|
<div class="relative mb-8">
|
||||||
|
<div
|
||||||
|
class="w-24 h-24 rounded-full bg-primary-950/60 border border-primary-700/40 flex items-center justify-center mx-auto">
|
||||||
|
<div class="absolute inset-0 rounded-full bg-primary-500/10 animate-ping" />
|
||||||
|
<UIcon name="i-heroicons-shield-check" class="text-primary-400 text-5xl relative z-10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Message -->
|
||||||
|
<div class="max-w-xs mx-auto mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-highlighted mb-3 leading-tight">
|
||||||
|
{{ $t('blocked.title') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Blocked domain badge -->
|
||||||
|
<div v-if="blockedDomain"
|
||||||
|
class="inline-flex items-center gap-2 bg-red-950/40 border border-red-700/30 rounded-full px-4 py-1.5 mb-4">
|
||||||
|
<UIcon name="i-heroicons-x-circle" class="text-red-400 text-sm shrink-0" />
|
||||||
|
<span class="text-red-300 text-sm font-mono truncate max-w-[200px]">{{ blockedDomain }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted text-sm leading-relaxed">
|
||||||
|
{{ $t('blocked.message') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA: Open App -->
|
||||||
|
<div class="w-full max-w-xs space-y-3">
|
||||||
|
<a href="https://apps.apple.com/app/rebreak" target="_blank" rel="noopener"
|
||||||
|
class="w-full flex items-center justify-center gap-3 bg-primary-600 hover:bg-primary-500 active:bg-primary-700 text-white font-semibold rounded-2xl px-6 py-4 transition-colors">
|
||||||
|
<UIcon name="i-heroicons-chat-bubble-left-ellipsis" class="text-xl" />
|
||||||
|
{{ $t('blocked.talk_lyra') }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button @click="goBack" class="w-full text-dimmed hover:text-muted text-sm py-2 transition-colors">
|
||||||
|
{{ $t('blocked.back_to_app') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom motivational quote -->
|
||||||
|
<div class="mt-10 max-w-xs">
|
||||||
|
<p class="text-xs text-dimmed italic leading-relaxed">
|
||||||
|
„{{ quote }}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ layout: false });
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const blockedDomain = computed(() => {
|
||||||
|
const d = route.query.domain as string | undefined;
|
||||||
|
return d ? decodeURIComponent(d) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const quotes = computed(() => [
|
||||||
|
t('blocked.quote_1'),
|
||||||
|
t('blocked.quote_2'),
|
||||||
|
t('blocked.quote_3'),
|
||||||
|
t('blocked.quote_4'),
|
||||||
|
t('blocked.quote_5'),
|
||||||
|
]);
|
||||||
|
const quote = computed(() => quotes.value[Math.floor(Math.random() * quotes.value.length)]);
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
window.history.back();
|
||||||
|
} else {
|
||||||
|
navigateTo('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
397
apps/marketing/app/pages/datenschutz.vue
Normal file
@ -0,0 +1,397 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-default font-sans pb-16 md:pb-0">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="pt-10 pb-8 text-center px-4">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-4 py-1.5 text-sm text-primary-300 mb-6">
|
||||||
|
<UIcon name="i-heroicons-lock-closed" class="text-primary-400" />
|
||||||
|
Datenschutz
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-3xl md:text-5xl font-extrabold text-highlighted mb-3">
|
||||||
|
Datenschutzerklärung
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted text-sm mb-4">
|
||||||
|
Stand: 9. Mai 2026
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Sprach-Toggle DE / EN -->
|
||||||
|
<div class="inline-flex items-center gap-1 bg-elevated border border-default rounded-full p-1">
|
||||||
|
<span class="px-3 py-1 text-xs font-semibold rounded-full bg-primary-600 text-white">
|
||||||
|
DE
|
||||||
|
</span>
|
||||||
|
<NuxtLink
|
||||||
|
to="/privacy-policy"
|
||||||
|
class="px-3 py-1 text-xs font-semibold rounded-full text-muted hover:text-highlighted transition-colors">
|
||||||
|
EN
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wichtiger Hinweis (Gesundheitsdaten Art. 9 DSGVO) -->
|
||||||
|
<div class="px-4 max-w-3xl mx-auto mb-8">
|
||||||
|
<div class="bg-amber-950/40 border border-amber-700/40 rounded-2xl p-5">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<UIcon name="i-heroicons-shield-exclamation"
|
||||||
|
class="text-amber-400 text-xl shrink-0 mt-0.5" />
|
||||||
|
<div class="text-sm text-amber-100 leading-relaxed">
|
||||||
|
<p class="font-semibold mb-2 text-amber-200">
|
||||||
|
Hinweis zur besonderen Datenkategorie nach Art. 9 DSGVO
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Die in Rebreak verarbeiteten Daten zu Ihrem Nutzungsverhalten von
|
||||||
|
Glücksspielangeboten, Ihre Streak- und Trigger-Logs sowie Ihre Konversationen
|
||||||
|
mit dem KI-Coach „Lyra" gelten als <strong>Gesundheitsdaten im Sinne von
|
||||||
|
Art. 4 Nr. 15 DSGVO</strong>. Sie unterliegen dem besonderen Schutz nach
|
||||||
|
Art. 9 DSGVO und werden ausschließlich auf Grundlage Ihrer ausdrücklichen
|
||||||
|
Einwilligung verarbeitet, die Sie jederzeit mit Wirkung für die Zukunft
|
||||||
|
widerrufen können.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inhaltsverzeichnis -->
|
||||||
|
<div class="px-4 max-w-3xl mx-auto mb-10">
|
||||||
|
<div class="bg-elevated border border-default rounded-2xl p-5">
|
||||||
|
<h2 class="text-base font-bold text-highlighted mb-3 flex items-center gap-2">
|
||||||
|
<UIcon name="i-heroicons-list-bullet" class="text-primary-400" />
|
||||||
|
Inhaltsverzeichnis
|
||||||
|
</h2>
|
||||||
|
<ol class="text-sm text-muted space-y-1.5 list-decimal list-inside">
|
||||||
|
<li><a href="#par1" class="hover:text-primary-400 transition-colors">Verantwortlicher und Kontakt</a></li>
|
||||||
|
<li><a href="#par2" class="hover:text-primary-400 transition-colors">Datenschutzbeauftragter</a></li>
|
||||||
|
<li><a href="#par3" class="hover:text-primary-400 transition-colors">Begriffsbestimmungen</a></li>
|
||||||
|
<li><a href="#par4" class="hover:text-primary-400 transition-colors">Verarbeitete Datenkategorien</a></li>
|
||||||
|
<li><a href="#par5" class="hover:text-primary-400 transition-colors">Zwecke und Rechtsgrundlagen der Verarbeitung</a></li>
|
||||||
|
<li><a href="#par6" class="hover:text-primary-400 transition-colors">Empfänger und Auftragsverarbeiter</a></li>
|
||||||
|
<li><a href="#par7" class="hover:text-primary-400 transition-colors">Datenübermittlung in Drittländer</a></li>
|
||||||
|
<li><a href="#par8" class="hover:text-primary-400 transition-colors">Speicherdauer und Löschung</a></li>
|
||||||
|
<li><a href="#par9" class="hover:text-primary-400 transition-colors">Cookies, Local Storage und Tracking</a></li>
|
||||||
|
<li><a href="#par10" class="hover:text-primary-400 transition-colors">Push-Benachrichtigungen</a></li>
|
||||||
|
<li><a href="#par11" class="hover:text-primary-400 transition-colors">Technische und organisatorische Sicherheitsmaßnahmen</a></li>
|
||||||
|
<li><a href="#par12" class="hover:text-primary-400 transition-colors">Mail-Schutz / Mail-Agent</a></li>
|
||||||
|
<li><a href="#par13" class="hover:text-primary-400 transition-colors">Ihre Betroffenenrechte</a></li>
|
||||||
|
<li><a href="#par14" class="hover:text-primary-400 transition-colors">Beschwerderecht bei der Aufsichtsbehörde</a></li>
|
||||||
|
<li><a href="#par15" class="hover:text-primary-400 transition-colors">Automatisierte Entscheidungsfindung und KI-Coach „Lyra"</a></li>
|
||||||
|
<li><a href="#par16" class="hover:text-primary-400 transition-colors">Änderungen dieser Datenschutzerklärung</a></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inhalt -->
|
||||||
|
<article class="px-4 max-w-3xl mx-auto pb-24 text-sm text-muted leading-relaxed space-y-10">
|
||||||
|
|
||||||
|
<!-- § 1 Verantwortlicher -->
|
||||||
|
<section id="par1">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 1 Verantwortlicher und Kontakt</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
(1) Verantwortlicher im Sinne des Art. 4 Nr. 7 DSGVO sowie sonstiger
|
||||||
|
datenschutzrechtlicher Bestimmungen für die Verarbeitung personenbezogener Daten
|
||||||
|
im Zusammenhang mit der Nutzung der mobilen Anwendung sowie der zugehörigen
|
||||||
|
Web-Anwendung „Rebreak" (nachfolgend gemeinsam „App" oder „Rebreak") ist:
|
||||||
|
</p>
|
||||||
|
<div class="bg-elevated border border-default rounded-xl p-4 mb-3 not-prose">
|
||||||
|
<p class="text-highlighted font-semibold mb-1">Chahine Brini</p>
|
||||||
|
<p>(natürliche Person, einzelkaufmännisch handelnd)</p>
|
||||||
|
<p class="mt-2">Lärchenweg 17</p>
|
||||||
|
<p>38368 Grasleben</p>
|
||||||
|
<p>Deutschland</p>
|
||||||
|
<p class="mt-2">Telefon: <a href="tel:+4915226897875" class="text-primary-400 hover:underline">+49 152 26897875</a></p>
|
||||||
|
<p class="mt-1">E-Mail (Datenschutz): <a href="mailto:datenschutz@rebreak.org" class="text-primary-400 hover:underline">datenschutz@rebreak.org</a></p>
|
||||||
|
</div>
|
||||||
|
<p class="mb-3">
|
||||||
|
(2) <strong>Hinweis zur geplanten Vertragsübernahme:</strong> Eine Überführung
|
||||||
|
des Geschäftsbetriebs in eine in Gründung befindliche „Raynis GmbH" mit Sitz in
|
||||||
|
Deutschland ist geplant. Mit Wirksamwerden der Übernahme wird die Raynis GmbH
|
||||||
|
neue verantwortliche Stelle im Sinne des Art. 4 Nr. 7 DSGVO. Sämtliche
|
||||||
|
bestehenden Einwilligungen, Verträge zur Auftragsverarbeitung und
|
||||||
|
Verarbeitungsverzeichnisse werden im Wege der Universalsukzession bzw. durch
|
||||||
|
gesonderte Übertragung auf die GmbH überführt.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
(3) Rebreak strebt die Listung als <strong>Digitale Gesundheitsanwendung (DiGA)</strong>
|
||||||
|
nach § 33a SGB V beim BfArM an. Diese Anbahnungen führen <strong>nicht</strong> zu
|
||||||
|
einer Übermittlung personenbezogener Daten der Nutzer.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- § 2 DSB -->
|
||||||
|
<section id="par2">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 2 Datenschutzbeauftragter</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
Eine gesetzliche Pflicht zur Bestellung eines Datenschutzbeauftragten besteht derzeit
|
||||||
|
<strong>nicht</strong>. Bis zur formalen Bestellung richten Sie alle Anfragen an:
|
||||||
|
</p>
|
||||||
|
<div class="bg-elevated border border-default rounded-xl p-4 not-prose">
|
||||||
|
<p>E-Mail: <a href="mailto:datenschutz@rebreak.org" class="text-primary-400 hover:underline">datenschutz@rebreak.org</a></p>
|
||||||
|
<p class="mt-1 text-xs text-muted">Betreff: „Datenschutz – Rebreak"</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- § 3 Begriffsbestimmungen -->
|
||||||
|
<section id="par3">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 3 Begriffsbestimmungen</h2>
|
||||||
|
<ul class="list-disc list-inside space-y-1.5">
|
||||||
|
<li><strong>Personenbezogene Daten</strong> (Art. 4 Nr. 1 DSGVO): alle Informationen, die sich auf eine identifizierte oder identifizierbare natürliche Person beziehen.</li>
|
||||||
|
<li><strong>Gesundheitsdaten</strong> (Art. 4 Nr. 15 DSGVO): personenbezogene Daten, die sich auf die körperliche oder geistige Gesundheit beziehen, einschließlich suchtbezogener Verhaltensweisen.</li>
|
||||||
|
<li><strong>Verarbeitung</strong> (Art. 4 Nr. 2 DSGVO): jeder Vorgang im Zusammenhang mit personenbezogenen Daten.</li>
|
||||||
|
<li><strong>Auftragsverarbeiter</strong> (Art. 4 Nr. 8 DSGVO): Dritte, die Daten im Auftrag des Verantwortlichen verarbeiten.</li>
|
||||||
|
<li><strong>Pseudonymisierung</strong> (Art. 4 Nr. 5 DSGVO): Verarbeitung, bei der Daten ohne Zusatzinformationen nicht mehr zugeordnet werden können.</li>
|
||||||
|
<li><strong>Drittland</strong>: ein Staat außerhalb der EU/EWR.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- § 4 Datenkategorien -->
|
||||||
|
<section id="par4">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 4 Verarbeitete Datenkategorien</h2>
|
||||||
|
<ul class="list-disc list-inside space-y-2">
|
||||||
|
<li><strong>Stammdaten / Account:</strong> E-Mail-Adresse, Pseudonym (Nickname), optional Avatar, Sprache, Zeitzone.</li>
|
||||||
|
<li><strong>Authentifizierungsdaten:</strong> Passwort-Hash, OAuth-Identifier, Sitzungs-Token, Geräte-Identifier.</li>
|
||||||
|
<li><strong>Demographische Daten (optional):</strong> Geburtsjahr, Geschlecht, Beruf, Ausbildungsgrad, Bundesland. Ausschließlich user-initiated, keine automatische Extraktion.</li>
|
||||||
|
<li><strong>Gesundheits- und Verhaltensdaten (Art. 9 DSGVO):</strong> Glücksspielverhalten, Streak-Zähler, Trigger-Logs, Urge-Einträge, SOS-Aktivierungen, Chat mit KI-Coach „Lyra", Lyra-Memories.</li>
|
||||||
|
<li><strong>Inhalts- und Community-Daten:</strong> Posts, Kommentare, Reaktionen (unter Pseudonym).</li>
|
||||||
|
<li><strong>Filter- und Sperrdaten:</strong> Custom-Domains, Filterstatistik, Cooldown-Konfiguration.</li>
|
||||||
|
<li><strong>Mail-Schutz-Daten (opt-in):</strong> OAuth-Tokens, Header-Hashes, Klassifikations-Ergebnis. E-Mail-Inhalte werden nicht dauerhaft gespeichert.</li>
|
||||||
|
<li><strong>Zahlungs- und Abonnement-Daten:</strong> Stripe-Customer-ID, Tarif-Status, Trial-Zeitraum.</li>
|
||||||
|
<li><strong>Technische Logdaten:</strong> IP (gekürzt), Datum/Uhrzeit, Geräteinformationen.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- § 5 Zwecke + Rechtsgrundlagen -->
|
||||||
|
<section id="par5">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 5 Zwecke und Rechtsgrundlagen der Verarbeitung</h2>
|
||||||
|
<div class="overflow-x-auto -mx-4 px-4 mb-3">
|
||||||
|
<table class="w-full text-xs border border-default rounded-lg overflow-hidden">
|
||||||
|
<thead class="bg-elevated text-highlighted text-left">
|
||||||
|
<tr>
|
||||||
|
<th class="p-2.5 border-b border-default font-semibold">Zweck</th>
|
||||||
|
<th class="p-2.5 border-b border-default font-semibold">Rechtsgrundlage</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-muted">
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">Bereitstellung des Nutzerkontos</td><td class="p-2.5">Art. 6 Abs. 1 lit. b DSGVO</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">Selbsthilfe-Funktionen (Streaks, SOS, KI-Coach)</td><td class="p-2.5">Art. 9 Abs. 2 lit. a DSGVO</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">Demographische Profilangaben (DiGA-Evidenz)</td><td class="p-2.5">Art. 6 Abs. 1 lit. a / Art. 9 Abs. 2 lit. a DSGVO</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">Pro-Trial-Reward für vollständige Profilangaben</td><td class="p-2.5">Art. 6 Abs. 1 lit. b DSGVO</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">Domain- und URL-Filterung</td><td class="p-2.5">Art. 6 Abs. 1 lit. b / Art. 9 Abs. 2 lit. a DSGVO</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">Mail-Schutz (opt-in)</td><td class="p-2.5">Art. 6 Abs. 1 lit. a / Art. 9 Abs. 2 lit. a DSGVO</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">Community-Bereich</td><td class="p-2.5">Art. 6 Abs. 1 lit. a DSGVO</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">Zahlungsabwicklung</td><td class="p-2.5">Art. 6 Abs. 1 lit. b DSGVO</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">IT-Sicherheit der App</td><td class="p-2.5">Art. 6 Abs. 1 lit. f DSGVO</td></tr>
|
||||||
|
<tr><td class="p-2.5">Gesetzliche Aufbewahrungspflichten</td><td class="p-2.5">Art. 6 Abs. 1 lit. c DSGVO</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="mb-3">
|
||||||
|
(3) <strong>Trennung strukturierter Profilangaben und narrativer Inhalte:</strong>
|
||||||
|
Demographische Daten werden ausschließlich aus der Profil-Eingabemaske gewonnen.
|
||||||
|
Eine automatische Extraktion durch den KI-Coach „Lyra" findet ausdrücklich nicht statt.
|
||||||
|
</p>
|
||||||
|
<p class="mb-3">
|
||||||
|
(4) <strong>Pro-Trial-Reward:</strong> Free-Nutzer, die ihr demographisches Profil vollständig ausfüllen,
|
||||||
|
erhalten als Anerkennung eine einwöchige Pro-Aktivierung. Die Verarbeitung Ihrer Daten ist
|
||||||
|
nicht an den Erhalt des Trials gekoppelt; Ablehnung hat keine Auswirkung auf den Free-Tarif.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- § 6 Empfänger -->
|
||||||
|
<section id="par6">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 6 Empfänger und Auftragsverarbeiter</h2>
|
||||||
|
<div class="overflow-x-auto -mx-4 px-4 mb-3">
|
||||||
|
<table class="w-full text-xs border border-default rounded-lg overflow-hidden">
|
||||||
|
<thead class="bg-elevated text-highlighted text-left">
|
||||||
|
<tr>
|
||||||
|
<th class="p-2.5 border-b border-default font-semibold">Anbieter</th>
|
||||||
|
<th class="p-2.5 border-b border-default font-semibold">Zweck</th>
|
||||||
|
<th class="p-2.5 border-b border-default font-semibold">Sitz</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-muted">
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">Hetzner Online GmbH</td><td class="p-2.5">Hosting, Datenbank, Backups</td><td class="p-2.5">Deutschland (EU)</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">Stripe Payments Europe</td><td class="p-2.5">Zahlungsabwicklung</td><td class="p-2.5">Irland (EU); USA – DPF + SCCs</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">Groq, Inc.</td><td class="p-2.5">LLM-Inferenz (Lyra, Free/Pro)</td><td class="p-2.5">USA – SCCs + TIA</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">Anthropic PBC</td><td class="p-2.5">LLM-Inferenz (Lyra, Legend)</td><td class="p-2.5">USA – SCCs + TIA</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">OpenRouter, Inc.</td><td class="p-2.5">LLM-Routing (Fallback)</td><td class="p-2.5">USA – SCCs + TIA</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">Cartesia, Inc.</td><td class="p-2.5">Text-to-Speech</td><td class="p-2.5">USA – SCCs + TIA</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">ElevenLabs Inc.</td><td class="p-2.5">Text-to-Speech (alternativ)</td><td class="p-2.5">USA – SCCs + TIA</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">Deepgram, Inc.</td><td class="p-2.5">Speech-to-Text</td><td class="p-2.5">USA – SCCs + TIA</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">Cloudflare, Inc.</td><td class="p-2.5">CDN, DNS, DDoS-Schutz</td><td class="p-2.5">USA – SCCs + DPF</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">Apple Inc. (APNs)</td><td class="p-2.5">Push iOS</td><td class="p-2.5">USA – DPF + SCCs</td></tr>
|
||||||
|
<tr class="border-b border-default/60"><td class="p-2.5">Google LLC (FCM)</td><td class="p-2.5">Push Android</td><td class="p-2.5">USA – DPF + SCCs</td></tr>
|
||||||
|
<tr><td class="p-2.5">Infisical Inc.</td><td class="p-2.5">Secret-Management (keine Nutzerdaten)</td><td class="p-2.5">USA – SCCs</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- § 7 Drittland -->
|
||||||
|
<section id="par7">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 7 Datenübermittlung in Drittländer</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
Soweit personenbezogene Daten an Empfänger in Drittländern (insbesondere USA) übermittelt werden,
|
||||||
|
erfolgt dies unter Beachtung der Art. 44 ff. DSGVO. Als Garantien setzen wir
|
||||||
|
<strong>Standardvertragsklauseln (SCCs)</strong> der EU-Kommission ein;
|
||||||
|
soweit Anbieter unter dem <strong>EU-US Data Privacy Framework</strong> zertifiziert sind,
|
||||||
|
stützt sich die Übermittlung zusätzlich auf Art. 45 DSGVO.
|
||||||
|
</p>
|
||||||
|
<p class="mb-3">
|
||||||
|
Ein <strong>Transfer-Impact-Assessment (TIA)</strong> wurde durchgeführt.
|
||||||
|
Ergänzende Schutzmaßnahmen: Übermittlung ohne Klarnamen/E-Mail, TLS 1.3 mit Forward Secrecy,
|
||||||
|
Datenminimierung, no-training-Zusagen der LLM-Anbieter.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- § 8 Speicherdauer -->
|
||||||
|
<section id="par8">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 8 Speicherdauer und Löschung</h2>
|
||||||
|
<ul class="list-disc list-inside space-y-2">
|
||||||
|
<li><strong>Account- und Stammdaten:</strong> bis zur Konto-Löschung.</li>
|
||||||
|
<li><strong>Demographische Daten:</strong> bis zur Löschung durch den Nutzer, spätestens bei Konto-Löschung.</li>
|
||||||
|
<li><strong>Chat-Verläufe und Lyra-Memories:</strong> standardmäßig 12 Monate, konfigurierbar.</li>
|
||||||
|
<li><strong>Trigger-, Urge- und Streak-Logs:</strong> bis zu 24 Monate, dann Aggregation.</li>
|
||||||
|
<li><strong>Community-Inhalte:</strong> bis zur Löschung oder Konto-Löschung.</li>
|
||||||
|
<li><strong>Mail-Schutz-Daten:</strong> OAuth-Tokens bis Verbindung getrennt; Header-Hashes 90 Tage.</li>
|
||||||
|
<li><strong>Server-Logs:</strong> 14 Tage.</li>
|
||||||
|
<li><strong>Rechnungsunterlagen:</strong> 10 Jahre (§§ 147 AO, 257 HGB).</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- § 9 Cookies -->
|
||||||
|
<section id="par9">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 9 Cookies, Local Storage und Tracking</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
Rebreak setzt <strong>keine Tracking-Pixel und keine Drittanbieter-Analytics-Cookies</strong> ein.
|
||||||
|
Wir nutzen ausschließlich technisch erforderliche Speichermechanismen (§ 25 Abs. 2 Nr. 2 TTDSG):
|
||||||
|
Authentifizierungs-Cookies, UI-Einstellungen, Filter-Cache.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- § 10 Push -->
|
||||||
|
<section id="par10">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 10 Push-Benachrichtigungen</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
Mit Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) versenden wir Push-Benachrichtigungen
|
||||||
|
via Apple APNs (iOS) und Google FCM (Android). Sie können den Empfang jederzeit in den
|
||||||
|
Systemeinstellungen deaktivieren.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- § 11 Sicherheit -->
|
||||||
|
<section id="par11">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 11 Technische und organisatorische Sicherheitsmaßnahmen</h2>
|
||||||
|
<ul class="list-disc list-inside space-y-1.5 mb-3">
|
||||||
|
<li>TLS 1.3 für alle Verbindungen;</li>
|
||||||
|
<li>Festplattenverschlüsselung (Encryption at Rest);</li>
|
||||||
|
<li>JWT mit kurzlebigen Access-Tokens und httpOnly-Refresh-Cookies;</li>
|
||||||
|
<li>OAuth 2.0 mit PKCE;</li>
|
||||||
|
<li>RBAC und Row-Level-Security in PostgreSQL;</li>
|
||||||
|
<li>Regelmäßige Backups, verschlüsselt;</li>
|
||||||
|
<li>Secret-Management via Infisical (kein Klartext-Speichern);</li>
|
||||||
|
<li>Datenminimierung gemäß Art. 5 Abs. 1 lit. c DSGVO.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mb-3">
|
||||||
|
<strong>Pseudonymisierung gegenüber LLM-Anbietern:</strong> Chat-Inhalte werden ohne
|
||||||
|
Klarnamen, E-Mail-Adressen oder Account-IDs übermittelt. Übertragen wird nur der Nickname
|
||||||
|
und Gesprächsinhalt. Ab Q3 2026 ist eine NER-Pipeline zur automatischen Maskierung geplant.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Anonymität durch Pseudonym:</strong> Innerhalb der App sind Sie für andere
|
||||||
|
ausschließlich unter Ihrem Nickname sichtbar. Klarnamen werden niemals angezeigt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- § 12 Mail-Schutz -->
|
||||||
|
<section id="par12">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 12 Mail-Schutz / Mail-Agent</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
Der Mail-Schutz ist ein optionales Opt-in-Modul, das eingehende E-Mails auf
|
||||||
|
Glücksspiel-bezogene Inhalte klassifiziert. Rechtsgrundlage ist Art. 6 Abs. 1 lit. a und
|
||||||
|
Art. 9 Abs. 2 lit. a DSGVO. E-Mail-Inhalte werden nicht dauerhaft gespeichert.
|
||||||
|
Sie können den Mail-Schutz jederzeit deaktivieren; die OAuth-Tokens werden dann
|
||||||
|
unverzüglich gelöscht.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- § 13 Betroffenenrechte -->
|
||||||
|
<section id="par13">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 13 Ihre Betroffenenrechte</h2>
|
||||||
|
<ul class="list-disc list-inside space-y-1.5">
|
||||||
|
<li><strong>Auskunft</strong> (Art. 15 DSGVO)</li>
|
||||||
|
<li><strong>Berichtigung</strong> (Art. 16 DSGVO)</li>
|
||||||
|
<li><strong>Löschung</strong> (Art. 17 DSGVO)</li>
|
||||||
|
<li><strong>Einschränkung</strong> (Art. 18 DSGVO)</li>
|
||||||
|
<li><strong>Datenübertragbarkeit</strong> (Art. 20 DSGVO)</li>
|
||||||
|
<li><strong>Widerspruch</strong> (Art. 21 DSGVO)</li>
|
||||||
|
<li><strong>Widerruf</strong> einer Einwilligung (Art. 7 Abs. 3 DSGVO)</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mt-3">
|
||||||
|
Anfragen an:
|
||||||
|
<a href="mailto:datenschutz@rebreak.org" class="text-primary-400 hover:underline">datenschutz@rebreak.org</a>.
|
||||||
|
In der App: Konto-Löschung und Datenexport über die Account-Einstellungen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- § 14 Aufsichtsbehörde -->
|
||||||
|
<section id="par14">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 14 Beschwerderecht bei der Aufsichtsbehörde</h2>
|
||||||
|
<p class="mb-3">Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO).</p>
|
||||||
|
<div class="bg-elevated border border-default rounded-xl p-4 not-prose">
|
||||||
|
<p class="text-highlighted font-semibold mb-1">Die Landesbeauftragte für den Datenschutz Niedersachsen</p>
|
||||||
|
<p>Prinzenstraße 5, 30159 Hannover</p>
|
||||||
|
<p class="mt-1">Tel: +49 511 120-4500</p>
|
||||||
|
<p>E-Mail: <a href="mailto:poststelle@lfd.niedersachsen.de" class="text-primary-400 hover:underline">poststelle@lfd.niedersachsen.de</a></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- § 15 KI-Coach -->
|
||||||
|
<section id="par15">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 15 Automatisierte Entscheidungsfindung und KI-Coach „Lyra"</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
Eine rechtlich erhebliche automatisierte Entscheidungsfindung (Art. 22 DSGVO)
|
||||||
|
findet <strong>nicht</strong> statt. Der KI-Coach „Lyra" nutzt je nach Tarif:
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc list-inside space-y-1.5 mb-3">
|
||||||
|
<li><strong>Free und Pro:</strong> Llama-Modelle via Groq (USA)</li>
|
||||||
|
<li><strong>Legend:</strong> Claude Haiku 4.5 via Anthropic (USA)</li>
|
||||||
|
<li><strong>Fallback:</strong> ergänzende Modelle via OpenRouter (USA)</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Die Inhalte sind statistisch generiert und stellen <strong>keine medizinische, therapeutische
|
||||||
|
oder rechtliche Beratung</strong> dar. In akuten Krisen: Telefonseelsorge 0800/111 0 111
|
||||||
|
oder ärztlicher Notdienst 116 117.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- § 16 Änderungen -->
|
||||||
|
<section id="par16">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 16 Änderungen dieser Datenschutzerklärung</h2>
|
||||||
|
<p>
|
||||||
|
Wesentliche Änderungen werden per E-Mail oder In-App-Mitteilung angekündigt.
|
||||||
|
Die aktuelle Fassung ist stets unter dieser URL abrufbar.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pt-6 border-t border-default">
|
||||||
|
<p class="text-xs text-muted mb-2">
|
||||||
|
<strong class="text-highlighted">Verantwortlicher:</strong> Chahine Brini, Lärchenweg 17, 38368 Grasleben ·
|
||||||
|
<a href="mailto:datenschutz@rebreak.org" class="text-primary-400 hover:underline">datenschutz@rebreak.org</a>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted"><strong class="text-highlighted">Stand:</strong> 9. Mai 2026</p>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ layout: "default" });
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Datenschutzerklärung – Rebreak',
|
||||||
|
meta: [
|
||||||
|
{ name: 'description', content: 'Datenschutzerklärung der Rebreak-App nach Art. 13/14 DSGVO. Stand: 9. Mai 2026.' },
|
||||||
|
{ name: 'robots', content: 'index,follow' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
104
apps/marketing/app/pages/download/android.vue
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-950 text-white flex flex-col items-center justify-center px-4 py-16">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<!-- Logo / Brand -->
|
||||||
|
<div class="flex items-center gap-3 mb-10">
|
||||||
|
<div class="w-12 h-12 rounded-2xl bg-indigo-500 flex items-center justify-center">
|
||||||
|
<span class="text-2xl font-bold">R</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight">Rebreak</h1>
|
||||||
|
<p class="text-sm text-gray-400">Gambling Recovery</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<h2 class="text-3xl font-extrabold mb-2">Rebreak für Android</h2>
|
||||||
|
<p class="text-gray-400 mb-1 text-sm">
|
||||||
|
Version {{ version }} · Build {{ buildDate }}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
class="inline-block bg-amber-500/15 text-amber-400 text-xs font-semibold px-2.5 py-1 rounded-full mb-8"
|
||||||
|
>
|
||||||
|
Beta — iOS ist die Hauptplattform
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Download Button -->
|
||||||
|
<a
|
||||||
|
:href="apkUrl"
|
||||||
|
class="block w-full bg-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 transition-colors text-white text-center font-bold text-lg py-4 rounded-2xl mb-4 shadow-lg shadow-indigo-900/40"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
APK herunterladen ({{ apkSizeMb }} MB)
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- SHA256 -->
|
||||||
|
<p class="text-xs text-gray-500 text-center break-all mb-10">
|
||||||
|
SHA256: <span class="font-mono">{{ sha256 }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Install Instructions -->
|
||||||
|
<div class="bg-gray-900 rounded-2xl p-6 mb-8">
|
||||||
|
<h3 class="font-bold text-base mb-4 text-white">Installation in 3 Schritten</h3>
|
||||||
|
<ol class="space-y-4">
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="w-7 h-7 shrink-0 rounded-full bg-indigo-600 flex items-center justify-center text-sm font-bold">1</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-sm text-white">APK laden</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">Oben auf "APK herunterladen" tippen und die Datei speichern.</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="w-7 h-7 shrink-0 rounded-full bg-indigo-600 flex items-center justify-center text-sm font-bold">2</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-sm text-white">Unbekannte Quellen erlauben</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">
|
||||||
|
Einstellungen → Apps → Spezieller App-Zugriff → Unbekannte Apps installieren → deinen Browser auswählen → erlauben.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span class="w-7 h-7 shrink-0 rounded-full bg-indigo-600 flex items-center justify-center text-sm font-bold">3</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-sm text-white">APK öffnen & installieren</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">Heruntergeladene Datei im Dateimanager öffnen und "Installieren" bestätigen.</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Beta Notice -->
|
||||||
|
<div class="bg-amber-950/40 border border-amber-800/30 rounded-xl p-4 mb-8">
|
||||||
|
<p class="text-amber-300 text-xs leading-relaxed">
|
||||||
|
<strong>Beta-Hinweis:</strong> Diese APK ist eine Vorschau-Version. Nicht alle Features
|
||||||
|
sind fertig. Fehler bitte per E-Mail melden:
|
||||||
|
<a href="mailto:support@rebreak.org" class="underline">support@rebreak.org</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<p class="text-center text-xs text-gray-600">
|
||||||
|
© {{ new Date().getFullYear() }} Rebreak ·
|
||||||
|
<NuxtLink to="/datenschutz" class="hover:text-gray-400">Datenschutz</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Diese Werte werden bei jedem Release-Build manuell oder per Script aktualisiert.
|
||||||
|
const version = "0.1.0";
|
||||||
|
const buildDate = "2026-04-28";
|
||||||
|
const apkSizeMb = "—"; // Wird nach Build eingetragen
|
||||||
|
const sha256 = "— wird nach Build eingetragen —";
|
||||||
|
const apkUrl = "/downloads/rebreak-android-latest.apk";
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: "Rebreak für Android – APK Download",
|
||||||
|
description: "Lade die Rebreak Gambling-Recovery App als APK für Android herunter. Beta-Version – iOS ist die Hauptplattform.",
|
||||||
|
});
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: false,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
131
apps/marketing/app/pages/impressum.vue
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-default font-sans pb-16 md:pb-0">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="pt-10 pb-8 text-center px-4">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-4 py-1.5 text-sm text-primary-300 mb-6">
|
||||||
|
<UIcon name="i-heroicons-identification" class="text-primary-400" />
|
||||||
|
Rechtliches
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-3xl md:text-5xl font-extrabold text-highlighted mb-3">
|
||||||
|
Impressum
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted text-sm">
|
||||||
|
Stand: 1. Mai 2026
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inhalt -->
|
||||||
|
<article class="px-4 max-w-3xl mx-auto pb-24 text-sm text-muted leading-relaxed space-y-10">
|
||||||
|
|
||||||
|
<section id="par1">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">Angaben gemäß § 5 DDG</h2>
|
||||||
|
<div class="bg-elevated border border-default rounded-xl p-5 not-prose">
|
||||||
|
<p class="text-highlighted font-semibold text-base mb-1">Chahine Brini</p>
|
||||||
|
<p class="mb-3 text-muted">(natürliche Person, einzelkaufmännisch handelnd)</p>
|
||||||
|
<p class="mb-1"><strong class="text-highlighted">Postanschrift:</strong></p>
|
||||||
|
<p class="mb-3 text-muted">
|
||||||
|
Lärchenweg 17<br />
|
||||||
|
38368 Grasleben<br />
|
||||||
|
Deutschland
|
||||||
|
</p>
|
||||||
|
<p class="mb-1"><strong class="text-highlighted">Kontakt:</strong></p>
|
||||||
|
<p>
|
||||||
|
E-Mail: <a href="mailto:c.brini@icloud.com" class="text-primary-400 hover:underline">c.brini@icloud.com</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-xs text-muted italic">
|
||||||
|
Hinweis: Der Geschäftsbetrieb wird derzeit als Einzelkaufmann geführt.
|
||||||
|
Eine Überführung in die in Gründung befindliche „Raynis GmbH" ist geplant;
|
||||||
|
die Angaben werden entsprechend aktualisiert.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par2">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV</h2>
|
||||||
|
<div class="bg-elevated border border-default rounded-xl p-4 not-prose">
|
||||||
|
<p class="text-highlighted font-semibold mb-1">Chahine Brini</p>
|
||||||
|
<p>Anschrift wie oben</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par3">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">Umsatzsteuer und unternehmensbezogene Angaben</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
Eine Umsatzsteuer-Identifikationsnummer gemäß § 27a UStG wird nach Erteilung ergänzt.
|
||||||
|
Eine Eintragung im Handelsregister besteht für den derzeitigen Anbieter nicht.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par4">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">Online-Streitbeilegung gemäß Art. 14 Abs. 1 ODR-VO</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung bereit:
|
||||||
|
<a href="https://ec.europa.eu/consumers/odr" class="text-primary-400 hover:underline" target="_blank" rel="noopener">
|
||||||
|
https://ec.europa.eu/consumers/odr
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
E-Mail für Verbraucheranfragen:
|
||||||
|
<a href="mailto:c.brini@icloud.com" class="text-primary-400 hover:underline">c.brini@icloud.com</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par5">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">Verbraucherstreitbeilegung</h2>
|
||||||
|
<p>
|
||||||
|
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer
|
||||||
|
Verbraucherschlichtungsstelle gemäß § 36 VSBG teilzunehmen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par6">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">Haftung für Inhalte</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
Als Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte verantwortlich.
|
||||||
|
Bei Bekanntwerden von Rechtsverletzungen werden wir diese Inhalte umgehend entfernen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par7">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">Haftung für Links</h2>
|
||||||
|
<p>
|
||||||
|
Unser Angebot enthält Links zu externen Seiten, auf deren Inhalte wir keinen Einfluss haben.
|
||||||
|
Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Links umgehend entfernen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par8">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">Urheberrecht</h2>
|
||||||
|
<p>
|
||||||
|
Die durch den Anbieter erstellten Inhalte unterliegen dem deutschen Urheberrecht.
|
||||||
|
Downloads und Kopien sind nur für den privaten, nicht kommerziellen Gebrauch gestattet.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pt-6 border-t border-default">
|
||||||
|
<p class="text-xs text-muted mb-2">
|
||||||
|
<strong class="text-highlighted">Hosting:</strong> Hetzner Online GmbH, Falkenstein/Nürnberg, Deutschland.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted mb-2">
|
||||||
|
<strong class="text-highlighted">Datenschutz:</strong>
|
||||||
|
<NuxtLink to="/datenschutz" class="text-primary-400 hover:underline">Datenschutzerklärung</NuxtLink>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted"><strong class="text-highlighted">Stand:</strong> 1. Mai 2026</p>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ layout: "default" });
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Impressum – Rebreak',
|
||||||
|
meta: [
|
||||||
|
{ name: 'description', content: 'Impressum der Rebreak-App nach § 5 DDG.' },
|
||||||
|
{ name: 'robots', content: 'index,follow' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
449
apps/marketing/app/pages/index.vue
Normal file
@ -0,0 +1,449 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-default overflow-x-hidden">
|
||||||
|
<!-- ─── HERO ─── -->
|
||||||
|
<section class="relative min-h-[80dvh] flex flex-col items-center justify-center px-4 text-center">
|
||||||
|
<div class="absolute inset-0 bg-linear-to-b from-gray-950 via-primary-950/20 to-gray-950 pointer-events-none" />
|
||||||
|
<div
|
||||||
|
class="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-175 h-175 bg-primary-900/15 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
|
<div class="relative max-w-3xl mx-auto">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-4 py-1.5 text-sm text-primary-300 mb-8">
|
||||||
|
<UIcon name="i-heroicons-user-group" class="text-primary-400" />
|
||||||
|
{{ $t('landing.hero_badge') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-3xl md:text-7xl font-black text-highlighted leading-[1.05] tracking-tight mb-6">
|
||||||
|
{{ $t('landing.hero_title') }}
|
||||||
|
<span class="block text-2xl text-transparent bg-clip-text bg-linear-to-r from-primary-300 to-primary-500">
|
||||||
|
{{ $t('landing.hero_subtitle') }}
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-sm text-muted max-w-lg mx-auto mb-10 leading-relaxed">
|
||||||
|
{{ $t('landing.hero_text') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<img src="/encrypted.svg" alt="Community Illustration" class="w-15 max-w-md mx-auto mb-10" />
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
<a href="https://apps.apple.com/app/rebreak" target="_blank" rel="noopener">
|
||||||
|
<UButton size="xl" class="px-8">
|
||||||
|
<UIcon name="i-heroicons-bolt" />
|
||||||
|
{{ $t('landing.cta_start') }}
|
||||||
|
</UButton>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center gap-x-10 mt-16 text-center">
|
||||||
|
<div>
|
||||||
|
<div class="text-3xl font-extrabold text-highlighted">
|
||||||
|
<AnimatedCounter :target="300" :duration="2000" />k+
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted mt-1">{{ $t('landing.stat_affected') }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-3xl font-extrabold text-primary-400">
|
||||||
|
<AnimatedCounter :target="208" :duration="1600" :delay="200" />k+
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted mt-1">{{ $t('landing.stat_blocked') }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-3xl font-extrabold text-primary-400">0€</div>
|
||||||
|
<div class="text-xs text-muted mt-1">{{ $t('landing.stat_free') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<UButton size="lg" variant="ghost" color="neutral" @click="scrollToInfo">
|
||||||
|
{{ $t('landing.more_info') }}
|
||||||
|
<UIcon name="i-heroicons-chevron-down" />
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── INFO SECTIONS ─── -->
|
||||||
|
<div v-if="showInfoSections" ref="infoSections">
|
||||||
|
<!-- Blocker -->
|
||||||
|
<section class="py-8 px-4">
|
||||||
|
<div v-motion :initial="{ opacity: 0, y: 50 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
|
||||||
|
class="max-w-6xl mx-auto grid lg:grid-cols-2 gap-16 items-center">
|
||||||
|
<div class="order-2 lg:order-1">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-3 py-1 text-xs text-primary-300 mb-6">
|
||||||
|
<UIcon name="i-heroicons-shield-exclamation" />
|
||||||
|
{{ $t('landing.blocker_badge') }}
|
||||||
|
</div>
|
||||||
|
<h2 class="text-4xl md:text-5xl font-black text-highlighted leading-tight mb-6">
|
||||||
|
<AnimatedCounter :target="100" :duration="2500" />k+ {{ $t('landing.blocker_title_domains') }}<br />
|
||||||
|
<span class="text-primary-400">{{ $t('landing.blocker_title_activated') }}</span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-muted leading-relaxed mb-8">
|
||||||
|
{{ $t('landing.blocker_desc') }}
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-3 text-sm text-default">
|
||||||
|
<li v-for="f in blockerFeatures" :key="f" class="flex items-center gap-3">
|
||||||
|
<UIcon name="i-heroicons-check-circle" class="text-primary-400 shrink-0" />{{ f }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="order-1 lg:order-2 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
class="w-72 h-72 rounded-3xl bg-linear-to-br from-primary-950/60 to-primary-900/20 border border-primary-800/20 flex items-center justify-center shadow-2xl shadow-primary-950/50">
|
||||||
|
<UIcon name="i-heroicons-shield-check" class="text-primary-400 w-32 h-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- OASIS Warning -->
|
||||||
|
<section class="py-8 px-4">
|
||||||
|
<div v-motion :initial="{ opacity: 0, y: 40 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
|
||||||
|
class="max-w-4xl mx-auto">
|
||||||
|
<div class="bg-orange-950/20 border border-orange-800/20 rounded-3xl p-6 md:p-8">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 bg-orange-950/60 border border-orange-800/40 rounded-full px-3 py-1 text-xs text-orange-300 mb-5">
|
||||||
|
<UIcon name="i-heroicons-exclamation-triangle" />
|
||||||
|
{{ $t('landing.oasis_badge') }}
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl md:text-3xl font-black text-highlighted leading-tight mb-4">
|
||||||
|
{{ $t('landing.oasis_title') }}<br class="hidden sm:block" />
|
||||||
|
<span class="text-orange-400">{{ $t('landing.oasis_subtitle') }}</span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted leading-relaxed mb-6 max-w-2xl">
|
||||||
|
{{ $t('landing.oasis_desc') }}
|
||||||
|
</p>
|
||||||
|
<div class="grid sm:grid-cols-3 gap-3">
|
||||||
|
<div class="bg-elevated rounded-2xl p-4">
|
||||||
|
<div class="text-orange-400 font-black text-2xl mb-1">50+</div>
|
||||||
|
<div class="text-xs text-muted leading-relaxed">{{ $t('landing.oasis_new_domains') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-elevated rounded-2xl p-4">
|
||||||
|
<div class="text-orange-400 font-black text-2xl mb-1">Offshore</div>
|
||||||
|
<div class="text-xs text-muted leading-relaxed">{{ $t('landing.oasis_offshore') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-elevated rounded-2xl p-4">
|
||||||
|
<div class="text-primary-400 font-black text-2xl mb-1">208.000+</div>
|
||||||
|
<div class="text-xs text-muted leading-relaxed">{{ $t('landing.oasis_updated') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Streak -->
|
||||||
|
<section class="py-8 px-4 bg-linear-to-b from-transparent via-orange-950/10 to-transparent">
|
||||||
|
<div v-motion :initial="{ opacity: 0, y: 50 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
|
||||||
|
class="max-w-6xl mx-auto grid lg:grid-cols-2 gap-16 items-center">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
class="w-72 h-72 rounded-3xl bg-linear-to-br from-orange-950/60 to-yellow-900/20 border border-orange-800/20 flex flex-col items-center justify-center shadow-2xl shadow-orange-950/50 gap-2">
|
||||||
|
<UIcon name="i-heroicons-fire" class="text-orange-400 w-20 h-20" />
|
||||||
|
<div class="text-5xl font-black text-highlighted">
|
||||||
|
<AnimatedCounter :target="47" :duration="2800" />
|
||||||
|
</div>
|
||||||
|
<div class="text-orange-300 text-sm font-medium">{{ $t('landing.streak_days_free') }}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="absolute -bottom-4 -right-4 bg-green-950 border border-green-700/40 rounded-2xl px-4 py-2 text-sm">
|
||||||
|
<span class="text-green-400 font-bold">+€
|
||||||
|
<AnimatedCounter :target="423" :duration="3000" />
|
||||||
|
</span>
|
||||||
|
<span class="text-muted ml-1">{{ $t('landing.streak_saved') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 bg-orange-950/60 border border-orange-800/40 rounded-full px-3 py-1 text-xs text-orange-300 mb-6">
|
||||||
|
<UIcon name="i-heroicons-fire" />
|
||||||
|
{{ $t('landing.streak_badge') }}
|
||||||
|
</div>
|
||||||
|
<h2 class="text-4xl md:text-5xl font-black text-highlighted leading-tight mb-6">
|
||||||
|
{{ $t('landing.streak_title') }}<br />
|
||||||
|
<span class="text-orange-400">{{ $t('landing.streak_subtitle') }}</span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-muted leading-relaxed">
|
||||||
|
{{ $t('landing.streak_desc') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SOS + Atemübungen -->
|
||||||
|
<section class="py-8 px-4">
|
||||||
|
<div v-motion :initial="{ opacity: 0, y: 50 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
|
||||||
|
class="max-w-6xl mx-auto">
|
||||||
|
<div class="text-center mb-10">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 bg-orange-950/60 border border-orange-800/40 rounded-full px-3 py-1 text-xs text-orange-300 mb-5">
|
||||||
|
<UIcon name="i-heroicons-bolt" />
|
||||||
|
{{ $t('landing.crisis_badge') }}
|
||||||
|
</div>
|
||||||
|
<h2 class="text-4xl md:text-5xl font-black text-highlighted leading-tight">
|
||||||
|
{{ $t('landing.crisis_title') }}<br />
|
||||||
|
<span class="text-orange-400">{{ $t('landing.crisis_subtitle') }}</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
|
<!-- SOS -->
|
||||||
|
<div class="bg-orange-950/20 border border-orange-800/20 rounded-3xl p-6 flex flex-col gap-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl bg-orange-900/60 border border-orange-700/30 flex items-center justify-center shrink-0">
|
||||||
|
<UIcon name="i-heroicons-bolt" class="text-orange-400 text-xl" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-bold text-highlighted">{{ $t('landing.sos_title') }}</div>
|
||||||
|
<div class="text-xs text-orange-400">{{ $t('landing.sos_subtitle') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted leading-relaxed">
|
||||||
|
{{ $t('landing.sos_desc') }}
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div class="bg-elevated rounded-xl px-3 py-2.5 flex items-center gap-2">
|
||||||
|
<span class="text-xl">😤</span>
|
||||||
|
<span class="text-sm text-default">{{ $t('landing.sos_angry') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-elevated rounded-xl px-3 py-2.5 flex items-center gap-2">
|
||||||
|
<span class="text-xl">😔</span>
|
||||||
|
<span class="text-sm text-default">{{ $t('landing.sos_sad') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-elevated rounded-xl px-3 py-2.5 flex items-center gap-2">
|
||||||
|
<span class="text-xl">😰</span>
|
||||||
|
<span class="text-sm text-default">{{ $t('landing.sos_stressed') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-elevated rounded-xl px-3 py-2.5 flex items-center gap-2">
|
||||||
|
<span class="text-xl">😶</span>
|
||||||
|
<span class="text-sm text-default">{{ $t('landing.sos_empty') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Atemübungen -->
|
||||||
|
<div class="bg-primary-950/20 border border-primary-800/20 rounded-3xl p-6 flex flex-col gap-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl bg-primary-900/60 border border-primary-700/30 flex items-center justify-center shrink-0">
|
||||||
|
<UIcon name="i-heroicons-heart" class="text-primary-400 text-xl" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-bold text-highlighted">{{ $t('landing.breathing_title') }}</div>
|
||||||
|
<div class="text-xs text-primary-400">{{ $t('landing.breathing_subtitle') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted leading-relaxed">
|
||||||
|
{{ $t('landing.breathing_desc') }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center justify-center py-6">
|
||||||
|
<div class="relative flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
class="w-24 h-24 rounded-full bg-primary-900/20 border border-primary-700/20 absolute animate-ping opacity-20">
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-20 h-20 rounded-full bg-primary-900/30 border border-primary-700/30 absolute opacity-40 scale-110">
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-16 h-16 rounded-full bg-primary-900/50 border border-primary-600/40 flex items-center justify-center">
|
||||||
|
<span class="text-primary-200 text-xs font-bold">{{ $t('landing.breathing_breathe') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-[11px] px-2">
|
||||||
|
<span class="text-primary-400 font-medium">{{ $t('landing.breathing_inhale') }}</span>
|
||||||
|
<span class="text-dimmed">→</span>
|
||||||
|
<span class="text-muted">{{ $t('landing.breathing_hold') }}</span>
|
||||||
|
<span class="text-dimmed">→</span>
|
||||||
|
<span class="text-muted">{{ $t('landing.breathing_exhale') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Coach + Community -->
|
||||||
|
<section class="py-8 px-4 bg-linear-to-b from-transparent via-primary-950/10 to-transparent">
|
||||||
|
<div v-motion :initial="{ opacity: 0, y: 50 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
|
||||||
|
class="max-w-6xl mx-auto grid lg:grid-cols-2 gap-16 items-center">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<div class="w-72 bg-elevated border border-default rounded-3xl p-6 space-y-3 shadow-2xl">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-primary-900 flex items-center justify-center">
|
||||||
|
<UIcon name="i-heroicons-cpu-chip" class="text-primary-400 text-sm" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-highlighted">{{ $t('landing.coach_label') }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="msg in chatMessages" :key="msg.text" :class="msg.isBot ? 'mr-8' : 'ml-8 text-right'">
|
||||||
|
<div :class="msg.isBot
|
||||||
|
? 'bg-muted text-default rounded-2xl rounded-tl-sm'
|
||||||
|
: 'bg-primary-600 text-white rounded-2xl rounded-tr-sm'
|
||||||
|
" class="inline-block px-3 py-2 text-xs leading-relaxed max-w-[90%]">
|
||||||
|
{{ msg.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-3 py-1 text-xs text-primary-300 mb-6">
|
||||||
|
<UIcon name="i-heroicons-user-group" />
|
||||||
|
{{ $t('landing.coach_badge') }}
|
||||||
|
</div>
|
||||||
|
<h2 class="text-4xl md:text-5xl font-black text-highlighted leading-tight mb-6">
|
||||||
|
{{ $t('landing.coach_title') }}<br />
|
||||||
|
<span class="text-primary-400">{{ $t('landing.coach_subtitle') }}</span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-muted leading-relaxed mb-6">
|
||||||
|
{{ $t('landing.coach_desc') }}
|
||||||
|
</p>
|
||||||
|
<div class="bg-primary-950/30 border border-primary-800/20 rounded-2xl p-4 space-y-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UIcon name="i-heroicons-rocket-launch" class="text-primary-400" />
|
||||||
|
<span class="text-xs font-bold text-primary-300 uppercase tracking-wider">{{
|
||||||
|
$t('landing.founding_badge') }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted">{{ $t('landing.founding_desc', { count: 50 }) }}</p>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex-1 bg-muted rounded-full h-1.5 overflow-hidden">
|
||||||
|
<div class="bg-linear-to-r from-primary-500 to-orange-500 h-full rounded-full" style="width: 34%">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-muted shrink-0">{{ $t('landing.founding_slots', { current: 17, total: 50 })
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<a href="https://apps.apple.com/app/rebreak" target="_blank" rel="noopener">
|
||||||
|
<UButton size="sm" class="w-full">
|
||||||
|
<UIcon name="i-heroicons-bolt" />
|
||||||
|
{{ $t('landing.founding_cta') }}
|
||||||
|
</UButton>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Mail Agent -->
|
||||||
|
<section class="py-8 px-4 bg-linear-to-b from-transparent via-primary-950/10 to-transparent">
|
||||||
|
<div v-motion :initial="{ opacity: 0, y: 50 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
|
||||||
|
class="max-w-6xl mx-auto grid lg:grid-cols-2 gap-16 items-center">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-3 py-1 text-xs text-primary-300 mb-6">
|
||||||
|
<UIcon name="i-heroicons-envelope" />
|
||||||
|
{{ $t('landing.mail_badge') }}
|
||||||
|
</div>
|
||||||
|
<h2 class="text-4xl md:text-5xl font-black text-highlighted leading-tight mb-6">
|
||||||
|
{{ $t('landing.mail_title') }}<br />
|
||||||
|
<span class="text-primary-400">{{ $t('landing.mail_subtitle') }}</span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-muted leading-relaxed mb-6">
|
||||||
|
{{ $t('landing.mail_desc') }}
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-3 text-sm text-default">
|
||||||
|
<li class="flex items-center gap-3">
|
||||||
|
<UIcon name="i-heroicons-check-circle" class="text-primary-400 shrink-0" />
|
||||||
|
{{ $t('landing.mail_feat_providers') }}
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3">
|
||||||
|
<UIcon name="i-heroicons-check-circle" class="text-primary-400 shrink-0" />
|
||||||
|
{{ $t('landing.mail_feat_intervals') }}
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3">
|
||||||
|
<UIcon name="i-heroicons-check-circle" class="text-primary-400 shrink-0" />
|
||||||
|
{{ $t('landing.mail_feat_privacy') }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<!-- Mock Mail-App UI -->
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<div class="w-72 bg-elevated border border-default rounded-3xl p-5 shadow-2xl space-y-4">
|
||||||
|
<div class="grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div class="bg-muted rounded-xl p-2.5">
|
||||||
|
<div class="text-xl font-black text-primary-400">276</div>
|
||||||
|
<div class="text-[10px] text-muted mt-0.5">{{ $t('landing.mail_mock_blocked') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-muted rounded-xl p-2.5">
|
||||||
|
<div class="text-xl font-black text-highlighted">1450</div>
|
||||||
|
<div class="text-[10px] text-muted mt-0.5">{{ $t('landing.mail_mock_scanned') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-muted rounded-xl p-2.5">
|
||||||
|
<div class="text-xl font-black text-orange-400">19%</div>
|
||||||
|
<div class="text-[10px] text-muted mt-0.5">{{ $t('landing.mail_mock_rate') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="text-xs text-muted font-medium">{{ $t('landing.mail_mock_accounts') }}</div>
|
||||||
|
<span class="text-xs text-primary-400 font-medium">2/5</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-muted rounded-xl p-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="w-6 h-6 rounded-full bg-primary-900/60 border border-primary-800/30 flex items-center justify-center shrink-0">
|
||||||
|
<UIcon name="i-heroicons-envelope" class="text-primary-400 text-xs" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-xs text-highlighted font-medium truncate">user@example.com</div>
|
||||||
|
<div class="text-[10px] text-primary-400">4 blockiert · 06.04.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── FINAL CTA ─── -->
|
||||||
|
<section class="py-16 px-4 pb-24 text-center relative">
|
||||||
|
<div class="absolute inset-0 bg-linear-to-t from-primary-950/20 to-transparent pointer-events-none" />
|
||||||
|
<div class="relative max-w-2xl mx-auto">
|
||||||
|
<h2 class="text-5xl font-black text-highlighted mb-4">{{ $t('landing.final_title') }}</h2>
|
||||||
|
<p class="text-muted mb-10 text-lg">
|
||||||
|
{{ $t('landing.final_desc') }}
|
||||||
|
</p>
|
||||||
|
<a href="https://apps.apple.com/app/rebreak" target="_blank" rel="noopener">
|
||||||
|
<UButton size="xl" class="px-12">
|
||||||
|
<UIcon name="i-heroicons-bolt" />
|
||||||
|
{{ $t('landing.final_cta') }}
|
||||||
|
</UButton>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ layout: "default" });
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const infoSections = ref<HTMLElement | null>(null);
|
||||||
|
const showInfoSections = ref(true);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (window.innerWidth < 768) showInfoSections.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function scrollToInfo() {
|
||||||
|
showInfoSections.value = true;
|
||||||
|
nextTick(() => infoSections.value?.scrollIntoView({ behavior: "smooth" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockerFeatures = computed(() => [
|
||||||
|
t('landing.blocker_feat_platforms'),
|
||||||
|
t('landing.blocker_feat_updated'),
|
||||||
|
t('landing.blocker_feat_custom'),
|
||||||
|
t('landing.blocker_feat_cooldown'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const chatMessages = computed(() => [
|
||||||
|
{ text: t('landing.chat_msg_1'), isBot: false },
|
||||||
|
{ text: t('landing.chat_msg_2'), isBot: true },
|
||||||
|
{ text: t('landing.chat_msg_3'), isBot: false },
|
||||||
|
{ text: t('landing.chat_msg_4'), isBot: true },
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
251
apps/marketing/app/pages/nutzungsbedingungen.vue
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-default font-sans pb-16 md:pb-0">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="pt-10 pb-8 text-center px-4">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-4 py-1.5 text-sm text-primary-300 mb-6">
|
||||||
|
<UIcon name="i-heroicons-document-text" class="text-primary-400" />
|
||||||
|
Rechtliches
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-3xl md:text-5xl font-extrabold text-highlighted mb-3">
|
||||||
|
Nutzungsbedingungen
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted text-sm">Stand: 1. Mai 2026</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wichtiger Hinweis -->
|
||||||
|
<div class="px-4 max-w-3xl mx-auto mb-8">
|
||||||
|
<div class="bg-amber-950/40 border border-amber-700/40 rounded-2xl p-5">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<UIcon name="i-heroicons-exclamation-triangle" class="text-amber-400 text-xl shrink-0 mt-0.5" />
|
||||||
|
<div class="text-sm text-amber-100 leading-relaxed">
|
||||||
|
<p class="font-semibold mb-2 text-amber-200">
|
||||||
|
Rebreak ist kein Ersatz für ärztliche, psychotherapeutische oder suchtmedizinische Behandlung.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Die App ist eine digitale Selbsthilfe-Begleitung. Bei akuten Krisen oder Suizidgedanken:
|
||||||
|
<strong>Telefonseelsorge 0800 1110111</strong> (kostenfrei, 24h) oder <strong>Notruf 112</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inhaltsverzeichnis -->
|
||||||
|
<div class="px-4 max-w-3xl mx-auto mb-10">
|
||||||
|
<div class="bg-elevated border border-default rounded-2xl p-5">
|
||||||
|
<h2 class="text-base font-bold text-highlighted mb-3 flex items-center gap-2">
|
||||||
|
<UIcon name="i-heroicons-list-bullet" class="text-primary-400" />
|
||||||
|
Inhaltsverzeichnis
|
||||||
|
</h2>
|
||||||
|
<ol class="text-sm text-muted space-y-1.5 list-decimal list-inside">
|
||||||
|
<li><a href="#par1" class="hover:text-primary-400 transition-colors">Geltungsbereich und Anbieter</a></li>
|
||||||
|
<li><a href="#par2" class="hover:text-primary-400 transition-colors">Vertragsabschluss und Mindestalter</a></li>
|
||||||
|
<li><a href="#par3" class="hover:text-primary-400 transition-colors">Leistungsbeschreibung und Charakter der App</a></li>
|
||||||
|
<li><a href="#par4" class="hover:text-primary-400 transition-colors">Pflichten des Nutzers</a></li>
|
||||||
|
<li><a href="#par5" class="hover:text-primary-400 transition-colors">Preise, Abonnement und automatische Verlängerung</a></li>
|
||||||
|
<li><a href="#par6" class="hover:text-primary-400 transition-colors">Widerrufsrecht für Verbraucher</a></li>
|
||||||
|
<li><a href="#par7" class="hover:text-primary-400 transition-colors">Verfügbarkeit und Wartung</a></li>
|
||||||
|
<li><a href="#par8" class="hover:text-primary-400 transition-colors">Haftung</a></li>
|
||||||
|
<li><a href="#par9" class="hover:text-primary-400 transition-colors">Geistige Eigentumsrechte und nutzergenerierte Inhalte</a></li>
|
||||||
|
<li><a href="#par10" class="hover:text-primary-400 transition-colors">Beendigung des Vertragsverhältnisses</a></li>
|
||||||
|
<li><a href="#par11" class="hover:text-primary-400 transition-colors">Änderungen dieser Nutzungsbedingungen</a></li>
|
||||||
|
<li><a href="#par12" class="hover:text-primary-400 transition-colors">Schlussbestimmungen</a></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inhalt -->
|
||||||
|
<article class="px-4 max-w-3xl mx-auto pb-24 text-sm text-muted leading-relaxed space-y-10">
|
||||||
|
|
||||||
|
<section id="par1">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 1 Geltungsbereich und Anbieter</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
Diese Nutzungsbedingungen regeln das Vertragsverhältnis zwischen dem Anbieter und
|
||||||
|
den Nutzerinnen und Nutzern der mobilen Anwendung sowie der zugehörigen Web-Anwendung „Rebreak".
|
||||||
|
</p>
|
||||||
|
<div class="bg-elevated border border-default rounded-xl p-4 mb-3 not-prose">
|
||||||
|
<p class="text-highlighted font-semibold mb-1">Chahine Brini</p>
|
||||||
|
<p>(natürliche Person, einzelkaufmännisch handelnd)</p>
|
||||||
|
<p class="mt-2">E-Mail: <a href="mailto:c.brini@icloud.com" class="text-primary-400 hover:underline">c.brini@icloud.com</a></p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted italic">
|
||||||
|
Eine Überführung in die in Gründung befindliche „Raynis GmbH" ist geplant.
|
||||||
|
Nutzer werden rechtzeitig informiert.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par2">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 2 Vertragsabschluss und Mindestalter</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
Die Nutzung setzt die Einrichtung eines Nutzerkontos voraus. Mit Abschluss der
|
||||||
|
Registrierung kommt ein Nutzungsvertrag über die kostenfreien Funktionen zustande.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Die Nutzung ist Personen ab dem vollendeten <strong>16. Lebensjahr</strong> gestattet.
|
||||||
|
Minderjährige benötigen die Einwilligung eines Erziehungsberechtigten.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par3">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 3 Leistungsbeschreibung und Charakter der App</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
Rebreak ist ein digitales <strong>Selbsthilfe-Tool zur Unterstützung bei problematischem
|
||||||
|
Glücksspielverhalten</strong>. Funktionen: Domain-/URL-Filter, KI-Coach „Lyra",
|
||||||
|
Streak-Zähler, Trigger-Logging, SOS-Atemübung, Community-Bereich.
|
||||||
|
</p>
|
||||||
|
<p class="mb-3">
|
||||||
|
<strong>Rebreak ist ausdrücklich kein Medizinprodukt, keine ärztliche Behandlung und
|
||||||
|
keine Therapie.</strong> Die App stellt keine Diagnosen und ersetzt keinen Arztbesuch.
|
||||||
|
Nutzer mit ausgeprägter Glücksspielproblematik werden aufgefordert, professionelle Hilfe
|
||||||
|
in Anspruch zu nehmen (BZgA: 0800 1372700).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Die durch „Lyra" generierten Antworten beruhen auf einem maschinellen Sprachmodell,
|
||||||
|
können fehlerhaft sein und stellen keine fachliche Empfehlung dar.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par4">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 4 Pflichten des Nutzers</h2>
|
||||||
|
<p class="mb-3">Der Nutzer verpflichtet sich insbesondere, folgende Handlungen zu unterlassen:</p>
|
||||||
|
<ul class="list-disc list-inside mb-3 space-y-1">
|
||||||
|
<li>Schutz- und Sperrmechanismen (Domain-Filter, Tamper-Lock, Cooldown) zu umgehen;</li>
|
||||||
|
<li>Dekompilierung oder Reverse Engineering der App;</li>
|
||||||
|
<li>Beeinträchtigung der Systemintegrität durch Massenanfragen oder Schadsoftware;</li>
|
||||||
|
<li>Veröffentlichung rechtswidriger, beleidigender oder kommerziell werbender Inhalte;</li>
|
||||||
|
<li>Bewerbung von Glücksspielangeboten;</li>
|
||||||
|
<li>Weitergabe des Nutzerkontos an Dritte.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par5">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 5 Preise, Abonnement und automatische Verlängerung</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
Die Grundfunktionen (Tarif „Free") sind kostenfrei. Kostenpflichtige Tarife:
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc list-inside mb-3 space-y-1">
|
||||||
|
<li>Tarif „Pro": 29,00 € pro Jahr</li>
|
||||||
|
<li>Tarif „Legend": 59,00 € pro Jahr</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mb-3">
|
||||||
|
Kostenpflichtige Abonnements verlängern sich automatisch, sofern der Nutzer nicht
|
||||||
|
spätestens 24 Stunden vor Ablauf kündigt. Kündigung über die Account-Verwaltung oder
|
||||||
|
den jeweiligen App-Store.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Preisänderungen werden mindestens 6 Wochen vorher per E-Mail angekündigt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par6">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 6 Widerrufsrecht für Verbraucher</h2>
|
||||||
|
<div class="bg-elevated border border-default rounded-xl p-4 mb-4">
|
||||||
|
<p class="font-semibold text-highlighted mb-2">Widerrufsbelehrung</p>
|
||||||
|
<p class="mb-3">
|
||||||
|
Sie haben das Recht, binnen <strong>vierzehn Tagen</strong> ohne Angabe von Gründen
|
||||||
|
diesen Vertrag zu widerrufen. Die Frist beginnt ab dem Tag des Vertragsabschlusses.
|
||||||
|
</p>
|
||||||
|
<p class="mb-3">
|
||||||
|
Zur Ausübung: Chahine Brini,
|
||||||
|
<a href="mailto:c.brini@icloud.com" class="text-primary-400 hover:underline">c.brini@icloud.com</a>
|
||||||
|
</p>
|
||||||
|
<p class="font-semibold text-highlighted mb-2">Erlöschen des Widerrufsrechts</p>
|
||||||
|
<p>
|
||||||
|
Das Widerrufsrecht erlischt bei digitalen Inhalten, wenn der Anbieter mit der
|
||||||
|
Ausführung begonnen hat und der Nutzer ausdrücklich zugestimmt und seine Kenntnis
|
||||||
|
vom Erlöschen des Widerrufsrechts bestätigt hat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par7">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 7 Verfügbarkeit und Wartung</h2>
|
||||||
|
<p>
|
||||||
|
Der Anbieter bemüht sich um größtmögliche Verfügbarkeit (Best-Effort). Eine konkrete
|
||||||
|
Verfügbarkeitsgarantie besteht nicht. Wartungsfenster werden – soweit möglich –
|
||||||
|
rechtzeitig angekündigt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par8">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 8 Haftung</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
Der Anbieter haftet unbeschränkt bei Vorsatz, grober Fahrlässigkeit sowie bei
|
||||||
|
Schäden aus Verletzungen von Leben, Körper oder Gesundheit. Bei einfacher Fahrlässigkeit
|
||||||
|
ist die Haftung auf den vertragstypischen, vorhersehbaren Schaden begrenzt.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Der Anbieter haftet nicht für Schäden, die dadurch entstehen, dass der Nutzer auf
|
||||||
|
erforderliche ärztliche oder therapeutische Behandlung verzichtet.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par9">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 9 Geistige Eigentumsrechte und nutzergenerierte Inhalte</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
Alle Rechte an der App, am Design, an der Marke „Rebreak" und am KI-Coach „Lyra"
|
||||||
|
stehen dem Anbieter oder seinen Lizenzgebern zu. Der Nutzer erhält ein einfaches,
|
||||||
|
nicht übertragbares Nutzungsrecht.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Nutzergenerierte Inhalte bleiben Eigentum des Nutzers. Der Nutzer räumt dem Anbieter
|
||||||
|
ein auf die Vertragsdauer beschränktes Nutzungsrecht zur Bereitstellung der App-Dienste ein.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par10">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 10 Beendigung des Vertragsverhältnisses</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
Der Nutzer kann den Free-Tarif jederzeit durch Löschung des Kontos beenden.
|
||||||
|
Kostenpflichtige Abonnements können zum Ende der laufenden Periode gekündigt werden.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Mit Beendigung werden die Nutzerinhalte gemäß Datenschutzerklärung gelöscht.
|
||||||
|
Ein Datenexport (Art. 20 DSGVO) wird vorher bereitgestellt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par11">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 11 Änderungen dieser Nutzungsbedingungen</h2>
|
||||||
|
<p>
|
||||||
|
Änderungen werden mindestens 4 Wochen vorher per E-Mail oder In-App-Mitteilung angekündigt.
|
||||||
|
Widerspricht der Nutzer nicht innerhalb von 4 Wochen, gelten die Änderungen als angenommen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="par12">
|
||||||
|
<h2 class="text-xl font-bold text-highlighted mb-3">§ 12 Schlussbestimmungen</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
Es gilt das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts.
|
||||||
|
Gerichtsstand ist der Sitz des Anbieters, soweit der Nutzer Kaufmann ist.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Sollten einzelne Bestimmungen unwirksam sein, bleibt die Gültigkeit der übrigen
|
||||||
|
Bestimmungen unberührt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pt-6 border-t border-default">
|
||||||
|
<p class="text-xs text-muted mb-2">
|
||||||
|
<strong class="text-highlighted">Anbieter:</strong> Chahine Brini ·
|
||||||
|
<a href="mailto:c.brini@icloud.com" class="text-primary-400 hover:underline">c.brini@icloud.com</a>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted"><strong class="text-highlighted">Stand:</strong> 1. Mai 2026</p>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ layout: "default" });
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Nutzungsbedingungen – Rebreak',
|
||||||
|
meta: [
|
||||||
|
{ name: 'description', content: 'Nutzungsbedingungen der Rebreak-App. Stand: 1. Mai 2026.' },
|
||||||
|
{ name: 'robots', content: 'index,follow' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
312
apps/marketing/app/pages/pricing.vue
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-default font-sans pb-16 md:pb-0">
|
||||||
|
<!-- Header -->
|
||||||
|
<div v-motion :initial="{ opacity: 0, y: -30 }" :visible="{ opacity: 1, y: 0, transition: { duration: 600 } }"
|
||||||
|
class="pt-10 pb-12 text-center px-4">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 bg-amber-950/60 border border-amber-700/40 rounded-full px-4 py-1.5 text-sm text-amber-300 mb-4 animate-pulse">
|
||||||
|
<UIcon name="i-heroicons-fire" class="text-amber-400" />
|
||||||
|
{{ $t('pricing.founding_banner') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 bg-primary-950/60 border border-primary-800/40 rounded-full px-4 py-1.5 text-sm text-primary-300 mb-6">
|
||||||
|
<UIcon name="i-heroicons-sparkles" class="text-primary-400" />
|
||||||
|
{{ $t('pricing.title') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-4xl md:text-5xl font-extrabold text-highlighted mb-4">
|
||||||
|
{{ $t('pricing.subtitle_start') }}<br />
|
||||||
|
<span
|
||||||
|
class="text-transparent bg-clip-text bg-linear-to-r from-primary-400 via-primary-300 to-green-400">
|
||||||
|
{{ $t('pricing.subtitle_end') }}
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Billing Cycle Picker -->
|
||||||
|
<div class="flex items-center justify-center gap-2 mt-8">
|
||||||
|
<button v-for="opt in billingOptions" :key="opt.value" @click="billing = opt.value"
|
||||||
|
class="relative px-4 py-2 rounded-full text-sm font-semibold transition-all"
|
||||||
|
:class="billing === opt.value ? 'bg-primary-700 text-white' : 'bg-muted text-muted hover:text-highlighted'">
|
||||||
|
{{ opt.label }}
|
||||||
|
<span v-if="opt.badge"
|
||||||
|
class="absolute -top-2 -right-2 bg-green-500 text-white text-[9px] font-bold px-1.5 py-0.5 rounded-full">{{
|
||||||
|
opt.badge }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pricing Plans -->
|
||||||
|
<div v-motion :initial="{ opacity: 0, y: 40 }"
|
||||||
|
:visible="{ opacity: 1, y: 0, transition: { duration: 700, delay: 100 } }"
|
||||||
|
class="px-4 pb-16 max-w-5xl mx-auto">
|
||||||
|
<UPricingPlans :plans="plans" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feature Comparison -->
|
||||||
|
<div v-motion :initial="{ opacity: 0, y: 50 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
|
||||||
|
class="px-4 pb-12 max-w-3xl mx-auto">
|
||||||
|
<div class="bg-elevated border border-purple-800/30 rounded-2xl p-6 md:p-8">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-purple-800/30 flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
<UIcon name="i-heroicons-shield-check" class="text-purple-400 text-xl" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-highlighted text-lg mb-2">{{ $t('pricing.pro_meaning_title') }}</h3>
|
||||||
|
<p class="text-muted text-sm leading-relaxed">
|
||||||
|
{{ $t('pricing.pro_meaning_desc') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feature Comparison Table -->
|
||||||
|
<div v-motion :initial="{ opacity: 0, y: 50 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
|
||||||
|
class="px-4 pb-12 max-w-4xl mx-auto">
|
||||||
|
<h2 class="text-2xl font-extrabold text-highlighted text-center mb-2">
|
||||||
|
{{ $t('pricing.comparison_title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted text-center text-sm mb-10">
|
||||||
|
{{ $t('pricing.comparison_subtitle') }}
|
||||||
|
</p>
|
||||||
|
<div class="bg-elevated border border-default rounded-2xl overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-default">
|
||||||
|
<th class="text-left p-4 text-muted font-semibold">{{ $t('pricing.feature') }}</th>
|
||||||
|
<th class="p-4 text-center text-muted font-semibold text-xs">
|
||||||
|
{{ $t('pricing.free') }}
|
||||||
|
</th>
|
||||||
|
<th class="p-4 text-center font-semibold text-xs text-primary-300">Pro</th>
|
||||||
|
<th class="p-4 text-center text-purple-400 font-semibold text-xs">Legend</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, i) in comparisonRows" :key="row.label"
|
||||||
|
:class="i % 2 === 0 ? 'bg-white/2' : ''">
|
||||||
|
<td class="p-4 text-default font-medium">{{ row.label }}</td>
|
||||||
|
<td class="p-4 text-center">
|
||||||
|
<UIcon v-if="row.free === true" name="i-heroicons-check" class="text-green-400" />
|
||||||
|
<span v-else-if="typeof row.free === 'string'" class="text-muted text-xs">{{ row.free }}</span>
|
||||||
|
<UIcon v-else name="i-heroicons-minus" class="text-dimmed" />
|
||||||
|
</td>
|
||||||
|
<td class="p-4 text-center">
|
||||||
|
<UIcon v-if="row.pro === true" name="i-heroicons-check-circle"
|
||||||
|
class="text-primary-400 text-lg" />
|
||||||
|
<span v-else-if="typeof row.pro === 'string'"
|
||||||
|
class="text-primary-300 text-xs font-semibold">{{ row.pro }}</span>
|
||||||
|
<UIcon v-else name="i-heroicons-minus" class="text-dimmed" />
|
||||||
|
</td>
|
||||||
|
<td class="p-4 text-center">
|
||||||
|
<UIcon v-if="row.legend === true" name="i-heroicons-check-circle"
|
||||||
|
class="text-purple-400 text-lg" />
|
||||||
|
<span v-else-if="typeof row.legend === 'string'"
|
||||||
|
class="text-purple-300 text-xs font-semibold">{{ row.legend }}</span>
|
||||||
|
<UIcon v-else name="i-heroicons-minus" class="text-dimmed" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quotes -->
|
||||||
|
<div v-motion :initial="{ opacity: 0, y: 40 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
|
||||||
|
class="px-4 pb-24 max-w-4xl mx-auto">
|
||||||
|
<h2 class="text-2xl font-extrabold text-highlighted text-center mb-2">
|
||||||
|
{{ $t('pricing.quotes_title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted text-center text-sm mb-10">{{ $t('pricing.quotes_subtitle') }}</p>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<UPageCard v-for="(q, i) in quotes" :key="q.author" v-motion :initial="{ opacity: 0, y: 30 }"
|
||||||
|
:visible="{ opacity: 1, y: 0, transition: { duration: 500, delay: i * 120 } }">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<UAvatar :src="q.image" :text="q.initials" size="md" class="ring-2 ring-white/10 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div class="text-highlighted text-sm font-semibold leading-tight">{{ q.author }}</div>
|
||||||
|
<div class="text-muted text-xs">{{ q.role }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-default text-sm leading-relaxed italic">“ {{ q.text }} ”</p>
|
||||||
|
</UPageCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FAQ -->
|
||||||
|
<div v-motion :initial="{ opacity: 0, y: 40 }" :visible="{ opacity: 1, y: 0, transition: { duration: 700 } }"
|
||||||
|
class="px-4 pb-24 max-w-2xl mx-auto">
|
||||||
|
<h2 class="text-2xl font-extrabold text-highlighted text-center mb-8">{{ $t('pricing.faq_title') }}</h2>
|
||||||
|
<UAccordion :items="faqItems" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<div v-motion :initial="{ opacity: 0, scale: 0.95 }"
|
||||||
|
:visible="{ opacity: 1, scale: 1, transition: { duration: 600 } }"
|
||||||
|
class="px-4 pb-32 max-w-xl mx-auto text-center">
|
||||||
|
<h2 class="text-3xl font-extrabold text-highlighted mb-3">{{ $t('pricing.cta_title') }}</h2>
|
||||||
|
<p class="text-muted mb-6">{{ $t('pricing.cta_desc') }}</p>
|
||||||
|
<a href="https://apps.apple.com/app/rebreak" target="_blank" rel="noopener">
|
||||||
|
<UButton size="xl" class="font-bold px-10 rounded-full">
|
||||||
|
{{ $t('pricing.cta_button') }}
|
||||||
|
<template #trailing>
|
||||||
|
<UIcon name="i-heroicons-arrow-right" />
|
||||||
|
</template>
|
||||||
|
</UButton>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PricingPlanProps } from "@nuxt/ui";
|
||||||
|
|
||||||
|
definePageMeta({ layout: "default" });
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const billing = ref<'monthly' | 'yearly'>('monthly');
|
||||||
|
|
||||||
|
const billingOptions = computed(() => [
|
||||||
|
{ value: 'monthly', label: t('pricing.billing_monthly'), badge: null },
|
||||||
|
{ value: 'yearly', label: t('pricing.billing_yearly'), badge: t('pricing.billing_save_pct') },
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
const proMonthly = 3.99;
|
||||||
|
const legendMonthly = 7.99;
|
||||||
|
|
||||||
|
const proPrice = computed(() => {
|
||||||
|
if (billing.value === 'yearly') return (29 / 12).toFixed(2);
|
||||||
|
return proMonthly.toFixed(2);
|
||||||
|
});
|
||||||
|
const legendPrice = computed(() => {
|
||||||
|
if (billing.value === 'yearly') return (59 / 12).toFixed(2);
|
||||||
|
return legendMonthly.toFixed(2);
|
||||||
|
});
|
||||||
|
const billingCycleLabel = computed(() => {
|
||||||
|
if (billing.value === 'yearly') return t('pricing.billing_per_year');
|
||||||
|
return t('pricing.billing_per_month');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Marketing: alle Plan-Buttons zeigen auf App-Store
|
||||||
|
const appStoreUrl = "https://apps.apple.com/app/rebreak";
|
||||||
|
|
||||||
|
const plans = computed<PricingPlanProps[]>(() => [
|
||||||
|
{
|
||||||
|
title: t('pricing.plan_free_title'),
|
||||||
|
description: t('pricing.plan_free_desc'),
|
||||||
|
price: "0€",
|
||||||
|
billingCycle: t('pricing.billing_forever'),
|
||||||
|
features: [
|
||||||
|
t('pricing.feat_free_domains'),
|
||||||
|
t('pricing.feat_free_mail'),
|
||||||
|
t('pricing.feat_coach_basic'),
|
||||||
|
t('pricing.feat_streak'),
|
||||||
|
t('pricing.feat_urge'),
|
||||||
|
t('pricing.feat_sos'),
|
||||||
|
t('pricing.feat_community'),
|
||||||
|
],
|
||||||
|
button: {
|
||||||
|
label: t('pricing.plan_free_btn'),
|
||||||
|
to: appStoreUrl,
|
||||||
|
target: "_blank",
|
||||||
|
color: "neutral" as const,
|
||||||
|
variant: "outline" as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pricing.plan_pro_title'),
|
||||||
|
description: t('pricing.plan_pro_desc'),
|
||||||
|
price: `${proPrice.value}€`,
|
||||||
|
billingCycle: billingCycleLabel.value,
|
||||||
|
scale: true,
|
||||||
|
badge: t('pricing.plan_recommended'),
|
||||||
|
features: [
|
||||||
|
t('pricing.feat_all_free'),
|
||||||
|
t('pricing.feat_blocklist'),
|
||||||
|
t('pricing.feat_pro_domains'),
|
||||||
|
t('pricing.feat_pro_mail'),
|
||||||
|
t('pricing.feat_community_post'),
|
||||||
|
t('pricing.feat_buddy'),
|
||||||
|
t('pricing.feat_coach_pro'),
|
||||||
|
t('pricing.feat_urge_stats'),
|
||||||
|
],
|
||||||
|
button: {
|
||||||
|
label: t('pricing.plan_pro_btn'),
|
||||||
|
to: appStoreUrl,
|
||||||
|
target: "_blank",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pricing.plan_legend_title'),
|
||||||
|
description: t('pricing.plan_legend_desc'),
|
||||||
|
price: `${legendPrice.value}€`,
|
||||||
|
billingCycle: billingCycleLabel.value,
|
||||||
|
features: [
|
||||||
|
t('pricing.feat_all_pro'),
|
||||||
|
t('pricing.feat_legend_domains'),
|
||||||
|
t('pricing.feat_legend_mail'),
|
||||||
|
t('pricing.feat_legend_add'),
|
||||||
|
t('pricing.feat_legend_validate'),
|
||||||
|
t('pricing.feat_legend_groups'),
|
||||||
|
t('pricing.feat_coach_legend'),
|
||||||
|
],
|
||||||
|
button: {
|
||||||
|
label: t('pricing.plan_legend_btn'),
|
||||||
|
to: appStoreUrl,
|
||||||
|
target: "_blank",
|
||||||
|
color: "neutral" as const,
|
||||||
|
variant: "subtle" as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const comparisonRows = computed(() => [
|
||||||
|
{ label: t('pricing.comp_domains'), free: t('pricing.comp_free_domains'), pro: t('pricing.comp_pro_domains'), legend: t('pricing.comp_legend_domains') },
|
||||||
|
{ label: t('pricing.comp_mail'), free: t('pricing.comp_free_mail_val'), pro: t('pricing.comp_pro_mail_val'), legend: t('pricing.comp_legend_mail_val') },
|
||||||
|
{ label: t('pricing.comp_coach'), free: t('pricing.comp_free_coach_val'), pro: t('pricing.comp_pro_coach_val'), legend: t('pricing.comp_legend_coach_val') },
|
||||||
|
{ label: t('pricing.comp_streak'), free: true, pro: true, legend: true },
|
||||||
|
{ label: t('pricing.comp_urge'), free: true, pro: true, legend: true },
|
||||||
|
{ label: t('pricing.comp_sos'), free: true, pro: true, legend: true },
|
||||||
|
{ label: t('pricing.comp_community'), free: true, pro: true, legend: true },
|
||||||
|
{ label: t('pricing.comp_blocklist'), free: false, pro: true, legend: true },
|
||||||
|
{ label: t('pricing.comp_post'), free: false, pro: true, legend: true },
|
||||||
|
{ label: t('pricing.comp_buddy'), free: false, pro: true, legend: true },
|
||||||
|
{ label: t('pricing.comp_urge_stats'), free: false, pro: true, legend: true },
|
||||||
|
{ label: t('pricing.comp_add_domain'), free: false, pro: false, legend: true },
|
||||||
|
{ label: t('pricing.comp_validate'), free: false, pro: false, legend: true },
|
||||||
|
{ label: t('pricing.comp_groups'), free: false, pro: false, legend: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const quotes = [
|
||||||
|
{
|
||||||
|
text: "Zwischen Reiz und Reaktion liegt ein Raum. In diesem Raum liegt unsere Macht, unsere Reaktion zu wählen.",
|
||||||
|
author: "Viktor Frankl",
|
||||||
|
role: "Psychiater & Logotherapeut",
|
||||||
|
image: "https://upload.wikimedia.org/wikipedia/commons/thumb/8/80/Viktor_Frankl2.jpg/200px-Viktor_Frankl2.jpg",
|
||||||
|
initials: "VF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Bis du das Unbewusste bewusst machst, wird es dein Leben lenken – und du wirst es Schicksal nennen.",
|
||||||
|
author: "Carl Gustav Jung",
|
||||||
|
role: "Psychiater & Begründer der analytischen Psychologie",
|
||||||
|
image: "https://upload.wikimedia.org/wikipedia/commons/thumb/0/00/CGJung.jpg/200px-CGJung.jpg",
|
||||||
|
initials: "CJ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Sucht ist keine Charakterschwäche. Sie ist eine Erkrankung des Gehirns – und sie ist behandelbar.",
|
||||||
|
author: "Nora Volkow",
|
||||||
|
role: "Neurowissenschaftlerin, Direktorin des NIDA",
|
||||||
|
image: "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9b/Nora_Volkow2.jpg/200px-Nora_Volkow2.jpg",
|
||||||
|
initials: "NV",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const faqItems = computed(() => [
|
||||||
|
{ label: t('pricing.faq1_q'), content: t('pricing.faq1_a') },
|
||||||
|
{ label: t('pricing.faq2_q'), content: t('pricing.faq2_a') },
|
||||||
|
{ label: t('pricing.faq3_q'), content: t('pricing.faq3_a') },
|
||||||
|
{ label: t('pricing.faq4_q'), content: t('pricing.faq4_a') },
|
||||||
|
{ label: t('pricing.faq5_q'), content: t('pricing.faq5_a') },
|
||||||
|
{ label: t('pricing.faq6_q'), content: t('pricing.faq6_a') },
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
212
apps/marketing/app/pages/resources.vue
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-4xl mx-auto px-4 pt-8 pb-24 space-y-20">
|
||||||
|
<!-- ─── BLOCKLIST ─── -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-start gap-5 mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-black text-highlighted leading-tight">
|
||||||
|
{{ $t('resources.blocklist_title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted text-sm mt-1">
|
||||||
|
{{ $t('resources.blocklist_desc', { count: domainCount.toLocaleString('de-DE') }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UPageCard>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<span class="text-xs text-muted uppercase tracking-wider font-medium">{{ $t('resources.chart_label') }}</span>
|
||||||
|
<span class="text-sm font-bold text-primary-500">{{ domainCount.toLocaleString("de-DE") }}</span>
|
||||||
|
</div>
|
||||||
|
<ChartsBlocklistGrowth :data="chartData" :height="160" />
|
||||||
|
</UPageCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── SOFORT-HILFE ─── -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-start gap-5 mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-black text-highlighted leading-tight">
|
||||||
|
{{ $t('resources.hotlines_title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted text-sm mt-1">
|
||||||
|
{{ $t('resources.hotlines_desc') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid sm:grid-cols-3 gap-4">
|
||||||
|
<a v-for="h in hotlines" :key="h.country" :href="h.url" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="block bg-elevated border border-default rounded-2xl p-5 hover:border-primary-500/40 transition-colors">
|
||||||
|
<div class="text-xs text-primary-500 font-bold mb-1">{{ h.country }}</div>
|
||||||
|
<div class="font-semibold text-highlighted text-sm mb-2">
|
||||||
|
{{ h.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-lg font-mono font-bold text-green-500">{{ h.phone }}</div>
|
||||||
|
<div class="text-xs text-muted mt-1">{{ h.hours }}</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── SELBSTHILFE TIPPS ─── -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-start gap-5 mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-black text-highlighted leading-tight">
|
||||||
|
{{ $t('resources.tips_title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted text-sm mt-1">
|
||||||
|
{{ $t('resources.tips_desc') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div v-for="tip in selfHelpTips" :key="tip.title"
|
||||||
|
class="bg-elevated border border-default rounded-2xl p-5 flex gap-4">
|
||||||
|
<img :src="tip.icon" class="w-10 h-10 shrink-0 opacity-80 mt-0.5" alt="" />
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-highlighted text-sm mb-1.5">
|
||||||
|
{{ tip.title }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-muted leading-relaxed">{{ tip.text }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── WARUM GEFÄHRLICH ─── -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-start gap-5 mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-black text-highlighted leading-tight">
|
||||||
|
{{ $t('resources.not_weak_title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted text-sm mt-1">
|
||||||
|
{{ $t('resources.not_weak_desc') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div v-for="fact in facts" :key="fact.title"
|
||||||
|
class="flex gap-4 bg-elevated border border-default rounded-2xl p-4 items-start">
|
||||||
|
<img :src="fact.icon" class="w-9 h-9 shrink-0 opacity-80 mt-0.5" alt="" />
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-highlighted text-sm">
|
||||||
|
{{ fact.title }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-muted mt-0.5">{{ fact.text }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── FINAL CTA ─── -->
|
||||||
|
<section class="text-center py-6">
|
||||||
|
<img src="/astronaut.svg" class="w-20 h-20 mx-auto mb-5 opacity-80" alt="" />
|
||||||
|
<h2 class="text-3xl font-black text-highlighted mb-2">
|
||||||
|
{{ $t('resources.cta_title') }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<a href="https://apps.apple.com/app/rebreak" target="_blank" rel="noopener">
|
||||||
|
<UButton size="xl" class="px-10">
|
||||||
|
<UIcon name="i-heroicons-bolt" />
|
||||||
|
{{ $t('resources.cta_button') }}
|
||||||
|
</UButton>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ layout: "default" });
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { public: { apiBase } } = useRuntimeConfig();
|
||||||
|
|
||||||
|
const domainCount = ref(0);
|
||||||
|
const chartData = ref<{ label: string; count: number }[]>([]);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Blocklist-Count vom Backend (public API, kein Auth nötig)
|
||||||
|
$fetch<{ count: number }>(`${apiBase}/api/blocklist/count`).then((r) => {
|
||||||
|
if (r?.count) domainCount.value = r.count;
|
||||||
|
}).catch(() => { });
|
||||||
|
|
||||||
|
$fetch<{
|
||||||
|
current: number;
|
||||||
|
history: { label: string; count: number }[];
|
||||||
|
}>(`${apiBase}/api/blocklist/stats`).then((stats) => {
|
||||||
|
if (stats?.current) domainCount.value = stats.current;
|
||||||
|
if (stats?.history) chartData.value = stats.history;
|
||||||
|
}).catch(() => { });
|
||||||
|
});
|
||||||
|
|
||||||
|
const hotlines = computed(() => [
|
||||||
|
{
|
||||||
|
country: t('resources.hotline_de'),
|
||||||
|
name: "BZgA – check-dein-spiel.de",
|
||||||
|
phone: "0800 1372700",
|
||||||
|
hours: "Mo–Do 10–22 Uhr, Fr–So 10–18 Uhr",
|
||||||
|
url: "https://www.check-dein-spiel.de",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
country: t('resources.hotline_at'),
|
||||||
|
name: "Spielsuchthilfe",
|
||||||
|
phone: "0800 040 080",
|
||||||
|
hours: "24h erreichbar",
|
||||||
|
url: "https://www.spielsuchthilfe.at",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
country: t('resources.hotline_ch'),
|
||||||
|
name: "Addiction Suisse",
|
||||||
|
phone: "0800 040 080",
|
||||||
|
hours: "Mo–Fr 9–17 Uhr",
|
||||||
|
url: "https://www.addictionsuisse.ch",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selfHelpTips = computed(() => [
|
||||||
|
{
|
||||||
|
icon: "/snowflake.svg",
|
||||||
|
title: t('resources.tip_breathing'),
|
||||||
|
text: t('resources.tip_breathing_desc'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "/diary.svg",
|
||||||
|
title: t('resources.tip_15min'),
|
||||||
|
text: t('resources.tip_15min_desc'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "/walk.svg",
|
||||||
|
title: t('resources.tip_move'),
|
||||||
|
text: t('resources.tip_move_desc'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "/alert.svg",
|
||||||
|
title: t('resources.tip_triggers'),
|
||||||
|
text: t('resources.tip_triggers_desc'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const facts = computed(() => [
|
||||||
|
{
|
||||||
|
icon: "/brain.svg",
|
||||||
|
title: t('resources.fact1_title'),
|
||||||
|
text: t('resources.fact1_text'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "/phone-call.svg",
|
||||||
|
title: t('resources.fact2_title'),
|
||||||
|
text: t('resources.fact2_text'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "/kidneys.svg",
|
||||||
|
title: t('resources.fact3_title'),
|
||||||
|
text: t('resources.fact3_text'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "/graph.svg",
|
||||||
|
title: t('resources.fact4_title'),
|
||||||
|
text: t('resources.fact4_text'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
1
apps/marketing/dist
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
/Users/chahinebrini/mono/rebreak-monorepo/apps/marketing/.output/public
|
||||||
72
apps/marketing/nuxt.config.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: "2025-07-15",
|
||||||
|
devtools: { enabled: false },
|
||||||
|
|
||||||
|
// SPA-mode: statisch servierbar via nginx try_files /index.html
|
||||||
|
ssr: false,
|
||||||
|
|
||||||
|
app: {
|
||||||
|
htmlAttrs: { lang: "de" },
|
||||||
|
head: {
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: "viewport",
|
||||||
|
content: "width=device-width, initial-scale=1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
modules: [
|
||||||
|
"@nuxt/ui",
|
||||||
|
"@nuxt/image",
|
||||||
|
"@nuxt/fonts",
|
||||||
|
"@nuxt/icon",
|
||||||
|
"@nuxtjs/i18n",
|
||||||
|
"@vueuse/motion/nuxt",
|
||||||
|
"@vueuse/nuxt",
|
||||||
|
],
|
||||||
|
|
||||||
|
fonts: {
|
||||||
|
families: [{ name: "Nunito", provider: "google" }],
|
||||||
|
},
|
||||||
|
|
||||||
|
i18n: {
|
||||||
|
locales: [
|
||||||
|
{ code: "de", name: "Deutsch", dir: "ltr", file: "de.json" },
|
||||||
|
{ code: "en", name: "English", dir: "ltr", file: "en.json" },
|
||||||
|
],
|
||||||
|
defaultLocale: "de",
|
||||||
|
strategy: "no_prefix",
|
||||||
|
// restructureDir:false verhindert dass i18n v9 den Nuxt-4-Default-Prefix
|
||||||
|
// "i18n/" vor langDir stellt. Ohne das würde es unter {rootDir}/i18n/locales/ suchen.
|
||||||
|
restructureDir: false,
|
||||||
|
langDir: "locales",
|
||||||
|
detectBrowserLanguage: {
|
||||||
|
useCookie: true,
|
||||||
|
cookieKey: "rebreak_lang",
|
||||||
|
cookieSecure: false,
|
||||||
|
fallbackLocale: "de",
|
||||||
|
redirectOn: "root",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
colorMode: {
|
||||||
|
preference: "dark",
|
||||||
|
fallback: "dark",
|
||||||
|
},
|
||||||
|
|
||||||
|
css: ["~/assets/css/main.css"],
|
||||||
|
|
||||||
|
devServer: {
|
||||||
|
port: 3020,
|
||||||
|
},
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
// Backend-API für public endpoints (Blocklist-Count etc.)
|
||||||
|
// Staging: api.staging.rebreak.org | Prod: api.rebreak.org
|
||||||
|
apiBase: process.env.NUXT_PUBLIC_API_BASE ?? "https://api.staging.rebreak.org",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
33
apps/marketing/package.json
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@rebreak/marketing",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nuxt dev --port 3020",
|
||||||
|
"build": "nuxt build",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@iconify-json/heroicons": "^1.2.3",
|
||||||
|
"@nuxt/fonts": "^0.11.4",
|
||||||
|
"@nuxt/icon": "^1.10.0",
|
||||||
|
"@nuxt/image": "^1.11.0",
|
||||||
|
"@nuxt/ui": "^4.5.1",
|
||||||
|
"@nuxtjs/i18n": "^9.5.6",
|
||||||
|
"@vueuse/motion": "^3.0.3",
|
||||||
|
"@vueuse/nuxt": "^14.2.1",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"nuxt": "4.1.3",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"vue": "^3.5.22",
|
||||||
|
"vue-chartjs": "^5.3.3",
|
||||||
|
"vue-router": "^4.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nuxt/devtools": "latest",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/marketing/public/alert.svg
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 512.001 512.001" style="enable-background:new 0 0 512.001 512.001;" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M503.839,395.379l-195.7-338.962C297.257,37.569,277.766,26.315,256,26.315c-21.765,0-41.257,11.254-52.139,30.102
|
||||||
|
L8.162,395.378c-10.883,18.85-10.883,41.356,0,60.205c10.883,18.849,30.373,30.102,52.139,30.102h391.398
|
||||||
|
c21.765,0,41.256-11.254,52.14-30.101C514.722,436.734,514.722,414.228,503.839,395.379z M477.861,440.586
|
||||||
|
c-5.461,9.458-15.241,15.104-26.162,15.104H60.301c-10.922,0-20.702-5.646-26.162-15.104c-5.46-9.458-5.46-20.75,0-30.208
|
||||||
|
L229.84,71.416c5.46-9.458,15.24-15.104,26.161-15.104c10.92,0,20.701,5.646,26.161,15.104l195.7,338.962
|
||||||
|
C483.321,419.836,483.321,431.128,477.861,440.586z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<rect x="241.001" y="176.01" width="29.996" height="149.982"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M256,355.99c-11.027,0-19.998,8.971-19.998,19.998s8.971,19.998,19.998,19.998c11.026,0,19.998-8.971,19.998-19.998
|
||||||
|
S267.027,355.99,256,355.99z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
apps/marketing/public/astronaut.svg
Normal file
|
After Width: | Height: | Size: 22 KiB |
54
apps/marketing/public/brain.svg
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 463 463" style="enable-background:new 0 0 463 463;" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<path d="M151.245,222.446C148.054,237.039,135.036,248,119.5,248c-4.142,0-7.5,3.357-7.5,7.5s3.358,7.5,7.5,7.5
|
||||||
|
c23.774,0,43.522-17.557,46.966-40.386c14.556-1.574,27.993-8.06,38.395-18.677c2.899-2.959,2.85-7.708-0.109-10.606
|
||||||
|
c-2.958-2.897-7.707-2.851-10.606,0.108C184.947,202.829,172.643,208,159.5,208c-26.743,0-48.5-21.757-48.5-48.5
|
||||||
|
c0-4.143-3.358-7.5-7.5-7.5s-7.5,3.357-7.5,7.5C96,191.715,120.119,218.384,151.245,222.446z"/>
|
||||||
|
<path d="M183,287.5c0-4.143-3.358-7.5-7.5-7.5c-35.014,0-63.5,28.486-63.5,63.5c0,0.362,0.013,0.725,0.019,1.088
|
||||||
|
C109.23,344.212,106.39,344,103.5,344c-4.142,0-7.5,3.357-7.5,7.5s3.358,7.5,7.5,7.5c26.743,0,48.5,21.757,48.5,48.5
|
||||||
|
c0,4.143,3.358,7.5,7.5,7.5s7.5-3.357,7.5-7.5c0-26.611-16.462-49.437-39.731-58.867c-0.178-1.699-0.269-3.418-0.269-5.133
|
||||||
|
c0-26.743,21.757-48.5,48.5-48.5C179.642,295,183,291.643,183,287.5z"/>
|
||||||
|
<path d="M439,223.5c0-17.075-6.82-33.256-18.875-45.156c1.909-6.108,2.875-12.426,2.875-18.844
|
||||||
|
c0-30.874-22.152-56.659-51.394-62.329C373.841,91.6,375,85.628,375,79.5c0-19.557-11.883-36.387-28.806-43.661
|
||||||
|
C317.999,13.383,287.162,0,263.5,0c-13.153,0-24.817,6.468-32,16.384C224.317,6.468,212.653,0,199.5,0
|
||||||
|
c-23.662,0-54.499,13.383-82.694,35.839C99.883,43.113,88,59.943,88,79.5c0,6.128,1.159,12.1,3.394,17.671
|
||||||
|
C62.152,102.841,40,128.626,40,159.5c0,6.418,0.965,12.735,2.875,18.844C30.82,190.244,24,206.425,24,223.5
|
||||||
|
c0,13.348,4.149,25.741,11.213,35.975C27.872,270.087,24,282.466,24,295.5c0,23.088,12.587,44.242,32.516,55.396
|
||||||
|
C56.173,353.748,56,356.626,56,359.5c0,31.144,20.315,58.679,49.79,68.063C118.611,449.505,141.965,463,167.5,463
|
||||||
|
c27.995,0,52.269-16.181,64-39.674c11.731,23.493,36.005,39.674,64,39.674c25.535,0,48.889-13.495,61.71-35.437
|
||||||
|
c29.475-9.385,49.79-36.92,49.79-68.063c0-2.874-0.173-5.752-0.516-8.604C426.413,339.742,439,318.588,439,295.5
|
||||||
|
c0-13.034-3.872-25.413-11.213-36.025C434.851,249.241,439,236.848,439,223.5z M167.5,448c-21.029,0-40.191-11.594-50.009-30.256
|
||||||
|
c-0.973-1.849-2.671-3.208-4.688-3.751C88.19,407.369,71,384.961,71,359.5c0-3.81,0.384-7.626,1.141-11.344
|
||||||
|
c0.702-3.447-1.087-6.92-4.302-8.35C50.32,332.018,39,314.626,39,295.5c0-8.699,2.256-17.014,6.561-24.379
|
||||||
|
C56.757,280.992,71.436,287,87.5,287c4.142,0,7.5-3.357,7.5-7.5s-3.358-7.5-7.5-7.5C60.757,272,39,250.243,39,223.5
|
||||||
|
c0-14.396,6.352-27.964,17.428-37.221c2.5-2.09,3.365-5.555,2.14-8.574C56.2,171.869,55,165.744,55,159.5
|
||||||
|
c0-26.743,21.757-48.5,48.5-48.5s48.5,21.757,48.5,48.5c0,4.143,3.358,7.5,7.5,7.5s7.5-3.357,7.5-7.5
|
||||||
|
c0-33.642-26.302-61.243-59.421-63.355C104.577,91.127,103,85.421,103,79.5c0-13.369,8.116-24.875,19.678-29.859
|
||||||
|
c0.447-0.133,0.885-0.307,1.308-0.527C127.568,47.752,131.447,47,135.5,47c12.557,0,23.767,7.021,29.256,18.325
|
||||||
|
c1.81,3.727,6.298,5.281,10.023,3.47c3.726-1.809,5.28-6.296,3.47-10.022c-6.266-12.903-18.125-22.177-31.782-25.462
|
||||||
|
C165.609,21.631,184.454,15,199.5,15c13.509,0,24.5,10.99,24.5,24.5v97.051c-6.739-5.346-15.25-8.551-24.5-8.551
|
||||||
|
c-4.142,0-7.5,3.357-7.5,7.5s3.358,7.5,7.5,7.5c13.509,0,24.5,10.99,24.5,24.5v180.279c-9.325-12.031-22.471-21.111-37.935-25.266
|
||||||
|
c-3.999-1.071-8.114,1.297-9.189,5.297c-1.075,4.001,1.297,8.115,5.297,9.189C206.8,343.616,224,366.027,224,391.5
|
||||||
|
C224,422.654,198.654,448,167.5,448z M395.161,339.807c-3.215,1.43-5.004,4.902-4.302,8.35c0.757,3.718,1.141,7.534,1.141,11.344
|
||||||
|
c0,25.461-17.19,47.869-41.803,54.493c-2.017,0.543-3.716,1.902-4.688,3.751C335.691,436.406,316.529,448,295.5,448
|
||||||
|
c-31.154,0-56.5-25.346-56.5-56.5c0-2.109-0.098-4.2-0.281-6.271c0.178-0.641,0.281-1.314,0.281-2.012V135.5
|
||||||
|
c0-13.51,10.991-24.5,24.5-24.5c4.142,0,7.5-3.357,7.5-7.5s-3.358-7.5-7.5-7.5c-9.25,0-17.761,3.205-24.5,8.551V39.5
|
||||||
|
c0-13.51,10.991-24.5,24.5-24.5c15.046,0,33.891,6.631,53.033,18.311c-13.657,3.284-25.516,12.559-31.782,25.462
|
||||||
|
c-1.81,3.727-0.256,8.214,3.47,10.022c3.726,1.81,8.213,0.257,10.023-3.47C303.733,54.021,314.943,47,327.5,47
|
||||||
|
c4.053,0,7.933,0.752,11.514,2.114c0.422,0.22,0.86,0.393,1.305,0.526C351.883,54.624,360,66.13,360,79.5
|
||||||
|
c0,5.921-1.577,11.627-4.579,16.645C322.302,98.257,296,125.858,296,159.5c0,4.143,3.358,7.5,7.5,7.5s7.5-3.357,7.5-7.5
|
||||||
|
c0-26.743,21.757-48.5,48.5-48.5s48.5,21.757,48.5,48.5c0,6.244-1.2,12.369-3.567,18.205c-1.225,3.02-0.36,6.484,2.14,8.574
|
||||||
|
C417.648,195.536,424,209.104,424,223.5c0,26.743-21.757,48.5-48.5,48.5c-4.142,0-7.5,3.357-7.5,7.5s3.358,7.5,7.5,7.5
|
||||||
|
c16.064,0,30.743-6.008,41.939-15.879c4.306,7.365,6.561,15.68,6.561,24.379C424,314.626,412.68,332.018,395.161,339.807z"/>
|
||||||
|
<path d="M359.5,240c-15.536,0-28.554-10.961-31.745-25.554C358.881,210.384,383,183.715,383,151.5c0-4.143-3.358-7.5-7.5-7.5
|
||||||
|
s-7.5,3.357-7.5,7.5c0,26.743-21.757,48.5-48.5,48.5c-13.143,0-25.447-5.171-34.646-14.561c-2.898-2.958-7.647-3.007-10.606-0.108
|
||||||
|
s-3.008,7.647-0.109,10.606c10.402,10.617,23.839,17.103,38.395,18.677C315.978,237.443,335.726,255,359.5,255
|
||||||
|
c4.142,0,7.5-3.357,7.5-7.5S363.642,240,359.5,240z"/>
|
||||||
|
<path d="M335.5,328c-2.89,0-5.73,0.212-8.519,0.588c0.006-0.363,0.019-0.726,0.019-1.088c0-35.014-28.486-63.5-63.5-63.5
|
||||||
|
c-4.142,0-7.5,3.357-7.5,7.5s3.358,7.5,7.5,7.5c26.743,0,48.5,21.757,48.5,48.5c0,1.714-0.091,3.434-0.269,5.133
|
||||||
|
C288.462,342.063,272,364.889,272,391.5c0,4.143,3.358,7.5,7.5,7.5s7.5-3.357,7.5-7.5c0-26.743,21.757-48.5,48.5-48.5
|
||||||
|
c4.142,0,7.5-3.357,7.5-7.5S339.642,328,335.5,328z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.4 KiB |
1
apps/marketing/public/determination.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg id="Layer_1" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m383.975 256.43c.632-2.527.958-5.148.957-7.816v-39.044c.001-8.571-3.354-16.646-9.447-22.739-6.092-6.093-14.167-9.448-22.737-9.448-6.869 0-13.242 2.163-18.475 5.843-4.746-11.866-16.36-20.269-29.9-20.269-6.549 0-12.646 1.965-17.734 5.338-4.179-12.931-16.333-22.312-30.637-22.312-12.31 0-23.026 6.945-28.439 17.123-5.485-4.34-12.412-6.933-19.935-6.933-17.748 0-32.187 14.438-32.187 32.186v16.009c-24.119 4.082-46.964 21.588-48.778 48.544l-2.585 38.392c-2.556 37.951 19.173 56.095 36.633 70.675 14.233 11.885 22.966 19.835 23.119 32.89v74.142c0 4.418 3.582 8 8 8h167.183c4.418 0 8-3.582 8-8v-71.079c16.195-17.732 24.232-36.08 25.908-59.072 1.583-21.703-2.597-45.801-7.887-76.311zm-31.228-63.047c4.297 0 8.354 1.691 11.424 4.761 3.07 3.071 4.761 7.128 4.761 11.425v39.045c0 4.297-1.69 8.354-4.761 11.424-3.07 3.07-7.127 4.761-11.426 4.761-4.296 0-8.354-1.69-11.424-4.761s-4.762-7.128-4.762-11.426c0-.004 0-.008 0-.012s0-.009 0-.013v-39.012c0-.001 0-.002 0-.003s0-.002 0-.003c.001-8.925 7.262-16.186 16.188-16.186zm-48.375-14.426c8.925 0 16.187 7.261 16.188 16.186v53.472c-.001 8.925-7.262 16.186-16.187 16.186-8.926 0-16.187-7.262-16.187-16.188v-53.47c.001-8.925 7.261-16.186 16.186-16.186zm-53.977 95.004c-.127-.13-.246-.263-.375-.392-.564-.565-1.155-1.106-1.75-1.642 10.884-2.672 19.764-10.483 23.917-20.706v7.581c0 8.926-7.261 16.188-16.186 16.188-1.937 0-3.814-.358-5.606-1.029zm-10.582-95.79c0-8.925 7.262-16.187 16.188-16.187 8.925 0 16.186 7.262 16.186 16.187v46.984c-5.167-12.716-17.648-21.71-32.198-21.71h-.175v-25.274zm-32.186-5.998c8.926 0 16.187 7.261 16.187 16.187v15.085h-32.374v-15.086c0-8.925 7.261-16.186 16.187-16.186zm143.385 288.837h-151.183v-58.219c95.813-.092 136.204-.085 151.183-.049zm4.538-74.252c-1.708-.008-104.409-.016-156.537.034-3.264-16.39-15.833-26.895-28.048-37.094-16.936-14.142-32.932-27.5-30.925-57.318l2.585-38.392c1.456-21.621 23.308-34.542 43.593-34.542h53.771c10.336 0 18.744 8.408 18.744 18.743s-8.408 18.744-18.744 18.744h-57.945c-4.418 0-8 3.582-8 8s3.582 8 8 8h15.124c19.649 0 33.621 4.017 41.526 11.938.349.35.684.708 1.009 1.073.03.035.059.07.09.104 9.116 10.31 9.088 26.272 9.058 40.42l-.003 2.415c0 4.418 3.582 8 8 8s8-3.582 8-8l.003-2.382c.023-11.134.049-24.059-4.179-35.853 10.312-1.506 19.053-7.914 23.768-16.78 5.485 4.339 12.413 6.933 19.935 6.934 9.633-.001 18.291-4.254 24.194-10.982.462.524.941 1.036 1.44 1.534 6.093 6.093 14.168 9.448 22.738 9.447 6.594 0 12.89-1.993 18.22-5.683 8.731 51.015 12.025 81.231-15.417 111.64zm-122.116 45.146c0 6.365-5.16 11.525-11.525 11.525s-11.524-5.16-11.524-11.525 5.159-11.524 11.524-11.524 11.525 5.159 11.525 11.524zm17.13-345.892v-43.022c0-4.418 3.582-8 8-8s8 3.582 8 8v43.022c0 4.418-3.582 8-8 8s-8-3.582-8-8zm-119.271-8.15c-2.21-3.826-.898-8.719 2.928-10.928 3.828-2.208 8.72-.897 10.928 2.928l21.512 37.258c2.21 3.826.898 8.719-2.928 10.928-1.26.727-2.636 1.073-3.992 1.073-2.766 0-5.454-1.435-6.936-4.001zm-37.671 108.419c-1.481 2.566-4.171 4.001-6.936 4.001-1.357 0-2.732-.346-3.992-1.073l-37.258-21.511c-3.826-2.209-5.138-7.102-2.928-10.928 2.208-3.827 7.102-5.138 10.928-2.928l37.258 21.511c3.827 2.209 5.138 7.101 2.928 10.928zm-34.6 102.145h-43.022c-4.418 0-8-3.582-8-8s3.582-8 8-8h43.022c4.418 0 8 3.582 8 8s-3.582 8-8 8zm32.037 86.833c2.21 3.827.898 8.719-2.928 10.928l-37.259 21.511c-1.26.727-2.636 1.073-3.992 1.073-2.766 0-5.454-1.435-6.936-4.001-2.21-3.827-.898-8.719 2.928-10.928l37.259-21.511c3.828-2.21 8.72-.898 10.928 2.928zm258.723-265.575 21.511-37.258c2.208-3.827 7.102-5.138 10.928-2.928 3.826 2.209 5.138 7.102 2.928 10.928l-21.511 37.258c-1.481 2.566-4.171 4.001-6.936 4.001-1.357 0-2.732-.346-3.992-1.073-3.826-2.209-5.138-7.102-2.928-10.928zm71.16 81.037c-2.21-3.826-.898-8.719 2.928-10.928l37.259-21.511c3.829-2.21 8.72-.897 10.928 2.928 2.21 3.826.898 8.719-2.928 10.928l-37.259 21.512c-1.26.727-2.636 1.073-3.992 1.073-2.766 0-5.454-1.435-6.936-4.002zm83.058 94.833c0 4.418-3.582 8-8 8h-43.021c-4.418 0-8-3.582-8-8s3.582-8 8-8h43.021c4.418 0 8 3.581 8 8zm-34.508 123.656c-1.481 2.566-4.171 4.001-6.936 4.001-1.357 0-2.732-.346-3.992-1.073l-37.258-21.511c-3.826-2.209-5.138-7.102-2.928-10.928 2.208-3.827 7.101-5.139 10.928-2.928l37.258 21.511c3.827 2.209 5.138 7.102 2.928 10.928z"/></svg>
|
||||||
|
After Width: | Height: | Size: 4.3 KiB |
1
apps/marketing/public/diary.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg id="Diary" enable-background="new 0 0 64 64" height="512" viewBox="0 0 64 64" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m31.62239 23.62939c-1.74505 0-6.80223-2.29461-8.96227-7.97515-.70931-1.86833-.71278-3.62901-.01042-5.09191.73448-1.54103 1.99769-2.59066 3.49444-2.96138 1.4273-.34814 2.9171-.05556 4.09436.81349.57821.42541 1.02967.85343 1.38389 1.24064.35422-.38721.80568-.81436 1.38736-1.2415 1.17552-.86819 2.67228-1.16424 4.08915-.81262 1.49849.36985 2.76256 1.41948 3.46753 2.8789.02084.04341.05556.13544.06945.18058.66156 1.36392.65808 3.1246-.05035 4.99293v.00087c-2.16178 5.68054-7.21809 7.97515-8.96314 7.97515zm-4.29231-14.3945c-.25351 0-.50702.03039-.75879.0929-.99668.2457-1.81017.92983-2.29114 1.9265-.64246 1.35784-.32904 2.79121.04167 3.76879 1.91956 5.04589 6.34383 6.82828 7.30057 6.82828s5.38101-1.78238 7.2997-6.82914c.37158-.97758.68413-2.41095.07119-3.68979-.50876-1.07481-1.32311-1.75894-2.31979-2.00551-.91333-.22399-1.85965-.03559-2.60976.51831-.88555.65114-1.40646 1.30575-1.72075 1.74158-.33338.46187-1.1078.46187-1.44119 0-.31602-.43756-.83693-1.09304-1.71727-1.74071-.54522-.40111-1.19288-.61121-1.85444-.61121zm12.42373 6.10334h.01736zm1.21198 35.11114h-18.68594c-1.58183 0-2.86935-1.28665-2.86935-2.86848v-2.16005c0-1.58183 1.28752-2.86848 2.86935-2.86848h18.68595c1.58183 0 2.86848 1.28665 2.86848 2.86848v2.16005c0 1.58183-1.28665 2.86848-2.86849 2.86848zm-18.68594-6.11897c-.60165 0-1.09131.48966-1.09131 1.09044v2.16005c0 .60078.48966 1.09044 1.09131 1.09044h18.68595c.60078 0 1.09044-.48966 1.09044-1.09044v-2.16005c0-.60078-.48966-1.09044-1.09044-1.09044zm36.34185-32.83901c-.35651-1.85376-1.8833-3.2514-3.70081-3.2514-.73999 0-1.42999.22998-2.01001.64001v-4.48999c.00001-2.42004-1.95995-4.39001-4.37994-4.39001h-40c-3.38 0-6.13 2.75-6.13 6.13v52.87c0 .01727.02753.57794.07129.84314.38977 2.37012 2.60455 4.15686 5.15002 4.15686h41.20062c2.25726 0 4.09259-1.83539 4.09259-4.09265v-5.03613l.78546 1.92877c.20996.5.69.83002 1.21997.83002.53003 0 1-.33002 1.21002-.84003l2.15997-5.27997c.039-.0968.05487-.20129.08844-.30005h.00159c.08997-.22998.15002-.46997.21002-.70996.00049-.00238-.00055-.00476-.00012-.00714.07159-.36328.11011-.73517.11011-1.11285v-13.89594c.65961-.46161 1.09406-1.22412 1.09406-2.0885v-6.07379c0-.86438-.43445-1.62689-1.09406-2.08844v-.53955c.14984.03168.30469.0495.46375.0495h.19104c1.23627 0 2.24335-1.00623 2.24335-2.24341v-7.47424c0-1.77106-1.28949-3.23896-2.97736-3.53425zm-5.71081.82862c0-1.27002.90002-2.30005 2.01001-2.30005 1.10004 0 2 1.03003 2 2.30005v12.45105h-4.01001zm-48.72998-6.19001c0-2.40002 1.95001-4.35004 4.35004-4.35004h1.16998v52.1925h-2.29572c-1.19165 0-2.32733.4314-3.2243 1.19373zm46.95654 51.97846h-42.5758c-.49139 0-.88904.39764-.88904.88904s.39764.88904.88904.88904h42.57581v.02081c0 1.27625-1.03833 2.31458-2.31458 2.31458h-41.20063c-1.68512 0-3.14539-1.146-3.3963-2.66705-.16064-.97583.09723-1.92218.72754-2.66534.61469-.72406 1.508-1.13904 2.45172-1.13904h43.73224zm.00348-8.72846v4.59247h-39.66003v-52.1925h37.05005c1.42999 0 2.60999 1.17004 2.60999 2.61005v20.38104h-1.07306c-3.08032 0-5.58765 2.50641-5.58765 5.58765 0 3.08118 2.50732 5.58765 5.58765 5.58765h1.07306v13.43364zm3.77997 5.68-1.56915-3.85004h3.13837zm2-5.68c0 .01703-.00458.03302-.00482.04999h-4.00024c-.00024-.01697-.00494-.03296-.00494-.04999v-13.43365h4.01001v13.43365zm1.09607-15.98443c0 .42633-.34729.77271-.77271.77271h-7.17639c-2.10101 0-3.80963-1.70856-3.80963-3.80957s1.70862-3.80963 3.80963-3.80963h7.17639c.42542 0 .77271.34644.77271.77271zm1.80407-10.89569c0 .25696-.20837.46533-.46533.46533h-.19104c-.25415 0-.45929-.20435-.46375-.45746v-9.16107c.65729.27386 1.12012.92346 1.12012 1.67896zm-10.48756 6.9702c-.49092 0-.88889.39797-.88889.8889s.39797.8889.88889.8889.8889-.39797.8889-.8889-.39798-.8889-.8889-.8889z"/></svg>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
2
apps/marketing/public/disruption.svg
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 512 512"><path d="M444.046,249.649a217.455,217.455,0,0,0-21.937-55.124,7,7,0,0,0-7.014-3.491l-37.831,5.006c-1.665-2.279-3.387-4.524-5.146-6.708a7,7,0,0,0-10.279-.683l-35.49,33.761a7,7,0,0,0-.756,9.3,115.362,115.362,0,1,1-207.114,76.215,7,7,0,0,0-7.827-6.548L62.18,307.215a7,7,0,0,0-6.145,7.47c.213,2.865.51,5.8.886,8.773L25.976,345.826a7,7,0,0,0-2.7,7.347,217.255,217.255,0,0,0,21.909,55.095A6.982,6.982,0,0,0,52.2,411.76l37.843-5a176.783,176.783,0,0,0,34.223,35.253l-6.077,37.659a7,7,0,0,0,3.3,7.112,218.632,218.632,0,0,0,54.474,23.458,7.014,7.014,0,0,0,7.421-2.488l23.212-30.284a177.107,177.107,0,0,0,49.122.679l22.365,30.945a7,7,0,0,0,7.349,2.7,217.414,217.414,0,0,0,55.124-21.938,7,7,0,0,0,3.491-7.014l-5.027-37.806a176.406,176.406,0,0,0,35.256-34.251l37.654,6.077a6.992,6.992,0,0,0,7.107-3.288,217.4,217.4,0,0,0,23.463-54.484,7,7,0,0,0-2.487-7.42l-30.283-23.212a176.95,176.95,0,0,0,.679-49.118L441.347,257A7,7,0,0,0,444.046,249.649Zm-45.252,20.8a7,7,0,0,0-2.826,6.718,163.138,163.138,0,0,1-.734,53.059,7,7,0,0,0,2.634,6.792l29.824,22.856a203.417,203.417,0,0,1-18.259,42.4l-37.091-5.984a6.987,6.987,0,0,0-6.746,2.754,162.479,162.479,0,0,1-38.077,36.988,7,7,0,0,0-2.944,6.676l4.954,37.232a203.387,203.387,0,0,1-42.9,17.078l-22.035-30.483a7,7,0,0,0-6.714-2.821,163.165,163.165,0,0,1-53.061-.734,7,7,0,0,0-6.789,2.636l-22.856,29.822a204.61,204.61,0,0,1-42.4-18.264l5.983-37.085a7,7,0,0,0-2.749-6.743,162.87,162.87,0,0,1-36.965-38.079,7,7,0,0,0-6.674-2.945l-37.26,4.932a203.317,203.317,0,0,1-17.054-42.883l30.481-22.028a7,7,0,0,0,2.824-6.72q-.4-2.678-.716-5.324l34.5-4.155a129.363,129.363,0,0,0,77.337,104.062A129.363,129.363,0,0,0,340.422,228.35l25.431-24.192q1.226,1.673,2.4,3.368a7.008,7.008,0,0,0,6.664,2.946l37.273-4.927a203.452,203.452,0,0,1,17.078,42.9ZM44.936,284.721a7,7,0,0,0,8.886,6.509l48.9-13.691a7,7,0,0,0,5.1-6.331A115.344,115.344,0,0,1,251.924,166.6a115.364,115.364,0,0,1,26.724,10.609,7,7,0,0,0,8.918-1.859L317.613,136.4a7,7,0,0,0-1.918-10.267c-1.933-1.169-3.963-2.345-6.067-3.516l.259-38.15a7,7,0,0,0-4.348-6.53,225.516,225.516,0,0,0-28.2-9.427,219.927,219.927,0,0,0-29.224-5.475,7.016,7.016,0,0,0-6.961,3.6l-18.3,33.469a176.911,176.911,0,0,0-48.649,6.822l-26.8-27.162a7.006,7.006,0,0,0-7.683-1.55,217.747,217.747,0,0,0-51.138,30.079,7,7,0,0,0-2.38,7.465l10.724,36.629a176.367,176.367,0,0,0-29.622,39.209l-38.148-.252h-.043a7,7,0,0,0-6.476,4.34A218.112,218.112,0,0,0,7.762,253.086a7,7,0,0,0,3.6,6.96l33.469,18.3C44.827,280.426,44.864,282.557,44.936,284.721ZM26.8,227.392a203.113,203.113,0,0,1,7.059-22.018l37.558.254h.053a7.006,7.006,0,0,0,6.2-3.749,162.451,162.451,0,0,1,31.988-42.343,7,7,0,0,0,1.9-7.037l-10.561-36.091A203.736,203.736,0,0,1,140.786,93l26.416,26.76a7,7,0,0,0,7.061,1.76,163.056,163.056,0,0,1,52.542-7.366,7.059,7.059,0,0,0,6.31-3.638l18.031-32.966a205.807,205.807,0,0,1,22.667,4.516,210.4,210.4,0,0,1,22.035,7.095l-.248,37.566a7,7,0,0,0,3.717,6.226q1.17.621,2.311,1.246l-21.573,27.963A129.391,129.391,0,0,0,94.239,265.372l-35.381,9.906q.008-.441.019-.877a7,7,0,0,0-3.637-6.315L22.273,250.053A204.938,204.938,0,0,1,26.8,227.392ZM378.214,75.877l-46.659,66.871-46.63,66.869a7,7,0,0,0,9.748,9.748l66.87-46.631,66.87-46.658a7,7,0,0,0,.948-10.69L409.142,95.14,388.9,74.929a7,7,0,0,0-10.689.948Zm6.7,14.863,28.633,28.631L318.869,185.42ZM504,154.66l-8.674-29.311a7,7,0,0,0-9.732-4.331l-88.131,42.124A7,7,0,0,0,401.4,176.4l96.8-12.812a7,7,0,0,0,5.8-8.928Zm-60.651,2.064,40.878-19.533,4.021,13.589Zm-110.51-46.2a7,7,0,0,0,8.31-3.691l21.064-44.056,21.033-44.079a7,7,0,0,0-4.329-9.729L349.631.291a7,7,0,0,0-8.928,5.8l-12.813,96.8A7,7,0,0,0,332.838,110.52Zm20.673-94.477,13.566,4.018L347.566,60.942Zm-93.6,186.064a15.976,15.976,0,0,0-11.678-4.9h-29.14a15.974,15.974,0,0,0-11.678,4.9,12.609,12.609,0,0,0-3.5,9.5l6.067,93.983c.5,7.575,7.184,13.509,15.206,13.509h16.952c8.062,0,14.73-5.956,15.179-13.521l6.093-93.956A12.616,12.616,0,0,0,259.911,202.107ZM243.357,304.636a1.405,1.405,0,0,1-1.218.459H225.187a1.647,1.647,0,0,1-1.233-.421l-6.017-93.2a2.61,2.61,0,0,1,1.156-.259h29.14a2.61,2.61,0,0,1,1.156.259Zm-9.694,31.669a25.428,25.428,0,1,0,25.427,25.427A25.47,25.47,0,0,0,233.663,336.305Zm0,36.851a11.424,11.424,0,1,1,11.424-11.424A11.45,11.45,0,0,1,233.663,373.156Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
1
apps/marketing/public/encrypted.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg id="Capa_1" enable-background="new 0 0 511.98 511.98" height="512" viewBox="0 0 511.98 511.98" width="512" xmlns="http://www.w3.org/2000/svg"><g><path d="m483.24 77.21v164.4c0 40.69-18.65 87.92-52.5 132.99-39.45 52.51-97.56 98.86-168.05 134.04l-6.7 3.34-6.7-3.34c-70.49-35.18-128.6-81.53-168.05-134.04-33.85-45.07-52.5-92.3-52.5-132.99v-164.4l110.56-77.21h233.38z" fill="#00358f"/><path d="m483.24 77.21v164.4c0 40.69-18.65 87.92-52.5 132.99-39.45 52.51-97.56 98.86-168.05 134.04l-6.7 3.34v-511.98h116.69z" fill="#012453"/><path d="m353.8 60h-195.62l-69.44 48.49v133.12c0 45.75 45.01 135.15 167.25 202.78 122.24-67.63 167.25-157.03 167.25-202.78v-133.12z" fill="#00b3fe"/><path d="m423.24 108.49v133.12c0 45.75-45.01 135.15-167.25 202.78v-384.39h97.81z" fill="#0274f9"/><path d="m358.52 45h-205.06l-79.72 55.67v140.94c0 29.26 14.82 66.44 40.65 102 31.45 43.3 77.9 82.69 134.34 113.91l7.26 4.02 7.26-4.02c56.44-31.22 102.89-70.61 134.34-113.91 25.83-35.56 40.65-72.74 40.65-102v-140.94zm49.72 196.61c0 39.91-40.71 121.4-152.25 185.56-111.54-64.16-152.25-145.65-152.25-185.56v-125.3l59.16-41.31h186.18l59.16 41.31z" fill="#eff5fa"/><path d="m438.24 100.67v140.94c0 29.26-14.82 66.44-40.65 102-31.45 43.3-77.9 82.69-134.34 113.91l-7.26 4.02v-34.37c111.54-64.16 152.25-145.65 152.25-185.56v-125.3l-59.16-41.31h-93.09v-30h102.53z" fill="#c6e1ec"/><path d="m351.75 174.24-95.76 98.42-.2.21-24.35 25.02-71.21-66.6 20.49-21.91 49.72 46.51 25.35-26.04.2-.21 74.26-76.32z" fill="#eff5fa"/><path d="m351.75 174.24-95.76 98.42v-43.02l74.26-76.32z" fill="#c6e1ec"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
1
apps/marketing/public/graph.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg height="512" viewBox="0 0 48 48" width="512" xmlns="http://www.w3.org/2000/svg"><g id="Icons"><path d="m3 24a1.059 1.059 0 0 0 .136-.009 77.375 77.375 0 0 0 21.274-6.079 77.1 77.1 0 0 0 18.838-12.028l-1.218 4.874a1 1 0 0 0 .727 1.212 1.025 1.025 0 0 0 .243.03 1 1 0 0 0 .969-.758l2-8a1 1 0 0 0 -.969-1.242h-8a1 1 0 0 0 0 2h5.369a75.2 75.2 0 0 1 -18.779 12.088 75.363 75.363 0 0 1 -20.725 5.921 1 1 0 0 0 .135 1.991z"/><path d="m45 44h-1v-27a1 1 0 0 0 -1-1h-10a1 1 0 0 0 -1 1v27h-2v-21a1 1 0 0 0 -1-1h-10a1 1 0 0 0 -1 1v21h-2v-15a1 1 0 0 0 -1-1h-10a1 1 0 0 0 -1 1v15h-1a1 1 0 0 0 0 2h42a1 1 0 0 0 0-2zm-11-26h8v26h-8zm-14 6h8v20h-8zm-14 6h8v14h-8z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 665 B |
56
apps/marketing/public/kidneys.svg
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M492.305,168.159c-1.823-3.718-6.314-5.256-10.037-3.43c-3.719,1.824-5.254,6.318-3.43,10.037
|
||||||
|
C493.849,205.363,497,236.841,497,257.86c0,28.404-5.893,81.948-45.354,121.409c-12.603,12.603-29.359,19.544-47.183,19.544
|
||||||
|
c-17.822,0-34.579-6.941-47.182-19.544c-12.604-12.603-19.544-29.359-19.544-47.183c0-11.363,2.832-22.286,8.143-31.982
|
||||||
|
c0.156-0.238,0.293-0.487,0.42-0.741c2.952-5.229,6.618-10.097,10.98-14.459c2.609-2.609,4.777-5.456,6.505-8.489
|
||||||
|
c0.027-0.048,0.056-0.093,0.082-0.142c0.427-0.756,0.831-1.522,1.202-2.301c7.775,4.727,12.43,11.955,14.72,16.429
|
||||||
|
c1.328,2.593,3.956,4.083,6.682,4.083c1.15,0,2.318-0.266,3.412-0.826c3.688-1.888,5.145-6.407,3.258-10.094
|
||||||
|
c-5.766-11.259-14.337-19.834-24.505-24.687c0.009-0.339,0.024-0.676,0.024-1.017c0-1.875-0.135-3.719-0.396-5.527
|
||||||
|
c10.34-4.817,19.023-13.436,24.876-24.868c1.888-3.687,0.429-8.206-3.258-10.094c-3.689-1.889-8.206-0.43-10.094,3.258
|
||||||
|
c-2.503,4.89-7.74,12.907-16.679,17.525c-0.341-0.552-0.698-1.097-1.07-1.635c-0.088-0.13-0.177-0.258-0.273-0.382
|
||||||
|
c-1.317-1.861-2.807-3.642-4.486-5.32c-26.017-26.017-26.017-68.349,0-94.366c12.602-12.603,29.359-19.544,47.182-19.544
|
||||||
|
s34.58,6.941,47.184,19.545c3.212,3.212,6.308,6.633,9.202,10.169c2.623,3.206,7.35,3.677,10.554,1.053
|
||||||
|
c3.206-2.624,3.678-7.349,1.054-10.554c-3.208-3.92-6.641-7.713-10.203-11.275c-31.866-31.866-83.715-31.865-115.579,0
|
||||||
|
c-26.887,26.887-31.087,68-12.602,99.332l-10.147-3.547c-10.773-3.766-19.819-11.298-25.472-21.211l-14.007-24.559
|
||||||
|
c-3.979-6.976-6.081-14.907-6.081-22.938V66.745c0-12.207-9.358-22.21-21.306-22.772c-6.193-0.29-12.049,1.897-16.517,6.16
|
||||||
|
c-4.405,4.203-6.932,10.104-6.932,16.192v86.598c0,8.03-2.103,15.962-6.081,22.938l-14.005,24.557
|
||||||
|
c-5.654,9.913-14.701,17.446-25.473,21.211l-10.098,3.529c7.369-12.447,11.309-26.682,11.309-41.526
|
||||||
|
c0-21.83-8.501-42.353-23.937-57.789c-15.436-15.436-35.959-23.938-57.789-23.938c-21.83,0-42.354,8.501-57.789,23.938
|
||||||
|
C6.463,169.129,0,227.135,0,257.86c0,26.67,5.009,77.662,38.557,119.521c2.59,3.232,7.311,3.753,10.543,1.162
|
||||||
|
c3.232-2.59,3.752-7.311,1.162-10.543C19.581,329.719,15,282.562,15,257.86c0-28.404,5.892-81.947,45.354-121.409
|
||||||
|
c12.603-12.603,29.359-19.544,47.183-19.544c17.823,0,34.58,6.941,47.183,19.544c12.603,12.603,19.544,29.359,19.544,47.183
|
||||||
|
c0,17.824-6.94,34.58-19.544,47.183c-1.676,1.676-3.163,3.453-4.479,5.31c-0.095,0.123-0.183,0.25-0.27,0.378
|
||||||
|
c-0.375,0.543-0.736,1.092-1.08,1.649c-8.939-4.618-14.176-12.635-16.68-17.526c-1.888-3.687-6.408-5.144-10.094-3.257
|
||||||
|
c-3.687,1.888-5.145,6.407-3.257,10.094c5.854,11.432,14.537,20.051,24.876,24.867c-0.261,1.809-0.395,3.653-0.395,5.527
|
||||||
|
c0,0.341,0.016,0.678,0.024,1.018c-10.169,4.853-18.74,13.427-24.505,24.687c-1.888,3.687-0.429,8.206,3.257,10.094
|
||||||
|
c1.095,0.56,2.262,0.826,3.412,0.826c2.726,0,5.354-1.491,6.682-4.083c2.291-4.474,6.945-11.702,14.721-16.429
|
||||||
|
c0.39,0.819,0.817,1.624,1.269,2.418c0.056,0.101,0.113,0.202,0.173,0.3c1.702,2.93,3.816,5.684,6.345,8.213
|
||||||
|
c4.36,4.36,8.024,9.225,10.975,14.451c0.13,0.259,0.27,0.513,0.429,0.756c5.308,9.694,8.139,20.616,8.139,31.976
|
||||||
|
c0,17.823-6.94,34.58-19.544,47.183c-21.278,21.277-54.337,25.684-80.392,10.714c-3.592-2.063-8.176-0.825-10.24,2.767
|
||||||
|
c-2.063,3.592-0.824,8.176,2.767,10.24c12.658,7.271,26.662,10.812,40.581,10.812c21.182,0,42.165-8.2,57.89-23.926
|
||||||
|
c15.436-15.436,23.937-35.959,23.937-57.789c0-8.279-1.229-16.368-3.589-24.065l1.057,0.331
|
||||||
|
c6.618,2.073,11.065,8.125,11.065,15.06v122.263c0,12.339,10.038,22.377,22.377,22.377c12.339,0,22.378-10.038,22.378-22.377
|
||||||
|
V323.413c0-26.602-17.056-49.817-42.442-57.77l-1.235-0.387l3.946-1.379c20.968-7.33,38.576-21.993,49.58-41.287l3.588-6.291
|
||||||
|
l3.587,6.289c11.005,19.296,28.613,33.959,49.582,41.289l3.957,1.383l-1.223,0.383c-25.386,7.952-42.441,31.168-42.441,57.77
|
||||||
|
v122.263c0,12.339,10.038,22.377,22.377,22.377s22.377-10.038,22.377-22.377V323.413c0-6.935,4.446-12.988,11.065-15.06
|
||||||
|
l1.057-0.331c-2.36,7.697-3.589,15.786-3.589,24.065c0,21.83,8.501,42.354,23.936,57.789
|
||||||
|
c15.935,15.935,36.858,23.901,57.79,23.899c20.925-0.002,41.858-7.968,57.789-23.899C505.537,346.592,512,288.584,512,257.86
|
||||||
|
C512,235.253,508.583,201.34,492.305,168.159z M227.551,323.413v122.263c0,4.068-3.31,7.377-7.378,7.377
|
||||||
|
c-4.068,0-7.377-3.309-7.377-7.377V323.413c0-13.527-8.673-25.331-21.582-29.375l-13.761-4.31
|
||||||
|
c-2.9-4.768-6.318-9.257-10.209-13.416l7.911-2.765l20.469,6.411C214.721,285.939,227.551,303.403,227.551,323.413z
|
||||||
|
M272.604,215.157l-8.803-15.436c-1.598-2.8-4.593-4.539-7.816-4.538c-3.223,0.001-6.217,1.74-7.812,4.539l-8.805,15.436
|
||||||
|
c-9.21,16.15-23.949,28.423-41.5,34.558L159,263.302c-0.423-1.748-0.662-3.558-0.662-5.443c0-3.95,0.937-7.605,2.786-10.928
|
||||||
|
L193,235.789c14.189-4.96,26.105-14.882,33.553-27.94l14.005-24.557c5.267-9.236,8.051-19.737,8.051-30.369V66.325
|
||||||
|
c0-2.037,0.812-3.933,2.286-5.339c1.473-1.405,3.416-2.125,5.457-2.03c3.866,0.182,7.011,3.676,7.011,7.789v86.176
|
||||||
|
c0,10.632,2.784,21.134,8.052,30.37l14.006,24.558c7.445,13.057,19.361,22.98,33.553,27.94l31.906,11.152
|
||||||
|
c1.845,3.321,2.779,6.972,2.779,10.918c0,1.888-0.24,3.701-0.664,5.451l-38.89-13.593
|
||||||
|
C296.555,243.582,281.816,231.309,272.604,215.157z M320.784,294.038c-12.908,4.044-21.581,15.848-21.581,29.375v122.263
|
||||||
|
c0,4.068-3.31,7.377-7.377,7.377c-4.067,0-7.377-3.309-7.377-7.377V323.413c0-20.01,12.83-37.474,31.926-43.455l20.457-6.407
|
||||||
|
l7.917,2.767c-3.888,4.157-7.304,8.644-10.203,13.409L320.784,294.038z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.5 KiB |
52
apps/marketing/public/logo.svg
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 56">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="rb-glow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#ffcc44" stop-opacity="1"/>
|
||||||
|
<stop offset="25%" stop-color="#ff9900" stop-opacity="0.85"/>
|
||||||
|
<stop offset="55%" stop-color="#ff6600" stop-opacity="0.45"/>
|
||||||
|
<stop offset="100%" stop-color="#ff3300" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="rb-metal" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#c8d8ea"/>
|
||||||
|
<stop offset="30%" stop-color="#8898aa"/>
|
||||||
|
<stop offset="60%" stop-color="#566070"/>
|
||||||
|
<stop offset="100%" stop-color="#a0b4c4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rb-shine" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="rgba(220,240,255,0.55)"/>
|
||||||
|
<stop offset="100%" stop-color="rgba(220,240,255,0)"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="70" cy="28" r="26" fill="url(#rb-glow)" opacity="0.95"/>
|
||||||
|
<g stroke="#ffcc44" stroke-linecap="round">
|
||||||
|
<line x1="70" y1="2" x2="70" y2="54" stroke-width="1.6"/>
|
||||||
|
<line x1="44" y1="28" x2="96" y2="28" stroke-width="1.6"/>
|
||||||
|
<line x1="51" y1="9" x2="89" y2="47" stroke-width="1.2"/>
|
||||||
|
<line x1="89" y1="9" x2="51" y2="47" stroke-width="1.2"/>
|
||||||
|
<line x1="58" y1="4" x2="56" y2="1" stroke-width="0.9" opacity="0.7"/>
|
||||||
|
<line x1="82" y1="4" x2="84" y2="1" stroke-width="0.9" opacity="0.7"/>
|
||||||
|
<line x1="94" y1="16" x2="97" y2="14" stroke-width="0.9" opacity="0.7"/>
|
||||||
|
<line x1="94" y1="40" x2="97" y2="42" stroke-width="0.9" opacity="0.7"/>
|
||||||
|
<line x1="82" y1="52" x2="84" y2="55" stroke-width="0.9" opacity="0.7"/>
|
||||||
|
<line x1="58" y1="52" x2="56" y2="55" stroke-width="0.9" opacity="0.7"/>
|
||||||
|
<line x1="46" y1="40" x2="43" y2="42" stroke-width="0.9" opacity="0.7"/>
|
||||||
|
<line x1="46" y1="16" x2="43" y2="14" stroke-width="0.9" opacity="0.7"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="61" cy="4" r="1.4" fill="#ffcc44" opacity="0.85"/>
|
||||||
|
<circle cx="79" cy="4" r="1.1" fill="#ffcc44" opacity="0.75"/>
|
||||||
|
<circle cx="96" cy="10" r="1.0" fill="#ffcc44" opacity="0.70"/>
|
||||||
|
<circle cx="98" cy="45" r="1.3" fill="#ffcc44" opacity="0.75"/>
|
||||||
|
<circle cx="62" cy="51" r="1.5" fill="#ffcc44" opacity="0.80"/>
|
||||||
|
<circle cx="44" cy="42" r="1.0" fill="#ffcc44" opacity="0.65"/>
|
||||||
|
<ellipse cx="24" cy="28" rx="16" ry="10.5" fill="none" stroke="url(#rb-metal)" stroke-width="6"/>
|
||||||
|
<ellipse cx="24" cy="24" rx="11" ry="4.5" fill="none" stroke="url(#rb-shine)" stroke-width="3"/>
|
||||||
|
<ellipse cx="46" cy="28" rx="16" ry="10.5" fill="none" stroke="url(#rb-metal)" stroke-width="6"/>
|
||||||
|
<ellipse cx="46" cy="24" rx="11" ry="4.5" fill="none" stroke="url(#rb-shine)" stroke-width="3"/>
|
||||||
|
<ellipse cx="94" cy="28" rx="16" ry="10.5" fill="none" stroke="url(#rb-metal)" stroke-width="6"/>
|
||||||
|
<ellipse cx="94" cy="24" rx="11" ry="4.5" fill="none" stroke="url(#rb-shine)" stroke-width="3"/>
|
||||||
|
<ellipse cx="116" cy="28" rx="16" ry="10.5" fill="none" stroke="url(#rb-metal)" stroke-width="6"/>
|
||||||
|
<ellipse cx="116" cy="24" rx="11" ry="4.5" fill="none" stroke="url(#rb-shine)" stroke-width="3"/>
|
||||||
|
<circle cx="70" cy="28" r="5" fill="#ffaa00"/>
|
||||||
|
<circle cx="70" cy="28" r="2.8" fill="#ffdd88"/>
|
||||||
|
<circle cx="70" cy="28" r="1.2" fill="#fffbe0"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
32
apps/marketing/public/phone-call.svg
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 505.709 505.709" style="enable-background:new 0 0 505.709 505.709;" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M427.554,71.862c-99.206-95.816-256.486-95.816-355.692,0c-98.222,101.697-95.405,263.762,6.292,361.984
|
||||||
|
c99.206,95.816,256.486,95.816,355.692,0C532.068,332.15,529.251,170.084,427.554,71.862z M421.814,421.814l-0.085-0.085
|
||||||
|
c-93.352,93.267-244.636,93.198-337.903-0.154S-9.372,176.94,83.98,83.673s244.636-93.198,337.903,0.153
|
||||||
|
c44.799,44.84,69.946,105.643,69.905,169.028C491.792,316.225,466.622,377.002,421.814,421.814z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M396.641,325.729l-47.957-47.787c-10.884-10.91-28.552-10.931-39.462-0.047c-0.016,0.016-0.031,0.031-0.047,0.047
|
||||||
|
l-27.477,27.477c-2.079,2.084-5.355,2.372-7.765,0.683c-15.039-10.51-29.117-22.333-42.069-35.328
|
||||||
|
c-11.6-11.574-22.271-24.042-31.915-37.291c-1.748-2.38-1.494-5.68,0.597-7.765l28.16-28.16
|
||||||
|
c10.872-10.893,10.872-28.531,0-39.424l-47.957-47.957c-11.051-10.565-28.458-10.565-39.509,0l-15.189,15.189
|
||||||
|
c-22.939,22.681-31.128,56.359-21.163,87.04c7.436,22.447,17.947,43.755,31.232,63.317c11.96,17.934,25.681,34.628,40.96,49.835
|
||||||
|
c16.611,16.73,35.011,31.581,54.869,44.288c21.83,14.245,45.799,24.904,70.997,31.573c6.478,1.597,13.126,2.399,19.797,2.389
|
||||||
|
c22.871-0.14,44.752-9.346,60.843-25.6l13.056-13.056C407.513,354.26,407.513,336.622,396.641,325.729z M384.557,353.514
|
||||||
|
c-0.011,0.011-0.022,0.023-0.034,0.034l0.085-0.256l-13.056,13.056c-16.775,16.987-41.206,23.976-64.427,18.432
|
||||||
|
c-23.395-6.262-45.635-16.23-65.877-29.525c-18.806-12.019-36.234-26.069-51.968-41.899
|
||||||
|
c-14.477-14.371-27.483-30.151-38.827-47.104c-12.408-18.242-22.229-38.114-29.184-59.051
|
||||||
|
c-7.973-24.596-1.366-51.585,17.067-69.717l15.189-15.189c4.223-4.242,11.085-4.257,15.326-0.034
|
||||||
|
c0.011,0.011,0.023,0.022,0.034,0.034l47.957,47.957c4.242,4.223,4.257,11.085,0.034,15.326
|
||||||
|
c-0.011,0.011-0.022,0.022-0.034,0.034l-28.16,28.16c-8.08,7.992-9.096,20.692-2.389,29.867
|
||||||
|
c10.185,13.978,21.456,27.131,33.707,39.339c13.659,13.718,28.508,26.197,44.373,37.291c9.167,6.394,21.595,5.316,29.525-2.56
|
||||||
|
l27.221-27.648c4.223-4.242,11.085-4.257,15.326-0.034c0.011,0.011,0.022,0.022,0.034,0.034l48.043,48.128
|
||||||
|
C388.765,342.411,388.78,349.272,384.557,353.514z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
1
apps/marketing/public/snowflake.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg id="Capa_1" enable-background="new 0 0 385.239 385.239" height="512" viewBox="0 0 385.239 385.239" width="512" xmlns="http://www.w3.org/2000/svg"><g><g id="Snowflake"><path d="m363.332 218.21c2.859 7.039-.529 15.063-7.567 17.923l-38.229 15.547 35.381 21.26c6.598 3.768 8.893 12.172 5.125 18.77s-12.172 8.893-18.77 5.125c-.178-.101-.353-.207-.526-.316l-35.379-21.257 4.221 41.069c.654 7.57-4.953 14.238-12.523 14.892-7.399.639-13.97-4.709-14.847-12.084l-6.329-61.59-67.611-40.615v78.874l49.531 37.148c6.079 4.559 7.311 13.183 2.752 19.262s-13.183 7.311-19.262 2.752l-33.02-24.765v41.276c0 7.599-6.16 13.759-13.759 13.759s-13.759-6.16-13.759-13.759v-41.276l-33.02 24.765c-6.079 4.559-14.703 3.327-19.262-2.752s-3.327-14.703 2.752-19.262l49.531-37.148v-78.874l-67.608 40.625-6.329 61.59c-.72 7.012-6.623 12.346-13.672 12.354-.473.002-.946-.022-1.417-.073-7.554-.771-13.053-7.519-12.282-15.073.001-.005.001-.01.002-.016l4.221-41.069-35.379 21.257c-6.424 4.059-14.922 2.141-18.98-4.283-4.059-6.424-2.141-14.922 4.283-18.98.173-.109.348-.215.526-.316l35.381-21.26-38.229-15.547c-6.993-2.973-10.252-11.051-7.28-18.044 2.906-6.836 10.713-10.131 17.638-7.445l57.354 23.323 68.82-41.358-68.826-41.354-57.354 23.33c-7.084 2.748-15.055-.766-17.803-7.851-2.687-6.925.609-14.733 7.445-17.638l38.229-15.547-35.382-21.267c-6.599-3.768-8.893-12.172-5.125-18.77 3.768-6.599 12.172-8.893 18.77-5.125.178.101.353.207.526.316l35.379 21.257-4.221-41.07c-.775-7.559 4.724-14.316 12.283-15.091s14.316 4.724 15.091 12.283l6.329 61.59 67.609 40.622v-78.874l-49.531-37.148c-6.081-4.557-7.317-13.18-2.76-19.261s13.18-7.317 19.261-2.76c.003.002.006.005.01.007l33.02 24.765v-41.274c0-7.599 6.16-13.759 13.759-13.759s13.759 6.16 13.759 13.759v41.276l33.02-24.765c6.079-4.559 14.703-3.327 19.262 2.752s3.327 14.703-2.752 19.262l-49.531 37.146v78.875l67.608-40.626 6.329-61.59c.897-7.546 7.741-12.935 15.286-12.039 7.375.876 12.723 7.448 12.084 14.847l-4.221 41.069 35.379-21.257c6.424-4.058 14.922-2.141 18.98 4.283s2.141 14.922-4.283 18.98c-.173.109-.348.215-.526.316l-35.381 21.26 38.229 15.547c6.993 2.973 10.252 11.051 7.279 18.044-2.906 6.836-10.713 10.131-17.638 7.445l-57.354-23.323-68.82 41.358 68.826 41.354 57.354-23.323c7.027-2.866 15.046.507 17.912 7.534.003.009.007.017.011.026z"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
1
apps/marketing/public/walk.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" image-rendering="optimizeQuality" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><g id="Layer_x0020_1"><g id="_231561928"><g><g><g id="_231561448"><path d="m265 113c-31 0-56-25-56-56 0-32 25-57 56-57s56 25 56 57c0 31-25 56-56 56zm0-99c-23 0-42 19-42 43 0 23 19 42 42 42 24 0 43-19 43-42 0-24-19-43-43-43z"/></g><g id="_231561064"><path d="m296 512c-19 0-35-16-35-36 0-2 0-6 0-12 0-19 1-73-1-83l-67-61c-18-13-12-51-11-55 3-16 7-38 10-53-2 1-3 3-3 4l-21 65c-4 14-17 23-31 23-4 0-7-1-10-2-17-5-27-24-21-41l26-83c3-9 30-26 44-34 16-10 47-25 72-25 22 0 36 11 40 31 2 9-4 57-7 77-2 17-5 34-7 46-2 11-3 17-4 21l49 44c1 1 14 13 13 30v108c0 20-16 36-36 36zm-94-326c2 0 4 1 5 3 2 2 2 5 1 9 0 2 0 5-1 8-1 6-3 15-4 25-4 18-7 36-7 36-2 11-4 36 5 42h1l69 63c3 3 6 9 4 92v12c0 6 2 11 6 15 4 5 10 7 15 7 12 0 22-10 22-22v-108c0-1 0-1 0-1 1-10-9-18-9-19l-52-47c-2-3-3-6-1-9 2-5 7-31 12-69 5-40 7-66 7-70-4-14-12-20-27-20-38 0-97 42-102 50l-27 82c-3 10 2 21 12 24 2 1 4 1 6 1 8 0 15-6 18-13l21-65c3-11 21-24 23-25 1 0 2-1 3-1z"/></g><g id="_231561976"><path d="m149 511c-4 0-7 0-11-2-9-3-16-10-20-19-4-8-4-19-1-28l50-140c0-2 2-4 5-4 2-1 4 0 6 2l32 33 12 13c2 2 3 4 2 7l-40 113c-3 9-10 17-18 21-6 3-11 4-17 4zm27-173-46 129c-2 6-2 12 1 18 2 5 6 9 12 11 5 2 11 2 16-1 6-3 10-8 12-14l38-109-9-9z"/></g><g id="_231561280"><path d="m378 305c-5 0-10-1-13-4l-72-52c-3-2-4-5-3-7l9-54c0-2 2-4 4-5s5-1 7 1l88 67c11 9 13 26 4 40-6 9-15 14-24 14zm-74-65 69 50c1 1 3 1 5 1 4 0 9-3 12-8 5-7 5-17 0-21l-79-60z"/></g></g><g id="_231561160"><path d="m438 512h-364c-3 0-7-3-7-7s4-7 7-7h364c3 0 7 3 7 7s-4 7-7 7z"/></g></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@ -3,6 +3,7 @@ import { Stack } from 'expo-router';
|
|||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
import { KeyboardProvider } from 'react-native-keyboard-controller';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
import { ActionSheetProvider } from '@expo/react-native-action-sheet';
|
import { ActionSheetProvider } from '@expo/react-native-action-sheet';
|
||||||
@ -158,6 +159,7 @@ function RootLayoutInner() {
|
|||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<KeyboardProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ActionSheetProvider>
|
<ActionSheetProvider>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
@ -165,6 +167,7 @@ export default function RootLayout() {
|
|||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</ActionSheetProvider>
|
</ActionSheetProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</KeyboardProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble';
|
import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble';
|
||||||
|
import { resolveAvatar } from '../lib/resolveAvatar';
|
||||||
import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
|
import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
|
||||||
import { useDmRealtime } from '../hooks/useChatRealtime';
|
import { useDmRealtime } from '../hooks/useChatRealtime';
|
||||||
import { useColors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
@ -241,7 +242,7 @@ export default function DmScreen() {
|
|||||||
<View style={styles.headerCenter}>
|
<View style={styles.headerCenter}>
|
||||||
<View style={styles.headerAvatar}>
|
<View style={styles.headerAvatar}>
|
||||||
{partner?.avatar ? (
|
{partner?.avatar ? (
|
||||||
<Image source={{ uri: partner.avatar }} style={styles.headerAvatarImg} />
|
<Image source={{ uri: resolveAvatar(partner.avatar, partner.nickname ?? '') }} style={styles.headerAvatarImg} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.headerAvatarInitials}>
|
<Text style={styles.headerAvatarInitials}>
|
||||||
{(partner?.nickname ?? '?').slice(0, 2).toUpperCase()}
|
{(partner?.nickname ?? '?').slice(0, 2).toUpperCase()}
|
||||||
|
|||||||
@ -103,9 +103,9 @@ export default function GamesScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
{/* Title bewusst entfernt — der Game-Picker hat das Spiel schon ausgewählt,
|
||||||
{t(GAME_META.find((g) => g.id === active)!.titleKey)}
|
Wiederholung im Header lenkt nur ab. Spacer balanciert den Back-Button. */}
|
||||||
</Text>
|
<View style={{ flex: 1 }} />
|
||||||
<View style={{ width: 60 }} />
|
<View style={{ width: 60 }} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@ -240,8 +240,8 @@ export default function SOSScreen() {
|
|||||||
const session = (await supabase.auth.getSession()).data.session;
|
const session = (await supabase.auth.getSession()).data.session;
|
||||||
if (controller.signal.aborted) return null;
|
if (controller.signal.aborted) return null;
|
||||||
const apiBase = Constants.expoConfig?.extra?.apiUrl as string;
|
const apiBase = Constants.expoConfig?.extra?.apiUrl as string;
|
||||||
const endpoint = endpointForProvider(currentProvider());
|
const endpoint = '/api/coach/speak';
|
||||||
const isGoogleCloud = endpoint.endsWith('/speak-google');
|
const isGoogleCloud = false;
|
||||||
const ttsRes = await fetch(`${apiBase}${endpoint}`, {
|
const ttsRes = await fetch(`${apiBase}${endpoint}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@ -444,7 +444,7 @@ export default function SOSScreen() {
|
|||||||
apiBase,
|
apiBase,
|
||||||
accessToken: session.access_token,
|
accessToken: session.access_token,
|
||||||
locale: i18n.language,
|
locale: i18n.language,
|
||||||
endpoint: endpointForProvider(currentProvider()),
|
endpoint: '/api/coach/speak',
|
||||||
onStart: () => { setIsSpeaking(true); setIsTtsLoading(false); },
|
onStart: () => { setIsSpeaking(true); setIsTtsLoading(false); },
|
||||||
onIdle: () => { setIsSpeaking(false); setIsTtsLoading(false); scheduleEmotionReset(0); bench.print(); },
|
onIdle: () => { setIsSpeaking(false); setIsTtsLoading(false); scheduleEmotionReset(0); bench.print(); },
|
||||||
onError: (err, sentence) => {
|
onError: (err, sentence) => {
|
||||||
@ -631,7 +631,7 @@ export default function SOSScreen() {
|
|||||||
apiBase,
|
apiBase,
|
||||||
accessToken: session.access_token,
|
accessToken: session.access_token,
|
||||||
locale: i18n.language,
|
locale: i18n.language,
|
||||||
endpoint: endpointForProvider(currentProvider()),
|
endpoint: '/api/coach/speak',
|
||||||
onStart: () => { setIsSpeaking(true); setIsTtsLoading(false); },
|
onStart: () => { setIsSpeaking(true); setIsTtsLoading(false); },
|
||||||
onIdle: () => { setIsSpeaking(false); setIsTtsLoading(false); scheduleEmotionReset(0); greetingBench.print(); },
|
onIdle: () => { setIsSpeaking(false); setIsTtsLoading(false); scheduleEmotionReset(0); greetingBench.print(); },
|
||||||
onError: (err, sentence) => {
|
onError: (err, sentence) => {
|
||||||
@ -1151,10 +1151,10 @@ export default function SOSScreen() {
|
|||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<GameHeader game={playingGame} emotion={emotion} onBack={handleGameAbandon} />
|
<GameHeader game={playingGame} emotion={emotion} onBack={handleGameAbandon} />
|
||||||
<View style={{ flex: 1, padding: 14 }}>
|
<View style={{ flex: 1, padding: 14 }}>
|
||||||
{playingGame === 'memory' && <MemoryGame onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
|
{playingGame === 'memory' && <MemoryGame mode="sos" onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
|
||||||
{playingGame === 'tictactoe' && <TicTacToeGame onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
|
{playingGame === 'tictactoe' && <TicTacToeGame mode="sos" onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
|
||||||
{playingGame === 'snake' && <SnakeGame onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
|
{playingGame === 'snake' && <SnakeGame mode="sos" onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
|
||||||
{playingGame === 'tetris' && <TetrisGame onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
|
{playingGame === 'tetris' && <TetrisGame mode="sos" onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -192,7 +192,7 @@ export function DeviceLimitReachedSheet() {
|
|||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('device_limit.subtitle', { max, plan: plan.toUpperCase() })}
|
{t('device_limit.subtitle', { count: devices.length, max, plan: plan.toUpperCase() })}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
|
|||||||
67
apps/rebreak-native/components/KeyboardAdjustedView.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Platform, ScrollView, StyleProp, ViewStyle } from 'react-native';
|
||||||
|
import { useKeyboardHeight } from '../hooks/useKeyboardHeight';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Universal-Wrapper für Forms/Pages mit TextInput.
|
||||||
|
*
|
||||||
|
* Für Vollbild-Formulare (Auth, Profile-Edit) reicht das alleine:
|
||||||
|
* - iOS: `automaticallyAdjustKeyboardInsets` (iOS 14+) verschiebt focused Input aktiv.
|
||||||
|
* - Android: `paddingBottom: keyboardHeight` + `windowSoftInputMode=adjustResize`
|
||||||
|
* im Manifest.
|
||||||
|
*
|
||||||
|
* Für FIXED-HEIGHT Sheets/Modals reicht das nicht — der Sheet selbst muss
|
||||||
|
* zusätzlich nach oben verschoben werden. Pattern:
|
||||||
|
* ```tsx
|
||||||
|
* const keyboardHeight = useKeyboardHeight();
|
||||||
|
* <Animated.View style={{
|
||||||
|
* transform: [{ translateY: ... }],
|
||||||
|
* marginBottom: keyboardHeight, // lift sheet above keyboard
|
||||||
|
* }}>
|
||||||
|
* <KeyboardAdjustedView>
|
||||||
|
* {form content}
|
||||||
|
* </KeyboardAdjustedView>
|
||||||
|
* </Animated.View>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Siehe `EditMailAccountSheet.tsx` für vollständiges Sheet-Pattern.
|
||||||
|
*
|
||||||
|
* Anti-Pattern: KeyboardAvoidingView mit `behavior="padding"` greift bei
|
||||||
|
* Vollbild-Layouts mit `paddingTop: insets.top` nicht — siehe
|
||||||
|
* `docs/internal/RECOVERY_LOG_2026-05-10.md` §7.2.
|
||||||
|
*/
|
||||||
|
export interface KeyboardAdjustedViewProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Style für den ScrollView (outer container). */
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
/** Style für den ScrollView-Inhalt (Padding gehört hier rein, nicht in `style`). */
|
||||||
|
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||||
|
/** Extra Padding bottom on top of keyboard height (z.B. wenn fixed CTA-Bar drüber sitzt). */
|
||||||
|
extraBottomOffset?: number;
|
||||||
|
/** Default 'handled' — Tap auf nicht-Input-Bereich schließt Keyboard. */
|
||||||
|
keyboardShouldPersistTaps?: 'always' | 'never' | 'handled';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyboardAdjustedView({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
contentContainerStyle,
|
||||||
|
extraBottomOffset = 0,
|
||||||
|
keyboardShouldPersistTaps = 'handled',
|
||||||
|
}: KeyboardAdjustedViewProps) {
|
||||||
|
const keyboardHeight = useKeyboardHeight();
|
||||||
|
const bottomPad = keyboardHeight > 0 ? keyboardHeight + extraBottomOffset : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={style}
|
||||||
|
contentContainerStyle={[contentContainerStyle, { paddingBottom: bottomPad }]}
|
||||||
|
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
|
||||||
|
keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'on-drag'}
|
||||||
|
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
apps/rebreak-native/components/KeyboardAwareSheet.tsx
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import { ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Keyboard,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
StyleProp,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useColors } from '../lib/theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Universal-Bottom-Sheet für Forms mit TextInput.
|
||||||
|
*
|
||||||
|
* Pattern (verifiziert auf PostCommentsSheet + EditMailAccountSheet):
|
||||||
|
*
|
||||||
|
* 1. Outer-Animated.View hat animated `height` (JS-driver) — Sheet WÄCHST
|
||||||
|
* bei Tastatur-Open um genau die Tastatur-Höhe.
|
||||||
|
* 2. Inner-Animated.View hat `transform: translateY` (Native-driver) —
|
||||||
|
* Slide-In/Out smooth. Driver-Mix-Trennung verhindert
|
||||||
|
* "Style property 'height' is not supported by native animated module"-Crash.
|
||||||
|
* 3. iOS: `paddingBottom: keyboardHeight` shifted Form innerhalb des
|
||||||
|
* gewachsenen Sheets über die Tastatur. Android: `windowSoftInputMode=adjustResize`
|
||||||
|
* im Manifest schrumpft das Window selbst.
|
||||||
|
* 4. Flex-Spacer drückt `children` (Form) automatisch an den Sheet-Bottom-Edge —
|
||||||
|
* sitzt direkt über der Tastatur ohne Gap.
|
||||||
|
*
|
||||||
|
* Anti-Pattern (siehe `docs/internal/RECOVERY_LOG_2026-05-10.md` §7.2):
|
||||||
|
* - `useKeyboardAnimation()` aus `react-native-keyboard-controller` liefert
|
||||||
|
* in iOS-Modals keine Höhe (separate UIWindow). Hier: plain RN
|
||||||
|
* `Keyboard.addListener` für die Höhe.
|
||||||
|
* - `Animated.subtract`/`marginBottom: keyboardHeight` mischen JS+Native-Driver
|
||||||
|
* auf demselben View → Bouncing oder Crash.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```tsx
|
||||||
|
* <KeyboardAwareSheet
|
||||||
|
* visible={visible}
|
||||||
|
* onClose={onClose}
|
||||||
|
* collapsedHeight={280}
|
||||||
|
* header={<HeaderRow title="Passwort" onCancel={onClose} />}
|
||||||
|
* >
|
||||||
|
* <View style={{ padding: 20, gap: 14 }}>
|
||||||
|
* <TextInput ... />
|
||||||
|
* <SaveButton ... />
|
||||||
|
* </View>
|
||||||
|
* </KeyboardAwareSheet>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface KeyboardAwareSheetProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Sheet-Höhe wenn Tastatur zu. Eng auf Inhalt zuschneiden — typisch 220-340px. */
|
||||||
|
collapsedHeight: number;
|
||||||
|
/** Optionaler Header (Cancel/Title-Row). Rendert direkt unter dem Drag-Handle. */
|
||||||
|
header?: ReactNode;
|
||||||
|
/** Form-Inhalt. Wird per Flex-Spacer an den Sheet-Bottom gedrückt — sitzt
|
||||||
|
* damit direkt über der Tastatur sobald die offen ist. */
|
||||||
|
children: ReactNode;
|
||||||
|
/** Default true — Tap auf Backdrop schließt das Sheet. */
|
||||||
|
dismissOnBackdrop?: boolean;
|
||||||
|
/** Default true — kleiner Drag-Handle ganz oben am Sheet. */
|
||||||
|
showDragHandle?: boolean;
|
||||||
|
/** Default true — fügt unten eine Safe-Area-Spacer-Höhe ein (insets.bottom). */
|
||||||
|
showSafeAreaSpacer?: boolean;
|
||||||
|
/** Default true — interner Flex-Spacer drückt children zum Sheet-Bottom.
|
||||||
|
* Auf false setzen wenn der Inhalt seine eigene Scroll-/Flex-Logik hat
|
||||||
|
* (z.B. ScrollView mit Provider-Grid, Listen). */
|
||||||
|
pushChildrenToBottom?: boolean;
|
||||||
|
/** Border-Radius oben. Default 20. */
|
||||||
|
topRadius?: number;
|
||||||
|
/** Optional zusätzlicher Style für den Sheet-Container. */
|
||||||
|
containerStyle?: StyleProp<ViewStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyboardAwareSheet({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
collapsedHeight,
|
||||||
|
header,
|
||||||
|
children,
|
||||||
|
dismissOnBackdrop = true,
|
||||||
|
showDragHandle = true,
|
||||||
|
showSafeAreaSpacer = true,
|
||||||
|
pushChildrenToBottom = true,
|
||||||
|
topRadius = 20,
|
||||||
|
containerStyle,
|
||||||
|
}: KeyboardAwareSheetProps) {
|
||||||
|
const colors = useColors();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const slideY = useRef(new Animated.Value(collapsedHeight)).current;
|
||||||
|
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetHeight = useRef(new Animated.Value(collapsedHeight)).current;
|
||||||
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
|
|
||||||
|
// Slide-In + Backdrop-Fade bei `visible=true`
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
slideY.setValue(collapsedHeight);
|
||||||
|
backdropOpacity.setValue(0);
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(slideY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 280,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(backdropOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 220,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}
|
||||||
|
}, [visible, slideY, backdropOpacity, collapsedHeight]);
|
||||||
|
|
||||||
|
// Sheet-Höhe wächst/schrumpft mit Tastatur
|
||||||
|
useEffect(() => {
|
||||||
|
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||||
|
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||||
|
const showSub = Keyboard.addListener(showEvent, (e) => {
|
||||||
|
const h = e.endCoordinates.height;
|
||||||
|
setKeyboardHeight(h);
|
||||||
|
Animated.timing(sheetHeight, {
|
||||||
|
toValue: collapsedHeight + h,
|
||||||
|
duration: Platform.OS === 'ios' ? (e.duration ?? 250) : 220,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
});
|
||||||
|
const hideSub = Keyboard.addListener(hideEvent, (e) => {
|
||||||
|
setKeyboardHeight(0);
|
||||||
|
Animated.timing(sheetHeight, {
|
||||||
|
toValue: collapsedHeight,
|
||||||
|
duration: Platform.OS === 'ios' ? (e?.duration ?? 250) : 220,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
showSub.remove();
|
||||||
|
hideSub.remove();
|
||||||
|
};
|
||||||
|
}, [sheetHeight, collapsedHeight]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
opacity: backdropOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dismissOnBackdrop && <Pressable style={{ flex: 1 }} onPress={onClose} />}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Outer: animated height (JS-driver) */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: sheetHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Inner: animated transform (Native-driver). Driver-Mix vermeiden
|
||||||
|
durch zwei verschachtelte Animated.Views. */}
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
borderTopLeftRadius: topRadius,
|
||||||
|
borderTopRightRadius: topRadius,
|
||||||
|
transform: [{ translateY: slideY }],
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? keyboardHeight : 0,
|
||||||
|
},
|
||||||
|
containerStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{showDragHandle && (
|
||||||
|
<View style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 4 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: colors.border,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{header}
|
||||||
|
{pushChildrenToBottom ? (
|
||||||
|
<>
|
||||||
|
{/* Flex-Spacer drückt children an den Sheet-Bottom */}
|
||||||
|
<View style={{ flex: 1 }} />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View style={{ flex: 1 }}>{children}</View>
|
||||||
|
)}
|
||||||
|
{showSafeAreaSpacer && <View style={{ height: insets.bottom }} />}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,17 +1,11 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Modal,
|
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Pressable,
|
Pressable,
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
Image,
|
Image,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
|
||||||
Dimensions,
|
|
||||||
Easing,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@ -22,9 +16,9 @@ import {
|
|||||||
type Tier,
|
type Tier,
|
||||||
} from '../../hooks/useCustomDomains';
|
} from '../../hooks/useCustomDomains';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
import { KeyboardAwareSheet } from '../KeyboardAwareSheet';
|
||||||
|
|
||||||
const SCREEN_HEIGHT = Dimensions.get('window').height;
|
const COLLAPSED_HEIGHT = 600;
|
||||||
const SHEET_HEIGHT = SCREEN_HEIGHT * 0.65; // wie bei PostCommentsSheet — 65% der Screen-Höhe
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@ -45,30 +39,6 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
const valid = isValidDomain(input);
|
const valid = isValidDomain(input);
|
||||||
const normalized = normalizeDomain(input);
|
const normalized = normalizeDomain(input);
|
||||||
|
|
||||||
// Slide-up Animation für die Sheet (translateY von SHEET_HEIGHT → 0)
|
|
||||||
const translateY = useRef(new Animated.Value(SHEET_HEIGHT)).current;
|
|
||||||
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
translateY.setValue(SHEET_HEIGHT);
|
|
||||||
backdropOpacity.setValue(0);
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(translateY, {
|
|
||||||
toValue: 0,
|
|
||||||
duration: 280,
|
|
||||||
easing: Easing.out(Easing.cubic),
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(backdropOpacity, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: 220,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
]).start();
|
|
||||||
}
|
|
||||||
}, [visible, translateY, backdropOpacity]);
|
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
setInput('');
|
setInput('');
|
||||||
setConfirmPermanent(false);
|
setConfirmPermanent(false);
|
||||||
@ -98,48 +68,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
? t('blocker.add_sheet_warning_free')
|
? t('blocker.add_sheet_warning_free')
|
||||||
: t('blocker.add_sheet_warning_pro');
|
: t('blocker.add_sheet_warning_pro');
|
||||||
|
|
||||||
return (
|
const header = (
|
||||||
<Modal visible={visible} transparent animationType="none" onRequestClose={close}>
|
|
||||||
{/* Backdrop — Tap-outside schließt */}
|
|
||||||
<Animated.View
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0 as any,
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
|
||||||
opacity: backdropOpacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pressable style={{ flex: 1 }} onPress={close} />
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Sheet — slide-up von unten, 65% der Screen-Höhe */}
|
|
||||||
<Animated.View
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: SHEET_HEIGHT,
|
|
||||||
backgroundColor: colors.bg,
|
|
||||||
borderTopLeftRadius: 20,
|
|
||||||
borderTopRightRadius: 20,
|
|
||||||
transform: [{ translateY }],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
{/* Drag-handle */}
|
|
||||||
<View style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 4 }}>
|
|
||||||
<View style={{ width: 36, height: 4, borderRadius: 2, backgroundColor: colors.border }} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -162,7 +91,16 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
</Text>
|
</Text>
|
||||||
<View style={{ width: 60 }} />
|
<View style={{ width: 60 }} />
|
||||||
</View>
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAwareSheet
|
||||||
|
visible={visible}
|
||||||
|
onClose={close}
|
||||||
|
collapsedHeight={COLLAPSED_HEIGHT}
|
||||||
|
header={header}
|
||||||
|
pushChildrenToBottom={false}
|
||||||
|
>
|
||||||
<View style={{ flex: 1, padding: 20, gap: 14 }}>
|
<View style={{ flex: 1, padding: 20, gap: 14 }}>
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<View>
|
<View>
|
||||||
@ -316,9 +254,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<Text
|
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: '#dc2626' }}>
|
||||||
style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: '#dc2626' }}
|
|
||||||
>
|
|
||||||
{error}
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@ -334,12 +270,14 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
marginBottom: insets.bottom > 0 ? 8 : 12,
|
marginBottom: insets.bottom > 0 ? 8 : 12,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<View style={{
|
<View
|
||||||
|
style={{
|
||||||
backgroundColor: !valid || !confirmPermanent ? '#d4d4d4' : '#dc2626',
|
backgroundColor: !valid || !confirmPermanent ? '#d4d4d4' : '#dc2626',
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{adding ? (
|
{adding ? (
|
||||||
<ActivityIndicator color="#fff" />
|
<ActivityIndicator color="#fff" />
|
||||||
) : (
|
) : (
|
||||||
@ -350,8 +288,6 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAwareSheet>
|
||||||
</Animated.View>
|
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Modal,
|
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
import { KeyboardAwareSheet } from '../KeyboardAwareSheet';
|
||||||
|
|
||||||
|
const COLLAPSED_HEIGHT = 480;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@ -37,6 +37,11 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
|
|||||||
setJoinMode('approval');
|
setJoinMode('approval');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed || creating) return;
|
if (!trimmed || creating) return;
|
||||||
@ -62,10 +67,14 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
<KeyboardAwareSheet
|
||||||
<Pressable style={styles.backdrop} onPress={onClose}>
|
visible={visible}
|
||||||
<Pressable style={styles.sheet} onPress={() => {}}>
|
onClose={handleClose}
|
||||||
<View style={styles.grabber} />
|
collapsedHeight={COLLAPSED_HEIGHT}
|
||||||
|
pushChildrenToBottom={false}
|
||||||
|
topRadius={22}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1, paddingHorizontal: 18, paddingTop: 6 }}>
|
||||||
<Text style={styles.title}>{t('chat.create_group')}</Text>
|
<Text style={styles.title}>{t('chat.create_group')}</Text>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
@ -87,10 +96,7 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Public toggle */}
|
{/* Public toggle */}
|
||||||
<Pressable
|
<Pressable style={styles.toggleRow} onPress={() => setIsPublic((v) => !v)}>
|
||||||
style={styles.toggleRow}
|
|
||||||
onPress={() => setIsPublic((v) => !v)}
|
|
||||||
>
|
|
||||||
<Text style={styles.toggleLabel}>{t('chat.public_room')}</Text>
|
<Text style={styles.toggleLabel}>{t('chat.public_room')}</Text>
|
||||||
<View style={[styles.toggle, isPublic && styles.toggleOn]}>
|
<View style={[styles.toggle, isPublic && styles.toggleOn]}>
|
||||||
<View style={[styles.toggleKnob, isPublic && styles.toggleKnobOn]} />
|
<View style={[styles.toggleKnob, isPublic && styles.toggleKnobOn]} />
|
||||||
@ -122,18 +128,17 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<View style={{ flex: 1 }} />
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<Pressable onPress={onClose} style={styles.cancelBtn}>
|
<Pressable onPress={handleClose} style={styles.cancelBtn}>
|
||||||
<Text style={styles.cancelText}>{t('common.cancel')}</Text>
|
<Text style={styles.cancelText}>{t('common.cancel')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={create}
|
onPress={create}
|
||||||
disabled={!name.trim() || creating}
|
disabled={!name.trim() || creating}
|
||||||
style={[
|
style={[styles.createBtn, { opacity: !name.trim() || creating ? 0.5 : 1 }]}
|
||||||
styles.createBtn,
|
|
||||||
{ opacity: !name.trim() || creating ? 0.5 : 1 },
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
{creating ? (
|
{creating ? (
|
||||||
<ActivityIndicator size="small" color="#fff" />
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
@ -142,34 +147,13 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
|
|||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</View>
|
||||||
</Pressable>
|
</KeyboardAwareSheet>
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStyles(colors: ReturnType<typeof useColors>) {
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
backdrop: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
},
|
|
||||||
sheet: {
|
|
||||||
backgroundColor: colors.bg,
|
|
||||||
borderTopLeftRadius: 22,
|
|
||||||
borderTopRightRadius: 22,
|
|
||||||
padding: 18,
|
|
||||||
paddingBottom: Platform.OS === 'ios' ? 32 : 18,
|
|
||||||
},
|
|
||||||
grabber: {
|
|
||||||
width: 36,
|
|
||||||
height: 4,
|
|
||||||
borderRadius: 2,
|
|
||||||
backgroundColor: colors.border,
|
|
||||||
alignSelf: 'center',
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
title: {
|
title: {
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
@ -255,7 +239,8 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
|||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
marginTop: 20,
|
marginTop: 4,
|
||||||
|
marginBottom: 10,
|
||||||
},
|
},
|
||||||
cancelBtn: {
|
cancelBtn: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@ -2,7 +2,10 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Keyboard,
|
||||||
Modal,
|
Modal,
|
||||||
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
@ -56,7 +59,36 @@ export function GameOverScreen({
|
|||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
// Slide-In Spring für den Sheet-Auftritt (eigene Bouncy-Animation behalten)
|
||||||
const slideAnim = useRef(new Animated.Value(500)).current;
|
const slideAnim = useRef(new Animated.Value(500)).current;
|
||||||
|
// Keyboard-Lift via plain RN Keyboard.addListener (funktioniert in Modals,
|
||||||
|
// anders als react-native-keyboard-controller's useKeyboardAnimation).
|
||||||
|
const keyboardLift = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||||
|
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||||
|
const showSub = Keyboard.addListener(showEvent, (e) => {
|
||||||
|
Animated.timing(keyboardLift, {
|
||||||
|
toValue: e.endCoordinates.height,
|
||||||
|
duration: Platform.OS === 'ios' ? (e.duration ?? 250) : 220,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
});
|
||||||
|
const hideSub = Keyboard.addListener(hideEvent, (e) => {
|
||||||
|
Animated.timing(keyboardLift, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: Platform.OS === 'ios' ? (e?.duration ?? 250) : 220,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
showSub.remove();
|
||||||
|
hideSub.remove();
|
||||||
|
};
|
||||||
|
}, [keyboardLift]);
|
||||||
|
|
||||||
const [rating, setRating] = useState(0);
|
const [rating, setRating] = useState(0);
|
||||||
const [feedback, setFeedback] = useState('');
|
const [feedback, setFeedback] = useState('');
|
||||||
@ -70,7 +102,6 @@ export function GameOverScreen({
|
|||||||
const [posted, setPosted] = useState(false);
|
const [posted, setPosted] = useState(false);
|
||||||
const [postError, setPostError] = useState(false);
|
const [postError, setPostError] = useState(false);
|
||||||
|
|
||||||
console.log('[GameOver] colors:', colors);
|
|
||||||
const emotion = isNewBest || score >= goodScore ? 'happy' : 'empathy';
|
const emotion = isNewBest || score >= goodScore ? 'happy' : 'empathy';
|
||||||
const msg = lyraMsg(gameName, score, goodScore, isNewBest, t);
|
const msg = lyraMsg(gameName, score, goodScore, isNewBest, t);
|
||||||
const displayScore = score;
|
const displayScore = score;
|
||||||
@ -87,6 +118,9 @@ export function GameOverScreen({
|
|||||||
}).start();
|
}).start();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Negativer Lift — translateY -keyboardHeight schiebt Sheet nach oben.
|
||||||
|
const keyboardLiftY = Animated.multiply(keyboardLift, -1);
|
||||||
|
|
||||||
function handleExit() {
|
function handleExit() {
|
||||||
Animated.timing(slideAnim, {
|
Animated.timing(slideAnim, {
|
||||||
toValue: 500,
|
toValue: 500,
|
||||||
@ -172,7 +206,10 @@ export function GameOverScreen({
|
|||||||
<TouchableOpacity onPress={handleExit} activeOpacity={1} style={{ flex: 1 }} />
|
<TouchableOpacity onPress={handleExit} activeOpacity={1} style={{ flex: 1 }} />
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
transform: [{ translateY: slideAnim }],
|
transform: [
|
||||||
|
{ translateY: slideAnim },
|
||||||
|
{ translateY: keyboardLiftY },
|
||||||
|
],
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
borderTopLeftRadius: 28,
|
borderTopLeftRadius: 28,
|
||||||
borderTopRightRadius: 28,
|
borderTopRightRadius: 28,
|
||||||
@ -226,20 +263,20 @@ export function GameOverScreen({
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: isNewBest ? '#fef3c7' : pillBg,
|
backgroundColor: isNewBest ? '#e7f0ff' : pillBg,
|
||||||
borderRadius: 14,
|
borderRadius: 12,
|
||||||
borderWidth: isNewBest ? 1.5 : 0,
|
borderWidth: isNewBest ? 1.5 : 0,
|
||||||
borderColor: isNewBest ? '#f59e0b' : 'transparent',
|
borderColor: isNewBest ? '#007AFF' : 'transparent',
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 2,
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 20, color: isNewBest ? '#d97706' : pillMuted }}>
|
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 20, color: isNewBest ? '#0051d4' : pillMuted }}>
|
||||||
{displayBest}
|
{displayBest}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: 10, color: isNewBest ? '#d97706' : pillMuted, textTransform: 'uppercase', letterSpacing: 1, fontFamily: 'Nunito_600SemiBold' }}>
|
<Text style={{ fontSize: 10, color: isNewBest ? '#0051d4' : pillMuted, textTransform: 'uppercase', letterSpacing: 1, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
{isNewBest ? t('gameOver.newBest') : t('gameOver.best')}
|
{isNewBest ? t('gameOver.newBest') : t('gameOver.best')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -251,7 +288,7 @@ export function GameOverScreen({
|
|||||||
value={rating}
|
value={rating}
|
||||||
size="lg"
|
size="lg"
|
||||||
interactive={!saved}
|
interactive={!saved}
|
||||||
filledColor="#f59e0b"
|
filledColor="#007AFF"
|
||||||
onChange={(v) => { if (!saved) setRating(v); }}
|
onChange={(v) => { if (!saved) setRating(v); }}
|
||||||
/>
|
/>
|
||||||
{saved ? (
|
{saved ? (
|
||||||
@ -287,9 +324,9 @@ export function GameOverScreen({
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#f59e0b',
|
backgroundColor: '#007AFF',
|
||||||
borderRadius: 14,
|
borderRadius: 12,
|
||||||
minHeight: 50,
|
minHeight: 40,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -314,11 +351,11 @@ export function GameOverScreen({
|
|||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#f59e0b',
|
backgroundColor: '#007AFF',
|
||||||
borderRadius: 14,
|
borderRadius: 12,
|
||||||
minHeight: 50,
|
minHeight: 40,
|
||||||
paddingVertical: 14,
|
paddingVertical: 10,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
@ -337,8 +374,8 @@ export function GameOverScreen({
|
|||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#e5e7eb',
|
backgroundColor: '#e5e7eb',
|
||||||
borderRadius: 14,
|
borderRadius: 12,
|
||||||
minHeight: 50,
|
minHeight: 40,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -391,7 +428,7 @@ export function GameOverScreen({
|
|||||||
numberOfLines={4}
|
numberOfLines={4}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: colors.surfaceElevated,
|
backgroundColor: colors.surfaceElevated,
|
||||||
borderRadius: 14,
|
borderRadius: 12,
|
||||||
padding: 14,
|
padding: 14,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
@ -415,8 +452,8 @@ export function GameOverScreen({
|
|||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#e5e7eb',
|
backgroundColor: '#e5e7eb',
|
||||||
borderRadius: 14,
|
borderRadius: 12,
|
||||||
minHeight: 50,
|
minHeight: 40,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -436,9 +473,9 @@ export function GameOverScreen({
|
|||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#f59e0b',
|
backgroundColor: '#007AFF',
|
||||||
borderRadius: 14,
|
borderRadius: 12,
|
||||||
minHeight: 50,
|
minHeight: 40,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
107
apps/rebreak-native/components/games/ScoreProgressBar.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Animated, View, Text } from 'react-native';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animierter Progress-Bar: aktueller Score vs. persönlicher Rekord.
|
||||||
|
*
|
||||||
|
* - Bar-Breite animiert zu `min(score / max(best, 1), 1) * 100%`
|
||||||
|
* - Bei `isNewBest=true`: Celebration-Animation (Gold-Pulse + Scale-Bounce + 🏆-Label)
|
||||||
|
* - Position direkt unter `<DigitalScore />` im Game-Layout
|
||||||
|
*
|
||||||
|
* Reusable für Snake / Tetris / Memory — pro Spiel den passenden `score`/`best`
|
||||||
|
* reinreichen. Optional `boardWidth` damit die Bar exakt das Board-Edge matcht.
|
||||||
|
*/
|
||||||
|
export interface ScoreProgressBarProps {
|
||||||
|
score: number;
|
||||||
|
best: number;
|
||||||
|
isNewBest: boolean;
|
||||||
|
boardWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScoreProgressBar({ score, best, isNewBest, boardWidth }: ScoreProgressBarProps) {
|
||||||
|
const widthAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const celebrationAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
// Bar-Breite zum aktuellen Score-Verhältnis
|
||||||
|
useEffect(() => {
|
||||||
|
const target = best > 0 ? Math.min(score / best, 1) : score > 0 ? 1 : 0;
|
||||||
|
Animated.timing(widthAnim, {
|
||||||
|
toValue: target,
|
||||||
|
duration: 280,
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
}, [score, best, widthAnim]);
|
||||||
|
|
||||||
|
// Celebration-Pulse bei neuem Rekord
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNewBest) {
|
||||||
|
celebrationAnim.setValue(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(celebrationAnim, { toValue: 1, duration: 280, useNativeDriver: false }),
|
||||||
|
Animated.timing(celebrationAnim, { toValue: 0, duration: 600, useNativeDriver: false }),
|
||||||
|
]).start();
|
||||||
|
}, [isNewBest, celebrationAnim]);
|
||||||
|
|
||||||
|
const widthInterp = widthAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: ['0%', '100%'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bar-Color: idle blau, beim Celebration-Pulse → gold
|
||||||
|
const barColor = celebrationAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: ['#007AFF', '#FFD60A'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Container leicht hochskalieren bei Celebration
|
||||||
|
const containerScale = celebrationAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [1, 1.04],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
width: boardWidth,
|
||||||
|
alignSelf: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
transform: [{ scale: containerScale }],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 3 }}>
|
||||||
|
<Text style={{ fontSize: 9, color: '#6b7280', letterSpacing: 1, fontFamily: 'Nunito_600SemiBold', textTransform: 'uppercase' }}>
|
||||||
|
{isNewBest ? '🏆 NEW RECORD' : 'PROGRESS'}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 9,
|
||||||
|
color: isNewBest ? '#b8860b' : '#6b7280',
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{score} / {Math.max(best, score)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: '#1f2937',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: widthInterp,
|
||||||
|
backgroundColor: barColor,
|
||||||
|
borderRadius: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
apps/rebreak-native/components/icons/LanguageIcon.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* LanguageIcon — custom SVG für Sprache-Setting (statt Ionicons language-outline).
|
||||||
|
*
|
||||||
|
* SVG-Source: User-provided (24×24 viewBox, currentColor stroke).
|
||||||
|
* Pattern: A-glyph + speech-bubble + Aa-letters → Translation/Language-Picker affordance.
|
||||||
|
*/
|
||||||
|
import { Svg, G, Path } from 'react-native-svg';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LanguageIcon({ size = 24, color = 'currentColor' }: Props) {
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||||
|
<G stroke={color} strokeLinecap="round" strokeWidth={2}>
|
||||||
|
<Path
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14 19c3.771 0 5.657 0 6.828-1.172S22 14.771 22 11s0-5.657-1.172-6.828S17.771 3 14 3h-4C6.229 3 4.343 3 3.172 4.172S2 7.229 2 11s0 5.657 1.172 6.828c.653.654 1.528.943 2.828 1.07"
|
||||||
|
/>
|
||||||
|
<Path d="M14 19c-1.236 0-2.598.5-3.841 1.145c-1.998 1.037-2.997 1.556-3.489 1.225s-.399-1.355-.212-3.404L6.5 17.5" />
|
||||||
|
<Path
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m5.5 13.5l1-2m0 0l1.106-2.211a1 1 0 0 1 1.788 0L10.5 11.5m-4 0h4m0 0l1 2m1-6h1.982V9c0 .5-.496 1.5-1.487 1.5m3.964-3v2m0 0v4m0-4H18.5"
|
||||||
|
/>
|
||||||
|
</G>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,13 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
|
||||||
Dimensions,
|
|
||||||
Easing,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Linking,
|
Linking,
|
||||||
Modal,
|
|
||||||
Platform,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Text,
|
Text,
|
||||||
@ -19,9 +13,9 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMailConnect, detectProvider, type MailProvider } from '../../hooks/useMailConnect';
|
import { useMailConnect, detectProvider, type MailProvider } from '../../hooks/useMailConnect';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
import { KeyboardAwareSheet } from '../KeyboardAwareSheet';
|
||||||
|
|
||||||
const SCREEN_HEIGHT = Dimensions.get('window').height;
|
const COLLAPSED_HEIGHT = 600;
|
||||||
const SHEET_HEIGHT = SCREEN_HEIGHT * 0.65;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@ -109,29 +103,6 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
const translateY = useRef(new Animated.Value(SHEET_HEIGHT)).current;
|
|
||||||
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
translateY.setValue(SHEET_HEIGHT);
|
|
||||||
backdropOpacity.setValue(0);
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(translateY, {
|
|
||||||
toValue: 0,
|
|
||||||
duration: 280,
|
|
||||||
easing: Easing.out(Easing.cubic),
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(backdropOpacity, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: 220,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
]).start();
|
|
||||||
}
|
|
||||||
}, [visible, translateY, backdropOpacity]);
|
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
setView('grid');
|
setView('grid');
|
||||||
setSelectedProvider(null);
|
setSelectedProvider(null);
|
||||||
@ -180,47 +151,7 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
const detectedProvider = email.includes('@') ? detectProvider(email) : null;
|
const detectedProvider = email.includes('@') ? detectProvider(email) : null;
|
||||||
const currentProvider = selectedProvider ?? null;
|
const currentProvider = selectedProvider ?? null;
|
||||||
|
|
||||||
return (
|
const header = (
|
||||||
<Modal visible={visible} transparent animationType="none" onRequestClose={handleClose}>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<Animated.View
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
|
||||||
opacity: backdropOpacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pressable style={{ flex: 1 }} onPress={handleClose} />
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Sheet */}
|
|
||||||
<Animated.View
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: SHEET_HEIGHT,
|
|
||||||
backgroundColor: colors.bg,
|
|
||||||
borderTopLeftRadius: 20,
|
|
||||||
borderTopRightRadius: 20,
|
|
||||||
transform: [{ translateY }],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
{/* Drag-Handle */}
|
|
||||||
<View style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 4 }}>
|
|
||||||
<View style={{ width: 36, height: 4, borderRadius: 2, backgroundColor: colors.border }} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -246,17 +177,23 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
{view === 'form' && currentProvider
|
{view === 'form' && currentProvider
|
||||||
? t(currentProvider.labelKey)
|
? t(currentProvider.labelKey)
|
||||||
: t('mail.connect_sheet_title')}
|
: t('mail.connect_sheet_title')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={{ width: 60 }} />
|
<View style={{ width: 60 }} />
|
||||||
</View>
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
{/* Content */}
|
return (
|
||||||
|
<KeyboardAwareSheet
|
||||||
|
visible={visible}
|
||||||
|
onClose={handleClose}
|
||||||
|
collapsedHeight={COLLAPSED_HEIGHT}
|
||||||
|
header={header}
|
||||||
|
pushChildrenToBottom={false}
|
||||||
|
>
|
||||||
{view === 'grid' ? (
|
{view === 'grid' ? (
|
||||||
<ProviderGrid providers={PROVIDERS} onSelect={handleProviderSelect} t={t} />
|
<ProviderGrid providers={PROVIDERS} onSelect={handleProviderSelect} t={t} />
|
||||||
) : (
|
) : (
|
||||||
@ -276,9 +213,7 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAwareSheet>
|
||||||
</Animated.View>
|
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,25 +1,13 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native';
|
||||||
ActivityIndicator,
|
|
||||||
Animated,
|
|
||||||
Dimensions,
|
|
||||||
Easing,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Modal,
|
|
||||||
Platform,
|
|
||||||
Pressable,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
View,
|
|
||||||
} from 'react-native';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMailConnect } from '../../hooks/useMailConnect';
|
import { useMailConnect } from '../../hooks/useMailConnect';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
import { humanizeMailError } from '../../lib/mailErrors';
|
||||||
|
import { KeyboardAwareSheet } from '../KeyboardAwareSheet';
|
||||||
|
|
||||||
const SCREEN_HEIGHT = Dimensions.get('window').height;
|
const COLLAPSED_HEIGHT = 280;
|
||||||
const SHEET_HEIGHT = SCREEN_HEIGHT * 0.5;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@ -35,38 +23,18 @@ type Props = {
|
|||||||
export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Props) {
|
export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { connect, connecting, error: connectError } = useMailConnect();
|
const { connect, connecting, error: connectError } = useMailConnect();
|
||||||
|
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
const translateY = useRef(new Animated.Value(SHEET_HEIGHT)).current;
|
function handleClose() {
|
||||||
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
setPassword('');
|
setPassword('');
|
||||||
setPasswordVisible(false);
|
setPasswordVisible(false);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
translateY.setValue(SHEET_HEIGHT);
|
onClose();
|
||||||
backdropOpacity.setValue(0);
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(translateY, {
|
|
||||||
toValue: 0,
|
|
||||||
duration: 280,
|
|
||||||
easing: Easing.out(Easing.cubic),
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(backdropOpacity, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: 220,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
]).start();
|
|
||||||
}
|
}
|
||||||
}, [visible, translateY, backdropOpacity]);
|
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!password.trim()) {
|
if (!password.trim()) {
|
||||||
@ -76,52 +44,14 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
|
|||||||
setFormError(null);
|
setFormError(null);
|
||||||
const result = await connect({ email, password });
|
const result = await connect({ email, password });
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
onClose();
|
handleClose();
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} else {
|
} else {
|
||||||
setFormError(result.error ?? t('mail.connect_failed'));
|
setFormError(result.error ?? t('mail.connect_failed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const header = (
|
||||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
|
|
||||||
<Animated.View
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
|
||||||
opacity: backdropOpacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
<Animated.View
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: SHEET_HEIGHT,
|
|
||||||
backgroundColor: colors.bg,
|
|
||||||
borderTopLeftRadius: 20,
|
|
||||||
borderTopRightRadius: 20,
|
|
||||||
transform: [{ translateY }],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
{/* Drag-Handle */}
|
|
||||||
<View style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 4 }}>
|
|
||||||
<View style={{ width: 36, height: 4, borderRadius: 2, backgroundColor: colors.border }} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -134,9 +64,9 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
|
|||||||
borderBottomColor: colors.border,
|
borderBottomColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pressable onPress={onClose} hitSlop={10}>
|
<Pressable onPress={handleClose} hitSlop={8}>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
<Text style={{ fontSize: 15, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||||||
{t('common.cancel')}
|
{t('mail.edit_account_cancel')}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
@ -144,8 +74,16 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
|
|||||||
</Text>
|
</Text>
|
||||||
<View style={{ width: 60 }} />
|
<View style={{ width: 60 }} />
|
||||||
</View>
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
<View style={{ flex: 1, padding: 20, gap: 14 }}>
|
return (
|
||||||
|
<KeyboardAwareSheet
|
||||||
|
visible={visible}
|
||||||
|
onClose={handleClose}
|
||||||
|
collapsedHeight={COLLAPSED_HEIGHT}
|
||||||
|
header={header}
|
||||||
|
>
|
||||||
|
<View style={{ padding: 20, gap: 14 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
@ -216,7 +154,9 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
|
|||||||
color: '#dc2626',
|
color: '#dc2626',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formError ?? connectError}
|
{formError
|
||||||
|
? formError
|
||||||
|
: t(humanizeMailError(connectError))}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@ -229,12 +169,14 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
|
|||||||
opacity: pressed ? 0.85 : 1,
|
opacity: pressed ? 0.85 : 1,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<View style={{
|
<View
|
||||||
|
style={{
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: !password.trim() || connecting ? '#bfdbfe' : '#007AFF',
|
backgroundColor: !password.trim() || connecting ? '#bfdbfe' : '#007AFF',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{connecting ? (
|
{connecting ? (
|
||||||
<ActivityIndicator color="#fff" />
|
<ActivityIndicator color="#fff" />
|
||||||
) : (
|
) : (
|
||||||
@ -244,11 +186,7 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<View style={{ height: insets.bottom }} />
|
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAwareSheet>
|
||||||
</Animated.View>
|
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,15 +45,186 @@ function resolveProviderIcon(provider: string): {
|
|||||||
return { icon: 'server', color: '#737373' };
|
return { icon: 'server', color: '#737373' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeTime(iso: string | null, t: (k: string) => string): string {
|
const STALE_THRESHOLD_MS = 5 * 60 * 1_000;
|
||||||
if (!iso) return t('mail.account_never_scanned');
|
const IDLE_HEARTBEAT_STALE_MS = STALE_THRESHOLD_MS;
|
||||||
const diff = Date.now() - new Date(iso).getTime();
|
const NO_NEW_MAIL_THRESHOLD_MS = 60 * 60_000;
|
||||||
const mins = Math.floor(diff / 60_000);
|
|
||||||
if (mins < 2) return t('mail.account_just_now');
|
function formatRelativeAbsolute(ts: Date): string {
|
||||||
if (mins < 60) return `${mins} min`;
|
const min = Math.floor((Date.now() - ts.getTime()) / 60_000);
|
||||||
const hours = Math.floor(mins / 60);
|
const todayStr = new Date().toDateString();
|
||||||
if (hours < 24) return `${hours}h`;
|
const yesterdayStr = new Date(Date.now() - 86_400_000).toDateString();
|
||||||
return `${Math.floor(hours / 24)}d`;
|
|
||||||
|
const hh = ts.getHours().toString().padStart(2, '0');
|
||||||
|
const mm = ts.getMinutes().toString().padStart(2, '0');
|
||||||
|
|
||||||
|
let dayLabel: string;
|
||||||
|
if (ts.toDateString() === todayStr) dayLabel = 'heute';
|
||||||
|
else if (ts.toDateString() === yesterdayStr) dayLabel = 'gestern';
|
||||||
|
else dayLabel = ts.toLocaleDateString('de', { day: '2-digit', month: '2-digit' });
|
||||||
|
|
||||||
|
let rel: string;
|
||||||
|
if (min < 1) rel = 'gerade eben';
|
||||||
|
else if (min < 60) rel = `vor ${min} min`;
|
||||||
|
else if (min < 1440) rel = `vor ${Math.floor(min / 60)}h`;
|
||||||
|
else rel = `vor ${Math.floor(min / 1440)}d`;
|
||||||
|
|
||||||
|
return `${rel} (${dayLabel} ${hh}:${mm})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function idleHeartbeatAlive(lastIdleHeartbeatAt: string | null | undefined): boolean {
|
||||||
|
if (!lastIdleHeartbeatAt) return false;
|
||||||
|
return Date.now() - new Date(lastIdleHeartbeatAt).getTime() < IDLE_HEARTBEAT_STALE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadgeRow({
|
||||||
|
account,
|
||||||
|
isLegend,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
account: MailAccount;
|
||||||
|
isLegend: boolean;
|
||||||
|
t: (k: string, opts?: Record<string, string | number>) => string;
|
||||||
|
}) {
|
||||||
|
// Priority 1 — auth / connect error
|
||||||
|
if (account.lastConnectError) {
|
||||||
|
const isAuthError =
|
||||||
|
account.lastConnectError.toLowerCase().includes('invalid credentials') ||
|
||||||
|
account.lastConnectError.toLowerCase().includes('authentication failed');
|
||||||
|
const errorLabel = isAuthError ? t('mail.status_auth_error') : t('mail.status_connect_error');
|
||||||
|
const since = account.lastConnectErrorAt
|
||||||
|
? formatRelativeAbsolute(new Date(account.lastConnectErrorAt))
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 3 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<Ionicons name="lock-closed" size={11} color="#dc2626" style={{ marginRight: 4 }} />
|
||||||
|
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#dc2626' }}>
|
||||||
|
{errorLabel}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginLeft: 4 }}
|
||||||
|
>
|
||||||
|
· {t('mail.status_error_tap_hint')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{since ? (
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 10, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }}
|
||||||
|
>
|
||||||
|
{since}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 5 — never connected
|
||||||
|
if (!account.lastScannedAt) {
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 3 }}>
|
||||||
|
<View style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: '#a3a3a3', marginRight: 5 }} />
|
||||||
|
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#a3a3a3' }}>
|
||||||
|
{t('mail.status_waiting_first_connect')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const heartbeatAlive = idleHeartbeatAlive(account.lastIdleHeartbeatAt);
|
||||||
|
const lastScannedTs = new Date(account.lastScannedAt);
|
||||||
|
const scannedAgo = Date.now() - lastScannedTs.getTime();
|
||||||
|
const scannedRelAbs = formatRelativeAbsolute(lastScannedTs);
|
||||||
|
|
||||||
|
// Priority 4 — stale: heartbeat missing/expired AND scan is old
|
||||||
|
if (!heartbeatAlive && scannedAgo > STALE_THRESHOLD_MS) {
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 3 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<View
|
||||||
|
style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: '#d97706', marginRight: 5 }}
|
||||||
|
/>
|
||||||
|
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#d97706' }}>
|
||||||
|
{t('mail.status_stale')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 10, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{t('mail.status_stale_last_scan', { rel: scannedRelAbs })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2 + 3 — heartbeat alive (or scan recent enough for pre-migration backend)
|
||||||
|
if (heartbeatAlive) {
|
||||||
|
const heartbeatTs = new Date(account.lastIdleHeartbeatAt!);
|
||||||
|
const heartbeatMin = Math.floor((Date.now() - heartbeatTs.getTime()) / 60_000);
|
||||||
|
const idleSince = heartbeatMin < 1 ? 'gerade eben' : `${heartbeatMin} min`;
|
||||||
|
|
||||||
|
if (scannedAgo > NO_NEW_MAIL_THRESHOLD_MS) {
|
||||||
|
// Priority 3 — connected but no new mail for >1h
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 3 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<View
|
||||||
|
style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: '#16a34a', marginRight: 5 }}
|
||||||
|
/>
|
||||||
|
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#16a34a' }}>
|
||||||
|
{isLegend ? t('mail.live') : t('mail.account_active')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 10, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{t('mail.status_live_no_new_mail', { rel: scannedRelAbs })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2 — live + heartbeat recent + scan recent
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 3 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<View
|
||||||
|
style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: '#16a34a', marginRight: 5 }}
|
||||||
|
/>
|
||||||
|
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#16a34a' }}>
|
||||||
|
{isLegend ? t('mail.live') : t('mail.account_active')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 10, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{t('mail.status_live_idle', { rel: idleSince })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback — scan recent, backend without heartbeat field
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 3 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<View
|
||||||
|
style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: '#16a34a', marginRight: 5 }}
|
||||||
|
/>
|
||||||
|
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#16a34a' }}>
|
||||||
|
{isLegend ? t('mail.live') : t('mail.account_active')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 10, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{scannedRelAbs}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const INTERVAL_OPTIONS_BY_PLAN: Record<'free' | 'pro' | 'legend', number[]> = {
|
const INTERVAL_OPTIONS_BY_PLAN: Record<'free' | 'pro' | 'legend', number[]> = {
|
||||||
@ -62,7 +233,6 @@ const INTERVAL_OPTIONS_BY_PLAN: Record<'free' | 'pro' | 'legend', number[]> = {
|
|||||||
legend: [1, 4, 8],
|
legend: [1, 4, 8],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Solid styles outside of render — no gap, no callback layout.
|
|
||||||
const HEADER_ROW = {
|
const HEADER_ROW = {
|
||||||
flexDirection: 'row' as const,
|
flexDirection: 'row' as const,
|
||||||
alignItems: 'center' as const,
|
alignItems: 'center' as const,
|
||||||
@ -99,6 +269,10 @@ export function MailAccountCard({
|
|||||||
const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan];
|
const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan];
|
||||||
|
|
||||||
function handleToggle() {
|
function handleToggle() {
|
||||||
|
if (account.lastConnectError) {
|
||||||
|
setEditVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||||
onToggle();
|
onToggle();
|
||||||
}
|
}
|
||||||
@ -115,11 +289,11 @@ export function MailAccountCard({
|
|||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e5e5',
|
borderColor: account.lastConnectError ? '#fecaca' : '#e5e5e5',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── Header ── */}
|
{/* Header */}
|
||||||
<Pressable onPress={handleToggle} android_ripple={{ color: '#f5f5f5' }}>
|
<Pressable onPress={handleToggle} android_ripple={{ color: '#f5f5f5' }}>
|
||||||
<View style={HEADER_ROW}>
|
<View style={HEADER_ROW}>
|
||||||
<View
|
<View
|
||||||
@ -143,37 +317,7 @@ export function MailAccountCard({
|
|||||||
>
|
>
|
||||||
{account.email}
|
{account.email}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 3 }}>
|
<StatusBadgeRow account={account} isLegend={isLegend} t={t} />
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: 3,
|
|
||||||
backgroundColor: account.isActive ? '#16a34a' : '#dc2626',
|
|
||||||
marginRight: 5,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
|
||||||
color: account.isActive ? '#16a34a' : '#dc2626',
|
|
||||||
marginRight: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{account.isActive
|
|
||||||
? isLegend
|
|
||||||
? t('mail.live')
|
|
||||||
: t('mail.account_active')
|
|
||||||
: t('mail.account_inactive')}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
· {formatRelativeTime(account.lastScannedAt, t)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Ionicons
|
<Ionicons
|
||||||
@ -184,10 +328,9 @@ export function MailAccountCard({
|
|||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{/* ── Body ── */}
|
{/* Body */}
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<View style={{ borderTopWidth: 1, borderTopColor: '#f5f5f5', padding: 14 }}>
|
<View style={{ borderTopWidth: 1, borderTopColor: '#f5f5f5', padding: 14 }}>
|
||||||
{/* Big stat: Blocked */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -201,9 +344,7 @@ export function MailAccountCard({
|
|||||||
>
|
>
|
||||||
<Ionicons name="shield-checkmark" size={20} color="#dc2626" style={{ marginRight: 10 }} />
|
<Ionicons name="shield-checkmark" size={20} color="#dc2626" style={{ marginRight: 10 }} />
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text
|
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#dc2626' }}>
|
||||||
style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#dc2626' }}
|
|
||||||
>
|
|
||||||
{t('mail.account_stat_blocked')}
|
{t('mail.account_stat_blocked')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
@ -217,16 +358,13 @@ export function MailAccountCard({
|
|||||||
{account.totalBlocked.toLocaleString()}
|
{account.totalBlocked.toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#737373' }}>
|
||||||
style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#737373' }}
|
|
||||||
>
|
|
||||||
{t('mail.account_of_scanned', {
|
{t('mail.account_of_scanned', {
|
||||||
scanned: account.totalScanned.toLocaleString(),
|
scanned: account.totalScanned.toLocaleString(),
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Scan Mode */}
|
|
||||||
{isLegend ? (
|
{isLegend ? (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -312,7 +450,6 @@ export function MailAccountCard({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Row */}
|
|
||||||
<View style={{ flexDirection: 'row' }}>
|
<View style={{ flexDirection: 'row' }}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setEditVisible(true)}
|
onPress={() => setEditVisible(true)}
|
||||||
|
|||||||
159
apps/rebreak-native/components/mail/MailWeeklyChart.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Pressable, Text, View } from 'react-native';
|
||||||
|
import Svg, { Rect, Text as SvgText } from 'react-native-svg';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
import type { DailyStat } from '../../hooks/useMailStatus';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
dailyStats: DailyStat[];
|
||||||
|
totalBlocked: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHART_HEIGHT = 72;
|
||||||
|
const BAR_RADIUS = 4;
|
||||||
|
const LABEL_HEIGHT = 16;
|
||||||
|
const SVG_HEIGHT = CHART_HEIGHT + LABEL_HEIGHT;
|
||||||
|
|
||||||
|
export function MailWeeklyChart({ dailyStats, totalBlocked }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const [activeIdx, setActiveIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const chartMax = Math.max(...dailyStats.map((d) => d.count), 1);
|
||||||
|
|
||||||
|
const weekTotal = dailyStats.reduce((s, d) => s + d.count, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 14,
|
||||||
|
paddingBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('mail.chart_title')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
color: colors.error,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('mail.chart_week_total', { count: weekTotal })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
{activeIdx !== null && dailyStats[activeIdx] !== undefined && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignSelf: 'center',
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dailyStats[activeIdx].label}: {dailyStats[activeIdx].count}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SVG Bar Chart */}
|
||||||
|
<View style={{ width: '100%' }}>
|
||||||
|
<Svg width="100%" height={SVG_HEIGHT} viewBox={`0 0 ${7 * 40} ${SVG_HEIGHT}`} preserveAspectRatio="none">
|
||||||
|
{dailyStats.map((day, i) => {
|
||||||
|
const barH = day.count > 0
|
||||||
|
? Math.max(6, Math.round((day.count / chartMax) * CHART_HEIGHT))
|
||||||
|
: 4;
|
||||||
|
const x = i * 40 + 4;
|
||||||
|
const barW = 32;
|
||||||
|
const y = CHART_HEIGHT - barH;
|
||||||
|
const isActive = activeIdx === i;
|
||||||
|
const fill = day.count > 0
|
||||||
|
? isActive ? '#b91c1c' : '#ef4444'
|
||||||
|
: colors.border;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rect
|
||||||
|
key={day.date}
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={barW}
|
||||||
|
height={barH}
|
||||||
|
rx={BAR_RADIUS}
|
||||||
|
ry={BAR_RADIUS}
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{dailyStats.map((day, i) => (
|
||||||
|
<SvgText
|
||||||
|
key={`label-${day.date}`}
|
||||||
|
x={i * 40 + 20}
|
||||||
|
y={SVG_HEIGHT - 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={10}
|
||||||
|
fill={colors.textMuted}
|
||||||
|
fontFamily="Nunito_400Regular"
|
||||||
|
>
|
||||||
|
{day.label}
|
||||||
|
</SvgText>
|
||||||
|
))}
|
||||||
|
</Svg>
|
||||||
|
|
||||||
|
{/* Invisible tap targets per bar */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
flexDirection: 'row',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dailyStats.map((day, i) => (
|
||||||
|
<Pressable
|
||||||
|
key={`tap-${day.date}`}
|
||||||
|
style={{ flex: 1, height: '100%' }}
|
||||||
|
onPress={() => setActiveIdx((prev) => (prev === i ? null : i))}
|
||||||
|
accessibilityLabel={`${day.label}: ${day.count}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
import { View, Text, Pressable, Dimensions, PanResponder, Platform } from 'react-native';
|
import { View, Text, Pressable, TouchableWithoutFeedback, Dimensions, PanResponder, Platform } from 'react-native';
|
||||||
import Svg, { Defs, Pattern, Path, Rect, Polyline, Circle, Line } from 'react-native-svg';
|
import Svg, { Defs, Pattern, Path, Rect, Polyline, Circle, Line } from 'react-native-svg';
|
||||||
import { SvgXml } from 'react-native-svg';
|
import { SvgXml } from 'react-native-svg';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@ -10,7 +10,9 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|||||||
import { memorySvg, snakeSvg, tetrisSvg, tictactoeSvg } from './gameSvgs';
|
import { memorySvg, snakeSvg, tetrisSvg, tictactoeSvg } from './gameSvgs';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
import { GameOverScreen } from '../games/GameOverScreen';
|
import { GameOverScreen } from '../games/GameOverScreen';
|
||||||
|
import { ScoreProgressBar } from '../games/ScoreProgressBar';
|
||||||
import { getBestScore, saveBestScore } from '../../lib/gameScores';
|
import { getBestScore, saveBestScore } from '../../lib/gameScores';
|
||||||
|
import { useSnakeSounds } from '../../hooks/useSnakeSounds';
|
||||||
|
|
||||||
// Haptic helper — fire-and-forget, swallow errors on platforms without taptic engine
|
// Haptic helper — fire-and-forget, swallow errors on platforms without taptic engine
|
||||||
function tapHaptic() {
|
function tapHaptic() {
|
||||||
@ -106,9 +108,13 @@ const OPPOSITES: Record<Dir, Dir> = { up: 'down', down: 'up', left: 'right', rig
|
|||||||
export function SnakeGame({
|
export function SnakeGame({
|
||||||
onComplete,
|
onComplete,
|
||||||
onAbandon,
|
onAbandon,
|
||||||
|
mode = 'standalone',
|
||||||
}: {
|
}: {
|
||||||
onComplete: (score: number) => void;
|
onComplete: (score: number) => void;
|
||||||
onAbandon: () => void;
|
onAbandon: () => void;
|
||||||
|
/** 'sos' = no GameOverScreen, fire onComplete(score) immediately when game ends.
|
||||||
|
* 'standalone' = render GameOverScreen with retry/exit/share. */
|
||||||
|
mode?: 'sos' | 'standalone';
|
||||||
}) {
|
}) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
// Cell size aus Bildschirmgröße — Header(80) + Drawer-Padding(40) + DPad(220) + Spacing(40) + Home-Indicator
|
// Cell size aus Bildschirmgröße — Header(80) + Drawer-Padding(40) + DPad(220) + Spacing(40) + Home-Indicator
|
||||||
@ -138,7 +144,29 @@ export function SnakeGame({
|
|||||||
const [gameOver, setGameOver] = useState(false);
|
const [gameOver, setGameOver] = useState(false);
|
||||||
const [isNewBest, setIsNewBest] = useState(false);
|
const [isNewBest, setIsNewBest] = useState(false);
|
||||||
const [activeDPad, setActiveDPad] = useState<Dir>('right');
|
const [activeDPad, setActiveDPad] = useState<Dir>('right');
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const sounds = useSnakeSounds(true);
|
||||||
|
const newRecordFiredRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameOver) {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000);
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
};
|
||||||
|
}, [gameOver]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameOver && mode === 'sos') {
|
||||||
|
onComplete(score);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [gameOver, mode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getBestScore('snake').then(setHighScore);
|
getBestScore('snake').then(setHighScore);
|
||||||
@ -168,6 +196,9 @@ export function SnakeGame({
|
|||||||
setIsNewBest(true);
|
setIsNewBest(true);
|
||||||
setHighScore(finalScore);
|
setHighScore(finalScore);
|
||||||
saveBestScore('snake', finalScore).catch(() => {});
|
saveBestScore('snake', finalScore).catch(() => {});
|
||||||
|
sounds.playNewRecord();
|
||||||
|
} else {
|
||||||
|
sounds.playGameOver();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,6 +214,8 @@ export function SnakeGame({
|
|||||||
setGameOver(false);
|
setGameOver(false);
|
||||||
setIsNewBest(false);
|
setIsNewBest(false);
|
||||||
setActiveDPad('right');
|
setActiveDPad('right');
|
||||||
|
setElapsed(0);
|
||||||
|
newRecordFiredRef.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game tick loop — single setInterval, side-effects driven via refs (NOT inside reducers).
|
// Game tick loop — single setInterval, side-effects driven via refs (NOT inside reducers).
|
||||||
@ -218,7 +251,18 @@ export function SnakeGame({
|
|||||||
const newFood = randomFood(newSnake);
|
const newFood = randomFood(newSnake);
|
||||||
foodRef.current = newFood;
|
foodRef.current = newFood;
|
||||||
setFood(newFood);
|
setFood(newFood);
|
||||||
setScore((s) => s + 1);
|
setScore((s) => {
|
||||||
|
const next = s + 1;
|
||||||
|
// Record-Pulse genau im Moment des Überschreitens (einmal pro Run)
|
||||||
|
if (highScore > 0 && next > highScore && !newRecordFiredRef.current) {
|
||||||
|
newRecordFiredRef.current = true;
|
||||||
|
setIsNewBest(true);
|
||||||
|
sounds.playNewRecord();
|
||||||
|
} else {
|
||||||
|
sounds.playEat();
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, SNAKE_TICK_MS);
|
}, SNAKE_TICK_MS);
|
||||||
return () => {
|
return () => {
|
||||||
@ -297,13 +341,19 @@ export function SnakeGame({
|
|||||||
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16), position: 'relative' }}>
|
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16), position: 'relative' }}>
|
||||||
{!gameOver && (
|
{!gameOver && (
|
||||||
<>
|
<>
|
||||||
{/* Lyra hint */}
|
<DigitalScore
|
||||||
<View style={{ marginBottom: 8 }}>
|
score={score}
|
||||||
<Text style={{ fontSize: 11, color: '#6b7280' }} numberOfLines={2}>{lyraMessage}</Text>
|
best={highScore}
|
||||||
</View>
|
extra={`${String(Math.floor(elapsed / 60)).padStart(2, '0')}:${String(elapsed % 60).padStart(2, '0')}`}
|
||||||
|
extraLabel="TIME"
|
||||||
{/* Digital score dashboard */}
|
boardWidth={boardW}
|
||||||
<DigitalScore score={score} best={highScore} boardWidth={boardW} />
|
/>
|
||||||
|
<ScoreProgressBar
|
||||||
|
score={score}
|
||||||
|
best={highScore}
|
||||||
|
isNewBest={isNewBest}
|
||||||
|
boardWidth={boardW}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -341,7 +391,7 @@ export function SnakeGame({
|
|||||||
<DPadBtn dir="up" active={activeDPad === 'up'} onPress={() => onDPad('up')} />
|
<DPadBtn dir="up" active={activeDPad === 'up'} onPress={() => onDPad('up')} />
|
||||||
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
|
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
|
||||||
<DPadBtn dir="left" active={activeDPad === 'left'} onPress={() => onDPad('left')} />
|
<DPadBtn dir="left" active={activeDPad === 'left'} onPress={() => onDPad('left')} />
|
||||||
<View style={{ width: 60, height: 60, borderRadius: 30, backgroundColor: 'transparent', alignItems: 'center', justifyContent: 'center' }}>
|
<View style={{ width: 48, height: 48, borderRadius: 24, backgroundColor: 'transparent', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: '#d1d5db' }} />
|
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: '#d1d5db' }} />
|
||||||
</View>
|
</View>
|
||||||
<DPadBtn dir="right" active={activeDPad === 'right'} onPress={() => onDPad('right')} />
|
<DPadBtn dir="right" active={activeDPad === 'right'} onPress={() => onDPad('right')} />
|
||||||
@ -350,7 +400,7 @@ export function SnakeGame({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{gameOver && (
|
{gameOver && mode === 'standalone' && (
|
||||||
<GameOverScreen
|
<GameOverScreen
|
||||||
score={score}
|
score={score}
|
||||||
bestScore={highScore}
|
bestScore={highScore}
|
||||||
@ -370,75 +420,61 @@ function DPadBtn({ dir, active, onPress }: { dir: Dir; active: boolean; onPress:
|
|||||||
const icons: Record<Dir, 'chevron-up' | 'chevron-down' | 'chevron-back' | 'chevron-forward'> = {
|
const icons: Record<Dir, 'chevron-up' | 'chevron-down' | 'chevron-back' | 'chevron-forward'> = {
|
||||||
up: 'chevron-up', down: 'chevron-down', left: 'chevron-back', right: 'chevron-forward',
|
up: 'chevron-up', down: 'chevron-down', left: 'chevron-back', right: 'chevron-forward',
|
||||||
};
|
};
|
||||||
const isIOS = Platform.OS === 'ios';
|
|
||||||
const tint = '#007aff';
|
const tint = '#007aff';
|
||||||
|
// Hard rule (siehe docs/internal/RECOVERY_LOG_2026-05-10.md §7.2):
|
||||||
|
// KEINE Pressable mit style-Funktion {({pressed}) => ...} — RN-Quirk schluckt
|
||||||
|
// Background-Properties manchmal. Stattdessen: TouchableWithoutFeedback + View
|
||||||
|
// mit static style. Visual-Active-State über `active`-Prop (nicht press-state).
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<TouchableWithoutFeedback
|
||||||
onPress={() => { tapHaptic(); onPress(); }}
|
onPress={() => { tapHaptic(); onPress(); }}
|
||||||
hitSlop={12}
|
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||||
android_ripple={{ color: 'rgba(0,122,255,0.22)', borderless: true, radius: 32 }}
|
>
|
||||||
style={({ pressed }) => {
|
<View
|
||||||
const bgIdle = 'rgba(0,122,255,0.10)';
|
style={{
|
||||||
const bgPressed = 'rgba(0,122,255,0.22)';
|
width: 48, height: 48, borderRadius: 24,
|
||||||
const bgActive = 'rgba(0,122,255,0.22)';
|
backgroundColor: active ? 'rgba(0,122,255,0.32)' : 'rgba(0,122,255,0.20)',
|
||||||
const bg = active ? bgActive : pressed ? bgPressed : bgIdle;
|
borderWidth: 1,
|
||||||
return {
|
borderColor: active ? tint : 'rgba(0,122,255,0.25)',
|
||||||
width: 60, height: 60, borderRadius: 30,
|
|
||||||
backgroundColor: bg,
|
|
||||||
borderWidth: 1.5,
|
|
||||||
borderColor: active ? tint : 'rgba(0,122,255,0.30)',
|
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
transform: [{ scale: pressed ? 0.96 : active ? 1.04 : 1 }],
|
|
||||||
};
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons name={icons[dir]} size={22} color={tint} />
|
||||||
name={icons[dir]}
|
</View>
|
||||||
size={28}
|
</TouchableWithoutFeedback>
|
||||||
color={tint}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action button für Tetris (Rotate, Drop) — größer & mit Label.
|
// Action button für Tetris (Rotate, Drop) — größer & mit Label.
|
||||||
// Idle: tönt sich leicht in accent-Farbe ein (kein white-on-white-Verlust auf weißem Screen).
|
// Idle: tönt sich leicht in accent-Farbe ein (kein white-on-white-Verlust auf weißem Screen).
|
||||||
function TetrisActionBtn({
|
function TetrisActionBtn({
|
||||||
icon, label, onPress, accent,
|
icon, label, onPress,
|
||||||
}: {
|
}: {
|
||||||
icon: 'sync' | 'arrow-down';
|
icon: 'sync' | 'arrow-down';
|
||||||
label: string;
|
label: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
accent?: string;
|
accent?: string; // ignored — vereinheitlicht auf iOS-blau
|
||||||
}) {
|
}) {
|
||||||
const accentColor = accent || '#1f2937';
|
// Hard rule (siehe RECOVERY_LOG §7.2): kein Pressable mit style-Funktion.
|
||||||
|
const tint = '#007AFF';
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<TouchableWithoutFeedback
|
||||||
onPress={() => { mediumHaptic(); onPress(); }}
|
onPress={() => { mediumHaptic(); onPress(); }}
|
||||||
hitSlop={12}
|
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||||
android_ripple={{ color: accentColor + '33', borderless: false }}
|
|
||||||
style={({ pressed }) => ({
|
|
||||||
width: 72, height: 72, borderRadius: 20,
|
|
||||||
// accent + '14' = ~8% Tönung im Idle-State, accent solid auf Press
|
|
||||||
backgroundColor: pressed ? accentColor : accentColor + '14',
|
|
||||||
borderWidth: 1.5,
|
|
||||||
borderColor: accentColor,
|
|
||||||
alignItems: 'center', justifyContent: 'center',
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: pressed ? 0.05 : 0.12,
|
|
||||||
shadowRadius: 5,
|
|
||||||
elevation: pressed ? 1 : 3,
|
|
||||||
transform: [{ scale: pressed ? 0.95 : 1 }],
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{({ pressed }) => (
|
<View
|
||||||
<>
|
style={{
|
||||||
<Ionicons name={icon} size={26} color={pressed ? '#ffffff' : accentColor} />
|
width: 56, height: 56, borderRadius: 16,
|
||||||
<Text style={{ fontSize: 10, marginTop: 2, fontFamily: 'Nunito_700Bold', color: pressed ? '#ffffff' : accentColor }}>{label}</Text>
|
backgroundColor: 'rgba(0,122,255,0.20)',
|
||||||
</>
|
borderWidth: 1,
|
||||||
)}
|
borderColor: 'rgba(0,122,255,0.25)',
|
||||||
</Pressable>
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={22} color={tint} />
|
||||||
|
<Text style={{ fontSize: 9, marginTop: 1, fontFamily: 'Nunito_700Bold', color: tint }}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -452,9 +488,11 @@ const MEMORY_EMOJIS = ['🛡️', '💪', '🌟', '🧠', '🌊', '🎯', '🌱'
|
|||||||
export function MemoryGame({
|
export function MemoryGame({
|
||||||
onComplete,
|
onComplete,
|
||||||
onAbandon,
|
onAbandon,
|
||||||
|
mode = 'standalone',
|
||||||
}: {
|
}: {
|
||||||
onComplete: (score: number) => void;
|
onComplete: (score: number) => void;
|
||||||
onAbandon: () => void;
|
onAbandon: () => void;
|
||||||
|
mode?: 'sos' | 'standalone';
|
||||||
}) {
|
}) {
|
||||||
type Card = { id: number; emoji: string; matched: boolean; revealed: boolean };
|
type Card = { id: number; emoji: string; matched: boolean; revealed: boolean };
|
||||||
const [cards, setCards] = useState<Card[]>([]);
|
const [cards, setCards] = useState<Card[]>([]);
|
||||||
@ -468,6 +506,14 @@ export function MemoryGame({
|
|||||||
|
|
||||||
useEffect(() => { getBestScore('memory').then(setBestMoves); }, []);
|
useEffect(() => { getBestScore('memory').then(setBestMoves); }, []);
|
||||||
|
|
||||||
|
// SOS-Mode: kein GameOverScreen, sofort onComplete(score) feuern
|
||||||
|
useEffect(() => {
|
||||||
|
if (showGameOver && mode === 'sos') {
|
||||||
|
onComplete(moveCount);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [showGameOver, mode]);
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
const pairs = shuffle([...MEMORY_EMOJIS, ...MEMORY_EMOJIS]);
|
const pairs = shuffle([...MEMORY_EMOJIS, ...MEMORY_EMOJIS]);
|
||||||
setCards(pairs.map((emoji, id) => ({ id, emoji, matched: false, revealed: false })));
|
setCards(pairs.map((emoji, id) => ({ id, emoji, matched: false, revealed: false })));
|
||||||
@ -579,7 +625,7 @@ export function MemoryGame({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
{showGameOver && (
|
{showGameOver && mode === 'standalone' && (
|
||||||
<GameOverScreen
|
<GameOverScreen
|
||||||
score={moveCount}
|
score={moveCount}
|
||||||
bestScore={bestMoves}
|
bestScore={bestMoves}
|
||||||
@ -632,9 +678,11 @@ function ttLyraAI(b: TTCell[]): number {
|
|||||||
export function TicTacToeGame({
|
export function TicTacToeGame({
|
||||||
onComplete,
|
onComplete,
|
||||||
onAbandon,
|
onAbandon,
|
||||||
|
mode = 'standalone',
|
||||||
}: {
|
}: {
|
||||||
onComplete: (score: number) => void;
|
onComplete: (score: number) => void;
|
||||||
onAbandon: () => void;
|
onAbandon: () => void;
|
||||||
|
mode?: 'sos' | 'standalone';
|
||||||
}) {
|
}) {
|
||||||
const [board, setBoard] = useState<TTCell[]>(Array(9).fill(null));
|
const [board, setBoard] = useState<TTCell[]>(Array(9).fill(null));
|
||||||
const [gameOver, setGameOver] = useState(false);
|
const [gameOver, setGameOver] = useState(false);
|
||||||
@ -805,9 +853,11 @@ function tetrisRotate(shape: number[][]) {
|
|||||||
export function TetrisGame({
|
export function TetrisGame({
|
||||||
onComplete,
|
onComplete,
|
||||||
onAbandon,
|
onAbandon,
|
||||||
|
mode = 'standalone',
|
||||||
}: {
|
}: {
|
||||||
onComplete: (score: number) => void;
|
onComplete: (score: number) => void;
|
||||||
onAbandon: () => void;
|
onAbandon: () => void;
|
||||||
|
mode?: 'sos' | 'standalone';
|
||||||
}) {
|
}) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
// CELL aus Bildschirmgröße — Header(80) + Padding(40) + Speed-Stepper(50) + Controls(110) + Spacing(20) + Home-Indicator
|
// CELL aus Bildschirmgröße — Header(80) + Padding(40) + Speed-Stepper(50) + Controls(110) + Spacing(20) + Home-Indicator
|
||||||
@ -832,6 +882,14 @@ export function TetrisGame({
|
|||||||
const [speedLevel, setSpeedLevel] = useState(3);
|
const [speedLevel, setSpeedLevel] = useState(3);
|
||||||
const tickTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const tickTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// SOS-Mode: kein GameOverScreen, sofort onComplete(score) feuern
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameOver && mode === 'sos') {
|
||||||
|
onComplete(score);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [gameOver, mode]);
|
||||||
|
|
||||||
const boardRef = useRef(board);
|
const boardRef = useRef(board);
|
||||||
const currentRef = useRef(current);
|
const currentRef = useRef(current);
|
||||||
useEffect(() => { boardRef.current = board; }, [board]);
|
useEffect(() => { boardRef.current = board; }, [board]);
|
||||||
@ -1017,15 +1075,7 @@ export function TetrisGame({
|
|||||||
return (
|
return (
|
||||||
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16), position: 'relative' }}>
|
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16), position: 'relative' }}>
|
||||||
{!gameOver && (
|
{!gameOver && (
|
||||||
<>
|
|
||||||
{/* Lyra hint */}
|
|
||||||
<View style={{ marginBottom: 8 }}>
|
|
||||||
<Text style={{ fontSize: 11, color: '#6b7280' }} numberOfLines={2}>{lyraMessage}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Digital score dashboard */}
|
|
||||||
<DigitalScore score={score} best={highScore} extra={level} extraLabel="LVL" boardWidth={boardWidth} />
|
<DigitalScore score={score} best={highScore} extra={level} extraLabel="LVL" boardWidth={boardWidth} />
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Board */}
|
{/* Board */}
|
||||||
@ -1085,28 +1135,18 @@ export function TetrisGame({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Controls — aligned to board width, centered on screen */}
|
{/* Controls — alle 4 Buttons zentriert in einer Reihe (besser thumb-reachable
|
||||||
|
als links/rechts gespalten am Board-Rand). */}
|
||||||
<View style={{ alignItems: 'center', marginTop: 18 }}>
|
<View style={{ alignItems: 'center', marginTop: 18 }}>
|
||||||
<View style={{
|
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
width: boardWidth,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}>
|
|
||||||
{/* Move Pad */}
|
|
||||||
<View style={{ flexDirection: 'row', gap: 14 }}>
|
|
||||||
<DPadBtn dir="left" active={false} onPress={moveLeft} />
|
<DPadBtn dir="left" active={false} onPress={moveLeft} />
|
||||||
|
<TetrisActionBtn icon="sync" label="Drehen" onPress={rotatePiece} />
|
||||||
|
<TetrisActionBtn icon="arrow-down" label="Drop" onPress={softDrop} />
|
||||||
<DPadBtn dir="right" active={false} onPress={moveRight} />
|
<DPadBtn dir="right" active={false} onPress={moveRight} />
|
||||||
</View>
|
</View>
|
||||||
{/* Action Pad */}
|
|
||||||
<View style={{ flexDirection: 'row', gap: 14 }}>
|
|
||||||
<TetrisActionBtn icon="sync" label="Drehen" accent="#7c3aed" onPress={rotatePiece} />
|
|
||||||
<TetrisActionBtn icon="arrow-down" label="Drop" accent="#0ea5e9" onPress={softDrop} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{gameOver && (
|
{gameOver && mode === 'standalone' && (
|
||||||
<GameOverScreen
|
<GameOverScreen
|
||||||
score={score}
|
score={score}
|
||||||
bestScore={highScore}
|
bestScore={highScore}
|
||||||
@ -1141,11 +1181,13 @@ function DigitalScore({
|
|||||||
}: {
|
}: {
|
||||||
score: number;
|
score: number;
|
||||||
best: number;
|
best: number;
|
||||||
extra?: number;
|
/** Number → zero-padded to 2 digits. String → rendered as-is (e.g. "01:23" for time). */
|
||||||
|
extra?: number | string;
|
||||||
extraLabel?: string;
|
extraLabel?: string;
|
||||||
boardWidth: number;
|
boardWidth: number;
|
||||||
}) {
|
}) {
|
||||||
const fmt = (n: number, digits = 5) => String(n).padStart(digits, '0');
|
const fmt = (n: number, digits = 5) => String(n).padStart(digits, '0');
|
||||||
|
const extraDisplay = typeof extra === 'string' ? extra : extra !== undefined ? fmt(extra, 2) : '';
|
||||||
return (
|
return (
|
||||||
<View style={{
|
<View style={{
|
||||||
width: boardWidth,
|
width: boardWidth,
|
||||||
@ -1167,7 +1209,7 @@ function DigitalScore({
|
|||||||
{extra !== undefined && extraLabel !== undefined && (
|
{extra !== undefined && extraLabel !== undefined && (
|
||||||
<>
|
<>
|
||||||
<View style={{ width: 1, height: 28, backgroundColor: '#1f2937' }} />
|
<View style={{ width: 1, height: 28, backgroundColor: '#1f2937' }} />
|
||||||
<ScoreCell label={extraLabel} value={fmt(extra, 2)} />
|
<ScoreCell label={extraLabel} value={extraDisplay} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
125
apps/rebreak-native/docs/internal/PLAY_STORE_LISTING.md
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
# Play Store Listing — ReBreak
|
||||||
|
|
||||||
|
Status: DRAFT — Version 0.1.0 (Internal Testing)
|
||||||
|
Letzte Aktualisierung: 2026-05-09
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kurztitel (30 Zeichen max)
|
||||||
|
|
||||||
|
ReBreak
|
||||||
|
|
||||||
|
## Untertitel / Tagline (80 Zeichen max, Play-Card)
|
||||||
|
|
||||||
|
Rückfallprävention mit KI-Begleitung und digitalem Schutz.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kurzbeschreibung (80 Zeichen max, Play-Card)
|
||||||
|
|
||||||
|
KI-Begleiterin Lyra, Streak-Tracking und aktiver Digitalschutz gegen Rückfälle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lange Beschreibung (max 4000 Zeichen)
|
||||||
|
|
||||||
|
ReBreak ist eine Rückfallpräventions-App für Menschen, die ihre Abhängigkeit von Glücksspiel oder anderen impulsiven Verhaltensweisen überwinden wollen.
|
||||||
|
|
||||||
|
**Lyra — deine KI-Begleiterin**
|
||||||
|
Lyra ist ein empathischer KI-Coach, der dir rund um die Uhr zur Seite steht. In akuten Krisenmomenten (SOS-Modus) gibt Lyra strukturierte Gesprächsbegleitung, Atemübungen und kognitive Umstrukturierungshilfen — ohne Wartezeiten, ohne Termin.
|
||||||
|
|
||||||
|
**Streak & Fortschritt**
|
||||||
|
Dein täglicher Streak zeigt dir deinen abstinenten Weg. Jeder Tag ohne Rückfall wird sichtbar gemacht — als Motivation, nicht als Kontrolle.
|
||||||
|
|
||||||
|
**Aktiver Digitalschutz**
|
||||||
|
ReBreak kann Zugang zu Glücksspiel-Websites und -Apps auf deinem Gerät blockieren. Der Schutz läuft lokal auf deinem Gerät — keine Daten verlassen dein Telefon.
|
||||||
|
|
||||||
|
**Anonymität**
|
||||||
|
Kein Klarname. Kein öffentliches Profil. Du bist nur mit deinem selbst gewählten Nickname sichtbar.
|
||||||
|
|
||||||
|
**Datenschutz**
|
||||||
|
ReBreak erfüllt die Anforderungen der deutschen Datenschutz-Grundverordnung (DSGVO). Alle Gespräche mit Lyra bleiben privat. Demografische Gesundheitsdaten (optional, für DiGA-Nachweisbarkeit) werden strukturiert und getrennt von Gesprächen gespeichert.
|
||||||
|
|
||||||
|
**Für wen ist ReBreak?**
|
||||||
|
- Menschen mit Glücksspielstörung (F63.0 ICD-10), die ambulante Unterstützung suchen
|
||||||
|
- Angehörige, die einen sicheren Kanal zur Begleitung suchen
|
||||||
|
- Personen, die eine digitale Ergänzung zu Therapie oder Selbsthilfegruppen wünschen
|
||||||
|
|
||||||
|
ReBreak ist kein Ersatz für professionelle Behandlung. Bei akuter Krise: Telefonseelsorge 0800 111 0 111 (kostenlos, 24/7).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Berechtigungen — Begründung für Review-Team
|
||||||
|
|
||||||
|
### BIND_ACCESSIBILITY_SERVICE
|
||||||
|
|
||||||
|
**Warum benötigt:**
|
||||||
|
ReBreak nutzt den Android Accessibility Service ausschließlich dazu, Glücksspiel-Apps zu erkennen, wenn sie in den Vordergrund gebracht werden, und diese sofort mit einem Sicherheitsbildschirm zu überblenden.
|
||||||
|
|
||||||
|
Der Service liest keine Texte, keine Passwörter, keine persönlichen Eingaben. Er reagiert ausschließlich auf `TYPE_WINDOW_STATE_CHANGED`-Events und prüft den Paketnamen der aktiven App gegen eine lokal gespeicherte Blockliste.
|
||||||
|
|
||||||
|
**Es findet keine Datenübertragung statt.** Kein Keylogging. Kein Screen-Recording. Kein Remote-Access.
|
||||||
|
|
||||||
|
Dies ist die einzige technisch verlässliche Methode, um auf Android einen App-Blocker zu implementieren, der nicht durch minimieren/wechseln umgangen werden kann.
|
||||||
|
|
||||||
|
Vergleichbare Apps mit gleicher Begründung: BlockSite, StayFree, AppBlock.
|
||||||
|
|
||||||
|
### BIND_VPN_SERVICE
|
||||||
|
|
||||||
|
**Warum benötigt:**
|
||||||
|
ReBreak nutzt den VPN-Service ausschließlich als lokales DNS-Filter — keine Verbindung zu externen VPN-Servern.
|
||||||
|
|
||||||
|
Alle DNS-Anfragen werden lokal auf dem Gerät abgefangen. Anfragen an bekannte Glücksspiel-Domains werden auf `0.0.0.0` umgeleitet (blockiert). Alle anderen DNS-Anfragen werden unverändert an den Standard-DNS-Resolver des Geräts weitergegeben.
|
||||||
|
|
||||||
|
**Kein Traffic verlässt das Gerät über diesen Service.** Kein Logging von Webseitenbesuchen außerhalb der Blockliste. Kein Remote-Server involviert.
|
||||||
|
|
||||||
|
Technische Alternative (um VPN zu vermeiden) existiert auf Android nicht: `hosts`-Datei-Modifikation erfordert Root-Zugriff; Private-DNS-Override erfordert Android 9+ und schützt nicht gegen App-basierte Anfragen.
|
||||||
|
|
||||||
|
### FOREGROUND_SERVICE
|
||||||
|
|
||||||
|
Wird benötigt, damit der Schutz-Service (DNS-Filter + App-Blocker) auch dann aktiv bleibt, wenn ReBreak selbst in den Hintergrund tritt. Ohne Foreground-Service würde Android den Service nach wenigen Minuten beenden — der Schutz wäre damit wirkungslos.
|
||||||
|
|
||||||
|
### POST_NOTIFICATIONS
|
||||||
|
|
||||||
|
Für Recovery-Erinnerungen, Streak-Meilensteine und Lyra-Nachrichten. Alle Notification-Typen sind in den App-Einstellungen einzeln deaktivierbar.
|
||||||
|
|
||||||
|
### RECORD_AUDIO
|
||||||
|
|
||||||
|
Für die Sprach-Eingabe im Lyra-Chat (SOS-Modus). Mikrofon wird ausschließlich aktiviert, wenn der User manuell die Spracheingabe startet. Kein Hintergrund-Recording.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots — Shotlist (8 Frames)
|
||||||
|
|
||||||
|
Alle Screenshots auf echtem iPhone Air / Pixel-Gerät (kein Simulator).
|
||||||
|
Format: 1290x2796 px (iPhone 15 Pro Max) + 1080x1920 px (Android).
|
||||||
|
|
||||||
|
| # | Screen | Beschreibung |
|
||||||
|
|---|--------|--------------|
|
||||||
|
| 1 | Hero / Homescreen | Streak-Anzeige, Tag-Zähler, Lyra-Avatar prominent. Tageslicht-Theme. |
|
||||||
|
| 2 | SOS-Modus | Lyra-Chat aktiv, Eingabefeld, Atemübungs-Card. Zeigt: "Du bist nicht allein." |
|
||||||
|
| 3 | Streak-Kalender | Monatsansicht mit Streak-Markierungen. |
|
||||||
|
| 4 | Blocker aktiv | Overlay wenn Glücksspiel-App erkannt: "ReBreak schützt dich." + Entsperr-Button. |
|
||||||
|
| 5 | Mail-Schutz | Postfach-Blocking-Screen: Werbemails werden abgeschirmt. |
|
||||||
|
| 6 | Lyra-Sprachmode | Mikrofon-Button aktiv, Sprechblase mit transkribierter Antwort. |
|
||||||
|
| 7 | Profil-Seite | Nickname, Streak-Stats, optional: Fortschritts-Ringe. Kein Klarname sichtbar. |
|
||||||
|
| 8 | Blocker-Einstellungen | Liste blockierter Apps + Domains. Toggle pro Kategorie. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App-Kategorie (Play Console)
|
||||||
|
|
||||||
|
Primary: Health & Fitness
|
||||||
|
Secondary: Medical
|
||||||
|
|
||||||
|
## Content-Rating
|
||||||
|
|
||||||
|
USK: 12 (Thema Glücksspiel-Prävention; kein Glücksspiel-Inhalt selbst)
|
||||||
|
Play-IARC-Fragebogen: keine Gewalt, kein Glücksspiel in der App, kein User-Generated-Content (kein öffentliches Forum)
|
||||||
|
|
||||||
|
## Datenschutzerklärung-URL (PFLICHT)
|
||||||
|
|
||||||
|
https://rebreak.org/privacy-policy
|
||||||
|
|
||||||
|
STATUS: 401 — URL nicht erreichbar (2026-05-09). Muss vor Submission live sein.
|
||||||
|
Zustaendig: Hans-Müller (DSB), rebreak-ops fuer Deployment.
|
||||||
54
apps/rebreak-native/eas.json
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 5.0.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal",
|
||||||
|
"ios": {
|
||||||
|
"simulator": false
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"ios": {
|
||||||
|
"resourceClass": "m-medium",
|
||||||
|
"autoIncrement": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk",
|
||||||
|
"autoIncrement": true
|
||||||
|
},
|
||||||
|
"channel": "preview"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"resourceClass": "m-medium",
|
||||||
|
"autoIncrement": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"buildType": "app-bundle",
|
||||||
|
"autoIncrement": true
|
||||||
|
},
|
||||||
|
"channel": "production"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"appleId": "tunisie@hotmail.de",
|
||||||
|
"ascAppId": "6762027467",
|
||||||
|
"appleTeamId": "84BQ7MTFYK"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"serviceAccountKeyPath": "<USER: lokaler Pfad zu Google-Cloud-Service-Account-JSON, z.B. ~/secrets/rebreak-play-service-account.json>",
|
||||||
|
"track": "internal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
apps/rebreak-native/hooks/useKeyboardHeight.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Keyboard, Platform } from 'react-native';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die aktuelle Keyboard-Höhe in px (0 wenn versteckt).
|
||||||
|
*
|
||||||
|
* Pattern aus components/PostCommentsSheet.tsx — iOS nutzt `keyboardWillShow`
|
||||||
|
* für glatte Animation, Android `keyboardDidShow` weil iOS-Will-Events dort nicht feuern.
|
||||||
|
*
|
||||||
|
* Für Standard-Forms reicht `<KeyboardAdjustedView>` (das nutzt diesen Hook intern).
|
||||||
|
* Direkten Hook-Zugriff nur wenn man die Höhe selbst irgendwo einrechnen muss
|
||||||
|
* (z.B. SOS-Chat mit FlatList + Input-Bar im selben Layout).
|
||||||
|
*/
|
||||||
|
export function useKeyboardHeight(): number {
|
||||||
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||||
|
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||||
|
const showSub = Keyboard.addListener(showEvent, (e) => {
|
||||||
|
setKeyboardHeight(e.endCoordinates.height);
|
||||||
|
});
|
||||||
|
const hideSub = Keyboard.addListener(hideEvent, () => {
|
||||||
|
setKeyboardHeight(0);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
showSub.remove();
|
||||||
|
hideSub.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return keyboardHeight;
|
||||||
|
}
|
||||||
@ -13,6 +13,9 @@ export type MailAccount = {
|
|||||||
totalScanned: number;
|
totalScanned: number;
|
||||||
scanInterval: number;
|
scanInterval: number;
|
||||||
blockRate: number;
|
blockRate: number;
|
||||||
|
lastConnectError?: string | null;
|
||||||
|
lastConnectErrorAt?: string | null;
|
||||||
|
lastIdleHeartbeatAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DailyStat = {
|
export type DailyStat = {
|
||||||
|
|||||||
@ -8,6 +8,10 @@ export type Plan = 'free' | 'pro' | 'legend';
|
|||||||
* `auth.users` mit `rebreak.profiles` server-side — wir bekommen alles in
|
* `auth.users` mit `rebreak.profiles` server-side — wir bekommen alles in
|
||||||
* einem Request: plan, avatar, nickname, streak.
|
* einem Request: plan, avatar, nickname, streak.
|
||||||
*
|
*
|
||||||
|
* Live-Update-Pattern (siehe RECOVERY_LOG): nach Profile-Edit (PATCH /api/auth/me)
|
||||||
|
* MUSS `invalidateMe()` aufgerufen werden — alle useMe-Konsumenten (AppHeader,
|
||||||
|
* PostCard, ComposeCard, etc.) re-fetchen automatisch via Listener-Subscribe.
|
||||||
|
*
|
||||||
* WICHTIG: nicht aus `supabase.auth.getUser().user_metadata` lesen — das
|
* WICHTIG: nicht aus `supabase.auth.getUser().user_metadata` lesen — das
|
||||||
* sind nur die JWT-Claims vom Signup-Zeitpunkt, NICHT der aktuelle Profile-
|
* sind nur die JWT-Claims vom Signup-Zeitpunkt, NICHT der aktuelle Profile-
|
||||||
* Stand (Avatar/Nickname/Plan werden via Profile-Edit-API geupdated, landen
|
* Stand (Avatar/Nickname/Plan werden via Profile-Edit-API geupdated, landen
|
||||||
@ -25,15 +29,40 @@ export type Me = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let cachedMe: Me | null = null;
|
let cachedMe: Me | null = null;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt /api/auth/me neu und benachrichtigt ALLE useMe-Konsumenten in der App.
|
||||||
|
* Nach jedem PATCH /api/auth/me aufrufen — sonst sehen Konsumenten alten Cache.
|
||||||
|
*/
|
||||||
|
export function invalidateMe(): void {
|
||||||
|
cachedMe = null;
|
||||||
|
for (const cb of listeners) cb();
|
||||||
|
}
|
||||||
|
|
||||||
export function useMe(): { me: Me | null; loading: boolean; reload: () => void } {
|
export function useMe(): { me: Me | null; loading: boolean; reload: () => void } {
|
||||||
const [me, setMe] = useState<Me | null>(cachedMe);
|
const [me, setMe] = useState<Me | null>(cachedMe);
|
||||||
const [loading, setLoading] = useState(cachedMe === null);
|
const [loading, setLoading] = useState(cachedMe === null);
|
||||||
const [version, setVersion] = useState(0);
|
const [version, setVersion] = useState(0);
|
||||||
|
|
||||||
|
// Auf globale Invalidierung lauschen (Avatar-/Nickname-Update aus Profile-Edit)
|
||||||
|
useEffect(() => {
|
||||||
|
const cb = () => setVersion((v) => v + 1);
|
||||||
|
listeners.add(cb);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(cb);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
|
// Falls cache schon frisch ist (von anderem Konsumenten gerade geladen): nutzen
|
||||||
|
if (cachedMe !== null) {
|
||||||
|
setMe(cachedMe);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<Me>('/api/auth/me');
|
const res = await apiFetch<Me>('/api/auth/me');
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@ -54,9 +83,7 @@ export function useMe(): { me: Me | null; loading: boolean; reload: () => void }
|
|||||||
me,
|
me,
|
||||||
loading,
|
loading,
|
||||||
reload: () => {
|
reload: () => {
|
||||||
cachedMe = null;
|
invalidateMe();
|
||||||
setLoading(true);
|
|
||||||
setVersion((v) => v + 1);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
101
apps/rebreak-native/hooks/useSheetKeyboardLift.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Animated, Easing, Platform } from 'react-native';
|
||||||
|
import { useKeyboardHeight } from './useKeyboardHeight';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-weite Composable für Sheets/Modals mit TextInput.
|
||||||
|
*
|
||||||
|
* Liefert ein **kombiniertes Animated.Value** für `transform: [{ translateY }]`,
|
||||||
|
* das gleichzeitig:
|
||||||
|
* - die Slide-In/Out-Animation des Sheets ausführt (von unten reinkommend)
|
||||||
|
* - das Sheet automatisch über die Tastatur lifted wenn TextInput fokussiert
|
||||||
|
*
|
||||||
|
* Beide Animationen laufen im **native driver** (Performance + smoother als
|
||||||
|
* height-Animationen). Kein Driver-Mix, kein Bouncing.
|
||||||
|
*
|
||||||
|
* Pattern (verifiziert auf EditMailAccountSheet + GameOverScreen):
|
||||||
|
* ```tsx
|
||||||
|
* const sheetH = SCREEN_HEIGHT * 0.5;
|
||||||
|
* const lift = useSheetKeyboardLift({ visible, offscreenY: sheetH });
|
||||||
|
*
|
||||||
|
* <Modal visible={visible}>
|
||||||
|
* <Animated.View
|
||||||
|
* style={{
|
||||||
|
* position: 'absolute',
|
||||||
|
* bottom: 0,
|
||||||
|
* height: sheetH,
|
||||||
|
* transform: [{ translateY: lift.translateY }],
|
||||||
|
* }}
|
||||||
|
* >
|
||||||
|
* {form content with TextInput}
|
||||||
|
* </Animated.View>
|
||||||
|
* </Modal>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Anti-Pattern (was schief ging): `height: animatedValue` + `transform: animatedValue`
|
||||||
|
* auf demselben Animated.View → native-animated-module-Crash. Stattdessen feste
|
||||||
|
* height + nur translateY animieren.
|
||||||
|
*
|
||||||
|
* Anti-Pattern 2: `marginBottom: keyboardHeight` als JS-style + native transform
|
||||||
|
* im selben View → Bouncing weil zwei verschiedene Threads layouten.
|
||||||
|
*
|
||||||
|
* Für FlatList-basierte Sheets (PostCommentsSheet) ist das Pattern anders:
|
||||||
|
* dort wächst die Sheet-Höhe selbst weil eine variable Liste drin ist. Diese
|
||||||
|
* Composable ist für FIXED-HEIGHT-Form-Sheets gedacht.
|
||||||
|
*/
|
||||||
|
export interface SheetKeyboardLiftOptions {
|
||||||
|
/** Ob das Sheet aktuell sichtbar ist. Nur dann läuft Slide-In an. */
|
||||||
|
visible: boolean;
|
||||||
|
/** Y-Offset des Sheets im verborgenen Zustand (typischerweise = SHEET_HEIGHT). */
|
||||||
|
offscreenY: number;
|
||||||
|
/** Slide-Dauer in ms. Default 280. */
|
||||||
|
slideDurationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSheetKeyboardLift({
|
||||||
|
visible,
|
||||||
|
offscreenY,
|
||||||
|
slideDurationMs = 280,
|
||||||
|
}: SheetKeyboardLiftOptions) {
|
||||||
|
const keyboardHeight = useKeyboardHeight();
|
||||||
|
const slideY = useRef(new Animated.Value(offscreenY)).current;
|
||||||
|
const keyboardLift = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
// Slide-In bei visible-Wechsel
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
slideY.setValue(offscreenY);
|
||||||
|
Animated.timing(slideY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: slideDurationMs,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
}, [visible, offscreenY, slideDurationMs, slideY]);
|
||||||
|
|
||||||
|
// Keyboard-Lift (iOS only — Android adjustResize macht das im Manifest)
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS !== 'ios') return;
|
||||||
|
Animated.timing(keyboardLift, {
|
||||||
|
toValue: keyboardHeight,
|
||||||
|
duration: 220,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}, [keyboardHeight, keyboardLift]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Direkt in `transform: [{ translateY }]` einsetzen. */
|
||||||
|
translateY: Animated.subtract(slideY, keyboardLift),
|
||||||
|
/** Manuelle Slide-Out-Animation (z.B. beim Close-Tap statt nur visible=false). */
|
||||||
|
slideOut: (cb?: () => void) =>
|
||||||
|
Animated.timing(slideY, {
|
||||||
|
toValue: offscreenY,
|
||||||
|
duration: 220,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => cb?.()),
|
||||||
|
/** Live keyboard-Höhe für extra Layout-Berechnungen wenn nötig. */
|
||||||
|
keyboardHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
78
apps/rebreak-native/hooks/useSnakeSounds.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snake-Sound + Haptic-Helper.
|
||||||
|
*
|
||||||
|
* Aktuell: nur Haptics (Apple Taptic-Engine, Android-Vibrator-Falls-Available).
|
||||||
|
* Funktioniert SOFORT ohne weitere Setup-Schritte.
|
||||||
|
*
|
||||||
|
* UPGRADE-PFAD zu echtem 8-Bit-Retro-Sound:
|
||||||
|
*
|
||||||
|
* 1. 4 kurze Audio-Files in `apps/rebreak-native/assets/sounds/` ablegen:
|
||||||
|
* - `snake-eat.mp3` — Apple-Pickup, ~80ms, tonale "blip"-Töne (chiptune)
|
||||||
|
* - `snake-move.mp3` — Optional, Tick-Sound bei jeder Bewegung, ~30ms, dezent
|
||||||
|
* - `snake-gameover.mp3` — Death, ~400ms, abfallende Töne
|
||||||
|
* - `snake-record.mp3` — New-Record, ~600ms, aufsteigender Chime
|
||||||
|
*
|
||||||
|
* Free-Quellen (CC0): freesound.org, opengameart.org/content/8-bit-sound-pack,
|
||||||
|
* sfxr.me (in-browser-Generator für klassische 8-Bit-Sounds).
|
||||||
|
*
|
||||||
|
* 2. `expo-av` (oder `expo-audio` nach SDK-54-Migration) installieren falls nicht da:
|
||||||
|
* `pnpm add expo-av` (im rebreak-native-Workspace)
|
||||||
|
*
|
||||||
|
* 3. In dieser Datei oben einfügen:
|
||||||
|
* ```ts
|
||||||
|
* import { Audio } from 'expo-av';
|
||||||
|
* const eatSrc = require('../assets/sounds/snake-eat.mp3');
|
||||||
|
* const moveSrc = require('../assets/sounds/snake-move.mp3');
|
||||||
|
* const gameoverSrc = require('../assets/sounds/snake-gameover.mp3');
|
||||||
|
* const recordSrc = require('../assets/sounds/snake-record.mp3');
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 4. Im Hook-useEffect die Sounds preloaden:
|
||||||
|
* ```ts
|
||||||
|
* Audio.Sound.createAsync(eatSrc, { volume: 0.5 }).then((r) => (eatRef.current = r.sound));
|
||||||
|
* // … analog für alle drei
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 5. In den `play*`-Funktionen `await ref.current?.replayAsync()` aufrufen.
|
||||||
|
*
|
||||||
|
* Wenn die Files fehlen aber expo-av da ist: keine Crashes — die createAsync-Calls
|
||||||
|
* fangen den Error und der Hook läuft im Haptic-only-Mode weiter.
|
||||||
|
*/
|
||||||
|
export function useSnakeSounds(enabled: boolean = true) {
|
||||||
|
const enabledRef = useRef(enabled);
|
||||||
|
useEffect(() => {
|
||||||
|
enabledRef.current = enabled;
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// Cleanup: bei späterer Audio-Integration unloadAsync() für alle Sounds.
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
playEat: () => {
|
||||||
|
if (!enabledRef.current) return;
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||||
|
// TODO Audio: eatRef.current?.replayAsync().catch(() => {});
|
||||||
|
},
|
||||||
|
playMove: () => {
|
||||||
|
// Bewusst leer — sonst zu viel Vibration bei jedem Tick.
|
||||||
|
// Nur via Audio (subtiler als Haptic).
|
||||||
|
// TODO Audio: moveRef.current?.replayAsync().catch(() => {});
|
||||||
|
},
|
||||||
|
playGameOver: () => {
|
||||||
|
if (!enabledRef.current) return;
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
|
||||||
|
// TODO Audio: gameoverRef.current?.replayAsync().catch(() => {});
|
||||||
|
},
|
||||||
|
playNewRecord: () => {
|
||||||
|
if (!enabledRef.current) return;
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||||
|
// TODO Audio: recordRef.current?.replayAsync().catch(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import * as Application from 'expo-application';
|
import * as Application from 'expo-application';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
|
||||||
const STORAGE_KEY = 'rebreak_device_id';
|
const STORAGE_KEY = 'rebreak_device_id';
|
||||||
|
|
||||||
@ -48,3 +49,36 @@ export function getPlatformName(): string {
|
|||||||
if (Platform.OS === 'android') return 'android';
|
if (Platform.OS === 'android') return 'android';
|
||||||
return 'web';
|
return 'web';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
deviceId: string;
|
||||||
|
platform: string;
|
||||||
|
name: string;
|
||||||
|
model: string;
|
||||||
|
osVersion: string;
|
||||||
|
appVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeviceInfo(): Promise<DeviceInfo> {
|
||||||
|
const deviceId = await getDeviceId();
|
||||||
|
const platform = getPlatformName();
|
||||||
|
|
||||||
|
const name =
|
||||||
|
(Constants as any).deviceName ||
|
||||||
|
Constants.platform?.ios?.model ||
|
||||||
|
platform;
|
||||||
|
|
||||||
|
const model =
|
||||||
|
Constants.platform?.ios?.model ||
|
||||||
|
Constants.platform?.android?.versionCode?.toString() ||
|
||||||
|
platform;
|
||||||
|
|
||||||
|
const osVersion =
|
||||||
|
Constants.platform?.ios?.systemVersion?.toString() ||
|
||||||
|
(Platform.Version as string | number)?.toString() ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
const appVersion = Application.nativeApplicationVersion || '';
|
||||||
|
|
||||||
|
return { deviceId, platform, name, model, osVersion, appVersion };
|
||||||
|
}
|
||||||
|
|||||||
79
apps/rebreak-native/lib/deviceModel.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
const IPHONE_MAP: Record<string, string> = {
|
||||||
|
'iPhone10,1': 'iPhone 8',
|
||||||
|
'iPhone10,4': 'iPhone 8',
|
||||||
|
'iPhone10,2': 'iPhone 8 Plus',
|
||||||
|
'iPhone10,5': 'iPhone 8 Plus',
|
||||||
|
'iPhone10,3': 'iPhone X',
|
||||||
|
'iPhone10,6': 'iPhone X',
|
||||||
|
'iPhone11,2': 'iPhone XS',
|
||||||
|
'iPhone11,4': 'iPhone XS Max',
|
||||||
|
'iPhone11,6': 'iPhone XS Max',
|
||||||
|
'iPhone11,8': 'iPhone XR',
|
||||||
|
'iPhone12,1': 'iPhone 11',
|
||||||
|
'iPhone12,3': 'iPhone 11 Pro',
|
||||||
|
'iPhone12,5': 'iPhone 11 Pro Max',
|
||||||
|
'iPhone12,8': 'iPhone SE (2. Gen.)',
|
||||||
|
'iPhone13,1': 'iPhone 12 mini',
|
||||||
|
'iPhone13,2': 'iPhone 12',
|
||||||
|
'iPhone13,3': 'iPhone 12 Pro',
|
||||||
|
'iPhone13,4': 'iPhone 12 Pro Max',
|
||||||
|
'iPhone14,2': 'iPhone 13 Pro',
|
||||||
|
'iPhone14,3': 'iPhone 13 Pro Max',
|
||||||
|
'iPhone14,4': 'iPhone 13 mini',
|
||||||
|
'iPhone14,5': 'iPhone 13',
|
||||||
|
'iPhone14,6': 'iPhone SE (3. Gen.)',
|
||||||
|
'iPhone14,7': 'iPhone 14',
|
||||||
|
'iPhone14,8': 'iPhone 14 Plus',
|
||||||
|
'iPhone15,2': 'iPhone 14 Pro',
|
||||||
|
'iPhone15,3': 'iPhone 14 Pro Max',
|
||||||
|
'iPhone15,4': 'iPhone 15',
|
||||||
|
'iPhone15,5': 'iPhone 15 Plus',
|
||||||
|
'iPhone16,1': 'iPhone 15 Pro',
|
||||||
|
'iPhone16,2': 'iPhone 15 Pro Max',
|
||||||
|
'iPhone17,1': 'iPhone 16 Pro',
|
||||||
|
'iPhone17,2': 'iPhone 16 Pro Max',
|
||||||
|
'iPhone17,3': 'iPhone 16',
|
||||||
|
'iPhone17,4': 'iPhone 16 Plus',
|
||||||
|
'iPhone17,5': 'iPhone 16e',
|
||||||
|
'iPhone18,1': 'iPhone 17 Pro',
|
||||||
|
'iPhone18,2': 'iPhone 17 Pro Max',
|
||||||
|
'iPhone18,3': 'iPhone 17',
|
||||||
|
'iPhone18,4': 'iPhone Air',
|
||||||
|
};
|
||||||
|
|
||||||
|
const IPAD_MAP: Record<string, string> = {
|
||||||
|
'iPad13,4': 'iPad Pro 11" (M1)',
|
||||||
|
'iPad13,5': 'iPad Pro 11" (M1)',
|
||||||
|
'iPad13,6': 'iPad Pro 11" (M1)',
|
||||||
|
'iPad13,7': 'iPad Pro 11" (M1)',
|
||||||
|
'iPad13,8': 'iPad Pro 12.9" (M1)',
|
||||||
|
'iPad13,9': 'iPad Pro 12.9" (M1)',
|
||||||
|
'iPad13,10': 'iPad Pro 12.9" (M1)',
|
||||||
|
'iPad13,11': 'iPad Pro 12.9" (M1)',
|
||||||
|
'iPad13,16': 'iPad Air (5. Gen.)',
|
||||||
|
'iPad13,17': 'iPad Air (5. Gen.)',
|
||||||
|
'iPad13,18': 'iPad (10. Gen.)',
|
||||||
|
'iPad13,19': 'iPad (10. Gen.)',
|
||||||
|
'iPad14,1': 'iPad mini (6. Gen.)',
|
||||||
|
'iPad14,2': 'iPad mini (6. Gen.)',
|
||||||
|
'iPad14,3': 'iPad Pro 11" (M2)',
|
||||||
|
'iPad14,4': 'iPad Pro 11" (M2)',
|
||||||
|
'iPad14,5': 'iPad Pro 12.9" (M2)',
|
||||||
|
'iPad14,6': 'iPad Pro 12.9" (M2)',
|
||||||
|
'iPad14,8': 'iPad Air 11" (M2)',
|
||||||
|
'iPad14,9': 'iPad Air 11" (M2)',
|
||||||
|
'iPad14,10': 'iPad Air 13" (M2)',
|
||||||
|
'iPad14,11': 'iPad Air 13" (M2)',
|
||||||
|
'iPad16,1': 'iPad mini (A17 Pro)',
|
||||||
|
'iPad16,2': 'iPad mini (A17 Pro)',
|
||||||
|
'iPad16,3': 'iPad Pro 11" (M4)',
|
||||||
|
'iPad16,4': 'iPad Pro 11" (M4)',
|
||||||
|
'iPad16,5': 'iPad Pro 13" (M4)',
|
||||||
|
'iPad16,6': 'iPad Pro 13" (M4)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function decodeAppleModel(modelCode: string | null | undefined): string {
|
||||||
|
if (!modelCode) return '';
|
||||||
|
const trimmed = modelCode.trim();
|
||||||
|
return IPHONE_MAP[trimmed] ?? IPAD_MAP[trimmed] ?? trimmed;
|
||||||
|
}
|
||||||
79
apps/rebreak-native/lib/mailErrors.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Übersetzt rohe Backend/IMAP-Fehlermeldungen in benutzerfreundliche Sätze.
|
||||||
|
*
|
||||||
|
* Backend liefert oft IMAP-Server-Antworten 1:1 durch (z.B.
|
||||||
|
* `[AUTHENTICATIONFAILED] Invalid credentials (Failure)`). Die zeigen wir
|
||||||
|
* dem User NICHT — stattdessen humane Übersetzung mit Hinweis was zu tun ist.
|
||||||
|
*/
|
||||||
|
export type MailErrorReason =
|
||||||
|
| 'auth_failed'
|
||||||
|
| 'app_password_required'
|
||||||
|
| 'connection_failed'
|
||||||
|
| 'host_unreachable'
|
||||||
|
| 'tls_error'
|
||||||
|
| 'rate_limited'
|
||||||
|
| 'unknown';
|
||||||
|
|
||||||
|
export function classifyMailError(raw: string | null | undefined): MailErrorReason {
|
||||||
|
if (!raw) return 'unknown';
|
||||||
|
const s = raw.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
s.includes('authenticationfailed') ||
|
||||||
|
s.includes('invalid credentials') ||
|
||||||
|
s.includes('authentication failed') ||
|
||||||
|
s.includes('login failed') ||
|
||||||
|
s.includes('auth failed') ||
|
||||||
|
s.includes('bad password') ||
|
||||||
|
s.includes('wrong password')
|
||||||
|
) {
|
||||||
|
return 'auth_failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
s.includes('application-specific password') ||
|
||||||
|
s.includes('app password required') ||
|
||||||
|
s.includes('weblogin_required') ||
|
||||||
|
s.includes('two-factor')
|
||||||
|
) {
|
||||||
|
return 'app_password_required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
s.includes('etimedout') ||
|
||||||
|
s.includes('econnrefused') ||
|
||||||
|
s.includes('connection timeout') ||
|
||||||
|
s.includes('socket timeout') ||
|
||||||
|
s.includes('connection reset')
|
||||||
|
) {
|
||||||
|
return 'connection_failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
s.includes('enotfound') ||
|
||||||
|
s.includes('host not found') ||
|
||||||
|
s.includes('getaddrinfo') ||
|
||||||
|
s.includes('dns')
|
||||||
|
) {
|
||||||
|
return 'host_unreachable';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.includes('tls') || s.includes('ssl') || s.includes('certificate')) {
|
||||||
|
return 'tls_error';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.includes('rate limit') || s.includes('too many') || s.includes('throttl')) {
|
||||||
|
return 'rate_limited';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den i18n-Schlüssel für die humane Variante eines Mail-Errors.
|
||||||
|
* Caller ruft `t(humanizeMailError(rawError))` für den finalen Text.
|
||||||
|
*/
|
||||||
|
export function humanizeMailError(raw: string | null | undefined): string {
|
||||||
|
const reason = classifyMailError(raw);
|
||||||
|
return `mail.errors.${reason}`;
|
||||||
|
}
|
||||||
@ -197,7 +197,7 @@ export class SosTtsQueue {
|
|||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
metric?: BenchOnMetric,
|
metric?: BenchOnMetric,
|
||||||
): Promise<{ uri: string } | null> {
|
): Promise<{ uri: string } | null> {
|
||||||
const endpoint = this.opts.endpoint ?? '/api/coach/speak-openai';
|
const endpoint = this.opts.endpoint ?? '/api/coach/speak';
|
||||||
const isGoogleCloud = endpoint.endsWith('/speak-google');
|
const isGoogleCloud = endpoint.endsWith('/speak-google');
|
||||||
metric?.('tts-fetch-start', { endpoint });
|
metric?.('tts-fetch-start', { endpoint });
|
||||||
const res = await fetch(`${this.opts.apiBase}${endpoint}`, {
|
const res = await fetch(`${this.opts.apiBase}${endpoint}`, {
|
||||||
|
|||||||
@ -117,7 +117,7 @@
|
|||||||
"title": "ReBreak Games",
|
"title": "ReBreak Games",
|
||||||
"subtitle": "Casual spielen ohne SOS — Memory, Snake, Tetris und Tic-Tac-Toe.",
|
"subtitle": "Casual spielen ohne SOS — Memory, Snake, Tetris und Tic-Tac-Toe.",
|
||||||
"back_to_picker": "Spiele",
|
"back_to_picker": "Spiele",
|
||||||
"last_score": "Score: {{score}}",
|
"last_score": "Score: %{score}",
|
||||||
"skeleton_footer": "Skeleton — Highscore-Leaderboard kommt in Phase C"
|
"skeleton_footer": "Skeleton — Highscore-Leaderboard kommt in Phase C"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
@ -172,7 +172,6 @@
|
|||||||
"status_approved": "Genehmigt",
|
"status_approved": "Genehmigt",
|
||||||
"status_rejected": "Abgelehnt",
|
"status_rejected": "Abgelehnt",
|
||||||
"status_pending": "Ausstehend",
|
"status_pending": "Ausstehend",
|
||||||
|
|
||||||
"add_sheet_title": "Domain blockieren",
|
"add_sheet_title": "Domain blockieren",
|
||||||
"add_sheet_label": "Domain",
|
"add_sheet_label": "Domain",
|
||||||
"add_sheet_placeholder": "z.B. bet365.com",
|
"add_sheet_placeholder": "z.B. bet365.com",
|
||||||
@ -182,9 +181,7 @@
|
|||||||
"add_sheet_confirm_permanent": "Ich verstehe dass diese Domain permanent ist.",
|
"add_sheet_confirm_permanent": "Ich verstehe dass diese Domain permanent ist.",
|
||||||
"add_sheet_add_failed": "Hinzufügen fehlgeschlagen.",
|
"add_sheet_add_failed": "Hinzufügen fehlgeschlagen.",
|
||||||
"add_sheet_already_global": "%{domain} steht bereits in der globalen Sperrliste — kein Slot nötig.",
|
"add_sheet_already_global": "%{domain} steht bereits in der globalen Sperrliste — kein Slot nötig.",
|
||||||
|
|
||||||
"cooldown_banner_title": "Cooldown läuft",
|
"cooldown_banner_title": "Cooldown läuft",
|
||||||
|
|
||||||
"deactivation_actionsheet_title": "24-Stunden-Cooldown starten?",
|
"deactivation_actionsheet_title": "24-Stunden-Cooldown starten?",
|
||||||
"deactivation_actionsheet_message": "Schutz bleibt während dieser Zeit aktiv. Du kannst jederzeit abbrechen.",
|
"deactivation_actionsheet_message": "Schutz bleibt während dieser Zeit aktiv. Du kannst jederzeit abbrechen.",
|
||||||
"deactivation_start_cta": "Cooldown starten",
|
"deactivation_start_cta": "Cooldown starten",
|
||||||
@ -202,7 +199,6 @@
|
|||||||
"deactivation_start_anyway": "Cooldown trotzdem starten",
|
"deactivation_start_anyway": "Cooldown trotzdem starten",
|
||||||
"deactivation_starting": "Cooldown wird gestartet…",
|
"deactivation_starting": "Cooldown wird gestartet…",
|
||||||
"deactivation_cancel_failed": "Cooldown konnte nicht abgebrochen werden.",
|
"deactivation_cancel_failed": "Cooldown konnte nicht abgebrochen werden.",
|
||||||
|
|
||||||
"domain_section_title": "Eigene Domains",
|
"domain_section_title": "Eigene Domains",
|
||||||
"domain_add_a11y": "Domain hinzufügen",
|
"domain_add_a11y": "Domain hinzufügen",
|
||||||
"domain_limit_title": "Limit erreicht",
|
"domain_limit_title": "Limit erreicht",
|
||||||
@ -226,10 +222,8 @@
|
|||||||
"domain_success_community_title": "Domain in Abstimmung",
|
"domain_success_community_title": "Domain in Abstimmung",
|
||||||
"domain_success_legend_message": "Das ReBreak-Team prüft die Domain manuell. Du bekommst eine Benachrichtigung beim Ergebnis.",
|
"domain_success_legend_message": "Das ReBreak-Team prüft die Domain manuell. Du bekommst eine Benachrichtigung beim Ergebnis.",
|
||||||
"domain_success_community_message": "Die Community kann jetzt abstimmen. Du wirst beim Ergebnis benachrichtigt.",
|
"domain_success_community_message": "Die Community kann jetzt abstimmen. Du wirst beim Ergebnis benachrichtigt.",
|
||||||
|
|
||||||
"upgrade_alert_title": "Pro-Upgrade",
|
"upgrade_alert_title": "Pro-Upgrade",
|
||||||
"upgrade_alert_desc": "Stripe-Checkout kommt in Step 11.",
|
"upgrade_alert_desc": "Stripe-Checkout kommt in Step 11.",
|
||||||
|
|
||||||
"protection_card_title": "ReBreak-Schutz",
|
"protection_card_title": "ReBreak-Schutz",
|
||||||
"protection_card_locked_title": "ReBreak-Schutz aktiv",
|
"protection_card_locked_title": "ReBreak-Schutz aktiv",
|
||||||
"protection_subtitle_inactive": "Tippe um den Schutz zu aktivieren",
|
"protection_subtitle_inactive": "Tippe um den Schutz zu aktivieren",
|
||||||
@ -244,7 +238,6 @@
|
|||||||
"protection_stat_method_native": "Native",
|
"protection_stat_method_native": "Native",
|
||||||
"protection_stat_status": "Status",
|
"protection_stat_status": "Status",
|
||||||
"protection_stat_status_live": "Live",
|
"protection_stat_status_live": "Live",
|
||||||
|
|
||||||
"activate_url_failed_title": "URL-Filter konnte nicht aktiviert werden",
|
"activate_url_failed_title": "URL-Filter konnte nicht aktiviert werden",
|
||||||
"activate_url_failed_msg": "Unbekannter Fehler.\nDu kannst es nochmal versuchen oder System-Einstellungen prüfen.",
|
"activate_url_failed_msg": "Unbekannter Fehler.\nDu kannst es nochmal versuchen oder System-Einstellungen prüfen.",
|
||||||
"activate_settings_btn": "Einstellungen",
|
"activate_settings_btn": "Einstellungen",
|
||||||
@ -253,7 +246,6 @@
|
|||||||
"sync_list_failed_title": "Filter-Liste konnte nicht geladen werden",
|
"sync_list_failed_title": "Filter-Liste konnte nicht geladen werden",
|
||||||
"sync_list_failed_msg": "Bitte später nochmal versuchen.",
|
"sync_list_failed_msg": "Bitte später nochmal versuchen.",
|
||||||
"activation_failed_title": "Aktivierung fehlgeschlagen",
|
"activation_failed_title": "Aktivierung fehlgeschlagen",
|
||||||
|
|
||||||
"details_done": "Fertig",
|
"details_done": "Fertig",
|
||||||
"details_title": "Schutz-Details",
|
"details_title": "Schutz-Details",
|
||||||
"details_active_title": "Schutz aktiv",
|
"details_active_title": "Schutz aktiv",
|
||||||
@ -272,7 +264,6 @@
|
|||||||
"details_lyra_cta_title": "Brauchst du den Schutz nicht mehr?",
|
"details_lyra_cta_title": "Brauchst du den Schutz nicht mehr?",
|
||||||
"details_lyra_cta_subtitle": "Sprich mit Lyra darüber — sie hört zu.",
|
"details_lyra_cta_subtitle": "Sprich mit Lyra darüber — sie hört zu.",
|
||||||
"details_deactivate_link": "Ich will trotzdem deaktivieren",
|
"details_deactivate_link": "Ich will trotzdem deaktivieren",
|
||||||
|
|
||||||
"layers_url_filter_title": "URL-Filter",
|
"layers_url_filter_title": "URL-Filter",
|
||||||
"layers_url_filter_subtitle_active": "System-weiter Filter aktiv",
|
"layers_url_filter_subtitle_active": "System-weiter Filter aktiv",
|
||||||
"layers_url_filter_subtitle_inactive": "Blockt Gambling-Seiten in Safari + Apps",
|
"layers_url_filter_subtitle_inactive": "Blockt Gambling-Seiten in Safari + Apps",
|
||||||
@ -280,7 +271,6 @@
|
|||||||
"layers_app_lock_subtitle_active": "Familienzugriff aktiv",
|
"layers_app_lock_subtitle_active": "Familienzugriff aktiv",
|
||||||
"layers_app_lock_subtitle_inactive": "Verhindert dass du ReBreak im Impuls löschst",
|
"layers_app_lock_subtitle_inactive": "Verhindert dass du ReBreak im Impuls löschst",
|
||||||
"layers_app_lock_warning": "Sobald aktiv kannst du den Schutz nur über einen 24-Stunden-Cooldown abschalten. Das ist gewollt.",
|
"layers_app_lock_warning": "Sobald aktiv kannst du den Schutz nur über einen 24-Stunden-Cooldown abschalten. Das ist gewollt.",
|
||||||
|
|
||||||
"kpi_global_label": "Geblockte Domains weltweit",
|
"kpi_global_label": "Geblockte Domains weltweit",
|
||||||
"kpi_global_subtitle": "Aktive Einträge in der globalen Blockliste",
|
"kpi_global_subtitle": "Aktive Einträge in der globalen Blockliste",
|
||||||
"delta_week": "diese Woche",
|
"delta_week": "diese Woche",
|
||||||
@ -296,7 +286,6 @@
|
|||||||
"kpi_avg_per_user": "Ø Domains pro User",
|
"kpi_avg_per_user": "Ø Domains pro User",
|
||||||
"kpi_avg_wait": "Ø Wartezeit",
|
"kpi_avg_wait": "Ø Wartezeit",
|
||||||
"kpi_days_suffix": "Tage",
|
"kpi_days_suffix": "Tage",
|
||||||
|
|
||||||
"faq_heading": "Häufige Fragen",
|
"faq_heading": "Häufige Fragen",
|
||||||
"faq1_q": "Wie funktioniert der Schutz?",
|
"faq1_q": "Wie funktioniert der Schutz?",
|
||||||
"faq1_a": "Der Schutz läuft direkt im iOS-System als Inhaltsfilter. Glücksspielseiten werden lokal auf deinem Gerät blockiert — kein Datenverkehr verlässt dein iPhone.",
|
"faq1_a": "Der Schutz läuft direkt im iOS-System als Inhaltsfilter. Glücksspielseiten werden lokal auf deinem Gerät blockiert — kein Datenverkehr verlässt dein iPhone.",
|
||||||
@ -306,7 +295,6 @@
|
|||||||
"faq3_a": "Ja. Über die Domain-Liste auf der Blocker-Seite kannst du eigene Domains hinzufügen, die zusätzlich zur globalen Liste blockiert werden.",
|
"faq3_a": "Ja. Über die Domain-Liste auf der Blocker-Seite kannst du eigene Domains hinzufügen, die zusätzlich zur globalen Liste blockiert werden.",
|
||||||
"faq4_q": "Warum kann ich den Schutz nicht sofort abschalten?",
|
"faq4_q": "Warum kann ich den Schutz nicht sofort abschalten?",
|
||||||
"faq4_a": "Wenn du im Drang bist, willst du oft schnell deaktivieren — und es danach bereuen. Der 24-Stunden-Cooldown gibt dir Zeit, den Drang abklingen zu lassen. Du kannst den Cooldown jederzeit abbrechen — der Schutz bleibt dann einfach an.",
|
"faq4_a": "Wenn du im Drang bist, willst du oft schnell deaktivieren — und es danach bereuen. Der 24-Stunden-Cooldown gibt dir Zeit, den Drang abklingen zu lassen. Du kannst den Cooldown jederzeit abbrechen — der Schutz bleibt dann einfach an.",
|
||||||
|
|
||||||
"more_info_title": "Wie funktioniert der Cooldown?"
|
"more_info_title": "Wie funktioniert der Cooldown?"
|
||||||
},
|
},
|
||||||
"mail": {
|
"mail": {
|
||||||
@ -326,16 +314,13 @@
|
|||||||
"provider_other": "Andere",
|
"provider_other": "Andere",
|
||||||
"empty_title": "Noch keine Mails blockiert",
|
"empty_title": "Noch keine Mails blockiert",
|
||||||
"empty_subtitle": "Verbinde dein Postfach, damit Rebreak automatisch schützt.",
|
"empty_subtitle": "Verbinde dein Postfach, damit Rebreak automatisch schützt.",
|
||||||
|
|
||||||
"connect_sheet_title": "Postfach verbinden",
|
"connect_sheet_title": "Postfach verbinden",
|
||||||
"connect_sheet_subtitle": "Wähle deinen E-Mail-Anbieter. Rebreak löscht Gambling-Mails automatisch — Inhalte werden nie gelesen.",
|
"connect_sheet_subtitle": "Wähle deinen E-Mail-Anbieter. Rebreak löscht Gambling-Mails automatisch — Inhalte werden nie gelesen.",
|
||||||
|
|
||||||
"provider_gmail": "Gmail",
|
"provider_gmail": "Gmail",
|
||||||
"provider_icloud": "iCloud Mail",
|
"provider_icloud": "iCloud Mail",
|
||||||
"provider_outlook": "Outlook",
|
"provider_outlook": "Outlook",
|
||||||
"provider_yahoo": "Yahoo Mail",
|
"provider_yahoo": "Yahoo Mail",
|
||||||
"provider_gmx": "GMX / Web.de",
|
"provider_gmx": "GMX / Web.de",
|
||||||
|
|
||||||
"app_password_required_title": "App-Passwort erforderlich",
|
"app_password_required_title": "App-Passwort erforderlich",
|
||||||
"app_password_guide_gmail": "Gmail erfordert ein App-spezifisches Passwort (kein normales Google-Passwort). Aktiviere 2FA und erstelle ein App-Passwort unter myaccount.google.com/apppasswords.",
|
"app_password_guide_gmail": "Gmail erfordert ein App-spezifisches Passwort (kein normales Google-Passwort). Aktiviere 2FA und erstelle ein App-Passwort unter myaccount.google.com/apppasswords.",
|
||||||
"app_password_guide_icloud": "iCloud erfordert ein App-spezifisches Passwort. Gehe zu appleid.apple.com → Anmelden → App-spezifische Passwörter.",
|
"app_password_guide_icloud": "iCloud erfordert ein App-spezifisches Passwort. Gehe zu appleid.apple.com → Anmelden → App-spezifische Passwörter.",
|
||||||
@ -344,7 +329,6 @@
|
|||||||
"app_password_guide_gmx": "GMX / Web.de: Aktiviere IMAP in den Einstellungen und verwende dein normales Passwort oder ein App-Passwort falls 2FA aktiv.",
|
"app_password_guide_gmx": "GMX / Web.de: Aktiviere IMAP in den Einstellungen und verwende dein normales Passwort oder ein App-Passwort falls 2FA aktiv.",
|
||||||
"app_password_guide_other": "Gib die IMAP-Zugangsdaten deines E-Mail-Anbieters ein. App-Passwort empfohlen wenn vorhanden.",
|
"app_password_guide_other": "Gib die IMAP-Zugangsdaten deines E-Mail-Anbieters ein. App-Passwort empfohlen wenn vorhanden.",
|
||||||
"app_password_open_link": "Jetzt App-Passwort erstellen",
|
"app_password_open_link": "Jetzt App-Passwort erstellen",
|
||||||
|
|
||||||
"form_email_label": "E-Mail-Adresse",
|
"form_email_label": "E-Mail-Adresse",
|
||||||
"form_email_placeholder": "deine@email.de",
|
"form_email_placeholder": "deine@email.de",
|
||||||
"form_password_label": "App-Passwort",
|
"form_password_label": "App-Passwort",
|
||||||
@ -353,14 +337,11 @@
|
|||||||
"form_connect_btn": "Postfach verbinden",
|
"form_connect_btn": "Postfach verbinden",
|
||||||
"form_fields_required": "E-Mail und Passwort sind erforderlich.",
|
"form_fields_required": "E-Mail und Passwort sind erforderlich.",
|
||||||
"connect_failed": "Verbindung fehlgeschlagen. Prüfe deine Zugangsdaten.",
|
"connect_failed": "Verbindung fehlgeschlagen. Prüfe deine Zugangsdaten.",
|
||||||
|
|
||||||
"section_accounts": "Postfächer",
|
"section_accounts": "Postfächer",
|
||||||
"add_account_a11y": "Postfach hinzufügen",
|
"add_account_a11y": "Postfach hinzufügen",
|
||||||
|
|
||||||
"empty_state_title": "Kein Postfach verbunden",
|
"empty_state_title": "Kein Postfach verbunden",
|
||||||
"empty_state_subtitle": "Verbinde dein erstes Postfach — Rebreak löscht Gambling-Mails automatisch, bevor du sie siehst.",
|
"empty_state_subtitle": "Verbinde dein erstes Postfach — Rebreak löscht Gambling-Mails automatisch, bevor du sie siehst.",
|
||||||
"empty_state_cta": "Erstes Postfach verbinden",
|
"empty_state_cta": "Erstes Postfach verbinden",
|
||||||
|
|
||||||
"account_active": "Aktiv",
|
"account_active": "Aktiv",
|
||||||
"account_inactive": "Inaktiv",
|
"account_inactive": "Inaktiv",
|
||||||
"account_last_scan": "Zuletzt vor %{time}",
|
"account_last_scan": "Zuletzt vor %{time}",
|
||||||
@ -372,7 +353,6 @@
|
|||||||
"account_disconnect_confirm_title": "Postfach trennen?",
|
"account_disconnect_confirm_title": "Postfach trennen?",
|
||||||
"account_disconnect_confirm_message": "%{email} wird getrennt und alle Scan-Daten werden gelöscht.",
|
"account_disconnect_confirm_message": "%{email} wird getrennt und alle Scan-Daten werden gelöscht.",
|
||||||
"account_disconnect_confirm_btn": "Trennen",
|
"account_disconnect_confirm_btn": "Trennen",
|
||||||
|
|
||||||
"stats_blocked": "Blockiert",
|
"stats_blocked": "Blockiert",
|
||||||
"stats_accounts": "Postfächer",
|
"stats_accounts": "Postfächer",
|
||||||
"stats_next_scan": "Nächster Scan",
|
"stats_next_scan": "Nächster Scan",
|
||||||
@ -382,10 +362,8 @@
|
|||||||
"scheduled": "Geplant",
|
"scheduled": "Geplant",
|
||||||
"account_of_scanned": "von %{scanned} gescannt",
|
"account_of_scanned": "von %{scanned} gescannt",
|
||||||
"activity_log_count": "%{count} Mail(s) blockiert",
|
"activity_log_count": "%{count} Mail(s) blockiert",
|
||||||
|
|
||||||
"connect_success_title": "Postfach verbunden",
|
"connect_success_title": "Postfach verbunden",
|
||||||
"connect_success_message": "Rebreak scannt ab jetzt automatisch nach Gambling-Mails.",
|
"connect_success_message": "Rebreak scannt ab jetzt automatisch nach Gambling-Mails.",
|
||||||
|
|
||||||
"add_account": "Postfach hinzufügen",
|
"add_account": "Postfach hinzufügen",
|
||||||
"section_accounts_count": "%{used} von %{max} verbunden",
|
"section_accounts_count": "%{used} von %{max} verbunden",
|
||||||
"section_accounts_count_unlimited": "%{used} verbunden · unbegrenzt",
|
"section_accounts_count_unlimited": "%{used} verbunden · unbegrenzt",
|
||||||
@ -393,24 +371,42 @@
|
|||||||
"disconnect": "Trennen",
|
"disconnect": "Trennen",
|
||||||
"loading": "Lädt…",
|
"loading": "Lädt…",
|
||||||
"app_password_placeholder": "App-Passwort",
|
"app_password_placeholder": "App-Passwort",
|
||||||
|
|
||||||
"scan_interval_label": "Scan-Intervall",
|
"scan_interval_label": "Scan-Intervall",
|
||||||
"realtime_desc": "Echtzeit-Blockierung via IMAP IDLE",
|
"realtime_desc": "Echtzeit-Blockierung via IMAP IDLE",
|
||||||
"free_scan_interval_hint": "Free-Plan: fest 4h. Upgrade für 1h.",
|
"free_scan_interval_hint": "Free-Plan: fest 4h. Upgrade für 1h.",
|
||||||
|
|
||||||
"account_change_password": "Passwort ändern",
|
"account_change_password": "Passwort ändern",
|
||||||
"edit_account_title": "Passwort aktualisieren",
|
"edit_account_title": "Passwort aktualisieren",
|
||||||
"edit_account_subtitle": "Gib das neue App-Passwort für %{email} ein. Das alte Passwort wird ersetzt.",
|
"edit_account_subtitle": "Gib das neue App-Passwort für %{email} ein. Das alte Passwort wird ersetzt.",
|
||||||
"edit_account_save": "Speichern",
|
"edit_account_save": "Speichern",
|
||||||
|
|
||||||
"activity_log_title": "Kürzlich blockiert",
|
"activity_log_title": "Kürzlich blockiert",
|
||||||
"activity_log_subtitle": "In den letzten 24h blockierte Mails",
|
"activity_log_subtitle": "In den letzten 24h blockierte Mails",
|
||||||
"activity_log_empty": "Keine Mails in den letzten 24h blockiert",
|
"activity_log_empty": "Keine Mails in den letzten 24h blockiert",
|
||||||
"activity_log_more": "+ %{count} weitere",
|
"activity_log_more": "+ %{count} weitere",
|
||||||
"activity_no_subject": "(kein Betreff)",
|
"activity_no_subject": "(kein Betreff)",
|
||||||
|
|
||||||
"upgrade_alert_title": "Mehr Postfächer",
|
"upgrade_alert_title": "Mehr Postfächer",
|
||||||
"upgrade_alert_desc": "Upgrade auf Pro für bis zu 3 Postfächer, auf Legend für unbegrenzte Postfächer."
|
"upgrade_alert_desc": "Upgrade auf Pro für bis zu 3 Postfächer, auf Legend für unbegrenzte Postfächer.",
|
||||||
|
"chart_title": "Letzte 7 Tage",
|
||||||
|
"chart_week_total": "%{count} diese Woche",
|
||||||
|
"status_auth_error": "Auth-Fehler",
|
||||||
|
"status_connect_error": "Verbindungsfehler",
|
||||||
|
"status_error_tap_hint": "Tippen zum Beheben",
|
||||||
|
"status_stale": "Stale",
|
||||||
|
"status_stale_last_scan": "letzter scan %{rel}",
|
||||||
|
"status_live_idle": "IDLE aktiv seit %{rel}",
|
||||||
|
"status_live_no_new_mail": "verbunden · keine neue mail seit %{rel}",
|
||||||
|
"status_waiting_first_connect": "Wartet auf erste Verbindung",
|
||||||
|
"auth_error_title": "App-Password ungültig",
|
||||||
|
"auth_error_subtitle": "Das App-Password für %{email} ist abgelaufen oder falsch. Bitte erneuer es und trag es hier ein.",
|
||||||
|
"auth_error_renew_link": "Neues App-Password erstellen",
|
||||||
|
"errors": {
|
||||||
|
"auth_failed": "Das App-Passwort ist nicht korrekt. Bitte erneuere es bei deinem Mail-Anbieter und trage es hier ein.",
|
||||||
|
"app_password_required": "Dein Mail-Anbieter verlangt ein App-spezifisches Passwort. Erstelle eines in den Account-Einstellungen.",
|
||||||
|
"connection_failed": "Verbindung zum Mail-Server fehlgeschlagen. Bitte später erneut versuchen.",
|
||||||
|
"host_unreachable": "Mail-Server ist gerade nicht erreichbar. Internet-Verbindung prüfen oder später erneut versuchen.",
|
||||||
|
"tls_error": "Sichere Verbindung zum Mail-Server konnte nicht hergestellt werden. Provider kontaktieren.",
|
||||||
|
"rate_limited": "Zu viele Verbindungsversuche. Bitte ein paar Minuten warten und erneut versuchen.",
|
||||||
|
"unknown": "Unbekannter Fehler beim Verbinden. Bitte App-Passwort prüfen oder erneut versuchen."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Einstellungen",
|
"title": "Einstellungen",
|
||||||
@ -474,13 +470,13 @@
|
|||||||
"debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)",
|
"debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)",
|
||||||
"devices_page_title": "Registrierte Geräte",
|
"devices_page_title": "Registrierte Geräte",
|
||||||
"devices_slots": "Geräte-Slots",
|
"devices_slots": "Geräte-Slots",
|
||||||
"devices_slots_desc": "Dein {{plan}}-Plan erlaubt diese Anzahl gleichzeitiger Geräte.",
|
"devices_slots_desc": "Dein %{plan}-Plan erlaubt diese Anzahl gleichzeitiger Geräte.",
|
||||||
"devices_this_device": "Dieses Gerät",
|
"devices_this_device": "Dieses Gerät",
|
||||||
"devices_since": "seit",
|
"devices_since": "seit",
|
||||||
"devices_just_now": "gerade aktiv",
|
"devices_just_now": "gerade aktiv",
|
||||||
"devices_mins_ago": "vor {{count}}m",
|
"devices_mins_ago": "vor %{count}m",
|
||||||
"devices_hours_ago": "vor {{count}}h",
|
"devices_hours_ago": "vor %{count}h",
|
||||||
"devices_days_ago": "vor {{count}}d",
|
"devices_days_ago": "vor %{count}d",
|
||||||
"devices_empty": "Keine Geräte registriert",
|
"devices_empty": "Keine Geräte registriert",
|
||||||
"devices_hint": "Geräte, die du entfernst, werden beim nächsten Login wieder registriert. Dieses Gerät kann nicht entfernt werden, solange du eingeloggt bist.",
|
"devices_hint": "Geräte, die du entfernst, werden beim nächsten Login wieder registriert. Dieses Gerät kann nicht entfernt werden, solange du eingeloggt bist.",
|
||||||
"devices_remove_title": "Gerät entfernen",
|
"devices_remove_title": "Gerät entfernen",
|
||||||
@ -489,7 +485,7 @@
|
|||||||
},
|
},
|
||||||
"device_limit": {
|
"device_limit": {
|
||||||
"title": "Geräte-Limit erreicht",
|
"title": "Geräte-Limit erreicht",
|
||||||
"subtitle": "{{max}} von {{max}} Geräten belegt ({{plan}}) — entferne ein Gerät um weiterzumachen",
|
"subtitle": "%{count} von %{max} Geräten belegt (%{plan}) — entferne ein Gerät um weiterzumachen",
|
||||||
"hint": "Entfernte Geräte können sich beim nächsten Login wieder registrieren.",
|
"hint": "Entfernte Geräte können sich beim nächsten Login wieder registrieren.",
|
||||||
"remove_cta": "Gerät entfernen"
|
"remove_cta": "Gerät entfernen"
|
||||||
},
|
},
|
||||||
@ -731,6 +727,24 @@
|
|||||||
"motivational_1": "Jede Minute Fokus ist eine Minute für dich.",
|
"motivational_1": "Jede Minute Fokus ist eine Minute für dich.",
|
||||||
"motivational_2": "Konzentration trainieren — genau das bist du gerade.",
|
"motivational_2": "Konzentration trainieren — genau das bist du gerade.",
|
||||||
"motivational_3": "Gut gespielt. Und gut, dass du hier bist.",
|
"motivational_3": "Gut gespielt. Und gut, dass du hier bist.",
|
||||||
"motivational_4": "Kleine Pausen, große Wirkung."
|
"motivational_4": "Kleine Pausen, große Wirkung.",
|
||||||
|
"lyra_title_record": "Neuer Rekord!",
|
||||||
|
"lyra_body_record": "Du hast dich selbst übertroffen. Stark.",
|
||||||
|
"lyra_title_good": "Klasse!",
|
||||||
|
"lyra_body_good": "Du bist voll im Flow — der Impuls hatte keine Chance.",
|
||||||
|
"lyra_title_ok": "Weiter so",
|
||||||
|
"lyra_body_ok": "Jede Runde bringt dich weiter. Bleib dabei.",
|
||||||
|
"lyra_title_low": "Nächstes Mal",
|
||||||
|
"lyra_body_low": "Aufzutauchen zählt schon. Du schaffst das.",
|
||||||
|
"rating_saved": "Bewertung gespeichert",
|
||||||
|
"save_rating": "Bewertung speichern",
|
||||||
|
"feedback_placeholder": "Was hat dir gefallen oder gefehlt?",
|
||||||
|
"share_result": "In Community teilen",
|
||||||
|
"share_to_community": "Ergebnis teilen",
|
||||||
|
"share_challenge": "Kannst du das schlagen?",
|
||||||
|
"share_loading": "Lyra formuliert...",
|
||||||
|
"post_to_community": "Posten",
|
||||||
|
"posted": "Im Community-Feed gepostet",
|
||||||
|
"post_error": "Posten fehlgeschlagen, nochmal versuchen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -117,7 +117,7 @@
|
|||||||
"title": "ReBreak Games",
|
"title": "ReBreak Games",
|
||||||
"subtitle": "Casual play outside SOS — Memory, Snake, Tetris and Tic-Tac-Toe.",
|
"subtitle": "Casual play outside SOS — Memory, Snake, Tetris and Tic-Tac-Toe.",
|
||||||
"back_to_picker": "Games",
|
"back_to_picker": "Games",
|
||||||
"last_score": "Score: {{score}}",
|
"last_score": "Score: %{score}",
|
||||||
"skeleton_footer": "Skeleton — Highscore leaderboard coming in Phase C"
|
"skeleton_footer": "Skeleton — Highscore leaderboard coming in Phase C"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
@ -172,7 +172,6 @@
|
|||||||
"status_approved": "Approved",
|
"status_approved": "Approved",
|
||||||
"status_rejected": "Rejected",
|
"status_rejected": "Rejected",
|
||||||
"status_pending": "Pending",
|
"status_pending": "Pending",
|
||||||
|
|
||||||
"add_sheet_title": "Block domain",
|
"add_sheet_title": "Block domain",
|
||||||
"add_sheet_label": "Domain",
|
"add_sheet_label": "Domain",
|
||||||
"add_sheet_placeholder": "e.g. bet365.com",
|
"add_sheet_placeholder": "e.g. bet365.com",
|
||||||
@ -182,9 +181,7 @@
|
|||||||
"add_sheet_confirm_permanent": "I understand this domain is permanent.",
|
"add_sheet_confirm_permanent": "I understand this domain is permanent.",
|
||||||
"add_sheet_add_failed": "Failed to add domain.",
|
"add_sheet_add_failed": "Failed to add domain.",
|
||||||
"add_sheet_already_global": "%{domain} is already on the global blocklist — no slot needed.",
|
"add_sheet_already_global": "%{domain} is already on the global blocklist — no slot needed.",
|
||||||
|
|
||||||
"cooldown_banner_title": "Cooldown running",
|
"cooldown_banner_title": "Cooldown running",
|
||||||
|
|
||||||
"deactivation_actionsheet_title": "Start 24-hour cooldown?",
|
"deactivation_actionsheet_title": "Start 24-hour cooldown?",
|
||||||
"deactivation_actionsheet_message": "Protection stays active during this time. You can cancel anytime.",
|
"deactivation_actionsheet_message": "Protection stays active during this time. You can cancel anytime.",
|
||||||
"deactivation_start_cta": "Start cooldown",
|
"deactivation_start_cta": "Start cooldown",
|
||||||
@ -202,7 +199,6 @@
|
|||||||
"deactivation_start_anyway": "Start cooldown anyway",
|
"deactivation_start_anyway": "Start cooldown anyway",
|
||||||
"deactivation_starting": "Starting cooldown…",
|
"deactivation_starting": "Starting cooldown…",
|
||||||
"deactivation_cancel_failed": "Could not cancel cooldown.",
|
"deactivation_cancel_failed": "Could not cancel cooldown.",
|
||||||
|
|
||||||
"domain_section_title": "Custom domains",
|
"domain_section_title": "Custom domains",
|
||||||
"domain_add_a11y": "Add domain",
|
"domain_add_a11y": "Add domain",
|
||||||
"domain_limit_title": "Limit reached",
|
"domain_limit_title": "Limit reached",
|
||||||
@ -226,10 +222,8 @@
|
|||||||
"domain_success_community_title": "Domain in voting",
|
"domain_success_community_title": "Domain in voting",
|
||||||
"domain_success_legend_message": "The ReBreak team is reviewing this domain manually. You'll get a notification with the result.",
|
"domain_success_legend_message": "The ReBreak team is reviewing this domain manually. You'll get a notification with the result.",
|
||||||
"domain_success_community_message": "The community can now vote. You'll be notified once the result is in.",
|
"domain_success_community_message": "The community can now vote. You'll be notified once the result is in.",
|
||||||
|
|
||||||
"upgrade_alert_title": "Pro upgrade",
|
"upgrade_alert_title": "Pro upgrade",
|
||||||
"upgrade_alert_desc": "Stripe checkout is coming in step 11.",
|
"upgrade_alert_desc": "Stripe checkout is coming in step 11.",
|
||||||
|
|
||||||
"protection_card_title": "ReBreak protection",
|
"protection_card_title": "ReBreak protection",
|
||||||
"protection_card_locked_title": "ReBreak protection active",
|
"protection_card_locked_title": "ReBreak protection active",
|
||||||
"protection_subtitle_inactive": "Tap to activate protection",
|
"protection_subtitle_inactive": "Tap to activate protection",
|
||||||
@ -244,7 +238,6 @@
|
|||||||
"protection_stat_method_native": "Native",
|
"protection_stat_method_native": "Native",
|
||||||
"protection_stat_status": "Status",
|
"protection_stat_status": "Status",
|
||||||
"protection_stat_status_live": "Live",
|
"protection_stat_status_live": "Live",
|
||||||
|
|
||||||
"activate_url_failed_title": "Could not activate URL filter",
|
"activate_url_failed_title": "Could not activate URL filter",
|
||||||
"activate_url_failed_msg": "Unknown error.\nYou can try again or check System Settings.",
|
"activate_url_failed_msg": "Unknown error.\nYou can try again or check System Settings.",
|
||||||
"activate_settings_btn": "Settings",
|
"activate_settings_btn": "Settings",
|
||||||
@ -253,7 +246,6 @@
|
|||||||
"sync_list_failed_title": "Filter list could not be loaded",
|
"sync_list_failed_title": "Filter list could not be loaded",
|
||||||
"sync_list_failed_msg": "Please try again later.",
|
"sync_list_failed_msg": "Please try again later.",
|
||||||
"activation_failed_title": "Activation failed",
|
"activation_failed_title": "Activation failed",
|
||||||
|
|
||||||
"details_done": "Done",
|
"details_done": "Done",
|
||||||
"details_title": "Protection details",
|
"details_title": "Protection details",
|
||||||
"details_active_title": "Protection active",
|
"details_active_title": "Protection active",
|
||||||
@ -272,7 +264,6 @@
|
|||||||
"details_lyra_cta_title": "Don't need protection anymore?",
|
"details_lyra_cta_title": "Don't need protection anymore?",
|
||||||
"details_lyra_cta_subtitle": "Talk to Lyra about it — she's listening.",
|
"details_lyra_cta_subtitle": "Talk to Lyra about it — she's listening.",
|
||||||
"details_deactivate_link": "Deactivate anyway",
|
"details_deactivate_link": "Deactivate anyway",
|
||||||
|
|
||||||
"layers_url_filter_title": "URL filter",
|
"layers_url_filter_title": "URL filter",
|
||||||
"layers_url_filter_subtitle_active": "System-wide filter active",
|
"layers_url_filter_subtitle_active": "System-wide filter active",
|
||||||
"layers_url_filter_subtitle_inactive": "Blocks gambling sites in Safari + apps",
|
"layers_url_filter_subtitle_inactive": "Blocks gambling sites in Safari + apps",
|
||||||
@ -280,7 +271,6 @@
|
|||||||
"layers_app_lock_subtitle_active": "Family access active",
|
"layers_app_lock_subtitle_active": "Family access active",
|
||||||
"layers_app_lock_subtitle_inactive": "Prevents you from deleting ReBreak on impulse",
|
"layers_app_lock_subtitle_inactive": "Prevents you from deleting ReBreak on impulse",
|
||||||
"layers_app_lock_warning": "Once active, you can only disable protection through a 24-hour cooldown. That's by design.",
|
"layers_app_lock_warning": "Once active, you can only disable protection through a 24-hour cooldown. That's by design.",
|
||||||
|
|
||||||
"kpi_global_label": "Domains blocked worldwide",
|
"kpi_global_label": "Domains blocked worldwide",
|
||||||
"kpi_global_subtitle": "Active entries in the global blocklist",
|
"kpi_global_subtitle": "Active entries in the global blocklist",
|
||||||
"delta_week": "this week",
|
"delta_week": "this week",
|
||||||
@ -296,7 +286,6 @@
|
|||||||
"kpi_avg_per_user": "Avg. domains per user",
|
"kpi_avg_per_user": "Avg. domains per user",
|
||||||
"kpi_avg_wait": "Avg. wait",
|
"kpi_avg_wait": "Avg. wait",
|
||||||
"kpi_days_suffix": "days",
|
"kpi_days_suffix": "days",
|
||||||
|
|
||||||
"faq_heading": "FAQ",
|
"faq_heading": "FAQ",
|
||||||
"faq1_q": "How does protection work?",
|
"faq1_q": "How does protection work?",
|
||||||
"faq1_a": "Protection runs directly in iOS as a content filter. Gambling sites are blocked locally on your device — no traffic leaves your iPhone.",
|
"faq1_a": "Protection runs directly in iOS as a content filter. Gambling sites are blocked locally on your device — no traffic leaves your iPhone.",
|
||||||
@ -306,7 +295,6 @@
|
|||||||
"faq3_a": "Yes. From the domain list on the blocker page you can add custom domains that get blocked in addition to the global list.",
|
"faq3_a": "Yes. From the domain list on the blocker page you can add custom domains that get blocked in addition to the global list.",
|
||||||
"faq4_q": "Why can't I turn protection off immediately?",
|
"faq4_q": "Why can't I turn protection off immediately?",
|
||||||
"faq4_a": "In the moment of urge, you often want to disable fast — and regret it after. The 24-hour cooldown gives you time for the urge to pass. You can cancel the cooldown anytime — protection then simply stays on.",
|
"faq4_a": "In the moment of urge, you often want to disable fast — and regret it after. The 24-hour cooldown gives you time for the urge to pass. You can cancel the cooldown anytime — protection then simply stays on.",
|
||||||
|
|
||||||
"more_info_title": "How does the cooldown work?"
|
"more_info_title": "How does the cooldown work?"
|
||||||
},
|
},
|
||||||
"mail": {
|
"mail": {
|
||||||
@ -326,16 +314,13 @@
|
|||||||
"provider_other": "Other",
|
"provider_other": "Other",
|
||||||
"empty_title": "No emails blocked yet",
|
"empty_title": "No emails blocked yet",
|
||||||
"empty_subtitle": "Connect your mailbox so Rebreak can protect you automatically.",
|
"empty_subtitle": "Connect your mailbox so Rebreak can protect you automatically.",
|
||||||
|
|
||||||
"connect_sheet_title": "Connect mailbox",
|
"connect_sheet_title": "Connect mailbox",
|
||||||
"connect_sheet_subtitle": "Choose your email provider. Rebreak deletes gambling emails automatically — your message content is never read.",
|
"connect_sheet_subtitle": "Choose your email provider. Rebreak deletes gambling emails automatically — your message content is never read.",
|
||||||
|
|
||||||
"provider_gmail": "Gmail",
|
"provider_gmail": "Gmail",
|
||||||
"provider_icloud": "iCloud Mail",
|
"provider_icloud": "iCloud Mail",
|
||||||
"provider_outlook": "Outlook",
|
"provider_outlook": "Outlook",
|
||||||
"provider_yahoo": "Yahoo Mail",
|
"provider_yahoo": "Yahoo Mail",
|
||||||
"provider_gmx": "GMX / Web.de",
|
"provider_gmx": "GMX / Web.de",
|
||||||
|
|
||||||
"app_password_required_title": "App password required",
|
"app_password_required_title": "App password required",
|
||||||
"app_password_guide_gmail": "Gmail requires an app-specific password (not your regular Google password). Enable 2FA and create an app password at myaccount.google.com/apppasswords.",
|
"app_password_guide_gmail": "Gmail requires an app-specific password (not your regular Google password). Enable 2FA and create an app password at myaccount.google.com/apppasswords.",
|
||||||
"app_password_guide_icloud": "iCloud requires an app-specific password. Go to appleid.apple.com → Sign in → App-specific passwords.",
|
"app_password_guide_icloud": "iCloud requires an app-specific password. Go to appleid.apple.com → Sign in → App-specific passwords.",
|
||||||
@ -344,7 +329,6 @@
|
|||||||
"app_password_guide_gmx": "GMX / Web.de: Enable IMAP in settings and use your regular password or an app password if 2FA is active.",
|
"app_password_guide_gmx": "GMX / Web.de: Enable IMAP in settings and use your regular password or an app password if 2FA is active.",
|
||||||
"app_password_guide_other": "Enter the IMAP credentials of your email provider. An app password is recommended if available.",
|
"app_password_guide_other": "Enter the IMAP credentials of your email provider. An app password is recommended if available.",
|
||||||
"app_password_open_link": "Create app password now",
|
"app_password_open_link": "Create app password now",
|
||||||
|
|
||||||
"form_email_label": "Email address",
|
"form_email_label": "Email address",
|
||||||
"form_email_placeholder": "your@email.com",
|
"form_email_placeholder": "your@email.com",
|
||||||
"form_password_label": "App password",
|
"form_password_label": "App password",
|
||||||
@ -353,14 +337,11 @@
|
|||||||
"form_connect_btn": "Connect mailbox",
|
"form_connect_btn": "Connect mailbox",
|
||||||
"form_fields_required": "Email and password are required.",
|
"form_fields_required": "Email and password are required.",
|
||||||
"connect_failed": "Connection failed. Please check your credentials.",
|
"connect_failed": "Connection failed. Please check your credentials.",
|
||||||
|
|
||||||
"section_accounts": "Mailboxes",
|
"section_accounts": "Mailboxes",
|
||||||
"add_account_a11y": "Add mailbox",
|
"add_account_a11y": "Add mailbox",
|
||||||
|
|
||||||
"empty_state_title": "No mailbox connected",
|
"empty_state_title": "No mailbox connected",
|
||||||
"empty_state_subtitle": "Connect your first mailbox — Rebreak will delete gambling emails automatically before you see them.",
|
"empty_state_subtitle": "Connect your first mailbox — Rebreak will delete gambling emails automatically before you see them.",
|
||||||
"empty_state_cta": "Connect first mailbox",
|
"empty_state_cta": "Connect first mailbox",
|
||||||
|
|
||||||
"account_active": "Active",
|
"account_active": "Active",
|
||||||
"account_inactive": "Inactive",
|
"account_inactive": "Inactive",
|
||||||
"account_last_scan": "%{time} ago",
|
"account_last_scan": "%{time} ago",
|
||||||
@ -372,7 +353,6 @@
|
|||||||
"account_disconnect_confirm_title": "Disconnect mailbox?",
|
"account_disconnect_confirm_title": "Disconnect mailbox?",
|
||||||
"account_disconnect_confirm_message": "%{email} will be disconnected and all scan data will be deleted.",
|
"account_disconnect_confirm_message": "%{email} will be disconnected and all scan data will be deleted.",
|
||||||
"account_disconnect_confirm_btn": "Disconnect",
|
"account_disconnect_confirm_btn": "Disconnect",
|
||||||
|
|
||||||
"stats_blocked": "Blocked",
|
"stats_blocked": "Blocked",
|
||||||
"stats_accounts": "Mailboxes",
|
"stats_accounts": "Mailboxes",
|
||||||
"stats_next_scan": "Next scan",
|
"stats_next_scan": "Next scan",
|
||||||
@ -382,13 +362,10 @@
|
|||||||
"scheduled": "Scheduled",
|
"scheduled": "Scheduled",
|
||||||
"account_of_scanned": "of %{scanned} scanned",
|
"account_of_scanned": "of %{scanned} scanned",
|
||||||
"activity_log_count": "%{count} mail(s) blocked",
|
"activity_log_count": "%{count} mail(s) blocked",
|
||||||
|
|
||||||
"connect_success_title": "Mailbox connected",
|
"connect_success_title": "Mailbox connected",
|
||||||
"connect_success_message": "Rebreak will now automatically scan for gambling emails.",
|
"connect_success_message": "Rebreak will now automatically scan for gambling emails.",
|
||||||
|
|
||||||
"upgrade_alert_title": "More mailboxes",
|
"upgrade_alert_title": "More mailboxes",
|
||||||
"upgrade_alert_desc": "Upgrade to Pro for up to 3 mailboxes, or Legend for unlimited.",
|
"upgrade_alert_desc": "Upgrade to Pro for up to 3 mailboxes, or Legend for unlimited.",
|
||||||
|
|
||||||
"add_account": "Add mailbox",
|
"add_account": "Add mailbox",
|
||||||
"section_accounts_count": "%{used} of %{max} connected",
|
"section_accounts_count": "%{used} of %{max} connected",
|
||||||
"section_accounts_count_unlimited": "%{used} connected · unlimited",
|
"section_accounts_count_unlimited": "%{used} connected · unlimited",
|
||||||
@ -396,21 +373,40 @@
|
|||||||
"disconnect": "Disconnect",
|
"disconnect": "Disconnect",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"app_password_placeholder": "App password",
|
"app_password_placeholder": "App password",
|
||||||
|
|
||||||
"scan_interval_label": "Scan interval",
|
"scan_interval_label": "Scan interval",
|
||||||
"realtime_desc": "Real-time blocking via IMAP IDLE",
|
"realtime_desc": "Real-time blocking via IMAP IDLE",
|
||||||
"free_scan_interval_hint": "Free plan: fixed 4h interval. Upgrade for 1h.",
|
"free_scan_interval_hint": "Free plan: fixed 4h interval. Upgrade for 1h.",
|
||||||
|
|
||||||
"account_change_password": "Change password",
|
"account_change_password": "Change password",
|
||||||
"edit_account_title": "Update password",
|
"edit_account_title": "Update password",
|
||||||
"edit_account_subtitle": "Enter the new app password for %{email}. The previous password will be replaced.",
|
"edit_account_subtitle": "Enter the new app password for %{email}. The previous password will be replaced.",
|
||||||
"edit_account_save": "Save",
|
"edit_account_save": "Save",
|
||||||
|
|
||||||
"activity_log_title": "Recently blocked",
|
"activity_log_title": "Recently blocked",
|
||||||
"activity_log_subtitle": "Mails blocked in the last 24h",
|
"activity_log_subtitle": "Mails blocked in the last 24h",
|
||||||
"activity_log_empty": "No mails blocked in the last 24h",
|
"activity_log_empty": "No mails blocked in the last 24h",
|
||||||
"activity_log_more": "+ %{count} more",
|
"activity_log_more": "+ %{count} more",
|
||||||
"activity_no_subject": "(no subject)"
|
"activity_no_subject": "(no subject)",
|
||||||
|
"chart_title": "Last 7 days",
|
||||||
|
"chart_week_total": "%{count} this week",
|
||||||
|
"status_auth_error": "Auth Error",
|
||||||
|
"status_connect_error": "Connection Error",
|
||||||
|
"status_error_tap_hint": "Tap to fix",
|
||||||
|
"status_stale": "Stale",
|
||||||
|
"status_stale_last_scan": "last scan %{rel}",
|
||||||
|
"status_live_idle": "IDLE active since %{rel}",
|
||||||
|
"status_live_no_new_mail": "connected · no new mail since %{rel}",
|
||||||
|
"status_waiting_first_connect": "Waiting for first connection",
|
||||||
|
"auth_error_title": "App Password invalid",
|
||||||
|
"auth_error_subtitle": "The app password for %{email} has expired or is incorrect. Please renew it and enter it below.",
|
||||||
|
"auth_error_renew_link": "Create new app password",
|
||||||
|
"errors": {
|
||||||
|
"auth_failed": "The app password is incorrect. Please regenerate it at your mail provider and enter it here.",
|
||||||
|
"app_password_required": "Your mail provider requires an app-specific password. Create one in your account settings.",
|
||||||
|
"connection_failed": "Could not connect to the mail server. Please try again later.",
|
||||||
|
"host_unreachable": "Mail server is currently unreachable. Check your internet connection or try again later.",
|
||||||
|
"tls_error": "Secure connection to the mail server failed. Please contact your provider.",
|
||||||
|
"rate_limited": "Too many connection attempts. Please wait a few minutes and try again.",
|
||||||
|
"unknown": "Unknown error while connecting. Please check the app password and try again."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
@ -474,13 +470,13 @@
|
|||||||
"debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)",
|
"debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)",
|
||||||
"devices_page_title": "Registered devices",
|
"devices_page_title": "Registered devices",
|
||||||
"devices_slots": "Device slots",
|
"devices_slots": "Device slots",
|
||||||
"devices_slots_desc": "Your {{plan}} plan allows this many simultaneous devices.",
|
"devices_slots_desc": "Your %{plan} plan allows this many simultaneous devices.",
|
||||||
"devices_this_device": "This device",
|
"devices_this_device": "This device",
|
||||||
"devices_since": "since",
|
"devices_since": "since",
|
||||||
"devices_just_now": "just active",
|
"devices_just_now": "just active",
|
||||||
"devices_mins_ago": "{{count}}m ago",
|
"devices_mins_ago": "%{count}m ago",
|
||||||
"devices_hours_ago": "{{count}}h ago",
|
"devices_hours_ago": "%{count}h ago",
|
||||||
"devices_days_ago": "{{count}}d ago",
|
"devices_days_ago": "%{count}d ago",
|
||||||
"devices_empty": "No devices registered",
|
"devices_empty": "No devices registered",
|
||||||
"devices_hint": "Devices you remove will re-register on next sign-in. This device cannot be removed while you are signed in.",
|
"devices_hint": "Devices you remove will re-register on next sign-in. This device cannot be removed while you are signed in.",
|
||||||
"devices_remove_title": "Remove device",
|
"devices_remove_title": "Remove device",
|
||||||
@ -489,7 +485,7 @@
|
|||||||
},
|
},
|
||||||
"device_limit": {
|
"device_limit": {
|
||||||
"title": "Device limit reached",
|
"title": "Device limit reached",
|
||||||
"subtitle": "{{max}} of {{max}} device slots used ({{plan}}) — remove a device to continue",
|
"subtitle": "%{count} of %{max} device slots used (%{plan}) — remove a device to continue",
|
||||||
"hint": "Removed devices can re-register on next sign-in.",
|
"hint": "Removed devices can re-register on next sign-in.",
|
||||||
"remove_cta": "Remove device"
|
"remove_cta": "Remove device"
|
||||||
},
|
},
|
||||||
@ -731,6 +727,24 @@
|
|||||||
"motivational_1": "Every minute of focus is a minute for you.",
|
"motivational_1": "Every minute of focus is a minute for you.",
|
||||||
"motivational_2": "Training your attention — that's exactly what you just did.",
|
"motivational_2": "Training your attention — that's exactly what you just did.",
|
||||||
"motivational_3": "Well played. And good that you're here.",
|
"motivational_3": "Well played. And good that you're here.",
|
||||||
"motivational_4": "Small pauses, big impact."
|
"motivational_4": "Small pauses, big impact.",
|
||||||
|
"lyra_title_record": "New record!",
|
||||||
|
"lyra_body_record": "You surpassed yourself. Impressive.",
|
||||||
|
"lyra_title_good": "Excellent!",
|
||||||
|
"lyra_body_good": "You were fully in the zone — the urge had no chance.",
|
||||||
|
"lyra_title_ok": "Keep going",
|
||||||
|
"lyra_body_ok": "Every round moves you forward. Stay with it.",
|
||||||
|
"lyra_title_low": "Next time",
|
||||||
|
"lyra_body_low": "Showing up already counts. You've got this.",
|
||||||
|
"rating_saved": "Rating saved",
|
||||||
|
"save_rating": "Save rating",
|
||||||
|
"feedback_placeholder": "What did you like or miss?",
|
||||||
|
"share_result": "Share to community",
|
||||||
|
"share_to_community": "Share your result",
|
||||||
|
"share_challenge": "Can you beat this?",
|
||||||
|
"share_loading": "Lyra is writing...",
|
||||||
|
"post_to_community": "Post",
|
||||||
|
"posted": "Posted to the community feed",
|
||||||
|
"post_error": "Posting failed, please try again"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,6 +55,7 @@
|
|||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-bottom-tabs": "^1.2.0",
|
"react-native-bottom-tabs": "^1.2.0",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
"react-native-keyboard-controller": "^1.21.7",
|
||||||
"react-native-mmkv": "^3.1.0",
|
"react-native-mmkv": "^3.1.0",
|
||||||
"react-native-reanimated": "~4.1.7",
|
"react-native-reanimated": "~4.1.7",
|
||||||
"react-native-safe-area-context": "5.6.2",
|
"react-native-safe-area-context": "5.6.2",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
import { getDeviceId, getPlatformName } from '../lib/deviceId';
|
import { getDeviceInfo } from '../lib/deviceId';
|
||||||
|
|
||||||
export interface UserDevice {
|
export interface UserDevice {
|
||||||
id: string;
|
id: string;
|
||||||
@ -18,45 +18,42 @@ type DevicesState = {
|
|||||||
maxDevices: number;
|
maxDevices: number;
|
||||||
plan: string;
|
plan: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
registered: boolean;
|
|
||||||
|
|
||||||
ensureRegistered: () => Promise<void>;
|
ensureRegistered: () => Promise<void>;
|
||||||
loadDevices: () => Promise<void>;
|
loadDevices: () => Promise<void>;
|
||||||
removeDevice: (id: string) => Promise<void>;
|
removeDevice: (id: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDevicesStore = create<DevicesState>((set, get) => ({
|
export const useDevicesStore = create<DevicesState>((set) => ({
|
||||||
devices: [],
|
devices: [],
|
||||||
maxDevices: 1,
|
maxDevices: 1,
|
||||||
plan: 'free',
|
plan: 'free',
|
||||||
loading: false,
|
loading: false,
|
||||||
registered: false,
|
|
||||||
|
|
||||||
ensureRegistered: async () => {
|
ensureRegistered: async () => {
|
||||||
if (get().registered) return;
|
const info = await getDeviceInfo().catch(() => null);
|
||||||
|
if (!info) return;
|
||||||
const deviceId = await getDeviceId().catch(() => null);
|
|
||||||
if (!deviceId) return;
|
|
||||||
|
|
||||||
const platform = getPlatformName();
|
|
||||||
|
|
||||||
await apiFetch('/api/devices/register', {
|
await apiFetch('/api/devices/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
skipDeviceHeader: true,
|
skipDeviceHeader: true,
|
||||||
body: { deviceId, platform },
|
body: {
|
||||||
|
deviceId: info.deviceId,
|
||||||
|
platform: info.platform,
|
||||||
|
name: info.name,
|
||||||
|
model: info.model,
|
||||||
|
osVersion: info.osVersion,
|
||||||
|
appVersion: info.appVersion,
|
||||||
|
},
|
||||||
}).then((res: any) => {
|
}).then((res: any) => {
|
||||||
set({ registered: true, maxDevices: res.max ?? 1 });
|
set({ maxDevices: res.max ?? 1 });
|
||||||
}).catch(() => {
|
}).catch(() => {});
|
||||||
// Limit reached or transient — App continues; limit UI is handled at auth level
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
loadDevices: async () => {
|
loadDevices: async () => {
|
||||||
set({ loading: true });
|
set({ loading: true });
|
||||||
try {
|
try {
|
||||||
if (!get().registered) {
|
await useDevicesStore.getState().ensureRegistered();
|
||||||
await get().ensureRegistered();
|
|
||||||
}
|
|
||||||
const res = await apiFetch<{ devices: UserDevice[]; max: number; plan: string }>(
|
const res = await apiFetch<{ devices: UserDevice[]; max: number; plan: string }>(
|
||||||
'/api/devices'
|
'/api/devices'
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
-- Migration: add_mail_connection_status_fields
|
||||||
|
-- Adds error-tracking + IDLE heartbeat timestamp to mail_connections.
|
||||||
|
-- Deploy: pnpm prisma migrate deploy (on server)
|
||||||
|
|
||||||
|
ALTER TABLE "rebreak"."mail_connections"
|
||||||
|
ADD COLUMN "last_connect_error" TEXT,
|
||||||
|
ADD COLUMN "last_connect_error_at" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "last_idle_heartbeat_at" TIMESTAMP(3);
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
-- Migration: add game_name column to community_posts
|
||||||
|
-- Generated: 2026-05-09
|
||||||
|
ALTER TABLE "rebreak"."community_posts" ADD COLUMN "game_name" TEXT;
|
||||||
158
docs/RIVE_ANIMATOR_BRIEF.md
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
# Rive Animator Brief — Lyra Avatar Emotion States
|
||||||
|
|
||||||
|
**Ready-to-publish — copy section between the dividers below into your job-post on Dribbble / Fiverr / Twitter / Rive Discord.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Project: Rebreak — Lyra Avatar Animation
|
||||||
|
|
||||||
|
I'm hiring a Rive-animator to extend **Lyra**, the AI-companion of **Rebreak** — a recovery app for people working through gambling addiction (German market, planned DiGA-listing in healthcare).
|
||||||
|
|
||||||
|
**Critical context:** This is a sensitive recovery-app, NOT a playful game. Lyra is a warm, present companion — never cute-mascot, never childish, never dramatic. Subtle > exaggerated.
|
||||||
|
|
||||||
|
**Budget: $100 USD, ~1 week target.** Self-qualify before applying.
|
||||||
|
|
||||||
|
## What I have (current `.riv`)
|
||||||
|
|
||||||
|
- File: `lyra-avatar.riv` (264 KB) — will be shared after engagement-confirmation
|
||||||
|
- **Artboard name:** `Artboard`
|
||||||
|
- **State machine name:** `State Machine 1`, default state `idle`
|
||||||
|
- **Existing animation timelines** (extracted via `strings`):
|
||||||
|
- `Idle Loop` — wired as `idle`
|
||||||
|
- `idle to Pose 1` + `Pose 1 loop` — wired as `happy` (2-phase: app manually switches via 900ms JS-timer)
|
||||||
|
- `01 Wave 1` — wired as `empathy`
|
||||||
|
- `01 Wave 2` — exists, NOT wired (orphan)
|
||||||
|
- `WALK` — placeholder, currently aliased as `thinking` (please replace with proper thinking-pose animation)
|
||||||
|
- `Kedip` (Indonesian "blink") — orphan from template
|
||||||
|
- **Runtime:** `rive-react-native ^9.0.1` (export against this compatible Rive editor version)
|
||||||
|
- **Avatar usage:** appears at 40px (small list), 112px (chat-header), 160px (onboarding hero) — **must read clearly even at 40px**
|
||||||
|
|
||||||
|
## ⚠️ Critical naming contract — do NOT rename existing names
|
||||||
|
|
||||||
|
The React code calls timelines **directly by name** (not via state-machine inputs). If you rename a timeline, the app silently breaks (no animation plays, no error). This means:
|
||||||
|
|
||||||
|
- **Keep:** `Artboard` artboard, `State Machine 1` SM-name, `idle` default-state, `Idle Loop` timeline name
|
||||||
|
- **New emotion-states must use exactly these timeline names** (snake_case): `sad`, `joy`, `confusion`, `calm`, `surprise`, `listening`, `thinking` (replacing `WALK`)
|
||||||
|
- **For multi-phase emotions** (e.g. dramatic intro → loop): use `<state> intro` + `<state> loop` pattern, just like existing `idle to Pose 1` + `Pose 1 loop`
|
||||||
|
- **Bonus** (+$X if you propose): expose state-machine inputs (`SetEmotion` enum-trigger) so we can transition imperatively. Optional — named-timelines remain the primary contract.
|
||||||
|
|
||||||
|
## Deliverables — emotion states
|
||||||
|
|
||||||
|
You'll add these to the existing state-machine. Total: **6 new states + 1 placeholder-replacement (`thinking`)**.
|
||||||
|
|
||||||
|
### Tier 1 — must-have (4 states, baseline budget)
|
||||||
|
|
||||||
|
| Internal name (timeline) | Trigger in app | Eyes | Brow | Mouth | Body |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **`thinking`** (REPLACE `WALK`) | LLM is generating response | look up-and-to-side, slow blink | one slightly raised | gently closed, slight pursed | finger-near-temple pose if possible, head tilt ~5° |
|
||||||
|
| **`listening`** | User mid-typing OR voice-recording | open, attentive, natural blink | neutral relaxed | gently closed, micro-Mona-Lisa upturn | subtle head nod every ~3s |
|
||||||
|
| **`calm`** | Breathing exercise / meditation active | half-closed | neutral relaxed | slight serene smile | very slow up/down breathing loop (~4s cycle to match 4-7-8 breathing pace) |
|
||||||
|
| **`sad`** | User describes loss/relapse/shame | softened, slight downcast, lower lid raised | inner-up, slight angle | closed, neutral or micro-downturn | tiny head tilt down (~5°), slow breathing |
|
||||||
|
|
||||||
|
### Tier 2 — nice-to-have (2-3 more states, push budget if you can)
|
||||||
|
|
||||||
|
| Internal name (timeline) | Trigger in app | Visual direction |
|
||||||
|
|---|---|---|
|
||||||
|
| **`joy`** | Streak milestone, big celebration | bigger smile than `happy` (warmer not goofy), small head bounce on intro then settle |
|
||||||
|
| **`confusion`** | Lyra needs clarification | one brow raised, slight squint, head tilt ~10° to one side |
|
||||||
|
| **`surprise`** | Unexpected user input | wide-open eyes (brief), both brows raised, small "o" mouth, quick head pull-back micro on intro then settle |
|
||||||
|
|
||||||
|
### Skip these (anti-patterns for recovery use-case)
|
||||||
|
|
||||||
|
- ❌ `angry` / `frustrated` — never from coach
|
||||||
|
- ❌ `shocked` / `horror` — too dramatic for trauma-context
|
||||||
|
- ❌ Cute-mascot expressions (winks, tongue-out, hearts in eyes)
|
||||||
|
- ❌ Heavy bone-rigs / particles — runtime cost too high
|
||||||
|
|
||||||
|
## Visual style guidelines
|
||||||
|
|
||||||
|
- **Match existing Lyra-look** (extract palette + line-weight from the `.riv` you'll receive)
|
||||||
|
- **Subtle is better** — these animations play during emotional moments, they should *support* not *demand* attention
|
||||||
|
- **Loop-friendly** — `idle`, `calm`, `listening` should breathe naturally, no pop on loop boundary
|
||||||
|
- **Smooth transitions** — prefer 200-400ms crossfades over hard cuts. Especially: `empathy → idle → happy` should never feel jarring (route through idle, never direct jump from negative to positive)
|
||||||
|
- **Readability at 40px** — exaggerate eye/brow shapes slightly, avoid mouth-only emotion (mouth is too small at 40px to carry expression)
|
||||||
|
|
||||||
|
## Technical Requirements
|
||||||
|
|
||||||
|
- **Output: ONE `.riv` file** named exactly `lyra-avatar.riv` (replaces current file)
|
||||||
|
- **Single artboard, single state-machine** — preserve existing structure
|
||||||
|
- **Rive editor version**: 2024.x (compatible with `rive-react-native ^9.0.1`)
|
||||||
|
- **Performance**: 60 fps target on mid-range Android (Pixel 5-class)
|
||||||
|
- **File-size**: ≤500KB (current 264KB, want headroom for new states)
|
||||||
|
- **Loop-cycle precision**: `calm` should be ~4 seconds (we sync app-side to user's 4-7-8 breathing exercise)
|
||||||
|
|
||||||
|
## Bonus task (optional, +scope)
|
||||||
|
|
||||||
|
Existing `happy` uses a 2-phase manual switch via 900ms JS-timeout — clunky. **If you can fix this so it loops cleanly inside the `.riv` itself** (intro auto-blends into loop without app-side coordination), that's worth +$X.
|
||||||
|
|
||||||
|
## Timeline + Budget
|
||||||
|
|
||||||
|
- **$100 USD flat**, paid: 50% on first-draft approval, 50% on final delivery
|
||||||
|
- **1 week** from brief-confirmation
|
||||||
|
- **Milestones**:
|
||||||
|
1. Day 0: brief-confirm + you receive `.riv` file + answers to your questions
|
||||||
|
2. Day 2-3: first draft of 1-2 states for visual-direction approval (style-confirm)
|
||||||
|
3. Day 4-6: remaining states + 1 round of revisions
|
||||||
|
4. Day 7: final delivery
|
||||||
|
|
||||||
|
## Deliverables you provide
|
||||||
|
|
||||||
|
1. **`lyra-avatar.riv`** — replaces existing file
|
||||||
|
2. **Short README** (1 page max):
|
||||||
|
- List of all timeline-names + when each plays
|
||||||
|
- Any limitations or known issues
|
||||||
|
- State-machine diagram (simple)
|
||||||
|
3. **Source-file** (Rive editor `.rev` or equivalent) for future edits
|
||||||
|
4. **Optional bonus**: short demo-video (15-30 sec) showing all states cycling — earns trust
|
||||||
|
|
||||||
|
## Questions to ask BEFORE starting (please answer in your application)
|
||||||
|
|
||||||
|
1. Can I use the current `.riv` as a base and add states, or do you want a clean rebuild?
|
||||||
|
2. Confirm Rive runtime version (`rive-react-native ^9.0.1`) — compatible with your export?
|
||||||
|
3. Should I also fix the existing `happy` 2-phase JS-timer (auto-blend in `.riv` instead)?
|
||||||
|
4. For the German market: any culture-specific gestures to avoid?
|
||||||
|
5. Any brand-colors / hex-codes I must match?
|
||||||
|
6. Audio cues or visual-only?
|
||||||
|
7. Do you have Figma / brand-guide I should reference?
|
||||||
|
|
||||||
|
## What I value in your work
|
||||||
|
|
||||||
|
- Restraint over flashiness
|
||||||
|
- Clean state-machine architecture (other devs may extend later)
|
||||||
|
- Honest communication if scope is too tight for budget — happy to scope down to 4 states (Tier 1 only)
|
||||||
|
- Async-first (Slack-like / email / Discord-DM)
|
||||||
|
|
||||||
|
## How to apply
|
||||||
|
|
||||||
|
Send me:
|
||||||
|
1. Link to **Rive portfolio** (not Lottie, not After-Effects — actual `.riv` work)
|
||||||
|
2. **Confirmation you've read this brief** (so I know it's not auto-applied)
|
||||||
|
3. Your suggested approach: extend existing state-machine OR rebuild?
|
||||||
|
4. Your answers to the 7 questions above
|
||||||
|
|
||||||
|
Looking forward to working together.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**End of brief — copy everything above into your job-post.**
|
||||||
|
|
||||||
|
## How to use this brief (internal — not for animator)
|
||||||
|
|
||||||
|
1. **Vor dem Senden** alle `[Platzhalter]`-Stellen (insbesondere Communication-Channel falls du das spezifizieren willst) ausfüllen
|
||||||
|
2. **Aktuelle `.riv` mitschicken** — Animator braucht sie als Style-Referenz + State-Machine-Setup
|
||||||
|
3. **Klarstellen**: Emotion-Namen in der Tabelle sind **Code-Contracts** und müssen exakt so im Rive-File heißen — sonst muss React-Code refactored werden
|
||||||
|
4. **Vor Vergabe** 2-3 Animator-Portfolios checken — suche „warm/subtle character"-style, NICHT nur knallige Logo-Animationen
|
||||||
|
5. **Nach Erhalt der ersten Draft** auf echtem Android-Mid-Range-Gerät testen (Pixel 5 oder älter), nicht nur iOS-Simulator
|
||||||
|
6. **Wo publishen** (in Reihenfolge der Wahrscheinlichkeit):
|
||||||
|
- Rive Discord (https://rive.app/community → Discord) — Rive-spezialisierte Animatoren, keine LottieFiles-Refugees
|
||||||
|
- Twitter `#RiveAnimation` hashtag + DMs an Animator-Portfolios die du gut findest
|
||||||
|
- Fiverr „Rive animator" custom-offers ($30-150 typische Gigs)
|
||||||
|
- Dribbble „Hiring" section — gemischte Quality, mehr Style-fokus
|
||||||
|
7. **Wenn `.riv` ankommt**: drag in `apps/rebreak-native/assets/lyra-avatar.riv` (overwrite), commit, fertig. Code ist schon flexibel (RiveAvatar accepts any state-name nach Task #39 component-flex).
|
||||||
|
|
||||||
|
## Sources / Internal-Files
|
||||||
|
|
||||||
|
- Brief-Audit: `apps/rebreak-native/components/RiveAvatar.tsx` (lines 42-51 — Emotion-API contract)
|
||||||
|
- Existing `.riv`: `apps/rebreak-native/assets/lyra-avatar.riv` (264 KB)
|
||||||
|
- Plugin: `apps/rebreak-native/plugins/with-rive-asset-android.js` (Android raw-resource auto-mirror)
|
||||||
|
- Trigger-context: `apps/rebreak-native/app/lyra.tsx` (lines 37-44, 306-323), `apps/rebreak-native/app/urge.tsx`, `apps/rebreak-native/lib/lyraResponse.ts:57-61` (existing `detectEmotion()`)
|
||||||
210
docs/internal/MAIL_DAEMON_DEPLOYMENT.md
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
# MAIL_DAEMON_DEPLOYMENT — Backyard Handoff
|
||||||
|
|
||||||
|
**Erstellt von:** Mo (Mail-Architektur-Agent)
|
||||||
|
**Datum:** 2026-05-09
|
||||||
|
**Status:** Bereit für Deployment — wartet auf Backyard-GO vom User
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Der `rebreak-imap-idle` Daemon ist ein eigenständiger Node.js-Prozess.
|
||||||
|
Er hält pro aktivem MailConnection-DB-Eintrag eine persistente IMAP-IDLE-Session
|
||||||
|
und triggert bei neuer Mail sofort `/api/mail/scan-internal` statt auf den 30min-Cron zu warten.
|
||||||
|
|
||||||
|
Der Daemon liegt unter `backend/imap-idle/index.mjs` und hat seine eigene `package.json`.
|
||||||
|
Er ist KEIN Teil des Nitro-Builds — er wird direkt via `node` gestartet.
|
||||||
|
|
||||||
|
## Was Backyard tun muss (in dieser Reihenfolge)
|
||||||
|
|
||||||
|
### Schritt 1 — GH-Actions: imap-idle ins Artifact einschließen
|
||||||
|
|
||||||
|
In `.github/workflows/deploy-backend.yml` (oder analog) muss das `backend/imap-idle/`-Verzeichnis
|
||||||
|
ins deploy-Artifact aufgenommen werden.
|
||||||
|
|
||||||
|
Das Artifact enthält aktuell wahrscheinlich nur `backend/.output-staging/` und `backend/prisma/`.
|
||||||
|
`backend/imap-idle/` muss ebenfalls mit kopiert werden.
|
||||||
|
|
||||||
|
Konkretes Beispiel (je nach Artifact-Aufbau anpassen):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# In der scp/rsync-Step des deploy-workflows:
|
||||||
|
- name: Copy imap-idle to server
|
||||||
|
run: |
|
||||||
|
scp -r backend/imap-idle/ rebreak-server:/srv/rebreak/backend/imap-idle/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 2 — npm install auf Server
|
||||||
|
|
||||||
|
Nach dem Artifact-Copy muss auf dem Server `npm install` in `backend/imap-idle/` laufen.
|
||||||
|
Das installiert `imapflow` und `pg` lokal für den Daemon.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/rebreak/backend/imap-idle && npm install --production
|
||||||
|
```
|
||||||
|
|
||||||
|
Diesen Schritt als deploy-Step in GH-Actions oder in `deploy-from-artifact.sh` ergänzen.
|
||||||
|
|
||||||
|
### Schritt 3 — Zombie-Prozesse aufräumen
|
||||||
|
|
||||||
|
Vor dem ersten Start der neuen pm2-Einträge alte Stale-Einträge entfernen
|
||||||
|
(falls `rebreak-imap-staging` oder `rebreak-idle-staging` aus altem Setup noch existieren):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 delete rebreak-idle-staging rebreak-imap-staging 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 4 — ecosystem.config.js erweitern
|
||||||
|
|
||||||
|
Die folgenden Einträge in `/srv/rebreak/ecosystem.config.js` ergänzen
|
||||||
|
(unterhalb des bestehenden `rebreak-staging`-Eintrags einfügen):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ─── IMAP IDLE Daemon Staging ───────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
name: "rebreak-idle-staging",
|
||||||
|
script: "/srv/rebreak/backend/imap-idle/index.mjs",
|
||||||
|
interpreter: "/root/.nvm/versions/node/v24.11.1/bin/node",
|
||||||
|
cwd: "/srv/rebreak/backend/imap-idle",
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: "256M",
|
||||||
|
env: {
|
||||||
|
NODE_ENV: "production",
|
||||||
|
// ACHTUNG: Keine Secrets hier hinterlegen.
|
||||||
|
// Infisical-Wrapper via start-idle-staging.sh (Schritt 5).
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── IMAP IDLE Daemon Prod (auskommentiert bis Prod-Cutover) ───────────────
|
||||||
|
// {
|
||||||
|
// name: "rebreak-idle-prod",
|
||||||
|
// script: "/srv/rebreak/backend/imap-idle/index.mjs",
|
||||||
|
// interpreter: "/root/.nvm/versions/node/v24.11.1/bin/node",
|
||||||
|
// cwd: "/srv/rebreak/backend/imap-idle",
|
||||||
|
// instances: 1,
|
||||||
|
// autorestart: true,
|
||||||
|
// watch: false,
|
||||||
|
// max_memory_restart: "256M",
|
||||||
|
// env: { NODE_ENV: "production" },
|
||||||
|
// },
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 5 — start-idle-staging.sh erstellen
|
||||||
|
|
||||||
|
Der Daemon braucht die gleichen Infisical-Secrets wie das Backend.
|
||||||
|
Eine eigene Start-Shell analog zu `backend/start-staging.sh` erstellen:
|
||||||
|
|
||||||
|
Pfad: `/srv/rebreak/backend/imap-idle/start-idle-staging.sh`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# rebreak-imap-idle Staging — Infisical-Secret-Injection
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
source /etc/environment
|
||||||
|
|
||||||
|
if [[ -z "${INFISICAL_CLIENT_ID:-}" || -z "${INFISICAL_CLIENT_SECRET:-}" ]]; then
|
||||||
|
echo "[idle] FEHLER: INFISICAL_CLIENT_ID / SECRET nicht gesetzt" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
INFISICAL_TOKEN=$(infisical login \
|
||||||
|
--method=universal-auth \
|
||||||
|
--client-id="${INFISICAL_CLIENT_ID}" \
|
||||||
|
--client-secret="${INFISICAL_CLIENT_SECRET}" \
|
||||||
|
--silent --plain 2>/dev/null)
|
||||||
|
|
||||||
|
[[ -z "$INFISICAL_TOKEN" ]] && { echo "[idle] Infisical login fehlgeschlagen" >&2; exit 1; }
|
||||||
|
|
||||||
|
NODE_BIN="/root/.nvm/versions/node/v24.11.1/bin/node"
|
||||||
|
DAEMON="/srv/rebreak/backend/imap-idle/index.mjs"
|
||||||
|
|
||||||
|
exec infisical run \
|
||||||
|
--projectId="${INFISICAL_PROJECT_ID:-14b11b35-ef59-4b8a-a16b-398f0cc3ad93}" \
|
||||||
|
--env=staging \
|
||||||
|
--token="$INFISICAL_TOKEN" \
|
||||||
|
-- bash -c '
|
||||||
|
set -e
|
||||||
|
export DATABASE_URL="${DATABASE_URL:-${NUXT_DATABASE_URL:-}}"
|
||||||
|
export ADMIN_SECRET="${ADMIN_SECRET:-${NUXT_ADMIN_SECRET:-}}"
|
||||||
|
export ENCRYPTION_KEY="${ENCRYPTION_KEY:-${NUXT_ENCRYPTION_KEY:-}}"
|
||||||
|
export BACKEND_URL="http://127.0.0.1:3016"
|
||||||
|
exec '"$NODE_BIN"' '"$DAEMON"'
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann `chmod +x start-idle-staging.sh` und in ecosystem.config.js den `script`-Key
|
||||||
|
auf `start-idle-staging.sh` zeigen lassen (mit `interpreter: "bash"`),
|
||||||
|
analog zum Pattern von `rebreak-staging`.
|
||||||
|
|
||||||
|
### Schritt 6 — pm2 starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 startOrReload /srv/rebreak/ecosystem.config.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verifikations-Schritte nach Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. pm2-Status prüfen
|
||||||
|
pm2 list
|
||||||
|
# Erwartung: rebreak-idle-staging → status=online, restart=0
|
||||||
|
|
||||||
|
# 2. Logs der ersten 60 Sekunden ansehen
|
||||||
|
pm2 logs rebreak-idle-staging --lines 100
|
||||||
|
# Erwartung: "[idle/<email>] connected (...)" für alle aktiven Mailboxen
|
||||||
|
# "[idle/db] refreshed — N active connections, N sessions"
|
||||||
|
|
||||||
|
# 3. Test: Mail an eine verbundene Mailbox schicken (Betreff: "casino bonus")
|
||||||
|
# Innerhalb von 5 Sekunden sollte im Log erscheinen:
|
||||||
|
# "[idle/<email>] exists-event received (new mail)"
|
||||||
|
# "[idle/<email>] scan-triggered → scanned=X blocked=1"
|
||||||
|
|
||||||
|
# 4. Memory-Check nach 10 Minuten
|
||||||
|
pm2 monit
|
||||||
|
# Erwartung: < 100MB bei <20 Connections, < 200MB bei 100 Connections
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback-Plan
|
||||||
|
|
||||||
|
Falls der Daemon crashed oder instabil ist:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 stop rebreak-idle-staging
|
||||||
|
# NICHT delete — damit Logs erhalten bleiben
|
||||||
|
```
|
||||||
|
|
||||||
|
Auswirkung: Mail-Scanning fällt auf den bestehenden 30min-Cron zurück.
|
||||||
|
Kein Komplett-Ausfall der Mail-Schutz-Funktion. Gambling-Mails werden
|
||||||
|
weiter gelöscht, nur mit bis zu 30min Verzögerung statt Echtzeit.
|
||||||
|
|
||||||
|
## Bekannte Provider-Quirks
|
||||||
|
|
||||||
|
| Provider | IMAP-Host | Port | TLS | Bekanntes Problem |
|
||||||
|
|-------------|-----------------------------|------|----------|--------------------------------------------|
|
||||||
|
| Gmail | imap.gmail.com | 993 | Implicit | App-Password erforderlich (kein OAuth2) |
|
||||||
|
| iCloud | imap.mail.me.com | 993 | Implicit | App-Specific-Password in Apple-Settings |
|
||||||
|
| Outlook | outlook.office365.com | 993 | Implicit | IDLE-Drop nach ~20min — disableCompression |
|
||||||
|
| GMX | imap.gmx.net | 993 | Implicit | Stabil, kein besonderer Quirk |
|
||||||
|
| Web.de | imap.web.de | 993 | Implicit | Stabil |
|
||||||
|
| T-Online | secureimap.t-online.de | 993 | Implicit | Stabil |
|
||||||
|
| Posteo | posteo.de | 993 | Implicit | Stabil |
|
||||||
|
|
||||||
|
Outlook-spezifisch: Der Daemon setzt `disableCompression: true` wenn der Host
|
||||||
|
`office365` enthält — verhindert partial-read-Fehler nach IDLE-Drop.
|
||||||
|
|
||||||
|
## Datei-Übersicht
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/imap-idle/
|
||||||
|
index.mjs — Daemon-Hauptdatei (ESM, standalone)
|
||||||
|
package.json — Eigene Dependencies (imapflow, pg)
|
||||||
|
README.md — Kurz-Doku (lokal starten, env-vars, log-format)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Abhaengigkeiten
|
||||||
|
|
||||||
|
- `imapflow ^1.2.18` — IMAP-Client-Library (bereits in backend/package.json)
|
||||||
|
- `pg ^8.16.3` — Direkter Postgres-Zugriff (kein Prisma im Daemon-Kontext)
|
||||||
|
- Node.js >= 20 (ESM, top-level await via main())
|
||||||
|
- Infisical-CLI auf dem Server (bereits installiert fuer rebreak-staging)
|
||||||
196
docs/internal/PRIVACY_POLICY_USER_NOTES.md
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
# Rebreak — Privacy-Policy User-Notes (DSB-Begleitdokument)
|
||||||
|
|
||||||
|
**Stand:** 2026-05-09
|
||||||
|
**Verfasser:** Hans Müller (externer DSB i.V.)
|
||||||
|
**Adressat:** Chahine Brini (Inhaber Rebreak / künftig Raynis GmbH)
|
||||||
|
**Kontext:** Begleitnotiz zur veröffentlichten Datenschutzerklärung v1 vom 09.05.2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ACHTUNG — HIGH-PRIO Flag
|
||||||
|
|
||||||
|
> **User wünscht ausdrücklich KEIN separates Consent-UI** für die Stufe-1-Übertragung
|
||||||
|
> von Lyra-Chat-Inhalten an Groq/Anthropic. Die Transparenz wird ausschließlich über
|
||||||
|
> § 11 Abs. 2–3 der Datenschutzerklärung sowie die übergeordnete Art. 9 Abs. 2 lit. a-
|
||||||
|
> Einwilligung beim Lyra-Onboarding hergestellt.
|
||||||
|
>
|
||||||
|
> **DSB-Bewertung:** Vertretbar bei Stufe 1, da kein Klarname/keine E-Mail/keine
|
||||||
|
> Account-ID übermittelt wird. Voraussetzung: das Lyra-Onboarding muss eine echte,
|
||||||
|
> granulare, vorab-eingeholte Einwilligung sein (nicht im AGB-Sammelhaken). Sobald
|
||||||
|
> identifizierende Inhalte im Chat stehen, ist Stufe 2 Pflicht. **Ziel-Datum für
|
||||||
|
> Stufe 2: Q3 2026 (siehe Migrations-Plan unten).**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. TODO — DPA / AVV-Status der 12 Sub-Auftragsverarbeiter
|
||||||
|
|
||||||
|
| # | Anbieter | Sitz | DPA-Status | TIA | Action |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | Hetzner Online GmbH | DE (EU) | TODO — Standard-AVV vorbereitet | n/a | Bei Hetzner: Anhang 4 + 5 ausfüllen, gegenzeichnen lassen |
|
||||||
|
| 2 | Stripe Payments Europe Ltd. | IE (EU) → Stripe Inc. (USA) | TODO — Stripe-DPA online akzeptieren (Dashboard) | TODO leichtgewichtig | https://stripe.com/legal/dpa |
|
||||||
|
| 3 | Groq, Inc. | USA | OFFEN — auf Groq-DPA warten / via Sales anfragen | TODO PFLICHT | Hoch-Prio: Groq Sales kontaktieren falls kein Self-Service DPA |
|
||||||
|
| 4 | Anthropic PBC | USA | TODO — Anthropic Commercial-Terms + DPA-Addendum | TODO PFLICHT | https://www.anthropic.com/legal/commercial-terms |
|
||||||
|
| 5 | OpenRouter, Inc. | USA | OFFEN — DPA-Verfügbarkeit unklar, ggf. nicht produktiv nutzen | TODO PFLICHT | Falls keine SCCs verfügbar: Provider rauswerfen |
|
||||||
|
| 6 | Cartesia, Inc. | USA | OFFEN — DPA anfordern | TODO PFLICHT | Falls TTS optional: erst aktivieren, wenn DPA vorliegt |
|
||||||
|
| 7 | ElevenLabs Inc. | USA | TODO — ElevenLabs Enterprise-DPA | TODO PFLICHT | https://elevenlabs.io/dpa |
|
||||||
|
| 8 | Deepgram, Inc. | USA | TODO — Deepgram-DPA via Account-Manager | TODO PFLICHT | Falls STT optional: erst aktivieren, wenn DPA vorliegt |
|
||||||
|
| 9 | Cloudflare, Inc. | USA (EU-Edge) | TODO — Cloudflare-DPA online | leichtgewichtig | https://www.cloudflare.com/cloudflare-customer-dpa/ |
|
||||||
|
| 10 | Apple Inc. (APNs) | USA | abgedeckt durch Apple Developer Program License Agreement | leichtgewichtig | Existierender ADP-Vertrag enthält DPA-Anhang |
|
||||||
|
| 11 | Google LLC (FCM) | USA | TODO — Firebase-DPA via Console | leichtgewichtig | https://firebase.google.com/terms/data-processing-terms |
|
||||||
|
| 12 | Infisical Inc. | USA | TODO — DPA falls verfügbar; ohne Endnutzer-PII niedrige Prio | n/a | Niedrige Prio (nur tech-Secrets, keine Endnutzer-Daten) |
|
||||||
|
|
||||||
|
**Zusammenfassung:** 0 von 12 DPAs aktuell formal abgeschlossen. Empfehlung: Hetzner +
|
||||||
|
Stripe + Anthropic + Groq + Cloudflare + Firebase als Top-6 priorisieren (decken die
|
||||||
|
materiellen Drittland-Übertragungen ab). Realistisches Zeitfenster: 4–6 Wochen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Anwalt-Review-Checkliste — 3 kritische Punkte
|
||||||
|
|
||||||
|
> **Ich (DSB) bin kein Rechtsanwalt.** Folgende Passagen sind vor produktiver
|
||||||
|
> Veröffentlichung der Policy zwingend durch eine im IT-/Datenschutzrecht
|
||||||
|
> spezialisierte Kanzlei zu prüfen.
|
||||||
|
|
||||||
|
### 2.1 Pro-Trial als Gegenleistung für demographische Daten — Risk: HOCH
|
||||||
|
|
||||||
|
**Stelle:** § 5 Abs. 4 Datenschutzerklärung („Pro-Trial-Reward")
|
||||||
|
**Norm:** Art. 7 Abs. 4 DSGVO (Kopplungsverbot), EDSA-Leitlinien 05/2020
|
||||||
|
**Risiko:** Aufsichtsbehörden bewerten „Vorteil gegen Datenpreisgabe" zunehmend
|
||||||
|
restriktiv. Argumentation „Pro-Features gehen über Kernleistung hinaus" ist tragfähig,
|
||||||
|
aber nicht risikolos.
|
||||||
|
**Anwaltsfrage:** Reicht die transparente Darstellung + jederzeitige
|
||||||
|
Widerrufsmöglichkeit + keine-Kopplung-an-Kernfunktion-Klausel zur Rechtfertigung?
|
||||||
|
Empfehlung: alternative Formulierungen (z. B. „Anerkennung" statt „Belohnung"), ggf.
|
||||||
|
Ergänzung um pseudonyme Erhebungs-Variante als Default.
|
||||||
|
|
||||||
|
### 2.2 LLM-Übertragung Stufe 1 ohne separates Consent-UI — Risk: MITTEL
|
||||||
|
|
||||||
|
**Stelle:** § 11 Abs. 2–3
|
||||||
|
**Norm:** Art. 9 Abs. 2 lit. a DSGVO, Erwägungsgrund 32 (Granularität der
|
||||||
|
Einwilligung), Art. 12 DSGVO (Verständlichkeit)
|
||||||
|
**Risiko:** Aufsichtsbehörden könnten argumentieren, dass die spezifische
|
||||||
|
Drittland-Übertragung von Gesundheitsdaten an US-LLM-Anbieter eine eigene, granulare
|
||||||
|
Einwilligung erfordert.
|
||||||
|
**Anwaltsfrage:** Reicht die übergeordnete Lyra-Onboarding-Einwilligung +
|
||||||
|
transparente Darstellung in der Datenschutzerklärung aus? Falls nein: Mindest-Anforderung
|
||||||
|
an UI-Hinweis (z. B. einmalige In-Chat-Notiz „Lyra-Antworten werden via US-Anbieter
|
||||||
|
generiert", ohne Klick-Block) festlegen. **User-Position:** kein separates Consent-UI
|
||||||
|
gewünscht — Anwalt soll konkret ja/nein dazu sagen.
|
||||||
|
|
||||||
|
### 2.3 Übergangsklausel Einzelunternehmer → Raynis GmbH — Risk: MITTEL
|
||||||
|
|
||||||
|
**Stelle:** § 1 Abs. 2
|
||||||
|
**Norm:** Art. 7 DSGVO (Einwilligung gegenüber konkretem Verantwortlichen), § 25 UmwG /
|
||||||
|
Asset-Deal-Mechanik, Erwägungsgrund 42 DSGVO
|
||||||
|
**Risiko:** Bestehende Einwilligungen wurden gegenüber „Chahine Brini, einzelkaufmännisch"
|
||||||
|
erteilt. Ein einseitiger „Geht-auf-die-GmbH-über"-Hinweis kann nach Aufsichtsbehörden-
|
||||||
|
Lesart unzureichend sein — insbesondere bei Art. 9-Daten.
|
||||||
|
**Anwaltsfrage:** Reicht eine vorab-Information per E-Mail + In-App-Notice zur
|
||||||
|
Übertragung der Einwilligung auf die GmbH, oder muss eine erneute aktive
|
||||||
|
Bestätigung („Re-Consent") eingeholt werden? Gilt unterschiedliche Behandlung für
|
||||||
|
Art. 6 vs. Art. 9 Daten? Wir empfehlen, als Default einen Re-Consent-Flow vorzubereiten
|
||||||
|
und ggf. nicht zu nutzen, falls Anwalt Entwarnung gibt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Stufe-2-Migrations-Plan: Lyra-Pseudonymisierung (Q3 2026)
|
||||||
|
|
||||||
|
### Ziel
|
||||||
|
Pre-Processing-Layer im backend zwischen `coach/sos-stream`-Endpoint und LLM-Provider,
|
||||||
|
der personenbezogene Entitäten (Namen, Orte, E-Mails, Telefonnummern, IBANs,
|
||||||
|
Verein-/Firmennamen) maskiert, BEVOR der Prompt das Backend verlässt.
|
||||||
|
|
||||||
|
### Technische Schritte (high-level Spec, Detail-Spec geht an `rebreak-backend`)
|
||||||
|
|
||||||
|
1. **Library-Auswahl (KW 27–28 / Juli 2026):**
|
||||||
|
- Kandidaten: `@microsoft/presidio-analyzer` (Python, via Sidecar), `compromise`
|
||||||
|
(JS, lightweight), self-hosted spaCy-Service mit dt. Modell.
|
||||||
|
- Trade-off: Latenz vs. Recall. Ziel: < 80 ms p95-Overhead pro Nachricht.
|
||||||
|
|
||||||
|
2. **Maskierungs-Mapping (KW 28):**
|
||||||
|
- Pro Conversation eine ephemere ID-Tabelle (memory-only, 30 min TTL).
|
||||||
|
- Maskierungen: `[PERSON_1]`, `[ORT_1]`, `[EMAIL]`, `[PHONE]`.
|
||||||
|
- Re-Substitution beim Streaming-Output: vor dem Senden an Client zurückübersetzen
|
||||||
|
(User soll seine eigenen Namen wieder sehen).
|
||||||
|
|
||||||
|
3. **Backend-Hook (KW 29):**
|
||||||
|
- Neuer Service `backend/server/services/pii-mask.ts` mit zwei Funktionen:
|
||||||
|
`maskBeforeLLM(prompt, conversationId)` und `unmaskAfterLLM(stream, conversationId)`.
|
||||||
|
- Integration in `backend/server/api/coach/sos-stream.get.ts`.
|
||||||
|
|
||||||
|
4. **Telemetrie + Eval (KW 30–31):**
|
||||||
|
- Anonymisierte Metriken: Anzahl Maskierungen pro Nachricht, Latenz, False-Positive-
|
||||||
|
Rate (manuelle Stichprobe von 200 Konversationen).
|
||||||
|
- DSFA-Update mit Stufe-2-Beschreibung.
|
||||||
|
|
||||||
|
5. **Privacy-Policy-Update (KW 32):**
|
||||||
|
- § 11 Abs. 2 in Stufe-1- und Stufe-2-Beschreibung umstellen.
|
||||||
|
- Versionierungs-Hinweis nach § 16.
|
||||||
|
|
||||||
|
### Voraussetzungen / Blocker
|
||||||
|
|
||||||
|
- DPAs aller LLM-Anbieter müssen vorher unterschrieben sein (siehe Punkt 1).
|
||||||
|
- DSFA-Update muss parallel laufen (Hans Müller).
|
||||||
|
- Backend-Sprint mit ca. 8–12 Personentagen Aufwand schätzbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Backyard-Migration-Empfehlung — Marketing-Site / Privacy-Page
|
||||||
|
|
||||||
|
### Status quo
|
||||||
|
|
||||||
|
- `datenschutz.vue` und (neu) `privacy-policy.vue` liegen aktuell im trucko-monorepo
|
||||||
|
unter `apps/rebreak/app/pages/`.
|
||||||
|
- Deployment der Marketing-Site läuft separat (nicht über Hetzner-Backend-Pipeline).
|
||||||
|
|
||||||
|
### Empfehlung an `rebreak-strategist` + `backyard`
|
||||||
|
|
||||||
|
Die öffentlich kommunizierte Datenschutzerklärung sollte mittelfristig im
|
||||||
|
**rebreak-monorepo** leben und über die etablierte Hetzner-Pipeline deployt werden.
|
||||||
|
Begründung:
|
||||||
|
|
||||||
|
1. **Versionskontrolle + Audit-Trail** — bei einer DiGA-Anwendung ist die Historie der
|
||||||
|
Datenschutzerklärung als Compliance-Nachweis relevant. Liegt sie im Hauptrepo, ist
|
||||||
|
sie Teil derselben CI/CD-Logik und Backups wie der Backend-Code.
|
||||||
|
2. **Stand-Konsistenz** — derzeitige Trennung führt zu Stand-Drift (Marketing-Site:
|
||||||
|
01.05.; Backend-Realität: 09.05.). Single-Source-of-Truth-Prinzip.
|
||||||
|
3. **Hetzner-Hosting** — Datenresidenz EU/DE-konsistent ohne Cloudflare-Pages-Drittland-
|
||||||
|
Risiko (sofern Marketing-Site aktuell dort läuft).
|
||||||
|
|
||||||
|
**Action:** Backyard-Agent erstellt Migrations-Plan (geschätzt 2 Sprint-Punkte). Bis
|
||||||
|
dahin pflegen wir die Datei in trucko, mit Cross-Reference im rebreak-monorepo
|
||||||
|
(`docs/internal/PRIVACY_POLICY_USER_NOTES.md` ← du liest sie gerade).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Risk-Summary (Snapshot 09.05.2026)
|
||||||
|
|
||||||
|
| Bereich | Risk-Level | Begründung | Mitigation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Drittland-Transfer Lyra (Groq/Anthropic) | KRITISCH bis DPAs vorliegen, danach MITTEL | Art. 9-Daten in USA, FISA 702 Risiko | DPAs + TIA + Stufe-2-Pseudo Q3 2026 |
|
||||||
|
| Pro-Trial-Kopplung | HOCH | Art. 7 Abs. 4 DSGVO Auslegungs-Risiko | Anwalt-Review + transparente Darstellung |
|
||||||
|
| Re-Consent bei Raynis-GmbH-Übergang | MITTEL | Einwilligungs-Adressat ändert sich | Re-Consent-Flow vorbereiten |
|
||||||
|
| Demographische Daten | NIEDRIG | Streng user-initiated, klare Trennung von Lyra-Memories | Profile-Form-Validierung |
|
||||||
|
| Mail-Schutz-Modul | MITTEL bei Aktivierung | Tiefer Eingriff in Mailbox eines Suchterkrankten | Echte opt-in, granulare Einwilligung |
|
||||||
|
| Cookie/Tracking | NIEDRIG | Keine Drittanbieter-Tracker im Einsatz | Keine Action |
|
||||||
|
| Push-Notifications | NIEDRIG–MITTEL | APNs/FCM = USA-Transfer + Inhalt kann Gesundheitsbezug haben | Inhalt der Push-Texte minimieren („Du hast eine Erinnerung" statt „Streak gefährdet") |
|
||||||
|
| Drittland Anbieter ohne DPA | KRITISCH bis geklärt | OpenRouter, Cartesia, Deepgram unklar | Falls kein DPA: Anbieter rauswerfen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Nächste Schritte (priorisiert)
|
||||||
|
|
||||||
|
1. **Diese Woche:** Hetzner-AVV + Stripe-DPA + Cloudflare-DPA + Firebase-DPA online
|
||||||
|
abschließen (alle Self-Service, < 2h Aufwand zusammen).
|
||||||
|
2. **KW 20 (12.–18.05.):** Anthropic Commercial-Terms / Groq DPA-Anfrage absetzen.
|
||||||
|
3. **KW 20:** Anwalt-Termin zu den 3 Punkten in Sektion 2 vereinbaren.
|
||||||
|
4. **KW 21:** OpenRouter / Cartesia / Deepgram-Status klären → ggf. aus Verarbeitungs-
|
||||||
|
verzeichnis und § 6-Tabelle streichen, bis DPA vorliegt.
|
||||||
|
5. **KW 22–23:** Verarbeitungsverzeichnis (Art. 30 DSGVO) als separates Dokument
|
||||||
|
erstellen (Vorlage GDD / LfD Niedersachsen verwenden).
|
||||||
|
6. **KW 24–28:** DSFA gemäß Art. 35 DSGVO finalisieren.
|
||||||
|
7. **Q3 2026:** Stufe-2-Pseudonymisierung implementieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Bei Rückfragen:** datenschutz@rebreak.org · Betreff „DSB-Notes v1"
|
||||||
283
docs/internal/RECOVERY_LOG_2026-05-10.md
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
# Recovery-Log 2026-05-10 — Lost Work + Workflow-Regeln
|
||||||
|
|
||||||
|
**Stand:** 2026-05-10
|
||||||
|
**Verantwortlich:** Chahine
|
||||||
|
**Anlass:** verlorene UI-Arbeit nach mehrfachen `git stash`/`cherry-pick`-Zyklen am 9. Mai
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Was passiert ist (Timeline)
|
||||||
|
|
||||||
|
### 1.1 Auslöser — Cutover-Incident 7. Mai 22:17
|
||||||
|
|
||||||
|
`apps/rebreak/` (Nuxt) → `backend/` (Standalone Nitro) Cutover. Force-Push aus dem neuen Mac-Repo zu `RaynisDev/rebreak.git` triggerte den Server-Webhook, der scheiterte:
|
||||||
|
|
||||||
|
- `cd /srv/rebreak/apps/rebreak`-Pfad existierte im neuen Layout nicht
|
||||||
|
- Auth-Middleware crashed mit HTTP 500 (`Cannot read properties of undefined (reading 'url')`) weil `backend/nitro.config.ts.runtimeConfig` keine `supabase`-Section hatte
|
||||||
|
- ALLE authentifizierten Endpoints kaputt
|
||||||
|
|
||||||
|
Rollback: `git reset --hard origin/main` → HEAD auf `922d5dc`. Tag `pre-revert-2217` als Sicherung gesetzt.
|
||||||
|
|
||||||
|
Siehe `ops/CUTOVER_PLAN.md` §1.3 für volle Incident-Beschreibung.
|
||||||
|
|
||||||
|
### 1.2 Folgesymptom — Stash-Hopping am 9. Mai
|
||||||
|
|
||||||
|
Nach dem Reset arbeitete der User intensiv am Cherry-Pick-Workflow zwischen `main` und `upgrade/sdk-54`. Reflog zeigt **10+ Branch-Switches in 4 Stunden** (14:51–18:11). Pattern:
|
||||||
|
|
||||||
|
```
|
||||||
|
commit auf upgrade/sdk-54
|
||||||
|
→ checkout main
|
||||||
|
→ cherry-pick (selber Commit, neuer Hash)
|
||||||
|
→ checkout upgrade/sdk-54
|
||||||
|
→ uncommitted changes: git stash
|
||||||
|
→ ... nächster Commit ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`git fsck --no-reflogs --lost-found` zeigt **9 dangling WIP-Stash-Commits** als Resultat:
|
||||||
|
`wip-pre-cherrypick`, `wip-pre-daemon-fix`, `wip-pre-daemon-push`, `wip-pre-backend-push-2`, `wip-pre-speak-fix-2`, `wip-mdm-session`, plus 3× `wip: sdk-54 ui/backend changes`.
|
||||||
|
|
||||||
|
### 1.3 Konkreter Verlust — Commit `35189b9` "wip-pre-cherrypick"
|
||||||
|
|
||||||
|
Am 9. Mai 17:57 wurde ein Stash mit gerade fertiggestellter UI-Arbeit angelegt — der Stash-Apply lief nicht sauber zurück (Conflict + `git checkout .` zum Aufräumen, oder `git stash drop` ohne saubere `pop`). Die Arbeit landete als dangling Merge-Commit `35189b9`, war aber im Working Tree weg.
|
||||||
|
|
||||||
|
**Was im Stash war:**
|
||||||
|
|
||||||
|
| File | Was wäre drin gewesen |
|
||||||
|
|---|---|
|
||||||
|
| `components/games/GameOverScreen.tsx` | 256 → **468 Zeilen**: StarRating, RiveAvatar, tier-aware Lyra-Messages, Rating-Form, Share-to-Community |
|
||||||
|
| `components/urge/UrgeGames.tsx` | Header-Refactor, `scoreLabel`/`goodScore`-Props |
|
||||||
|
| `app/settings.tsx` | LanguageIcon-Block, dynamic icon-rendering |
|
||||||
|
| `locales/de.json` + `en.json` | `gameOver.lyra_title_*` / `lyra_body_*` Keys (record/good/ok/low), Rating-Strings, Share-Strings |
|
||||||
|
|
||||||
|
User hat das nach 24h beim Test gemerkt: SOS sah aus „wie alter Stand" — kein neuer GameOverScreen, kein Snake-Score-Dashboard, OpenAI-TTS statt ElevenLabs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Recovery-Aktion 2026-05-10
|
||||||
|
|
||||||
|
`35189b9` Files via `git checkout 35189b9 -- <pfad>` ins Working Tree zurückgeholt. Locales **chirurgisch** gemerged (Python-Script für `gameOver`-Section only — andere Sections — Mail-Status, Auth-Errors etc. — blieben unangetastet, weil aktuelle de.json/en.json neuere Strings enthielten die in 35189b9 nicht waren).
|
||||||
|
|
||||||
|
Plus: `urge.tsx` + `lib/sosTtsQueue.ts` umgestellt von `endpointForProvider(currentProvider())` (alter TtsProviderToggle-Pfad mit OpenAI-Default) auf `/api/coach/speak` — der **tier-aware Backend-Dispatcher** (siehe §5).
|
||||||
|
|
||||||
|
Backend (`backend/server/api/coach/speak.post.ts`) war bereits korrekt fertig (mtime 10. Mai 16:18, Plan-aware: Free→Google / Pro→Cartesia / Legend→ElevenLabs) — kein Touch nötig.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Was JETZT NOCH FEHLT (nicht aus 35189b9 wiederhergestellt)
|
||||||
|
|
||||||
|
| Feature | Status | Notiz |
|
||||||
|
|---|---|---|
|
||||||
|
| **Game-Sharing-Post** | offen | Aus User-Erinnerung: Game-Result-Sharing zur Community (Post mit Score + Lyra-Caption). Nicht in 35189b9 enthalten — wahrscheinlich anderer dangling stash. **TODO separat**. |
|
||||||
|
| **TtsProviderToggle Wiring** | offen, aber nicht kritisch | Component existiert (`components/urge/TtsProviderToggle.tsx`), nirgends gerendert. Laut `ops/UI_MIGRATION_PLAN.md §3 Tab 5 Debug` gehört der in `__DEV__`-Tab. |
|
||||||
|
|
||||||
|
Game-Sharing kommt in nächster Session. Anderen dangling stash-Commits prüfen via `git show <sha>` aus `git fsck --lost-found` Output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Workflow-Regeln gegen Wiederholung
|
||||||
|
|
||||||
|
### 4.1 KEIN rapides Stash + Cherry-Pick mehr
|
||||||
|
|
||||||
|
**Verboten:** mehrere `git stash` hintereinander während Branch-Switching. `git stash list` darf nie länger als 1 Eintrag werden.
|
||||||
|
|
||||||
|
**Stattdessen (in Priorität):**
|
||||||
|
|
||||||
|
1. **`git worktree add`** — zweiter Working-Tree für andere Branches:
|
||||||
|
```bash
|
||||||
|
git worktree add ../rebreak-main main
|
||||||
|
# Cherry-pick im 2. Worktree, kein stash nötig
|
||||||
|
cd ../rebreak-main
|
||||||
|
git cherry-pick <sha>
|
||||||
|
git push
|
||||||
|
cd ../rebreak-monorepo
|
||||||
|
```
|
||||||
|
Beide Trees sind unabhängig, parallel benutzbar in zwei IDE-Fenstern.
|
||||||
|
|
||||||
|
2. **Commit-First-Pattern** — vor jedem `checkout` immer committen (auch WIP-commits sind besser als stash):
|
||||||
|
```bash
|
||||||
|
git add -A && git commit -m "wip: in progress"
|
||||||
|
git checkout main
|
||||||
|
# ... arbeit auf main ...
|
||||||
|
git checkout upgrade/sdk-54
|
||||||
|
# WIP commit unverloren, kann amended werden
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **NIE `git stash drop`** — nur `git stash pop`. Wenn pop conflicted: NICHT mit `git checkout .` aufräumen, sondern Conflict-Markers manuell auflösen + committen.
|
||||||
|
|
||||||
|
### 4.2 Recovery-Kommandos für die Zukunft
|
||||||
|
|
||||||
|
Falls trotzdem mal wieder Arbeit verloren geht:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Alle dangling commits auflisten:
|
||||||
|
git fsck --no-reflogs --lost-found
|
||||||
|
|
||||||
|
# Inhalt eines Commits inspizieren:
|
||||||
|
git show <sha> --stat
|
||||||
|
|
||||||
|
# Files aus einem dangling commit zurückholen (ohne git history zu touchen):
|
||||||
|
git checkout <sha> -- <pfad/zur/datei>
|
||||||
|
|
||||||
|
# Volles Reflog mit Datum:
|
||||||
|
git reflog --date=format:"%Y-%m-%d %H:%M"
|
||||||
|
```
|
||||||
|
|
||||||
|
Tag `pre-revert-2217` (vom 7. Mai) bleibt als Notbremse-Anker erhalten.
|
||||||
|
|
||||||
|
### 4.3 Zwei Branches gleichzeitig sind ein Anti-Pattern
|
||||||
|
|
||||||
|
Aktuell: `main` (Production) + `upgrade/sdk-54` (Dev). Cherry-Pick-Pflicht zwischen beiden ist die **eigentliche Wurzel** des Problems.
|
||||||
|
|
||||||
|
**Empfehlung (User-Decision):** sobald `upgrade/sdk-54` stabil ist → `main` durch `upgrade/sdk-54` ersetzen (force-push) und nur noch *einen* Branch fahren. Die GH-Actions-Pipeline deployt von `main`, also nach Force-Push ist alles auf einem Branch konsolidiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Tier-Aware TTS-Architektur (jetzt aktiv)
|
||||||
|
|
||||||
|
Damit klar ist wie das System nach dem Recovery funktioniert:
|
||||||
|
|
||||||
|
```
|
||||||
|
User auf SOS-Page (urge.tsx)
|
||||||
|
→ ttsQueue.endpoint = '/api/coach/speak'
|
||||||
|
→ POST /api/coach/speak { text, mode: 'sos' }
|
||||||
|
→ Backend: speak.post.ts
|
||||||
|
→ requireUser(event)
|
||||||
|
→ profile.plan aus DB
|
||||||
|
→ free → speakGoogle() (60s/day quota)
|
||||||
|
→ pro → speakCartesia() (300s/day quota)
|
||||||
|
→ legend → speakElevenLabs() (unlimited)
|
||||||
|
→ Backend liefert raw audio/mpeg stream
|
||||||
|
→ Client erwartet immer raw audio/mpeg (kein isGoogleCloud-Branch mehr nötig)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig:** Kein User-Toggle. Der Provider hängt **ausschließlich** an `profile.plan`. Wenn ein Pro-User Cartesia-Stimme nicht mag → Plan-Tier muss geändert werden, nicht ein Toggle.
|
||||||
|
|
||||||
|
`TtsProviderToggle.tsx` Component bleibt im Repo aber ohne Wiring. Falls Debug-Tab gebaut wird (`UI_MIGRATION_PLAN.md §3 Tab 5`), kommt der Toggle dort hin (`__DEV__`-only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Game-Flow in SOS vs Standalone
|
||||||
|
|
||||||
|
| Mode | Eintritt | Game-Over-Verhalten |
|
||||||
|
|---|---|---|
|
||||||
|
| **SOS-Mode** (`urge.tsx`) | aus Lyra-Chip „Spiel" | Game endet → `onComplete(score)` direkt → SOS-Session läuft weiter, Lyra antwortet auf Score. **KEIN GameOverScreen.** |
|
||||||
|
| **Standalone-Mode** (`games.tsx`) | aus Header-Dropdown „Games" | Game endet → `<GameOverScreen />` rendert mit StarRating + Lyra-Message + Share-to-Community-Button. Retry/Exit drinnen. |
|
||||||
|
|
||||||
|
Implementation: `mode: 'sos' \| 'standalone'`-Prop auf SnakeGame/MemoryGame/TicTacToeGame/TetrisGame. Default = `'standalone'`. urge.tsx setzt explizit `mode="sos"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Keyboard-Overlap — generische Lösung
|
||||||
|
|
||||||
|
App-übergreifender Bug: TextInput wird beim Tippen vom Keyboard verdeckt (Mail-Password-Edit, Auth-Forms, Profile-Edit, Demographics, ComposeCard, Chat-Input, etc.).
|
||||||
|
|
||||||
|
**Aktiver Stack ab 2026-05-10:** [`react-native-keyboard-controller`](https://github.com/kirillzyusko/react-native-keyboard-controller) — de-facto Standard seit 2024 für RN-Keyboard-Avoidance. Native Synced (iOS-Curve pixel-genau), kein Driver-Mix, kein Bouncing.
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
- Bereits installiert: `pnpm add react-native-keyboard-controller` ✓
|
||||||
|
- Root-Layout wrapped mit `<KeyboardProvider>` (`apps/rebreak-native/app/_layout.tsx`) ✓
|
||||||
|
- **Native-Build nötig nach Install:** `cd apps/rebreak-native/ios && pod install` (Autolinking macht den Rest), dann frischer Xcode-Build
|
||||||
|
|
||||||
|
**Migrierte Components (Reference-Beispiele):**
|
||||||
|
- `EditMailAccountSheet.tsx` — `useKeyboardAnimation()` + `Animated.subtract(slideY, height)`
|
||||||
|
- `GameOverScreen.tsx` — gleiche Pattern, mit Spring-Slide-In bewahrt
|
||||||
|
|
||||||
|
### 7.1 Wann was nutzen
|
||||||
|
|
||||||
|
Empfehlung in dieser Reihenfolge:
|
||||||
|
|
||||||
|
| Situation | Lösung |
|
||||||
|
|---|---|
|
||||||
|
| **Bottom-Sheet mit Form/Input** (EditMail, ConnectMail, AddDomain, GameOver, künftig…) | **`<KeyboardAwareSheet>`** Composable (`components/KeyboardAwareSheet.tsx`). Kapselt Modal + Backdrop + Slide-In + Sheet-Grow + Form-an-Bottom-Spacer. **Beispiel:** `EditMailAccountSheet.tsx`. |
|
||||||
|
| **Vollbild-Form** (Auth, Profile-Edit, Signup) | `<KeyboardAvoidingView />` aus `react-native-keyboard-controller` (NICHT von RN!) als Outermost. Drop-in, funktioniert wie erwartet. |
|
||||||
|
| **Sticky-Bottom-Bar über Tastatur** (Send-Button am Screen-Edge, etc.) | `<KeyboardStickyView />` aus der Library — sticked automatisch über Tastatur. |
|
||||||
|
| **Chat/SOS** (FlatList + Input-Bar) | Wie bisher in `PostCommentsSheet.tsx`. Funktioniert weiter. |
|
||||||
|
| **Legacy** | `hooks/useSheetKeyboardLift.ts` + `hooks/useKeyboardHeight.ts` + `components/KeyboardAdjustedView.tsx` bleiben im Repo aber sollten **nicht mehr neu verwendet werden** — durch `<KeyboardAwareSheet>` ersetzt. |
|
||||||
|
|
||||||
|
### 7.1.1 Auto-sized Sheets (kein leerer Platz unterhalb des Inhalts)
|
||||||
|
|
||||||
|
Für kompakte Forms (1 Input + Save-Button — z.B. EditMailAccountSheet): KEINE feste `height` setzen, Sheet auto-sized via `position: 'absolute', bottom: 0`. `useSheetKeyboardLift({ offscreenY: SCREEN_HEIGHT })` für initial-off-screen + Keyboard-Lift. Resultat: Sheet sitzt eng über der Tastatur ohne weißen Leerraum darunter.
|
||||||
|
|
||||||
|
Für Sheets mit variablem Listen-Inhalt (Comments, längere Forms): `height` setzen. ScrollView braucht constrained height zum scrollen.
|
||||||
|
|
||||||
|
### 7.1.2 Library-Migration-Pfad: `react-native-keyboard-controller`
|
||||||
|
|
||||||
|
De-facto-Standard seit 2024 für Keyboard-Avoidance in RN. Löst alle Pain-Points (Driver-Mix, iOS-Modal-Quirks, Sheet-Lifts, smooth Animationen) systemisch über native Module — kein eigener Animated-Code mehr nötig. Kostet:
|
||||||
|
|
||||||
|
- `pnpm add react-native-keyboard-controller`
|
||||||
|
- `npx expo prebuild` + iOS pod install (= neuer Native-Build nötig)
|
||||||
|
- Wrapper am App-Root: `<KeyboardProvider>`
|
||||||
|
- Components ersetzen: `<KeyboardAvoidingView />` von der Library statt RN's eigenes
|
||||||
|
- Plus: `useKeyboardAnimation()` Hook für custom Animationen
|
||||||
|
|
||||||
|
**Empfehlung:** wenn 2-3 weitere Sheets/Forms Probleme machen → migrieren. Bis dahin: `useSheetKeyboardLift()` Pattern reicht für die meisten Fälle.
|
||||||
|
|
||||||
|
### 7.2 Anti-Pattern zu vermeiden
|
||||||
|
|
||||||
|
- **`<KeyboardAvoidingView behavior="padding">`** — funktioniert nur in Modals zuverlässig, bricht bei Full-Screens mit `paddingTop: insets.top`. **Nicht mehr neu nutzen.**
|
||||||
|
- **Pressable mit style-Funktion** für Buttons mit kritischem Visual: `style={({pressed}) => ...}` schluckt manchmal Style-Properties (RN-Quirk). Für Buttons mit solidem BG + Border lieber `<TouchableWithoutFeedback><View style={...}>` Pattern.
|
||||||
|
- **Driver-Mix auf einem `<Animated.View>`** — z.B. `height: animatedValue` (JS-driver) zusammen mit `transform: [{ translateY: animatedValue }]` (native driver). Crashed mit `"Style property 'height' is not supported by native animated module"`. **Lösung:** `useSheetKeyboardLift()` Composable nutzt nur translate (beides native).
|
||||||
|
- **`marginBottom: keyboardHeight` als JS-Style** + native transform im selben View → Bouncing weil zwei Threads layouten. **Lösung:** Animated.subtract(slideY, keyboardLift), beides Animated.Values, native driver konsistent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Open Issues (zukünftige Sessions)
|
||||||
|
|
||||||
|
### 8.1 Aus aktueller Session 2026-05-10 verschoben
|
||||||
|
|
||||||
|
- [ ] **Game-Sharing-Post-Render** — soll genau wie in `trucko-monorepo/apps/rebreak/app/components/CommunityPostCard.vue` (category=`game_share`) aussehen. Aktuell rendert die Native-App einen generischen Post statt einer Game-Share-Card mit Score-Pill + Lyra-Caption + Challenge-CTA. Source-of-truth: Vue-Pendant.
|
||||||
|
- [ ] **Mail-Page-Chart** — `MailWeeklyChart.tsx` ist bereits angelegt aber Render-Logic noch nicht 1:1 vom Nuxt-`mail-stats-chart.vue` portiert. 7-Tage-Bar-Chart mit `account_*`-Stats.
|
||||||
|
- [ ] **iron.png-Warning vollständig fixen** — `dm.tsx` ist gefixt, aber `room.tsx` (3 Stellen: 308, 537, 598) und `components/chat/RoomCard.tsx:52` nutzen noch raw `room.avatarUrl` / `m.avatar`. Wenn das vom Backend ein Avatar-ID statt URL liefert, gleicher Bug. `resolveAvatar()` darüber wickeln.
|
||||||
|
- [ ] **TetrisActionBtn / DPadBtn rollout** — falls noch andere Stellen Pressable-mit-style-funktion nutzen für Game-relevante Buttons, gleichen TouchableWithoutFeedback-Pattern anwenden.
|
||||||
|
|
||||||
|
### 8.2 Aus voriger Session
|
||||||
|
|
||||||
|
- [ ] `KeyboardAdjustedView` rollout über alle TextInput-Stellen (siehe Liste in §9)
|
||||||
|
- [ ] TtsProviderToggle in `__DEV__`-Debug-Tab einbauen
|
||||||
|
- [ ] Single-Branch-Konsolidierung: `upgrade/sdk-54` → `main` Force-Push
|
||||||
|
- [ ] Andere 8 dangling stashes inspizieren ob noch was Wertvolles drin ist
|
||||||
|
- [ ] expo-av Deprecation Warning — Migration zu `expo-audio` + `expo-video` (SDK 54 Pflicht). Tracker.
|
||||||
|
|
||||||
|
### 8.3 Snake-Sounds — Audio-Files droppen
|
||||||
|
|
||||||
|
`hooks/useSnakeSounds.ts` läuft aktuell im Haptic-only-Mode. Für echten 8-Bit-Retro-Sound:
|
||||||
|
|
||||||
|
1. `apps/rebreak-native/assets/sounds/` Dir anlegen
|
||||||
|
2. 4 kurze Audio-Files reinlegen (Free-Quellen: freesound.org, opengameart.org/content/8-bit-sound-pack, sfxr.me):
|
||||||
|
- `snake-eat.mp3` ~80ms tonale "blip"
|
||||||
|
- `snake-move.mp3` ~30ms Tick (optional)
|
||||||
|
- `snake-gameover.mp3` ~400ms abfallende Töne
|
||||||
|
- `snake-record.mp3` ~600ms aufsteigender Chime
|
||||||
|
3. `useSnakeSounds.ts` öffnen, `require()` und `Audio.Sound.createAsync()` Lines unkommentieren (in der Datei-Doku exakt beschrieben)
|
||||||
|
|
||||||
|
Nach Drop fallen die Haptics nicht weg — Audio + Haptic feuern dann beide.
|
||||||
|
|
||||||
|
### 8.4 Cache-Invalidierung — neuer Pattern in `useMe.ts`
|
||||||
|
|
||||||
|
Profile-Avatar-/Nickname-Änderungen sind jetzt **app-weit live**: nach jedem `PATCH /api/auth/me` muss `invalidateMe()` aus `hooks/useMe.ts` aufgerufen werden (oder `reload()` einer useMe-Instanz, was intern gleichbedeutend ist). Alle anderen useMe-Konsumenten (AppHeader, ComposeCard, PostCard, NotificationsDropdown, …) re-fetchen via Listener-Subscribe automatisch — kein App-Reload mehr nötig.
|
||||||
|
|
||||||
|
**Dasselbe Pattern für andere User-Daten** (Streak, Demographics, Devices) wenn das gleiche Bug-Symptom auftritt.
|
||||||
|
|
||||||
|
## 9. Files mit TextInput (für KeyboardAdjustedView-Rollout)
|
||||||
|
|
||||||
|
```
|
||||||
|
app/room.tsx
|
||||||
|
app/lyra.tsx
|
||||||
|
app/urge.tsx
|
||||||
|
app/(auth)/signup.tsx
|
||||||
|
app/(auth)/signin.tsx
|
||||||
|
app/(auth)/forgot-password.tsx
|
||||||
|
app/(auth)/confirm-otp.tsx
|
||||||
|
app/profile/edit.tsx
|
||||||
|
components/PostCommentsSheet.tsx ← bereits korrekt (Vorbild-Pattern)
|
||||||
|
components/ComposeCard.tsx
|
||||||
|
components/chat/CreateRoomSheet.tsx
|
||||||
|
components/chat/ChatInput.tsx
|
||||||
|
components/mail/ConnectMailSheet.tsx
|
||||||
|
components/mail/EditMailAccountSheet.tsx ← User-explizit gemeldet
|
||||||
|
components/blocker/AddDomainSheet.tsx
|
||||||
|
components/urge/InlineRatingDrawer.tsx
|
||||||
|
components/urge/SosFeedbackModal.tsx
|
||||||
|
components/urge/ShareSuccessDrawer.tsx
|
||||||
|
components/games/GameOverScreen.tsx
|
||||||
|
```
|
||||||
432
ops/ACCESSIBILITY_AUDIT.md
Normal file
@ -0,0 +1,432 @@
|
|||||||
|
# Rebreak-Native — Accessibility Audit & DiGA-Roadmap
|
||||||
|
|
||||||
|
Author: Ahmed (QA) · Stand: 2026-05-07 · Status: Initial Audit, READ-ONLY
|
||||||
|
|
||||||
|
User-Trigger 2026-05-08: „Thema accessibility auf beide Plattformen checken — es gibt
|
||||||
|
Test-Frameworks dafür. Damit können wir bei DiGA punkten."
|
||||||
|
|
||||||
|
Scope dieses Dokuments:
|
||||||
|
1. Bestandsaufnahme der A11y-Awareness im rebreak-native-Code (iOS + Android)
|
||||||
|
2. Mapping auf WCAG 2.1 Level AA + DiGA-Anforderungen
|
||||||
|
3. Test-Framework-Empfehlung (RN-spezifisch)
|
||||||
|
4. Roadmap Pre-TestFlight / Pre-DiGA-Antrag / Post-Launch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
**Aktuelle A11y-Coverage in rebreak-native: ~1,3 %.**
|
||||||
|
|
||||||
|
Empirisch: 6 Treffer für `accessibilityLabel|accessibilityRole|accessibilityHint|accessibilityState|accessible=`-Props
|
||||||
|
verteilt auf 5 Files (Mail-Add-Account, ProtectionLockedCard, AppHeader-Back-Button,
|
||||||
|
ProtectionCard-Settings-Icon, DomainGrid-Add-Btn + DomainGrid-State).
|
||||||
|
|
||||||
|
Demgegenüber im selben Tree: **453 Touchable-Komponenten** (`Pressable` /
|
||||||
|
`TouchableOpacity` / `<Button`) und **50 TextInputs** verteilt auf **83 tsx-Files** —
|
||||||
|
also faktisch **>97 % unbeschriftet** für Screen-Reader (VoiceOver / TalkBack).
|
||||||
|
|
||||||
|
**DiGA-Risk-Score: HOCH.**
|
||||||
|
|
||||||
|
Begründung:
|
||||||
|
- BfArM verlangt für DiGA-Zertifizierung die Erfüllung von **WCAG 2.1 Level AA**
|
||||||
|
(DVG §139e, BIK BITV-konformes Verfahren). Heute erfüllt rebreak-native diese
|
||||||
|
Stufe **auf keiner Seite**.
|
||||||
|
- Recovery-User-Kohorte hat überdurchschnittlich oft Komorbiditäten:
|
||||||
|
Sehbeeinträchtigung (Diabetes, Augenleiden), motorische Einschränkungen,
|
||||||
|
starke kognitive Last in Krisen-Momenten — d.h. der **SOS-Flow ist
|
||||||
|
a11y-mission-critical**, und der ist heute komplett unzugänglich für Screen-Reader.
|
||||||
|
- Apple App-Review (für External Beta) und Google Play prüfen seit 2024 bei
|
||||||
|
Mental-Health-/Health-Apps zunehmend systematisch auf VoiceOver/TalkBack-Walkthrough.
|
||||||
|
|
||||||
|
**Empfehlung:** Sofortmaßnahme für SOS-Flow + Auth-Flow + Demographics-Form vor
|
||||||
|
TestFlight Internal (Wochenend-Cutover). Roadmap-Arbeit für Vollabdeckung pre-DiGA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Component-by-Component-Status (Critical Paths)
|
||||||
|
|
||||||
|
Legende: ✅ ausreichend / ⚠️ teilweise / 🔴 fehlt komplett
|
||||||
|
|
||||||
|
### 2.1 SOS-Flow — `app/urge.tsx` (1333 Lines) — 🔴
|
||||||
|
|
||||||
|
Höchste Priorität (Krisen-Use-Case, Recovery-Schutz).
|
||||||
|
|
||||||
|
| Element | Line | A11y-Status | Finding |
|
||||||
|
| ----------------------------------------------------- | ------ | ----------- | ----------------------------------------------------------------------- |
|
||||||
|
| Exit-Button (Pressable + Icon-only) | 1094 | 🔴 | kein `accessibilityLabel`, ScreenReader liest „Button" (oder schweigt) |
|
||||||
|
| TTS-Stop-Button | 1116 | 🔴 | kein Label, kein Hint, keine `accessibilityState={{busy: …}}` |
|
||||||
|
| Sound-Toggle-Button | 1123 | 🔴 | toggled `soundEnabled` ohne `accessibilityState={{checked}}` |
|
||||||
|
| Scroll-Down-Button | 1160 | 🔴 | nur Icon |
|
||||||
|
| Lyra-Chip-Button (`handleChip`) | 1209 | 🔴 | kritisch: das sind die SOS-Action-Chips (Atem/Spiel/Cooldown), Screen-Reader liest gar nichts |
|
||||||
|
| Eingabefeld TextInput | 1231 | 🔴 | kein `accessibilityLabel` |
|
||||||
|
| Send-Button (Pressable, mit Disabled-State) | 1244 | 🔴 | `disabled={thinking || !input.trim()}` — Screen-Reader bekommt kein `accessibilityState={{disabled}}` |
|
||||||
|
|
||||||
|
**Verdict:** SOS-Flow ist für sehbehinderte User nicht bedienbar. Absoluter
|
||||||
|
DiGA-Blocker.
|
||||||
|
|
||||||
|
### 2.2 SOS-Spiele — `components/urge/UrgeGames.tsx` (1067 Lines) — 🔴
|
||||||
|
|
||||||
|
Wir haben gerade native D-Pad-Buttons gemacht (Snake) und Tetris-Action-Buttons.
|
||||||
|
**Keiner** hat `accessibilityLabel` oder `accessibilityRole`.
|
||||||
|
|
||||||
|
| Element | Line | Finding |
|
||||||
|
| ------------------------------------ | ---- | -------------------------------------------------------------------------- |
|
||||||
|
| Game-Picker-Pressable | 37 | 🔴 ohne Label |
|
||||||
|
| Snake-D-Pad-Btn (Up/Down/Left/Right) | 360 | 🔴 reine Icon-Pressable, Direction-Info komplett unsichtbar für VoiceOver |
|
||||||
|
| Tetris-Action-Btn (Rotate/Drop) | 405 | ⚠️ hat sichtbares Label aber kein explicit `accessibilityLabel` |
|
||||||
|
| Memory-Card-Pressable | 541 | 🔴 ohne Label, kein State-Info „revealed/matched" |
|
||||||
|
| Abandon-Button | 292 | 🔴 ohne Label |
|
||||||
|
| Replay/Continue-Buttons | 724 | ⚠️ haben Text-Inhalt — aber `accessibilityRole="button"` fehlt |
|
||||||
|
|
||||||
|
**Verdict:** Snake/Tetris/Memory/RPS sind faktisch reine Sehende-Spiele. Für
|
||||||
|
DiGA-Recovery-Effektmessung problematisch (a11y-User können die Distraktions-
|
||||||
|
Mechanik nicht nutzen).
|
||||||
|
|
||||||
|
### 2.3 Profile + Demographics — `app/profile/index.tsx` + `components/profile/DemographicsAccordion.tsx` (272 / 621 Lines) — 🔴
|
||||||
|
|
||||||
|
DiGA-Pflicht-Daten-Erhebung (Phase C).
|
||||||
|
|
||||||
|
| Element | Line | Finding |
|
||||||
|
| ------------------------------------------------ | -------- | ------------------------------------------------------------------------------ |
|
||||||
|
| TextInput Geburtsjahr | 267 | 🔴 kein Label, nur Placeholder „z.B. 1989" — nicht für VoiceOver lesbar |
|
||||||
|
| SelectButton Geschlecht | 296 | 🔴 nicht als „Combobox/Spinner" markiert, Screen-Reader nennt keine Auswahl |
|
||||||
|
| TextInput Beruf | 304 | 🔴 wie Geburtsjahr |
|
||||||
|
| SelectButton Familienstand | 320 | 🔴 |
|
||||||
|
| SelectButton Bundesland | 328 | 🔴 |
|
||||||
|
| TextInput Stadt | 336 | 🔴 |
|
||||||
|
| Modal-Picker (`onSelect(value)`) | 587 | 🔴 jeder Picker-Eintrag ist `Pressable` ohne Label → VoiceOver liest gar nichts |
|
||||||
|
| Revoke-Consent-Pressable | 351 | 🔴 wichtig für DSGVO Art. 7(3) |
|
||||||
|
| Modal-Backdrop | 539 | 🔴 fehlt `accessibilityViewIsModal` (iOS) / Focus-Trap |
|
||||||
|
|
||||||
|
**Verdict:** Gerade diese Form ist DSGVO Art. 9 + DiGA-Datensatz-Pflicht. Wenn
|
||||||
|
ein blinder User die Demographics nicht ausfüllen kann, **fehlen seine Daten in
|
||||||
|
der DiGA-Versorgungs-Studie** (Bias).
|
||||||
|
|
||||||
|
### 2.4 Header + Dropdown — `components/AppHeader.tsx` + `components/header/HeaderDropdownMenu.tsx` — ⚠️
|
||||||
|
|
||||||
|
| Element | Line | Finding |
|
||||||
|
| ---------------------------------- | ---------- | ---------------------------------------------------- |
|
||||||
|
| Back-Button | 56 | ✅ `accessibilityLabel="Zurück"` |
|
||||||
|
| Notification-Bell-Pressable | 72 | 🔴 kein Label, Badge-Count gar nicht angesagt |
|
||||||
|
| Avatar/Dropdown-Trigger | 88 | 🔴 kein Label „Profilmenü öffnen" |
|
||||||
|
| Dropdown Backdrop-Pressable | 86 (Menu) | 🔴 |
|
||||||
|
| Dropdown Items (Profile/Settings/Logout) | 108–239 | 🔴 keine Labels, Modal hat kein `accessibilityViewIsModal` |
|
||||||
|
|
||||||
|
### 2.5 ComposeCard — `components/ComposeCard.tsx` — ⚠️
|
||||||
|
|
||||||
|
hitSlop ≥44pt korrekt umgesetzt (Apple-HIG-konform), aber **keine** a11y-Labels:
|
||||||
|
|
||||||
|
| Element | Line | Finding |
|
||||||
|
| ----------------------------- | ---- | -------------------------------------------------- |
|
||||||
|
| TextInput Compose | 110 | 🔴 nur Placeholder, kein Label |
|
||||||
|
| Image-Remove-Pressable | 130 | 🔴 nur Close-Icon |
|
||||||
|
| Image-Picker-Pressable | 147 | 🔴 Text „Foto" nur visuell |
|
||||||
|
| Cancel-Pressable | 160 | ⚠️ hat Text-Child, aber Role/Label-Mapping unklar |
|
||||||
|
| Share-Submit-Pressable | 168 | 🔴 disabled-State nicht annonciert |
|
||||||
|
|
||||||
|
### 2.6 Blocker — `app/(app)/blocker.tsx` + `components/blocker/*.tsx` — ⚠️ (best-of)
|
||||||
|
|
||||||
|
Hier ist mit Abstand die meiste a11y-Awareness — Schutz-Settings sind DiGA-
|
||||||
|
mission-critical und das ist hier korrekt erkannt:
|
||||||
|
|
||||||
|
| Element | A11y-Status | Note |
|
||||||
|
| -------------------------------------- | ----------- | --------------------------------------------- |
|
||||||
|
| ProtectionCard Settings-Icon | ✅ | `accessibilityLabel={t('blocker.protection_settings_a11y')}` |
|
||||||
|
| ProtectionLockedCard Settings-Icon | ✅ | dito |
|
||||||
|
| DomainGrid Add-Domain-Pressable | ✅ | Label + `accessibilityState={{disabled}}` |
|
||||||
|
| Switch (LayerSwitchCard, ProtectionCard) | ⚠️ | RN-Switch hat default `accessibilityRole="switch"` aber kein i18n-Label |
|
||||||
|
| ProtectionDetailsSheet | 🔴 | Modal ohne `accessibilityViewIsModal` |
|
||||||
|
| AddDomainSheet TextInput | 🔴 | kein Label |
|
||||||
|
| CooldownBanner | 🔴 | Animation, kein `accessibilityLiveRegion="polite"` |
|
||||||
|
| DeactivationExplainerSheet | 🔴 | Modal-Pattern wie oben |
|
||||||
|
|
||||||
|
### 2.7 Auth-Flow — `app/(auth)/{signin,signup,forgot-password,confirm,confirm-otp,device-limit}.tsx` — 🔴
|
||||||
|
|
||||||
|
Zugang zur App = a11y-Pflicht-Pfad.
|
||||||
|
|
||||||
|
| Element (signin) | Line | Finding |
|
||||||
|
| ----------------------------- | ---- | ---------------------------------------------- |
|
||||||
|
| OAuth-Google-Btn | 103 | 🔴 nur Icon-+-Text-Children, kein expliziter Label |
|
||||||
|
| OAuth-Apple-Btn | 117 | 🔴 dito |
|
||||||
|
| TextInput Email | 139 | ✅ `autoComplete="email"` + ⚠️ kein expliziter `accessibilityLabel` |
|
||||||
|
| TextInput Password | 151 | ✅ `autoComplete="password"` + ⚠️ kein Label |
|
||||||
|
| Forgot-Password-Pressable | 162 | 🔴 |
|
||||||
|
| Submit-Button | 173 | 🔴 disabled-State nicht annonciert |
|
||||||
|
| Signup-Link-Pressable | 186 | 🔴 |
|
||||||
|
|
||||||
|
`autoComplete` hilft Password-Manager, ersetzt aber **kein** `accessibilityLabel`.
|
||||||
|
|
||||||
|
### 2.8 Community / PostCard / PostCommentsSheet — 🔴
|
||||||
|
|
||||||
|
`components/PostCard.tsx`: hitSlop ≥44pt korrekt, aber Like-/Comment-Buttons
|
||||||
|
ohne Label. Like-Count (`localCount`) wird visuell angezeigt aber nicht in
|
||||||
|
`accessibilityValue` exposed → Screen-Reader liest nur „Button".
|
||||||
|
|
||||||
|
### 2.9 Animation & Reduce-Motion — 🔴 (Cross-cutting)
|
||||||
|
|
||||||
|
- **270 Animation/Reanimated-Usages** im Code (`Animated.*` / `FadeIn` /
|
||||||
|
`useNativeDriver` / `useSharedValue`).
|
||||||
|
- **0 Treffer** für `AccessibilityInfo.isReduceMotionEnabled` /
|
||||||
|
`useReduceMotion()`.
|
||||||
|
|
||||||
|
WCAG 2.3.3 (Animation from Interactions, AAA) und 2.2.2 (Pause/Stop/Hide, AA) sind
|
||||||
|
heute pauschal verletzt — die App respektiert Systemeinstellung „Bewegung
|
||||||
|
reduzieren" nicht. Für vestibuläre Empfindlichkeit problematisch.
|
||||||
|
|
||||||
|
### 2.10 Dynamic-Type / Font-Scaling — ⚠️ (Cross-cutting)
|
||||||
|
|
||||||
|
- 398 explizite `fontSize:` / `font-size`-Vorkommen im Code.
|
||||||
|
- 0 `allowFontScaling={false}` Bypässe (gut!) — RN skaliert per default mit
|
||||||
|
iOS Dynamic-Type / Android-Font-Scale.
|
||||||
|
- ABER: viele Layouts nutzen `fontSize` als hartcodierten Pixel — bei extremem
|
||||||
|
Font-Scale (Accessibility Sizes XXX-Large) brechen die Layouts wahrscheinlich.
|
||||||
|
|
||||||
|
### 2.11 Color-Contrast — ⚠️ (Cross-cutting)
|
||||||
|
|
||||||
|
Häufige Hex-Codes aus Codebase:
|
||||||
|
- `#a3a3a3` (neutral-400) auf `#ffffff` → contrast-ratio **2,84:1** → **fail
|
||||||
|
WCAG AA** (4.5:1 für Body-Text)
|
||||||
|
- `#737373` (neutral-500) auf `#ffffff` → contrast-ratio **4,48:1** → **fail
|
||||||
|
WCAG AA** (knapp; Norm fordert 4,5:1)
|
||||||
|
- `placeholderTextColor="#a3a3a3"` (signin Lines 143, 155) → fail
|
||||||
|
|
||||||
|
→ Alle „muted" / „placeholder" Texte erfüllen WCAG AA nicht. Screenshot-
|
||||||
|
basierter axe-Audit würde dutzende Findings melden.
|
||||||
|
|
||||||
|
### 2.12 Screen-Reader-Detection — 🔴 (Cross-cutting)
|
||||||
|
|
||||||
|
`grep AccessibilityInfo|isScreenReaderEnabled` → 0 Treffer.
|
||||||
|
|
||||||
|
Heißt: keine Komponente verhält sich anders, wenn Screen-Reader an ist
|
||||||
|
(z.B. Auto-Play-Audio von Lyra-TTS bei aktivem VoiceOver = problematisch,
|
||||||
|
wenn beide gleichzeitig sprechen).
|
||||||
|
|
||||||
|
### 2.13 Touch-Target-Size — ⚠️
|
||||||
|
|
||||||
|
- iOS HIG verlangt 44×44pt → vielfach via `hitSlop=12` nachträglich erfüllt
|
||||||
|
(gut: ComposeCard, PostCard).
|
||||||
|
- Android Material verlangt **48×48dp** → heutige Mehrheit der Buttons ist
|
||||||
|
hitSlop=12 → **44pt erreicht aber 48dp Android-Norm fehlt knapp**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Test-Framework-Empfehlung
|
||||||
|
|
||||||
|
### 3.1 Was es gibt für RN
|
||||||
|
|
||||||
|
| Tool | Was es kann | Empfehlung |
|
||||||
|
| ------------------------------------------ | ----------------------------------------------------------------------------- | ---------- |
|
||||||
|
| `@testing-library/react-native` (RNTL) | `getByA11yLabel`, `getByRole`, `getByA11yState` — Component-Test-Assertions | **JA — Pflicht** |
|
||||||
|
| `@testing-library/jest-native` | Custom Matchers `toBeAccessible`, `toHaveAccessibilityValue` | **JA** |
|
||||||
|
| `react-native-accessibility-engine` (Meta) | DEV-time Audit-Output beim Render — gibt Warnings für fehlende Labels | **JA, Phase 2** |
|
||||||
|
| `axe-core-react-native` | Programmatic axe-Engine-Run gegen Component-Tree | **Optional**, instabil für SDK 53 |
|
||||||
|
| Maestro | E2E — kann `id: "<accessibilityLabel>"` als Selektor → indirekt a11y-Test | **JA** |
|
||||||
|
| Apple Accessibility Inspector (Xcode) | manuelle Audit-Tour mit Audit-Button | **Pflicht**, manuell pre-Release |
|
||||||
|
| Android Accessibility Scanner (Play Store) | manueller Audit über App, gibt Findings-Report | **Pflicht**, manuell pre-Release |
|
||||||
|
| BIK BITV-Test (DE) | offizieller deutscher BITV-Test-Bericht — DiGA-konform | **Pflicht für DiGA-Antrag**, externer Provider |
|
||||||
|
|
||||||
|
### 3.2 Empfehlung in einem Satz
|
||||||
|
|
||||||
|
**`jest-expo` + `@testing-library/react-native` + `jest-native` für automatisierte Component-A11y-Assertions, plus `react-native-accessibility-engine` als Dev-Time-Linter, plus Maestro-Flows mit `id: "<accessibilityLabel>"`-Selektoren als E2E-Validation. Manuelle VoiceOver/TalkBack-Tour pre-Release. BIK BITV-Test als externer Audit pre-DiGA-Antrag.**
|
||||||
|
|
||||||
|
### 3.3 Setup-Aufwand
|
||||||
|
|
||||||
|
| Schritt | Aufwand |
|
||||||
|
| -------------------------------------------------------------- | --------- |
|
||||||
|
| `pnpm add -D jest-expo @testing-library/react-native @testing-library/jest-native` | 15 min |
|
||||||
|
| `jest.config.js` + `jest-setup.ts` mit jest-native-Matchern | 30 min |
|
||||||
|
| Erste 3 Component-A11y-Tests (Smoke) | 2 h |
|
||||||
|
| `react-native-accessibility-engine` integrieren | 1 h |
|
||||||
|
| Maestro-A11y-Selektoren in vorhandenen Flows umstellen | 1 h |
|
||||||
|
| Dokumentierte VoiceOver/TalkBack-Manual-Test-Checkliste | 2 h |
|
||||||
|
|
||||||
|
### 3.4 Beispiel — A11y-Component-Test
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/rebreak-native/tests/components/AppHeader.a11y.test.tsx
|
||||||
|
import { render } from '@testing-library/react-native';
|
||||||
|
import '@testing-library/jest-native/extend-expect';
|
||||||
|
import { AppHeader } from '../../components/AppHeader';
|
||||||
|
|
||||||
|
describe('AppHeader — a11y contracts', () => {
|
||||||
|
it('Back-Button hat accessibilityLabel und role="button"', () => {
|
||||||
|
const { getByA11yLabel } = render(<AppHeader showBack title="Profil" />);
|
||||||
|
const back = getByA11yLabel('Zurück');
|
||||||
|
expect(back).toBeTruthy();
|
||||||
|
expect(back).toHaveAccessibilityRole('button');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Notification-Bell hat Label mit Badge-Count', () => {
|
||||||
|
const { getByA11yLabel } = render(<AppHeader notifCount={3} />);
|
||||||
|
expect(getByA11yLabel(/Benachrichtigungen.*3/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Avatar/Dropdown-Trigger hat Label', () => {
|
||||||
|
const { getByA11yLabel } = render(<AppHeader />);
|
||||||
|
expect(getByA11yLabel(/Profilmenü/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. WCAG 2.1 Level AA — Mapping rebreak-native (heute)
|
||||||
|
|
||||||
|
Pflicht-Kriterien für DiGA, geprüft gegen Codebase 2026-05-07:
|
||||||
|
|
||||||
|
| WCAG-SC | Level | Status heute | Begründung |
|
||||||
|
| -------------------------------- | ----- | ------------ | ---------------------------------------------------- |
|
||||||
|
| 1.1.1 Non-text Content | A | 🔴 Fail | 97 % der Icon-Pressables ohne `accessibilityLabel` |
|
||||||
|
| 1.3.1 Info and Relationships | A | 🔴 Fail | Form-Labels in Demographics fehlen, Modal-Roles fehlen |
|
||||||
|
| 1.3.5 Identify Input Purpose | AA | ⚠️ Partial | Auth nutzt `autoComplete`, sonst nirgends |
|
||||||
|
| 1.4.3 Contrast (Minimum) Text | AA | 🔴 Fail | `#a3a3a3 / #ffffff` = 2,84:1 — siehe oben |
|
||||||
|
| 1.4.4 Resize Text | AA | ⚠️ Partial | RN font scales by default, aber Layout bricht bei XXL |
|
||||||
|
| 1.4.10 Reflow | AA | ⚠️ Unknown | nicht systematisch getestet |
|
||||||
|
| 1.4.11 Non-text Contrast | AA | 🔴 Fail | Switch-Border, Icon-Outlines auf vielen Hellgrau-Backgrounds |
|
||||||
|
| 2.1.1 Keyboard | A | n.a. | RN nativ (kein Keyboard-Use-Case auf Phone) |
|
||||||
|
| 2.2.2 Pause, Stop, Hide | A | 🔴 Fail | Animationen pausieren nicht bei Reduce-Motion |
|
||||||
|
| 2.4.3 Focus Order | A | 🔴 Unknown | Modals haben kein Focus-Trap → Order kaputt mit VoiceOver |
|
||||||
|
| 2.4.6 Headings and Labels | AA | 🔴 Fail | Headings wie ProfileHeader haben kein `accessibilityRole="header"` |
|
||||||
|
| 2.5.5 Target Size | AA | ⚠️ Partial | iOS 44pt via hitSlop OK, Android 48dp knapp |
|
||||||
|
| 3.2.1 On Focus | A | ✅ Pass | keine unerwarteten Context-Changes on Focus |
|
||||||
|
| 3.3.1 Error Identification | A | ⚠️ Partial | Errors als Text gerendert, aber kein `accessibilityLiveRegion="assertive"` |
|
||||||
|
| 3.3.2 Labels or Instructions | A | 🔴 Fail | Form-Inputs haben Placeholder statt Label |
|
||||||
|
| 4.1.2 Name, Role, Value | A | 🔴 Fail | überall fehlt Name/Role-Markup |
|
||||||
|
| 4.1.3 Status Messages | AA | 🔴 Fail | Toasts/SuccessAlert nicht als `accessibilityLiveRegion` |
|
||||||
|
|
||||||
|
**Zusammenfassung:** rebreak-native erfüllt heute ~3 von 17 für DiGA relevanten
|
||||||
|
WCAG-AA-Kriterien.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. DiGA-Punkte-Strategy
|
||||||
|
|
||||||
|
### 5.1 Was BfArM real fragt (laut DiGA-Verfahrensverzeichnis)
|
||||||
|
|
||||||
|
DiGA-Antrag-Modul „Barrierefreiheit" verlangt:
|
||||||
|
- **Selbsterklärung WCAG 2.1 AA-Konformität** (Pflicht, schriftlich)
|
||||||
|
- **Test-Bericht** (BIK BITV-Test ODER eigener dokumentierter Audit)
|
||||||
|
- **Nutzergruppen-Reflexion** (welche Behinderungs-Pattern wurden wie adressiert?)
|
||||||
|
- **Process-Commitment** (wie wird A11y in Entwicklung+Releases sichergestellt?)
|
||||||
|
|
||||||
|
### 5.2 Low-hanging-fruit (hoher BfArM-Eindruck, niedriger Aufwand)
|
||||||
|
|
||||||
|
| Maßnahme | Effort | DiGA-Score |
|
||||||
|
| --------------------------------------------------------------------------- | ------ | ---------- |
|
||||||
|
| `accessibilityLabel` auf alle Icon-Pressables im SOS-Flow + Auth + Demographics | 1 Tag | hoch |
|
||||||
|
| `accessibilityRole="header"` auf alle h1/h2-Texte | 2 h | mittel |
|
||||||
|
| `useReduceMotion`-Hook + `Animated.timing` skip wenn true | 4 h | hoch |
|
||||||
|
| `accessibilityViewIsModal` auf alle 12 Modals | 3 h | mittel |
|
||||||
|
| Color-Tokens in `lib/theme.ts` auf WCAG-AA-konforme Hex anheben (`#a3a3a3` → `#737373`, etc.) | 4 h | hoch |
|
||||||
|
| Standard-Typing-Pattern für Forms: `<FieldRow accessibilityLabel={…}>` | 1 Tag | hoch |
|
||||||
|
|
||||||
|
### 5.3 Architektur-Investments (hoher Effort, höherer Score)
|
||||||
|
|
||||||
|
- **A11y-Wrapper-Komponenten**: `<A11yPressable>`, `<A11yTextInput>` zentral mit
|
||||||
|
Pflicht-Props. Migrationsweg über alle 453 Touchables.
|
||||||
|
- **Theme-Audit-Pipeline**: lint-rule die jede neue Hex-Color gegen
|
||||||
|
`getContrastRatio(fg, bg)` prüft.
|
||||||
|
- **CI-Gate**: jest-A11y-Tests und `react-native-accessibility-engine` in CI,
|
||||||
|
PR-Block bei Regression.
|
||||||
|
- **Dynamic-Type-aware-Layouts**: alle „fixed-width-Cards" auf flex-basiert
|
||||||
|
refactoren, Tests bei XXL-Font.
|
||||||
|
- **i18n-Pflicht-Audit**: jeder neue `accessibilityLabel` muss aus `t(…)`
|
||||||
|
kommen, nicht hartcodiert „Zurück" wie heute in AppHeader.
|
||||||
|
|
||||||
|
### 5.4 DiGA-Self-Statement (Vorschlag für DSFA mit Hans-Müller)
|
||||||
|
|
||||||
|
> rebreak verpflichtet sich zur Erfüllung der WCAG-2.1-Level-AA-Kriterien
|
||||||
|
> entsprechend BIK-BITV-Test-Standard. Pre-Release-Audit erfolgt durch
|
||||||
|
> [BIK-Provider], jährliches Re-Assessment ist Bestandteil unserer
|
||||||
|
> Entwicklungsprozesse. Automatisierte A11y-Component-Tests sind Bestandteil
|
||||||
|
> unseres CI-Gates (Pull-Request-Blocker bei Regression).
|
||||||
|
|
||||||
|
→ vor DiGA-Antrag prüfen mit Hans-Müller (DSB).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Roadmap
|
||||||
|
|
||||||
|
### 6.1 Pre-TestFlight (Wochenende 2026-05-09/10) — absolutes Minimum
|
||||||
|
|
||||||
|
Ziel: A11y-Apple-Review-Risk reduzieren, ohne den Cutover zu blockieren.
|
||||||
|
|
||||||
|
| Task | Form | Aufwand |
|
||||||
|
| --------------------------------------------------------------------------------- | ------------- | ------- |
|
||||||
|
| `accessibilityLabel` auf alle 7 Pressables in `app/urge.tsx` | manuell | 30 min |
|
||||||
|
| `accessibilityLabel` auf 4 OAuth + Forgot + Submit + Signup-Link in `signin.tsx` | manuell | 20 min |
|
||||||
|
| `accessibilityLabel` auf Notification-Bell + Avatar-Trigger in `AppHeader.tsx` | manuell | 10 min |
|
||||||
|
| `accessibilityLabel` auf Demographics-TextInputs + SelectButtons (8 Felder) | manuell | 30 min |
|
||||||
|
| `accessibilityViewIsModal={true}` auf SosFeedbackModal + GamePickerDrawer + InlineRatingDrawer + ProtectionDetailsSheet | manuell | 30 min |
|
||||||
|
| Manueller VoiceOver-Smoke-Walk (Login → SOS-Trigger → Lyra-Chip) auf iPhone-Build | manuell | 30 min |
|
||||||
|
|
||||||
|
**Owner:** rebreak-native-ui (UI-Edit-Approval beim User holen).
|
||||||
|
|
||||||
|
**Wichtig:** das ist NUR Pflaster für TestFlight Internal. Reicht nicht für DiGA.
|
||||||
|
|
||||||
|
### 6.2 Pre-DiGA-Antrag (Phase nach Public-Beta) — Vollabdeckung Critical Paths
|
||||||
|
|
||||||
|
| Task | Aufwand |
|
||||||
|
| ---------------------------------------------------------------------------------- | ------- |
|
||||||
|
| jest-expo + RNTL + jest-native installieren + jest.config.js | 1 h |
|
||||||
|
| `react-native-accessibility-engine` als Dev-Plugin | 1 h |
|
||||||
|
| A11y-Wrapper-Components `<A11yPressable>` + `<A11yTextInput>` | 1 Tag |
|
||||||
|
| Migration: alle 453 Pressables → A11yPressable mit Pflicht-Label | 5 Tage |
|
||||||
|
| Migration: alle 50 TextInputs → A11yTextInput mit Pflicht-Label | 1 Tag |
|
||||||
|
| `useReduceMotion()`-Hook in alle Animation-Files | 2 Tage |
|
||||||
|
| Color-Token-Audit in `lib/theme.ts` (WCAG-AA-konform) | 1 Tag |
|
||||||
|
| Headings-Roles auf alle Section-Titel | 4 h |
|
||||||
|
| `accessibilityLiveRegion="polite"` auf Toasts/SuccessAlert/CooldownBanner | 2 h |
|
||||||
|
| jest-A11y-Component-Tests (5 wichtigste Components, je 4–6 Assertions) | 1 Tag |
|
||||||
|
| Maestro-Flows: Selektoren auf accessibilityLabel umstellen | 1 Tag |
|
||||||
|
| Dokumentierte VoiceOver/TalkBack-Manual-QA-Checkliste | 0,5 Tag |
|
||||||
|
|
||||||
|
**Owner:** rebreak-native-ui + Ahmed (Tests).
|
||||||
|
|
||||||
|
### 6.3 Post-Launch — kontinuierliches A11y-Gate
|
||||||
|
|
||||||
|
| Task | Aufwand |
|
||||||
|
| ------------------------------------------------------------------------------- | ------- |
|
||||||
|
| GitHub Action: jest-A11y-Tests + RN-A11y-Engine in CI, PR-Block bei Regression | 2 h |
|
||||||
|
| BIK BITV-Test-Provider beauftragen pre-DiGA-Antrag | User-Action |
|
||||||
|
| Apple Accessibility Audit (Xcode) als Pre-Release-Step in `ops/` dokumentieren | 1 h |
|
||||||
|
| Android Accessibility Scanner als Pre-Release-Step | 1 h |
|
||||||
|
| jährliche A11y-Audit-Cycle (DSFA-Anhang) | User+Hans-Müller |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Konkrete TODOs nach Priorität
|
||||||
|
|
||||||
|
### Hoch (vor TestFlight, Wochenende)
|
||||||
|
|
||||||
|
1. SOS-Flow `app/urge.tsx` Lines 1094, 1116, 1123, 1160, 1209, 1231, 1244 — `accessibilityLabel` ergänzen (rebreak-native-ui).
|
||||||
|
2. Auth-Flow `(auth)/signin.tsx` Lines 103, 117, 139, 151, 162, 173, 186 — Labels (rebreak-native-ui).
|
||||||
|
3. AppHeader Lines 72, 88 — Notification + Avatar Labels (rebreak-native-ui).
|
||||||
|
4. Demographics Form Lines 267, 296, 304, 320, 328, 336, 351 — Labels (rebreak-native-ui).
|
||||||
|
5. Modals: `accessibilityViewIsModal` setzen (5 Sheets/Modals) (rebreak-native-ui).
|
||||||
|
|
||||||
|
### Mittel (Pre-DiGA-Antrag)
|
||||||
|
|
||||||
|
6. Test-Framework-Setup (`jest-expo`, RNTL, jest-native) (Ahmed).
|
||||||
|
7. A11y-Wrapper-Components (rebreak-native-ui + Ahmed-Konsultation für Test-Hooks).
|
||||||
|
8. Color-Token-Refactor in `lib/theme.ts` (rebreak-native-ui).
|
||||||
|
9. `useReduceMotion`-Cross-cutting (rebreak-native-ui).
|
||||||
|
10. BIK BITV-Test-Provider auswählen (User-Decision).
|
||||||
|
|
||||||
|
### Niedrig (Post-Launch)
|
||||||
|
|
||||||
|
11. CI-A11y-Gate (Ahmed + DevOps/Backyard).
|
||||||
|
12. Dokumentierte Pre-Release-Checkliste in `ops/RELEASE_READINESS.md` ergänzen (Ahmed).
|
||||||
|
13. Quartals-A11y-Re-Audit nach Feature-Release (DSFA-Anhang) (Ahmed + Hans-Müller).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Open Questions an User
|
||||||
|
|
||||||
|
1. **A11y-Bug-Fix-Scope am Wochenende vor TestFlight:** Soll rebreak-native-ui die ~25 fehlenden Labels in SOS-/Auth-/Demographics-/Header-Critical-Paths noch in den Cutover-Build einbauen (Effort ~2 h, Apple-Review-Risk-Reducer für External Beta), oder erst V2 nach Internal?
|
||||||
|
2. **BIK BITV-Test-Provider:** Soll Ahmed Provider-Vorschläge sammeln (BIT-inklusiv, BFIT-Bund, etc., Kosten 5–15k EUR), oder hat User schon Kontakt? Zeitpunkt: vor oder nach DiGA-Antrag-Submission?
|
||||||
|
3. **DiGA-Self-Statement-Wording:** Soll im DSFA-Anhang explizit „WCAG 2.1 AA"-Commitment stehen mit Test-Coverage-Quote (siehe TESTING_STATE.md §4.4) oder bewusst weicher formulieren („wir streben an…")? Hans-Müller-Frage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Ende. — Ahmed
|
||||||
244
ops/mac-version-research.md
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
# ReBreak macOS — Entscheidungsgrundlage
|
||||||
|
|
||||||
|
**Stand:** 2026-05-10
|
||||||
|
**Scope:** Research only. Kein Prototype, kein Code, keine Dependencies.
|
||||||
|
**ReBreak-Stack:** Expo SDK 54, RN 0.81, NEFilterDataProvider (iOS App Extension), FamilyControls/ManagedSettings, Hermes + NewArch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. TL;DR
|
||||||
|
|
||||||
|
Pfad 4 (Browser-Extension) ist der schnellste Weg zu einem funktionierenden macOS-Blocker ohne App-Umbau. Pfad 3 (Native Swift Mac-App) ist der langfristig sauberste Weg mit echtem System-Level-Blocking, aber erfordert einen separaten Greenfield-Build. Pfad 1 und 2 scheitern beide am selben fundamentalen Problem: FamilyControls und NEFilterDataProvider in ihrer iOS-Form existieren auf macOS nicht — die RN-zu-Mac-Bridges kaufen dir UI-Portierung, lösen aber nicht das Kernproblem Blocking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Pfad-Vergleich-Tabelle
|
||||||
|
|
||||||
|
| Pfad | Effort (Wochen) | Blocking funktioniert? | Cross-platform? | Maintenance | Risiko |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 — Mac Catalyst | 6–10 | Nein (FamilyControls iOS-only) | Nein | Hoch (Apple API-drift) | Sehr hoch |
|
||||||
|
| 2 — RN macOS | 8–14 | Nein (kein FamilyControls, NEFilter anders) | Nein | Mittel-Hoch | Hoch |
|
||||||
|
| 3 — Native Swift | 8–12 | Ja (NEFilterDataProvider System Extension) | Nein (nur Mac) | Niedrig | Mittel |
|
||||||
|
| 4 — Browser-Extension | 3–5 | Eingeschränkt (kein App-Bypass, kein HTTPS-Intercept ohne Proxy) | Ja (Win/Mac/Linux) | Niedrig | Niedrig |
|
||||||
|
| 5 — MDM-Profil | 0 | Ja (DNS-Level, kein Bypass ohne IT-Admin) | Nein (nur eigenes Device) | Null | Null |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Pfad-Details
|
||||||
|
|
||||||
|
### Pfad 1: Mac Catalyst
|
||||||
|
|
||||||
|
**Was funktioniert:**
|
||||||
|
- react-native-bottom-tabs explizit macOS-fähig (Callstack hat Screenshots im README)
|
||||||
|
- nativewind: README sagt "works on all RN platforms"
|
||||||
|
- react-native-screens: keine aktiven macOS-spezifischen open Bugs
|
||||||
|
- Nuxt-unabhängige Logik (API-calls, Auth via Supabase-JS) wäre ohne Änderung nutzbar
|
||||||
|
|
||||||
|
**Was bricht:**
|
||||||
|
|
||||||
|
FamilyControls und ManagedSettings sind iOS/iPadOS-only. Apple hat diese Frameworks nie auf macOS portiert, auch nicht via Catalyst. `@available(macOS, unavailable)` ist in Apples eigenen Headers gesetzt. Das bedeutet: der gesamte Screen-Time-Layer (AppShield, App-Blocking, Activity-Monitoring) ist auf macOS nicht verfügbar. Kein Workaround ohne komplettes Redesign.
|
||||||
|
|
||||||
|
NEFilterDataProvider auf iOS ist ein App Extension, der ohne Sondergenehmigung läuft. Auf macOS Catalyst ist das Framework technisch präsent, aber Network Extension Content Filter auf macOS erfordert die Entitlement `com.apple.developer.network-extension.content-filter`, die bei Apple manuell beantragt werden muss und an System Extensions (nicht App Extensions) gebunden ist. Catalyst-Apps sind keine System Extensions.
|
||||||
|
|
||||||
|
Konkrete Module-Probleme:
|
||||||
|
- `react-native-mmkv` v3+: README nennt nur iOS/Android/Web. GitHub zeigt 47 offene Issues, macOS/Catalyst nicht erwähnt. Das Library liefert ab v3 precompiled XCFrameworks — und das `maccatalyst`-Slice fehlt laut Issue #1268 (Stand Mai 2026 open, keine Aktivität).
|
||||||
|
- `@react-native-async-storage/async-storage`: Issue #1268 (open, März 2026): v3 liefert kein `maccatalyst`-Slice im XCFramework mehr. Build bricht.
|
||||||
|
- `@lodev09/react-native-true-sheet`: Issues gefunden unter macOS "Designed for iPhone"-Modus (macOS führt iOS-Apps seit macOS 11 aus, aber das ist kein Catalyst-Build). Fix-PR für diesen Modus war in Arbeit, Status unklar.
|
||||||
|
- `rive-react-native`: Kein macOS-Support erwähnt, iOS/Android only laut README.
|
||||||
|
- `lottie-react-native`: 0 macOS/Catalyst Issues — deutet darauf hin dass niemand es versucht (kein positiver Support-Signal).
|
||||||
|
- `expo-apple-authentication`: Funktioniert technisch auf macOS Catalyst (Sign in with Apple ist verfügbar), aber ist nicht dokumentiert.
|
||||||
|
- `expo-haptics`: No-op oder crash auf macOS (kein Taptic Engine).
|
||||||
|
|
||||||
|
**Geschätzter Effort:** 6–10 Wochen allein für Build-Green auf Catalyst, ohne dass Blocking funktioniert.
|
||||||
|
|
||||||
|
**Blocking-Fazit:** Nicht machbar. Catalyst löst nur das UI-Problem, nicht das Kern-Feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pfad 2: React Native macOS (Microsoft Fork)
|
||||||
|
|
||||||
|
**Maintenance-State:**
|
||||||
|
- Aktuelles Release: v0.81.2 (2026-02-11) — exakt kompatibel mit ReBreaks RN 0.81.5
|
||||||
|
- Repo aktiv: zuletzt geupdated 2026-05-09, 4.327 Stars, 96 open Issues
|
||||||
|
- NewArch (Fabric): teilweise implementiert. Aktive open Bugs: TextInput multiline scrollt nicht in Fabric, Focus-Ring-Verhalten, Transform-Clipping. Grundlegende Fabric-Issues sind aber weiter zugemacht worden (Text selectable, platform colors etc.) — die Richtung stimmt.
|
||||||
|
- RN macOS unterstützt macOS 11 (Big Sur) und neuer.
|
||||||
|
|
||||||
|
**Expo-Kompatibilität:**
|
||||||
|
RN macOS ist ein Fork von facebook/react-native, nicht kompatibel mit Expo Managed Workflow. Expo prebuild (bare workflow) ist theoretisch möglich, aber Expo-Module sind nicht für RN macOS gebaut. expo-modules-core enthält keine macOS-Targets. Kein Expo SDK Modul (expo-av, expo-notifications, expo-haptics, expo-apple-authentication etc.) hat offiziell RN macOS Support.
|
||||||
|
|
||||||
|
Das bedeutet: alle Expo-Module müssten durch native macOS Äquivalente ersetzt oder komplett gestripped werden.
|
||||||
|
|
||||||
|
**Was funktioniert:**
|
||||||
|
- react-native-bottom-tabs: explizit macOS-Support vorhanden (Callstack README zeigt macOS Screenshot)
|
||||||
|
- react-navigation/native: läuft auf RN macOS (Microsoft nutzt es intern für Teams/Outlook-Teile)
|
||||||
|
- zustand, react-query, i18next: pure JS, kein Problem
|
||||||
|
- Supabase-JS: pure JS, kein Problem
|
||||||
|
|
||||||
|
**Was bricht / fehlt:**
|
||||||
|
- Kein FamilyControls, kein ManagedSettings — exakt gleiche Lage wie Pfad 1
|
||||||
|
- NEFilterDataProvider auf macOS: ANDERS als auf iOS. Auf macOS muss der Filter-Provider als System Extension laufen (eigener Prozess, privilegiert, separate App-Bundle-Component). RN macOS hat kein Framework dafür — das wäre nativer Swift/ObjC Code komplett außerhalb des RN-Layers.
|
||||||
|
- react-native-mmkv: iOS/Android/Web only (README explizit). Kein macOS-Target.
|
||||||
|
- react-native-reanimated: 354 offene Issues mit "macos" im Search-Context, aber keines bezieht sich auf RN macOS spezifisch. Reanimated macht seine eigene JSI-Integration und ist nicht für RN macOS portiert (Hypothese, ungeprüft — kein positiver Hinweis auf Support).
|
||||||
|
- react-native-gesture-handler: hat 151 macOS-related Issues, aber die meisten beziehen sich auf Mac Catalyst oder unrelated. Keine explizite RN macOS Unterstützung in der README.
|
||||||
|
- rive-react-native: iOS/Android only
|
||||||
|
- lottie-react-native: 0 macOS Issues (kein positiver Signal)
|
||||||
|
- expo-router: nicht kompatibel mit RN macOS (Expo-Abhängigkeit)
|
||||||
|
|
||||||
|
**Effort-Schätzung:**
|
||||||
|
- Woche 1–2: RN macOS initialisieren, Build-System aufsetzen, Podfile anpassen
|
||||||
|
- Woche 3–5: Expo-Module ersetzen (expo-av → AVFoundation nativ, expo-notifications → NSUserNotifications, etc.)
|
||||||
|
- Woche 6–8: mmkv durch NSUserDefaults oder nativem Äquivalent ersetzen, Reanimated patchen oder ersetzen
|
||||||
|
- Woche 9–12: Rive-Animationen entfernen/ersetzen, Layout-Bugs fixen (Fabric-Issues), macOS-spezifische UI (Fenster-Resize, Menübar, Kontextmenüs)
|
||||||
|
- Woche 13–14: Testing, kein Blocking-Feature
|
||||||
|
|
||||||
|
Mindestens 12–14 Wochen, und am Ende kein Blocking. Das ist die Investition ohne das Kern-Feature.
|
||||||
|
|
||||||
|
**Blocking-Fazit:** Nicht machbar mit vertretbarem Aufwand. System Extension ist nativer Swift-Code komplett außerhalb des RN-Layers, und der Aufwand dafür überschneidet sich mit Pfad 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pfad 3: Native Swift Mac-App (Greenfield)
|
||||||
|
|
||||||
|
**NEFilterDataProvider auf macOS — Status:**
|
||||||
|
NEFilterDataProvider ist auf macOS seit macOS 10.15 als System Extension verfügbar (via `NetworkExtension.framework`). SelfControl (4.344 Stars, aktiv, zuletzt 2026-05-10 geupdated) nutzt als Alternative `/etc/hosts`-Manipulation + Berkeley Packet Filter (`pf`) via `PacketFilter.m` und `HostFileBlocker.m`. Das ist der einfachere Weg, erfordert aber Adminrechte.
|
||||||
|
|
||||||
|
System Extension NEFilterDataProvider (kein Root nötig, aber):
|
||||||
|
- Entitlement `com.apple.developer.network-extension.content-filter` muss bei Apple beantragt werden (kein normales Developer-Account-Feature). Apple erwartet Use-Case-Begründung.
|
||||||
|
- System Extension muss vom User in Systemeinstellungen > Sicherheit aktiviert werden (macOS-Gatekeeper-Flow).
|
||||||
|
- Build-Komplexität: separate Bundle-Target in Xcode, eigener App-Lifecycle, IPC zwischen Main-App und Extension.
|
||||||
|
- Gut dokumentiert in Apple Human Interface Guidelines und Network Extension Programming Guide.
|
||||||
|
|
||||||
|
Kein öffentliches Swift-Beispiel auf GitHub gefunden (API-Suche lieferte 0 Ergebnisse für NEFilterDataProvider + Swift + macOS in Repositories). Das deutet auf closed-source-Landschaft hin (Freedom, Focus, Cold Turkey, Parental Controls etc. sind alle proprietär).
|
||||||
|
|
||||||
|
Alternativer Ansatz — NEDNSProxyProvider (DNS-Level-Filter):
|
||||||
|
- Einfachere Entitlement, kein System Extension Review-Prozess
|
||||||
|
- Blockiert auf DNS-Ebene (kein per-Request-Filtering, kein HTTPS-Inspection)
|
||||||
|
- Wirksam gegen ~208k Casino-Domains wenn die Blocklist als lokaler DNS-Resolver fungiert
|
||||||
|
- Vergleichbar mit NextDNS/AdGuard DNS-Approach
|
||||||
|
|
||||||
|
Reuse vom Backend:
|
||||||
|
- REST-API (Nuxt Nitro auf Hetzner): vollständig wiederverwendbar
|
||||||
|
- Blocklist JSON (208k Domains): format-kompatibel, nur laden + DNS-Lookup-Check
|
||||||
|
- Auth-Flow (Supabase JWT): standard HTTP, kein Problem
|
||||||
|
- Nur die iOS-UI und die iOS-spezifischen APIs müssen neu gebaut werden
|
||||||
|
|
||||||
|
**Notarization:**
|
||||||
|
Apple Developer Account (Raynis e.K.) ist vorhanden. Notarization ist Standard-Prozess bei Xcode Archive. kein Zusatz-Review nötig (im Gegensatz zu App Store).
|
||||||
|
|
||||||
|
**Minimaler Feature-Scope für ersten Build:**
|
||||||
|
1. Login via Sign in with Apple (macOS unterstützt das)
|
||||||
|
2. Blocklist laden von API oder gebündelt
|
||||||
|
3. NEDNSProxyProvider aktivieren (DNS-Blocking)
|
||||||
|
4. Status-Anzeige (aktiv/inaktiv, Anzahl blockierter Anfragen)
|
||||||
|
5. Ein-/Ausschalten
|
||||||
|
Das ist eine kleine App, nicht feature-parity mit der iOS-App.
|
||||||
|
|
||||||
|
**Geschätzter Effort:**
|
||||||
|
- Woche 1–2: Xcode-Projekt setup, Network Extension Target, Entitlement-Antrag bei Apple
|
||||||
|
- Woche 3–4: NEDNSProxyProvider implementieren + Blocklist-Integration
|
||||||
|
- Woche 5–6: Auth (Sign in with Apple + Supabase), API-Sync der Blocklist
|
||||||
|
- Woche 7–8: Minimal-UI (SwiftUI, Menübar-App oder Hauptfenster), Notarization
|
||||||
|
- Woche 9–10: Testing, Edge-Cases, macOS 13/14/15 Kompatibilität
|
||||||
|
|
||||||
|
Realistisch: 10–12 Wochen für einen funktionsfähigen, testbaren Build. Feature-parity (Streak, SOS-Chat, Games) wäre deutlich mehr.
|
||||||
|
|
||||||
|
**Risiken:**
|
||||||
|
- Entitlement-Genehmigung: Apple kann Anfragen für `com.apple.developer.network-extension.content-filter` ablehnen oder verzögern. Mit NEDNSProxyProvider ist dieses Risiko geringer.
|
||||||
|
- System Extension Activation: User muss explizit in macOS Systemeinstellungen bestätigen. Onboarding-Hürde.
|
||||||
|
- macOS Gatekeeper + Notarization: kann bei Libraries/Deps Probleme machen, aber mit reinem SwiftUI + Apple Frameworks ist das manageable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pfad 4: Browser-Extension (Safari / Chrome / Firefox)
|
||||||
|
|
||||||
|
**Blocking-Mechanismus:**
|
||||||
|
WebExtension Standard (MV3): `declarativeNetRequest` API blockiert Requests bevor sie das Netzwerk erreichen.
|
||||||
|
|
||||||
|
Chrome MV3 Limits:
|
||||||
|
- Static Rules (in Extension Bundle): bis zu 330.000 Regeln via mehrere `rule_resources`-Rulesets (jedes bis zu 30.000 Regeln, 11 Rulesets möglich)
|
||||||
|
- Dynamic Rules (laufzeit-änderbar): 5.000 (Issue bei w3c/webextensions #319 für Erhöhung auf 30.000 ist offen, Stand Mai 2026 noch nicht merged)
|
||||||
|
- 208k Domains als static ruleset: realisierbar mit ~7 Rulesets à 30k Regeln
|
||||||
|
|
||||||
|
Limitierung:
|
||||||
|
- Browser-Extension blockiert nur Browser-Traffic. Native Apps (Casino-Apps, andere Browser) sind nicht betroffen.
|
||||||
|
- Kein HTTPS-Inspection nötig für Domain-Blocking (anders als Port-based Blocking)
|
||||||
|
- User kann Extension deaktivieren (kein Self-Binding-Enforcement wie bei iOS)
|
||||||
|
|
||||||
|
**Cross-Browser-Status:**
|
||||||
|
- Chrome/Chromium: MV3 vollständig, declarativeNetRequest stabil
|
||||||
|
- Firefox: MV3 Support seit Firefox 127 (Mai 2024), declarativeNetRequest verfügbar aber mit leicht anderen Limits
|
||||||
|
- Safari: Web Extensions seit Safari 14 (MV2 + einige MV3 Features). declarativeNetRequest in Safari 16.4+. Webkit Content Blocker (separates Format, bis 150k Regeln) ist eine Safari-Alternative.
|
||||||
|
|
||||||
|
**Effort-Schätzung:**
|
||||||
|
- Woche 1: Extension-Scaffolding (Manifest V3, background service worker, popup UI)
|
||||||
|
- Woche 2: declarativeNetRequest ruleset generation aus der bestehenden 208k-Domain JSON-Blocklist
|
||||||
|
- Woche 3: Auth-Integration (Popup login via Supabase, JWT-Sync), Account-check ob aktiv
|
||||||
|
- Woche 4: Safari-spezifisches Xcode-Wrapping (Safari Web Extensions brauchen ein macOS/iOS App-Bundle), Notarization
|
||||||
|
- Woche 5: Testing Chrome + Firefox + Safari, Edge-Cases (subdomain handling, www-prefix)
|
||||||
|
|
||||||
|
3–5 Wochen für einen ersten funktionierenden Chrome + Firefox Build. Safari kostet 1–2 extra Wochen (Xcode-Packaging).
|
||||||
|
|
||||||
|
**Vorteile:**
|
||||||
|
- Läuft auf Windows und Linux ebenfalls (ohne Mehraufwand)
|
||||||
|
- Kein Apple-Entitlement-Antrag nötig
|
||||||
|
- Extension Store Distribution: Chrome Web Store + Firefox Add-ons sind einfach
|
||||||
|
- Safari über App Store Distribution möglich (aber kein Pflicht, Sideloading geht auch)
|
||||||
|
|
||||||
|
**Nachteile:**
|
||||||
|
- Kein Bypass-Schutz: User kann die Extension deaktivieren
|
||||||
|
- Kein App-Blocking (Native Casino-Apps auf macOS sind nicht betroffen)
|
||||||
|
- Kein Awareness-Feature (Streak, SOS-Chat) integrierbar ohne Login-Popup
|
||||||
|
- Blocklist-Updates: müssen als Extension-Update gepusht werden (oder dynamisch via API mit dem 5k-Limit, was für 208k nicht ausreicht — static rulesets bleiben Pflicht)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pfad 5: Persönliches MDM-Profil (Web Content Filter Payload)
|
||||||
|
|
||||||
|
Apple Configuration Profile mit `WebContentFilter`-Payload:
|
||||||
|
- Filtert DNS-Requests oder URLs via Supervised-Device-Mechanism
|
||||||
|
- Für einzelnes Device: `.mobileconfig`-Datei installieren in Systemeinstellungen > Allgemein > VPN und Geräteverwaltung
|
||||||
|
- kein Produktfeature — nur für Chahine selbst, nicht für andere ReBreak-User deploybar (außer man baut eine MDM-Infrastruktur, was Scope-mäßig Pfad 3 übersteigt)
|
||||||
|
- SelfControl-Alternative: kostenlos, Open-Source, kein Dev-Aufwand, läuft heute
|
||||||
|
|
||||||
|
**Für Chahine persönlich:** SelfControl (selfcontrolapp.com) macht genau das, ohne ein einziges Line Code zu schreiben. Blocklist importieren, Timer setzen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Empfehlung
|
||||||
|
|
||||||
|
**Heute (wenn Luft da ist): Pfad 4 — Browser-Extension**
|
||||||
|
Niedrigster Aufwand, sofortiger Mehrwert für alle ReBreak-User (nicht nur macOS). Blocking für Browser-Traffic (Hauptweg ins Online-Casino) funktioniert. Kein Apple-Entitlement nötig. Chrome + Firefox in 3–4 Wochen machbar. Safari kommt als +1-Woche-Add-on über Xcode-Wrapper.
|
||||||
|
|
||||||
|
**Bei ARR > 50k EUR oder DiGA-Zulassung: Pfad 3 — Native Swift Mac-App**
|
||||||
|
Erst dann rechtfertigt sich der Aufwand für echtes System-Level-Blocking. Greenfield-Build, NEDNSProxyProvider als Blocking-Engine, SwiftUI-UI wiederverwendend keine iOS-RN-Komponenten. Backend-API vollständig wiederverwendbar.
|
||||||
|
|
||||||
|
**Pfad 1 und 2: nicht verfolgen.**
|
||||||
|
Beide kaufen nur UI-Portierung, lösen aber das Kern-Feature (Blocking) nicht. Der Aufwand für Pfad 2 übersteigt Pfad 3 bei gleichzeitig schlechterem Ergebnis.
|
||||||
|
|
||||||
|
**Pfad 5 (MDM): für Chahine persönlich sofort** — SelfControl installieren, Blocklist aus ReBreak JSON importieren. Kein Dev-Aufwand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Offene Fragen (ungeprüft / hypothetisch markiert)
|
||||||
|
|
||||||
|
- **Hypothese, ungeprüft:** `com.apple.developer.network-extension.content-filter` Entitlement — wie lange dauert Apples Review-Prozess aktuell? Apple-Forum-Posts aus 2023 beschreiben 2–4 Wochen. Stand 2026 unbekannt.
|
||||||
|
- **Hypothese, ungeprüft:** react-native-reanimated läuft nicht auf RN macOS. Keine Issues gefunden, aber auch kein positiver Hinweis. Wäre durch Testbuild verifizierbar.
|
||||||
|
- **Hypothese, ungeprüft:** Safari declarativeNetRequest mit 208k static rules ist performant genug. WebKit's Content Blocker (alternatives Format) wäre eine Safari-native Alternative ohne diese Unsicherheit.
|
||||||
|
- **Offen:** Firefox MV3 Static-Ruleset-Limits — 330k-Limit ist Chrome-spezifisch. Firefox-Limits bei mehreren rule_resources nicht abschließend recherchiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Quellen
|
||||||
|
|
||||||
|
- react-native-macos Repo: https://github.com/microsoft/react-native-macos
|
||||||
|
- react-native-macos releases: v0.81.2 (2026-02-11)
|
||||||
|
- react-native-bottom-tabs macOS support: https://github.com/callstack/react-native-bottom-tabs (README, Platform-Tabelle)
|
||||||
|
- async-storage Catalyst Issue #1268 (open, März 2026): https://github.com/react-native-async-storage/async-storage/issues/1268
|
||||||
|
- w3c/webextensions Issue #319 (dynamic rules limit, open): https://github.com/w3c/webextensions/issues/319
|
||||||
|
- SelfControl macOS app (BlockManager + PacketFilter approach): https://github.com/SelfControlApp/selfcontrol
|
||||||
|
- Apple Network Extension Programming Guide: https://developer.apple.com/documentation/networkextension
|
||||||
|
- Apple FamilyControls (iOS/iPadOS only): https://developer.apple.com/documentation/familycontrols
|
||||||
|
- react-native-mmkv platform support (iOS/Android/Web): https://github.com/mrousavy/react-native-mmkv
|
||||||
|
- rive-react-native platform support (iOS/Android): https://github.com/rive-app/rive-react-native
|
||||||
|
- Chrome declarativeNetRequest (static rulesets, 330k total limit): https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest
|
||||||
|
- Safari Web Extensions: https://developer.apple.com/documentation/safariservices/safari-web-extensions
|
||||||
122
ops/mdm/ARCHITECTURE.md
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
# MDM Server — Technische Architektur
|
||||||
|
|
||||||
|
## Server
|
||||||
|
|
||||||
|
- **Hostname:** rebreak-mdm
|
||||||
|
- **IP:** 178.105.101.137
|
||||||
|
- **Provider:** Hetzner Cloud
|
||||||
|
- **OS:** Ubuntu 24.04
|
||||||
|
- **SSH:** `ssh rebreak-mdm` (via ~/.ssh/config Alias)
|
||||||
|
|
||||||
|
## DNS
|
||||||
|
|
||||||
|
- **Domain:** mdm.rebreak.org
|
||||||
|
- **Registrar:** IONOS
|
||||||
|
- **Record:** A-Record, 178.105.101.137
|
||||||
|
- **TTL:** Standard (300-3600s)
|
||||||
|
|
||||||
|
## Stack-Komponenten
|
||||||
|
|
||||||
|
### nginx (System-Service)
|
||||||
|
- Version: nginx/1.24.0
|
||||||
|
- Port 80: HTTP-zu-HTTPS-Redirect (301)
|
||||||
|
- Port 443: SSL/TLS mit HTTP/2, reverse proxy zu nanomdm
|
||||||
|
- Config: `/etc/nginx/sites-available/mdm.rebreak.org` (symlinked in sites-enabled)
|
||||||
|
- TLS: Let's Encrypt via certbot, auto-renewal via systemd-Timer
|
||||||
|
|
||||||
|
### NanoMDM (Docker-Container)
|
||||||
|
- Image: `ghcr.io/micromdm/nanomdm:latest` (v0.9.0 zum Zeitpunkt Setup)
|
||||||
|
- Container-Name: `nanomdm`
|
||||||
|
- Compose-File: `/opt/nanomdm/docker-compose.yml`
|
||||||
|
- Netzwerk-Mode: `host` (kein Bridge-Netzwerk — direkter Zugriff auf localhost:5432)
|
||||||
|
- Lauscht: `127.0.0.1:9000` (nur localhost, nginx proxiet)
|
||||||
|
- Restart-Policy: `unless-stopped`
|
||||||
|
- Volumes:
|
||||||
|
- `/opt/nanomdm/certs:/certs:ro` (CA-cert + Push-cert)
|
||||||
|
- `nanomdm-data:/data` (Docker-Volume)
|
||||||
|
|
||||||
|
### PostgreSQL (System-Service)
|
||||||
|
- Version: PostgreSQL 16
|
||||||
|
- Socket: `127.0.0.1:5432` (localhost only)
|
||||||
|
- Datenbank: `nanomdm`
|
||||||
|
- User: `nanomdm`
|
||||||
|
- Passwort: in `/root/.nanomdm_db_pass` (chmod 600)
|
||||||
|
- pg_hba: scram-sha-256 für localhost + 172.17.0.0/16 + 172.18.0.0/16 (Docker-Netze)
|
||||||
|
|
||||||
|
### Certbot (System-Service)
|
||||||
|
- Cert-Pfad: `/etc/letsencrypt/live/mdm.rebreak.org/`
|
||||||
|
- Auto-Renewal: systemd-Timer (certbot.timer), prüft 2x täglich
|
||||||
|
- Renewal: nginx-Reload nach Renewal via Hook
|
||||||
|
|
||||||
|
## Port-Übersicht
|
||||||
|
|
||||||
|
| Port | Bind | Service | Beschreibung |
|
||||||
|
|------|--------------|-----------|------------------------------------|
|
||||||
|
| 80 | 0.0.0.0 | nginx | HTTP → HTTPS redirect |
|
||||||
|
| 443 | 0.0.0.0 | nginx | HTTPS, TLS termination, MDM-proxy |
|
||||||
|
| 9000 | 127.0.0.1 | nanomdm | MDM-Protokoll (intern only) |
|
||||||
|
| 5432 | 127.0.0.1 | postgres | DB (intern only) |
|
||||||
|
| 22 | 0.0.0.0 | sshd | Admin-SSH |
|
||||||
|
|
||||||
|
UFW-Regeln: 22/tcp, 80/tcp, 443/tcp erlaubt. Alles andere denied by default.
|
||||||
|
|
||||||
|
## Zertifikat-Pfade
|
||||||
|
|
||||||
|
| Datei | Inhalt | Permissions |
|
||||||
|
|------------------------------------|---------------------|-------------|
|
||||||
|
| `/opt/nanomdm/certs/ca.crt` | MDM CA (self-signed)| 644 |
|
||||||
|
| `/opt/nanomdm/certs/ca.key` | MDM CA Private Key | 600 |
|
||||||
|
| `/opt/nanomdm/certs/push.csr` | Apple Push CSR | 644 |
|
||||||
|
| `/opt/nanomdm/certs/push.key` | Apple Push Priv-Key | 600 |
|
||||||
|
| `/opt/nanomdm/certs/push.pem` | Apple Push Cert (*) | 600 geplant |
|
||||||
|
| `/root/.nanomdm_db_pass` | Postgres-Passwort | 600 |
|
||||||
|
|
||||||
|
(*) `push.pem` existiert noch nicht — warte auf Apple-Portal-Upload (Phase D.1)
|
||||||
|
|
||||||
|
## Apple Push Zertifikat — Ablauf
|
||||||
|
|
||||||
|
Apple-Geräte erhalten MDM-Befehle via Apple Push Notification Service (APNS). Dafür braucht NanoMDM ein von Apple signiertes Push-Zertifikat.
|
||||||
|
|
||||||
|
Ablauf (einmal jährlich zu erneuern):
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Server generiert push.key + push.csr (einmalig, Key bleibt gleich bei Renewal)
|
||||||
|
2. Admin lädt push.csr auf identity.apple.com/pushcert hoch
|
||||||
|
3. Apple signiert und stellt push.pem aus (Download)
|
||||||
|
4. push.pem wird auf Server kopiert: /opt/nanomdm/certs/push.pem
|
||||||
|
5. nanomdm via -apns-cert oder Umgebungsvariable konfigurieren
|
||||||
|
6. docker compose restart nanomdm
|
||||||
|
```
|
||||||
|
|
||||||
|
Wichtig: Bei Renewal (jährlich) den GLEICHEN push.key verwenden. Wenn ein neuer Key generiert wird, müssen alle enrollten Geräte re-enrollen.
|
||||||
|
|
||||||
|
## Trust-Modell
|
||||||
|
|
||||||
|
```
|
||||||
|
Chahine (Device-Owner)
|
||||||
|
- enrolled freiwillig
|
||||||
|
- hat KEINEN MDM-Admin-Zugriff
|
||||||
|
- kann Profil NICHT selbst entfernen
|
||||||
|
|
||||||
|
Olfa (Co-Admin)
|
||||||
|
- hat SSH-Zugriff auf rebreak-mdm
|
||||||
|
- kennt MDM-Admin-API-Key (nach Phase E generiert)
|
||||||
|
- kann Profil entfernen via nanomdm API
|
||||||
|
|
||||||
|
Ina Wittek (Trustee, ina.wittek@gmx.de)
|
||||||
|
- bekommt Notfall-Credentials per Email (Phase E)
|
||||||
|
- kann Profil entfernen wenn weder Chahine noch Olfa erreichbar
|
||||||
|
- hat kein Server-Zugriff, nur Credentials für nanomdm-Endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recovery-Szenarien
|
||||||
|
|
||||||
|
| Szenario | Lösung |
|
||||||
|
|-----------------------------------|-------------------------------------------------------------|
|
||||||
|
| Profil-Entfernung nötig | Olfa oder Ina nutzen MDM-API oder nanomdm-UI |
|
||||||
|
| Server down | `ssh rebreak-mdm` → `docker compose -f /opt/nanomdm/docker-compose.yml up -d` |
|
||||||
|
| Apple Push Cert abgelaufen | Neues Push Cert via identity.apple.com, gleicher push.key |
|
||||||
|
| DB korrupt | Backup einspielen (pg_dump), dann nanomdm restart |
|
||||||
|
| Server kompromittiert | Apple Push Cert revoken auf identity.apple.com, neuer Server, neues Enrollment |
|
||||||
|
| Device verloren (gestohlen) | MDM-remote-wipe triggern (löscht Gerät), nicht MDM-Profil |
|
||||||
|
| Factory-Reset vom User | Nuclear option: alle Daten weg, aber MDM-Profil auch weg. Dann re-enroll. |
|
||||||
259
ops/mdm/PHASES.md
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
# MDM Setup — Phasen
|
||||||
|
|
||||||
|
## Phase A ✅ Server-Bootstrap
|
||||||
|
|
||||||
|
Erledigt vor 2026-05-10.
|
||||||
|
|
||||||
|
- apt-update + apt-upgrade
|
||||||
|
- Pakete installiert: nginx, postgresql, docker.io, certbot, python3-certbot-nginx, ufw, fail2ban
|
||||||
|
- UFW konfiguriert: 22/tcp, 80/tcp, 443/tcp erlaubt, default-deny
|
||||||
|
- fail2ban aktiv (SSH-Brute-Force-Schutz)
|
||||||
|
- DNS: IONOS A-Record `mdm.rebreak.org` → 178.105.101.137
|
||||||
|
|
||||||
|
## Phase B ✅ TLS-Zertifikat
|
||||||
|
|
||||||
|
Erledigt vor 2026-05-10.
|
||||||
|
|
||||||
|
- `certbot --nginx -d mdm.rebreak.org` ausgeführt
|
||||||
|
- Cert liegt in `/etc/letsencrypt/live/mdm.rebreak.org/`
|
||||||
|
- certbot.timer (systemd) erneuert automatisch
|
||||||
|
|
||||||
|
## Phase C ✅ NanoMDM Container + nginx-Vhost
|
||||||
|
|
||||||
|
Erledigt 2026-05-10.
|
||||||
|
|
||||||
|
**Was gemacht wurde:**
|
||||||
|
|
||||||
|
1. PostgreSQL-Datenbank `nanomdm` mit User `nanomdm` und Passwort aus `/root/.nanomdm_db_pass` angelegt
|
||||||
|
2. `ALTER USER nanomdm WITH PASSWORD '...'` explizit gesetzt (scram-sha-256 braucht explizites Passwort)
|
||||||
|
3. `pg_hba.conf` ergänzt für Docker-Netze (172.17.0.0/16, 172.18.0.0/16)
|
||||||
|
4. `listen_addresses` in `postgresql.conf` auf `localhost,172.17.0.1,172.18.0.1` erweitert
|
||||||
|
5. MDM CA generiert: `ca.key` + `ca.crt` in `/opt/nanomdm/certs/`
|
||||||
|
6. `/opt/nanomdm/.env` mit `NANOMDM_DB_PASS` geschrieben (chmod 600)
|
||||||
|
7. `/opt/nanomdm/docker-compose.yml` mit `network_mode: host` (kritisch, sonst postgres nicht erreichbar wegen NAT-Masquerade)
|
||||||
|
8. `docker compose up -d` — Container läuft, `starting server listen=127.0.0.1:9000` bestätigt
|
||||||
|
9. nginx-Vhost `/etc/nginx/sites-available/mdm.rebreak.org` geschrieben + in sites-enabled symlinkt
|
||||||
|
10. `nginx -t && systemctl reload nginx`
|
||||||
|
11. Externer Verify: `curl -sI https://mdm.rebreak.org/` → `HTTP/2 404` von nanomdm (korrekt, kein 502)
|
||||||
|
|
||||||
|
**Bekannte Tücken aus diesem Setup:**
|
||||||
|
|
||||||
|
- `micromdm/nanomdm` auf Docker Hub existiert nicht. Korrektes Image: `ghcr.io/micromdm/nanomdm:latest`
|
||||||
|
- nanomdm v0.9 kennt `-storage postgres` nicht. Korrekt: `-storage pgsql` (bzw. `NANOMDM_STORAGE=pgsql`)
|
||||||
|
- Docker-Compose-Netzwerk (172.18.x) geht via NAT durch Host — Postgres sieht externe IP als Source. Lösung: `network_mode: host` im Compose, dann verbindet nanomdm direkt zu `127.0.0.1:5432`
|
||||||
|
- nginx 1.24 kennt `http2 on;` nicht (das ist nginx 1.25+). Korrekt: `listen 443 ssl http2;`
|
||||||
|
|
||||||
|
## Phase D ✅ Apple Push CSR generiert
|
||||||
|
|
||||||
|
Erledigt 2026-05-10.
|
||||||
|
|
||||||
|
```
|
||||||
|
openssl req -newkey rsa:2048 -nodes \
|
||||||
|
-keyout /opt/nanomdm/certs/push.key \
|
||||||
|
-out /opt/nanomdm/certs/push.csr \
|
||||||
|
-subj '/CN=ReBreak MDM Push/O=Raynis/C=DE'
|
||||||
|
chmod 600 /opt/nanomdm/certs/push.key
|
||||||
|
```
|
||||||
|
|
||||||
|
CSR-Content liegt in `/opt/nanomdm/certs/push.csr`. Der private Key `push.key` verlässt den Server nie.
|
||||||
|
|
||||||
|
## Phase D.0.5 ✅ mdmcert.download Signing-Request
|
||||||
|
|
||||||
|
Erledigt 2026-05-10.
|
||||||
|
|
||||||
|
**Warum dieser Schritt notwendig ist:**
|
||||||
|
|
||||||
|
Apple Push Notification Service (APNS) für MDM akzeptiert keine rohen CSRs von Self-Hostern direkt im Apple Push Portal. Apple verlangt, dass die CSR von einem akkreditierten MDM-Vendor signiert wird. Self-Hoster ohne Apple-MDM-Vendor-Status nutzen `mdmcert.download` — ein Service des MicroMDM-Teams, der die CSR mit einem akzeptierten Vendor-Key gegen-signiert und encrypted per Email zurückschickt.
|
||||||
|
|
||||||
|
**Was passiert:**
|
||||||
|
1. Wir schicken unseren CSR base64-encoded + eine Encryption-Cert an `https://mdmcert.download/api/v1/signrequest`
|
||||||
|
2. mdmcert.download signiert ihn mit ihrem Apple-akkreditierten Vendor-Key
|
||||||
|
3. Sie verschlüsseln das Ergebnis mit unserer Encryption-Cert (PKCS7) und senden es per Email an `hello@chahine-brini.com`
|
||||||
|
4. Das entschlüsselte Ergebnis (nicht der raw CSR, nicht das `.b64.p7`) wird im Apple Push Portal hochgeladen
|
||||||
|
|
||||||
|
**Was gemacht wurde:**
|
||||||
|
|
||||||
|
1. Encryption-Keypair auf dem MDM-Server generiert:
|
||||||
|
- Cert: `/opt/nanomdm/certs/mdmcert-encryption.crt` (public, wird an mdmcert.download geschickt)
|
||||||
|
- Key: `/opt/nanomdm/certs/mdmcert-encryption.key` (chmod 600, verlässt Server nie)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl req -new -newkey rsa:2048 -nodes \
|
||||||
|
-keyout /opt/nanomdm/certs/mdmcert-encryption.key \
|
||||||
|
-x509 -days 365 \
|
||||||
|
-out /opt/nanomdm/certs/mdmcert-encryption.crt \
|
||||||
|
-subj '/CN=ReBreak mdmcert encryption'
|
||||||
|
chmod 600 /opt/nanomdm/certs/mdmcert-encryption.key
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Signing-Request an mdmcert.download abgeschickt (shared public API-Key aus micromdm-Source, öffentlich dokumentiert):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PUSH_CSR_B64=$(base64 -w0 /opt/nanomdm/certs/push.csr)
|
||||||
|
ENC_CRT_B64=$(base64 -w0 /opt/nanomdm/certs/mdmcert-encryption.crt)
|
||||||
|
|
||||||
|
curl -X POST https://mdmcert.download/api/v1/signrequest \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "User-Agent: micromdm/certhelper" \
|
||||||
|
-d "{\"csr\":\"$PUSH_CSR_B64\",\"email\":\"hello@chahine-brini.com\",\"key\":\"<shared-api-key>\",\"encrypt\":\"$ENC_CRT_B64\"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwort: `{"result":"success"}`
|
||||||
|
|
||||||
|
**Naechster Schritt:** Email von mdmcert.download bei `hello@chahine-brini.com` prüfen. Anhang-Name hat Format `mdm_signed_request.YYYYMMDD_HHMMSS_NNN.plist.b64.p7`. Dann weiter mit Phase D.0.7.
|
||||||
|
|
||||||
|
**Technische Details (wichtig fuer Decrypt):**
|
||||||
|
- Der Dateiname endet auf `.b64.p7` — irreführend. Der tatsächliche Inhalt ist **hex-encoded PKCS7**, nicht base64. (Quelle: micromdm/micromdm cmd/mdmctl/mdmcert.download.go, Decrypt-Pfad)
|
||||||
|
- Der Decrypt-Befehl (`openssl cms` oder PKCS7-Tooling) muss zuerst hex→binary decodieren, dann PKCS7 mit dem mdmcert-encryption.key entschlüsseln
|
||||||
|
|
||||||
|
## Phase D.0.7 ⏳ Signed CSR entschlüsseln
|
||||||
|
|
||||||
|
**Voraussetzung:** Email von mdmcert.download mit Anhang empfangen (Phase D.0.5 abgeschlossen)
|
||||||
|
|
||||||
|
**Wer:** Chahine schickt den Anhang per `scp` auf den MDM-Server. Oder Backyard entschlüsselt wenn Anhang auf den Server kopiert wurde.
|
||||||
|
|
||||||
|
**Schritte:**
|
||||||
|
|
||||||
|
1. Anhang von Email speichern (z.B. `mdm_signed_request.20260510_XXXXXX.plist.b64.p7`)
|
||||||
|
|
||||||
|
2. Datei auf Server kopieren:
|
||||||
|
```bash
|
||||||
|
scp ~/Downloads/mdm_signed_request.*.plist.b64.p7 rebreak-mdm:/opt/nanomdm/certs/signed_request.p7
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Hex→Binary dekodieren + PKCS7 entschlüsseln (micromdm-Tooling macht beides intern):
|
||||||
|
```bash
|
||||||
|
# Hex-String aus der Datei zu Binary konvertieren
|
||||||
|
xxd -r -p /opt/nanomdm/certs/signed_request.p7 > /opt/nanomdm/certs/signed_request.der
|
||||||
|
|
||||||
|
# PKCS7 mit unserem Encryption-Key entschlüsseln
|
||||||
|
openssl cms -decrypt \
|
||||||
|
-in /opt/nanomdm/certs/signed_request.der \
|
||||||
|
-inform DER \
|
||||||
|
-inkey /opt/nanomdm/certs/mdmcert-encryption.key \
|
||||||
|
-recip /opt/nanomdm/certs/mdmcert-encryption.crt \
|
||||||
|
-out /opt/nanomdm/certs/push_request.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Ergebnis `/opt/nanomdm/certs/push_request.plist` prüfen — sollte eine Apple Plist-Datei sein.
|
||||||
|
```bash
|
||||||
|
head -5 /opt/nanomdm/certs/push_request.plist
|
||||||
|
# Erwartete Ausgabe: <?xml version="1.0" ... oder PEM-ähnliches Format
|
||||||
|
```
|
||||||
|
|
||||||
|
5. DIESE Datei (`push_request.plist`) wird bei https://identity.apple.com hochgeladen (Phase D.1).
|
||||||
|
|
||||||
|
**Status:** Wartet auf Email-Empfang bei `hello@chahine-brini.com`
|
||||||
|
|
||||||
|
## Phase D.1 ⏳ Apple Push Cert — Benutzeraktion
|
||||||
|
|
||||||
|
**Voraussetzung:** Phase D.0.7 abgeschlossen (entschlüsseltes Plist `push_request.plist` auf Server)
|
||||||
|
|
||||||
|
**Wer:** Chahine (muss mit Apple-ID eingeloggt sein, die als MDM-Zertifikats-Owner gelten soll)
|
||||||
|
|
||||||
|
**WICHTIG: NICHT den raw push.csr oder die `.b64.p7`-Datei hochladen.**
|
||||||
|
Hochgeladen wird die entschlüsselte `push_request.plist` aus Phase D.0.7.
|
||||||
|
|
||||||
|
**Schritte:**
|
||||||
|
|
||||||
|
1. Phase D.0.7 abschliessen — `push_request.plist` auf Server entschlüsselt
|
||||||
|
2. Datei lokal runterladen: `scp rebreak-mdm:/opt/nanomdm/certs/push_request.plist ~/Downloads/`
|
||||||
|
3. Oeffne https://identity.apple.com/pushcert/ im Browser (einloggen mit Apple-ID)
|
||||||
|
4. Klicke "Create a Certificate"
|
||||||
|
5. Lade `push_request.plist` hoch (NICHT push.csr, NICHT die `.b64.p7`-Datei)
|
||||||
|
6. Download das ausgestellte Zertifikat (`.pem` oder `.cer`)
|
||||||
|
7. Kopiere es auf den Server: `scp ~/Downloads/MDMCertificate.pem rebreak-mdm:/opt/nanomdm/certs/push.pem`
|
||||||
|
7. Setze Permissions: `ssh rebreak-mdm "chmod 600 /opt/nanomdm/certs/push.pem"`
|
||||||
|
8. Informiere Backyard-Agent fuer Phase E
|
||||||
|
|
||||||
|
**Wichtig:**
|
||||||
|
- Das Zertifikat ist an die Apple-ID geknuepft, mit der es erstellt wurde
|
||||||
|
- Gueltigkeitsdauer: 1 Jahr
|
||||||
|
- Bei Renewal: GLEICHEN `push.key` verwenden (kein neues keypair generieren)
|
||||||
|
- Wenn push.key verloren geht: alle Geraete muessen re-enrollen
|
||||||
|
|
||||||
|
## Phase D.2 ✅ NanoMDM mit Push-Cert konfiguriert
|
||||||
|
|
||||||
|
Erledigt 2026-05-10.
|
||||||
|
|
||||||
|
**Was gemacht wurde:**
|
||||||
|
|
||||||
|
1. NanoMDM API-Key generiert (32-char-hex), in `/opt/nanomdm/.env` (`NANOMDM_API=`) + `/root/.nanomdm_admin_pass` (chmod 600)
|
||||||
|
2. Container force-recreated mit neuem env-file
|
||||||
|
3. Postgres-Schema von https://raw.githubusercontent.com/micromdm/nanomdm/main/storage/pgsql/schema.sql geladen + applied (8 tables: devices, push_certs, commands, etc.) — fehlte aus initial-setup
|
||||||
|
4. Push-Cert via `PUT /v1/pushcert` (basic-auth) hochgeladen → in `push_certs` table
|
||||||
|
5. Verify: Topic `com.apple.mgmt.External.816a2d4a-4ce1-4b44-9264-2831b891206a`, valid bis 2027-05-10
|
||||||
|
6. External smoke-test: `curl -u nanomdm:<key> https://mdm.rebreak.org/version` → `{"version":"v0.9.0"}` ✅
|
||||||
|
|
||||||
|
**Bekannte Tücke:** Initial-setup hat das postgres-schema nicht angewendet. NanoMDM-Container hat keine eingebaute migrate-step. Schema muss manuell via `psql -f schema.sql` geladen werden bevor erster API-call funktioniert.
|
||||||
|
|
||||||
|
## Phase E ⏸ Email-Distribution an Ina — geparkt (User-Decision 2026-05-10)
|
||||||
|
|
||||||
|
**Status: PARKED — alles server-side ready, Versand verschoben.**
|
||||||
|
|
||||||
|
User-Entscheidung: PIN-Versand an Ina jetzt nicht — wird später nachgeholt. iPhone-Enrollment kann ohne laufen (MASTER-PIN ist Recovery-Backup, nicht Voraussetzung für enrollment).
|
||||||
|
|
||||||
|
Server-Status:
|
||||||
|
- ✅ MASTER-Recovery-PIN auf Server: `/root/.nanomdm_master_pin` (chmod 600)
|
||||||
|
- ✅ Ina-Email-Draft auf Server: `/root/INA_EMAIL_DRAFT.md` (chmod 600)
|
||||||
|
- ✅ Resend-API-Key auf Server: `/root/.resend_api_key` (chmod 600)
|
||||||
|
- ⏸ Resend-Domain-Verify ungetan — Versand würde fehlschlagen ohne `chahine-brini.com` oder `rebreak.org` verified
|
||||||
|
|
||||||
|
Reaktivierung: User sagt „Phase E GO", wir verifizieren Domain in Resend, senden, fertig. Files bleiben bis dahin auf Server.
|
||||||
|
|
||||||
|
## Phase F ⏳ Device-Enrollment
|
||||||
|
|
||||||
|
Wartet auf Phase E.
|
||||||
|
|
||||||
|
Was passiert:
|
||||||
|
1. iPhone auf Werkseinstellungen zurücksetzen (Backup vorher!)
|
||||||
|
2. Während Setup: iPhone via USB-C mit Mac verbinden, Apple Configurator 2 öffnen
|
||||||
|
3. In Apple Configurator 2: Gerät preparieren (Supervised Mode aktivieren)
|
||||||
|
4. MDM-Enrollment-Profil von `https://mdm.rebreak.org/enroll` auf Gerät installieren
|
||||||
|
5. Verifyieren dass Profil als "nicht entfernbar" markiert ist
|
||||||
|
6. Apps installieren (ReBreak, etc.)
|
||||||
|
|
||||||
|
**Hinweis zum Supervised Mode:** Ohne Supervision kann das MDM-Profil vom User entfernt werden. Supervision braucht einmalig USB + Apple Configurator. Danach ist OTA-MDM-Update möglich.
|
||||||
|
|
||||||
|
**Scope-Constraint (User-bestätigt 2026-05-10):** Profil enthält NUR `allowAppRemoval=false` für Bundle-ID `org.rebreak.app` + `allowMDMProfileRemoval=false`. KEIN App-Store-Block, keine weiteren Restrictions. iOS-App-Store hat keine Echtgeld-Casino-Apps (Apple-Policy), Browser-Casinos werden von ReBreak's NEFilter geblockt.
|
||||||
|
|
||||||
|
## Phase G ⏳ iPad-Enrollment (optional, später)
|
||||||
|
|
||||||
|
Identisch zu Phase F, gleicher flow:
|
||||||
|
1. iPad via USB-C mit Mac verbinden
|
||||||
|
2. Apple Configurator 2 → Supervised-Mode → factory-reset
|
||||||
|
3. MDM-enrollment-profile von `https://mdm.rebreak.org/enroll`
|
||||||
|
4. ReBreak-iOS app installieren (läuft nativ auf iPad)
|
||||||
|
5. Verifyieren: ReBreak nicht entfernbar, MDM-profile nicht entfernbar
|
||||||
|
|
||||||
|
**Aufwand:** ~30min nach Phase F. Apple Push Cert deckt iPad mit ab (kein zusätzlicher cert nötig).
|
||||||
|
|
||||||
|
**Voraussetzung:** Phase F erfolgreich getestet auf iPhone.
|
||||||
|
|
||||||
|
## Phase H ⏳ MacBook-Enrollment (optional, später)
|
||||||
|
|
||||||
|
Anders als iPhone/iPad weil:
|
||||||
|
- **Kein ReBreak-Mac-app** existiert → MDM-profile muss eigene Blocking-Mechanik mitbringen
|
||||||
|
- Lösung: **Web-Content-Filter-Payload** im profile (DNS/URL-blocklist auf OS-Ebene)
|
||||||
|
- Mac-Supervised-Mode: factory-reset des MacBook nötig (analog iPad), via Apple Configurator 2 + USB-C
|
||||||
|
|
||||||
|
**Schritte:**
|
||||||
|
|
||||||
|
1. ReBreak-Blocklist (~208k domains) als Web-Content-Filter-Payload formattieren
|
||||||
|
- Payload-type: `com.apple.webcontent-filter`
|
||||||
|
- oder `com.apple.dnsSettings.managed` für DNS-level-block
|
||||||
|
2. MDM-profile assemblen mit:
|
||||||
|
- `allowMDMProfileRemoval=false` (braucht supervised-mode)
|
||||||
|
- Web-Content-Filter mit Casino-Blocklist
|
||||||
|
- Optional: `allowSafariAutoFill=false` (verhindert auto-login auf bekannten casino-sites)
|
||||||
|
3. MacBook factory-reset → Apple Configurator 2 → supervised-mode → MDM-enrollment
|
||||||
|
4. Verify: Casino-domain im Browser → blocked
|
||||||
|
|
||||||
|
**Aufwand:** ~1 Tag (blocklist-conversion + profile-assembly + test). Plus factory-reset-zeit.
|
||||||
|
|
||||||
|
**Voraussetzung:**
|
||||||
|
- Phase F+G erfolgreich
|
||||||
|
- User explizites GO (factory-reset MacBook = großer Schritt)
|
||||||
|
- Backup von wichtigen MacBook-Daten
|
||||||
|
|
||||||
|
**Tradeoff:** Kein ReBreak-Mac-app = nur URL-blocking, keine SOS-features, kein Lyra, keine Community auf Mac. Wer ReBreak-features auf Mac will, braucht später entweder native Mac-app (s. `ops/mac-version-research.md`) oder Browser-Extension.
|
||||||
76
ops/mdm/README.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# ReBreak MDM — Projektübersicht
|
||||||
|
|
||||||
|
## Was ist das
|
||||||
|
|
||||||
|
MDM steht für Mobile Device Management. Dieses Projekt setzt einen selbst-gehosteten MDM-Server (NanoMDM) auf, der ein iPhone dauerhaft unter Supervision halten kann — mit dem Ziel, dass der Nutzer (Chahine) eine Spiel-Blockade nicht ohne Aufwand umgehen kann.
|
||||||
|
|
||||||
|
Das Szenario: Chahine enrolled sein iPhone freiwillig in das MDM-Profil (self-binding). Das Profil kann er nicht selbst entfernen, weil dafür ein Admin-PIN oder die Zustimmung eines Trustees nötig ist. Die Trustees sind Olfa und Ina Wittek.
|
||||||
|
|
||||||
|
**Kein Enterprise-MDM.** Kein Firmenzweck. Kein App-Store-Management. Ausschließlich: Entfernung des MDM-Profils blockieren.
|
||||||
|
|
||||||
|
## Warum getrennter VPS
|
||||||
|
|
||||||
|
Der MDM-Server läuft auf einem separaten Hetzner-VPS (`rebreak-mdm`, 178.105.101.137), getrennt von `rebreak-server` (49.13.55.22, Nuxt-App). Gründe:
|
||||||
|
|
||||||
|
- Kein Crossover-Risiko: ein Deploy-Fehler auf dem App-Server betrifft nicht den MDM-Server
|
||||||
|
- Unabhängige Uptime: MDM muss laufen auch wenn die App deployed wird
|
||||||
|
- Klarere Verantwortung: MDM-Server hat keine App-Logik, nur nanomdm + postgres + nginx
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
[Chahines iPhone]
|
||||||
|
|
|
||||||
|
|-- NEFilter (ReBreak iOS App, anderer Scope)
|
||||||
|
| Blockiert Gambling-Domains via Network Extension
|
||||||
|
|
|
||||||
|
|-- MDM-Profil (dieser Server)
|
||||||
|
Verhindert Entfernung der App ohne Admin-Zustimmung
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[mdm.rebreak.org] (178.105.101.137)
|
||||||
|
|
|
||||||
|
+-- nginx (443 SSL) --> nanomdm (127.0.0.1:9000)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
postgres (127.0.0.1:5432)
|
||||||
|
DB: nanomdm, User: nanomdm
|
||||||
|
```
|
||||||
|
|
||||||
|
Apple-Push-Zertifikat-Flow:
|
||||||
|
```
|
||||||
|
[Server: push.csr] --> [identity.apple.com] --> [push.pem download]
|
||||||
|
|
|
||||||
|
[scp push.pem to server]
|
||||||
|
|
|
||||||
|
[nanomdm benutzt push.pem
|
||||||
|
um Apple APNS zu erreichen
|
||||||
|
= MDM-Befehle ans Gerät]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trust-Modell
|
||||||
|
|
||||||
|
- **Chahine**: Gerät-Owner, enrolled sich selbst. Hat keinen MDM-Admin-Zugriff (Sinn der Sache).
|
||||||
|
- **Olfa**: Co-Admin. Hat Zugriff zu MDM-Credentials (in `/opt/nanomdm/` auf dem Server).
|
||||||
|
- **Ina Wittek** (`ina.wittek@gmx.de`): Trustee. Bekommt per Email einen Notfall-Schlüssel, mit dem sie das MDM-Profil entfernen kann falls Chahine z.B. das Gerät für dringende Arbeit braucht und weder er noch Olfa erreichbar sind.
|
||||||
|
|
||||||
|
Factory-Reset = nuclear option. Zerstört alle Daten. Sollte nur als letztes Mittel genutzt werden.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- Phase A ✅ Server-Bootstrap
|
||||||
|
- Phase B ✅ TLS-Zertifikat
|
||||||
|
- Phase C ✅ NanoMDM container + nginx
|
||||||
|
- Phase D ✅ Apple Push CSR generiert — Benutzeraktion ausstehend
|
||||||
|
- Phase E ⏳ Email an Ina (blocked: Apple-cert + Resend-key fehlen)
|
||||||
|
- Phase F ⏳ Device-Enrollment (factory-reset + USB-Supervision + Profil-Installation)
|
||||||
|
|
||||||
|
Details in `PHASES.md`.
|
||||||
|
|
||||||
|
## Quick Links
|
||||||
|
|
||||||
|
- SSH: `ssh rebreak-mdm` (178.105.101.137)
|
||||||
|
- NanoMDM: https://mdm.rebreak.org
|
||||||
|
- Apple Push Portal: https://identity.apple.com/pushcert/
|
||||||
|
- Resend (Email-Service): https://resend.com
|
||||||
|
- NanoMDM Docs: https://github.com/micromdm/nanomdm
|
||||||
190
ops/mdm/RUNBOOK.md
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
# MDM Server — Operations Runbook
|
||||||
|
|
||||||
|
## SSH-Zugriff
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm
|
||||||
|
# entspricht: ssh root@178.105.101.137
|
||||||
|
```
|
||||||
|
|
||||||
|
## NanoMDM Container
|
||||||
|
|
||||||
|
### Status prüfen
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "docker ps | grep nanomdm"
|
||||||
|
ssh rebreak-mdm "cd /opt/nanomdm && docker compose ps"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs anschauen
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "cd /opt/nanomdm && docker compose logs -f"
|
||||||
|
# Nur letzte 50 Zeilen:
|
||||||
|
ssh rebreak-mdm "cd /opt/nanomdm && docker compose logs --tail=50"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "cd /opt/nanomdm && docker compose restart"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop + Start (hard restart)
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "cd /opt/nanomdm && docker compose down && docker compose up -d"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auf neue Version updaten
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "cd /opt/nanomdm && docker compose pull && docker compose up -d"
|
||||||
|
```
|
||||||
|
|
||||||
|
## PostgreSQL
|
||||||
|
|
||||||
|
### Zugriff auf nanomdm-DB
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "sudo -u postgres psql nanomdm"
|
||||||
|
```
|
||||||
|
|
||||||
|
### DB-Passwort abrufen
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "cat /root/.nanomdm_db_pass"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tabellen-Übersicht
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "sudo -u postgres psql nanomdm -c '\dt'"
|
||||||
|
```
|
||||||
|
|
||||||
|
### DB-Backup
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "sudo -u postgres pg_dump nanomdm > /tmp/nanomdm-$(date +%Y%m%d).sql"
|
||||||
|
# Lokal kopieren:
|
||||||
|
scp rebreak-mdm:/tmp/nanomdm-*.sql ./backups/
|
||||||
|
```
|
||||||
|
|
||||||
|
### DB-Restore (nach Backup)
|
||||||
|
```bash
|
||||||
|
# Achtung: destructive — nur nach User-Bestätigung
|
||||||
|
ssh rebreak-mdm "sudo -u postgres psql nanomdm < /path/to/backup.sql"
|
||||||
|
```
|
||||||
|
|
||||||
|
## nginx
|
||||||
|
|
||||||
|
### Config testen
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "nginx -t"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reload (nach Config-Änderung)
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "systemctl reload nginx"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vhost-Config
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "cat /etc/nginx/sites-available/mdm.rebreak.org"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "tail -f /var/log/nginx/access.log"
|
||||||
|
ssh rebreak-mdm "tail -f /var/log/nginx/error.log"
|
||||||
|
```
|
||||||
|
|
||||||
|
## TLS-Zertifikat (Let's Encrypt)
|
||||||
|
|
||||||
|
### Status prüfen
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "certbot certificates"
|
||||||
|
ssh rebreak-mdm "systemctl status certbot.timer"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manuelle Renewal (Notfall)
|
||||||
|
```bash
|
||||||
|
# ACHTUNG: Rate-Limit bei --force-renewal. Nur wenn wirklich nötig.
|
||||||
|
# Erst ohne force testen:
|
||||||
|
ssh rebreak-mdm "certbot renew --dry-run"
|
||||||
|
# Dann renewal:
|
||||||
|
ssh rebreak-mdm "certbot renew"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cert-Expiry prüfen
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "openssl x509 -in /etc/letsencrypt/live/mdm.rebreak.org/cert.pem -noout -dates"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Apple Push Zertifikat
|
||||||
|
|
||||||
|
### Expiry prüfen
|
||||||
|
```bash
|
||||||
|
# Nach Phase D.1 (wenn push.pem vorhanden):
|
||||||
|
ssh rebreak-mdm "openssl x509 -in /opt/nanomdm/certs/push.pem -noout -dates"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Jährliche Renewal
|
||||||
|
1. CSR-File ist noch da: `/opt/nanomdm/certs/push.csr`
|
||||||
|
2. Gleichen CSR auf identity.apple.com hochladen (neues Cert, gleicher Key)
|
||||||
|
3. Neues `.pem` auf Server kopieren: `scp ./MDMCertificate.pem rebreak-mdm:/opt/nanomdm/certs/push.pem`
|
||||||
|
4. `chmod 600 /opt/nanomdm/certs/push.pem`
|
||||||
|
5. `docker compose restart` auf Server
|
||||||
|
|
||||||
|
### Neues CSR generieren (nur wenn push.key verloren!)
|
||||||
|
```bash
|
||||||
|
# ACHTUNG: Neuer Key = alle Geräte müssen re-enrollen
|
||||||
|
ssh rebreak-mdm "cd /opt/nanomdm/certs && openssl req -newkey rsa:2048 -nodes \
|
||||||
|
-keyout push.key -out push.csr \
|
||||||
|
-subj '/CN=ReBreak MDM Push/O=Raynis/C=DE' && chmod 600 push.key"
|
||||||
|
ssh rebreak-mdm "cat /opt/nanomdm/certs/push.csr"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Externer Health-Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Erwartet: HTTP 404 von nanomdm (normales Verhalten auf /)
|
||||||
|
curl -sI https://mdm.rebreak.org/
|
||||||
|
# Erwartet: "Bad Request" (MDM-Endpoint ohne gültigen Apple-Payload)
|
||||||
|
curl -s https://mdm.rebreak.org/mdm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Firewall (UFW)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "ufw status numbered"
|
||||||
|
# Regel hinzufügen (Beispiel SSH von spezifischer IP):
|
||||||
|
ssh rebreak-mdm "ufw allow from 1.2.3.4 to any port 22"
|
||||||
|
```
|
||||||
|
|
||||||
|
## System-Ressourcen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "df -h && free -h && docker stats --no-stream"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### nanomdm startet nicht
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "cd /opt/nanomdm && docker compose logs --tail=50"
|
||||||
|
```
|
||||||
|
|
||||||
|
Häufige Ursachen:
|
||||||
|
- DB-Verbindung: `postgres://nanomdm:PASS@127.0.0.1:5432/nanomdm` — postgres läuft? `systemctl is-active postgresql@16-main`
|
||||||
|
- CA-Cert fehlt: `/opt/nanomdm/certs/ca.crt` vorhanden?
|
||||||
|
- .env-File: `cat /opt/nanomdm/.env` — NANOMDM_DB_PASS gesetzt?
|
||||||
|
- network_mode host nötig: in docker-compose.yml prüfen
|
||||||
|
|
||||||
|
### 502 Bad Gateway von nginx
|
||||||
|
|
||||||
|
Bedeutet nanomdm läuft nicht oder antwortet nicht auf 127.0.0.1:9000.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "curl -sv http://127.0.0.1:9000/"
|
||||||
|
ssh rebreak-mdm "cd /opt/nanomdm && docker compose up -d"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Postgres startet nicht
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh rebreak-mdm "journalctl -u postgresql@16-main -n 50"
|
||||||
|
ssh rebreak-mdm "pg_lsclusters"
|
||||||
|
```
|
||||||
67
ops/mdm/SECURITY.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# MDM Server — Security
|
||||||
|
|
||||||
|
## Was muss geheim bleiben
|
||||||
|
|
||||||
|
| Secret | Wo es liegt | Permissions | Wer hat Zugriff |
|
||||||
|
|-------------------------------------|--------------------------------------|-------------|---------------------|
|
||||||
|
| PostgreSQL-Passwort | `/root/.nanomdm_db_pass` | 600 (root) | Chahine, Olfa |
|
||||||
|
| MDM CA Private Key | `/opt/nanomdm/certs/ca.key` | 600 (root) | Chahine, Olfa |
|
||||||
|
| Apple Push Private Key | `/opt/nanomdm/certs/push.key` | 600 (root) | Chahine, Olfa |
|
||||||
|
| nanomdm .env (enthält DB-Pass) | `/opt/nanomdm/.env` | 600 (root) | Chahine, Olfa |
|
||||||
|
| MDM-Admin-API-Key (nach Phase E) | Wird nach Phase E generiert | - | Chahine, Olfa, Ina* |
|
||||||
|
| Ina-Notfall-Credentials | Per Email (Phase E) + evt. Kopie | - | Ina |
|
||||||
|
|
||||||
|
(*) Ina bekommt nur den API-Key, keinen SSH-Zugriff.
|
||||||
|
|
||||||
|
## Was NICHT geheim sein muss
|
||||||
|
|
||||||
|
- `push.csr` — CSR ist öffentlich (geht ans Apple-Portal)
|
||||||
|
- `ca.crt` — CA-Zertifikat ist öffentlich (wird ans Gerät übertragen)
|
||||||
|
- nginx-Config, docker-compose.yml (ohne Passwörter)
|
||||||
|
|
||||||
|
## Threat-Modelle
|
||||||
|
|
||||||
|
### Server-Kompromittierung
|
||||||
|
|
||||||
|
Was der Angreifer bekommt:
|
||||||
|
- DB-Pass → Zugriff auf nanomdm-DB (Device-Liste, Enrollment-Daten)
|
||||||
|
- push.key → Kann eigene MDM-Befehle an Geräte senden (mit Apple-Cert)
|
||||||
|
- ca.key → Kann eigene Device-Identity-Certs ausstellen
|
||||||
|
|
||||||
|
Was zu tun ist:
|
||||||
|
1. Apple Push Cert sofort auf identity.apple.com revoken
|
||||||
|
2. Neuen VPS aufsetzen (Phase A-D wiederholen)
|
||||||
|
3. Geräte re-enrollen mit neuem Push-Cert + neuem CA-Cert
|
||||||
|
4. Neues DB-Passwort aus `/root/.nanomdm_db_pass` (von Chahine neu generiert)
|
||||||
|
|
||||||
|
### Device-Verlust (gestohlen)
|
||||||
|
|
||||||
|
- MDM-Remote-Wipe triggern: löscht alle Daten auf Gerät
|
||||||
|
- Apple-ID-basiertes "Find My" als zusätzliche Schicht (unabhängig vom MDM)
|
||||||
|
- MDM-Profil ist nach Factory-Reset weg → Gerät ist dann nicht mehr enrollt
|
||||||
|
|
||||||
|
### Angreifer hat Zugriff auf Ina's Email-Account
|
||||||
|
|
||||||
|
Ina's Notfall-Credentials (Phase E) geben nur Zugriff auf nanomdm-API um das Profil zu entfernen, keinen Server-SSH-Zugriff. Worst-case: Angreifer entfernt MDM-Profil vom Gerät. Das MDM kann dann re-enrollen wenn Chahine zustimmt.
|
||||||
|
|
||||||
|
### Abgelaufenes Apple Push Cert
|
||||||
|
|
||||||
|
Wenn push.pem abläuft (nach 1 Jahr): nanomdm kann keine Befehle mehr ans Gerät schicken. Gerät ist aber noch enrollt (Profil ist drauf). Nach Cert-Renewal (gleicher push.key) funktioniert Kommunikation wieder.
|
||||||
|
|
||||||
|
## Geheimhaltungs-Regeln
|
||||||
|
|
||||||
|
1. `push.key`, `ca.key`, DB-Passwort werden NIEMALS in Git committed
|
||||||
|
2. `/opt/nanomdm/.env` hat chmod 600 — Änderung würde nanomdm-Container-Restart erfordern
|
||||||
|
3. Keine Passwörter in Docker-Logs (env-vars sind als values gesetzt, nicht als --env in command-line args)
|
||||||
|
4. SSH-Zugriff nur via Key-Auth (kein Password-SSH auf dem Server)
|
||||||
|
|
||||||
|
## Audit-Trail
|
||||||
|
|
||||||
|
Relevante Events zum Dokumentieren in `/opt/nanomdm/SETUP-LOG.md` auf dem Server:
|
||||||
|
|
||||||
|
- Wann wurde welcher Container deployed
|
||||||
|
- Wann wurde Apple Push Cert erneuert (Datum + Apple-ID die es ausgestellt hat)
|
||||||
|
- Wann wurde Enrollment durchgeführt (Gerät, Datum)
|
||||||
|
- Wann wurde Profil entfernt (wer hat entfernt, warum)
|
||||||
|
|
||||||
|
Format: `[DATUM] [WER] [WAS]` — plain text, kein JSON.
|
||||||
75
ops/mdm/USER-ACTIONS-PENDING.md
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
# Ausstehende Benutzeraktionen
|
||||||
|
|
||||||
|
Zuletzt aktualisiert: 2026-05-10
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sofort (Phase D.1) — Apple Push Zertifikat
|
||||||
|
|
||||||
|
- [ ] **Apple Portal öffnen:** https://identity.apple.com/pushcert/
|
||||||
|
- Mit der Apple-ID einloggen, die als MDM-Zertifikats-Eigentümer gelten soll
|
||||||
|
- Empfehlung: dieselbe Apple-ID, die auch für den Apple Developer Account genutzt wird
|
||||||
|
|
||||||
|
- [ ] **CSR herunterladen** (oder Inhalt kopieren):
|
||||||
|
```bash
|
||||||
|
scp rebreak-mdm:/opt/nanomdm/certs/push.csr ~/Desktop/push.csr
|
||||||
|
```
|
||||||
|
Alternativ Inhalt anzeigen: `ssh rebreak-mdm "cat /opt/nanomdm/certs/push.csr"`
|
||||||
|
|
||||||
|
- [ ] **Im Apple Portal:** "Create a Certificate" → CSR hochladen → Cert herunterladen (`.pem` oder `.cer`)
|
||||||
|
|
||||||
|
- [ ] **Cert auf Server kopieren:**
|
||||||
|
```bash
|
||||||
|
scp ~/Downloads/MDMCertificate.pem rebreak-mdm:/opt/nanomdm/certs/push.pem
|
||||||
|
ssh rebreak-mdm "chmod 600 /opt/nanomdm/certs/push.pem"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Backyard-Agent neu starten** für Phase E (mit Hinweis: "Apple-cert ist da")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Danach (Phase E-Vorbereitung) — Resend API-Key
|
||||||
|
|
||||||
|
- [ ] **Resend-Account erstellen:** https://resend.com (Free-Plan reicht für eine Email)
|
||||||
|
- [ ] **API-Key generieren:** Settings → API Keys → Create API Key
|
||||||
|
- [ ] **API-Key an Backyard weitergeben** (in der nächsten Session)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ina vorwarnen (optional, aber empfohlen)
|
||||||
|
|
||||||
|
- [ ] **Kurze Info an Ina** (`ina.wittek@gmx.de`) per WhatsApp/Signal/Telefon:
|
||||||
|
"Du bekommst demnächst eine Email von mir bezüglich ReBreak MDM. Das ist eine Art Notfall-Treuhänder-Funktion. Die Email erklärt alles."
|
||||||
|
|
||||||
|
Damit die Email nicht als Spam landet und Ina nicht überrascht wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Später (Phase F) — Device Enrollment
|
||||||
|
|
||||||
|
- [ ] **Apple Configurator 2** auf Mac installieren (kostenlos im Mac App Store)
|
||||||
|
- [ ] **USB-C-Kabel** bereithalten (iPhone-zu-Mac)
|
||||||
|
- [ ] **Backup vom iPhone** erstellen (iCloud oder Finder-Backup)
|
||||||
|
- [ ] **Koordination mit Chahine** — Factory-Reset ist nötig, alle lokalen Daten gehen verloren
|
||||||
|
|
||||||
|
Reihenfolge:
|
||||||
|
1. Backup verifizieren
|
||||||
|
2. Factory-Reset iPhone
|
||||||
|
3. Bei Setup: USB-Verbindung zu Mac mit Apple Configurator
|
||||||
|
4. Supervision aktivieren
|
||||||
|
5. MDM-Profil enrollen
|
||||||
|
6. Backup wiederherstellen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status-Übersicht
|
||||||
|
|
||||||
|
| Phase | Status | Warte auf |
|
||||||
|
|------------|------------|-----------------------------------|
|
||||||
|
| A (Server) | ✅ Done | - |
|
||||||
|
| B (TLS) | ✅ Done | - |
|
||||||
|
| C (NanoMDM)| ✅ Done | - |
|
||||||
|
| D (CSR) | ✅ Done | - |
|
||||||
|
| D.1 (Cert) | ⏳ User | Apple Portal Upload (diese Liste) |
|
||||||
|
| E (Email) | ⏳ Blocked | Apple-cert + Resend-Key |
|
||||||
|
| F (Device) | ⏳ Later | Phase E abgeschlossen |
|
||||||
48
ops/mdm/rebreak-mac-dns-filter.mobileconfig
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PayloadContent</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>PayloadDisplayName</key>
|
||||||
|
<string>ReBreak DNS-Filter</string>
|
||||||
|
<key>PayloadDescription</key>
|
||||||
|
<string>Leitet DNS-Anfragen über dns.rebreak.org. Glücksspiel-Domains werden blockiert.</string>
|
||||||
|
<key>PayloadIdentifier</key>
|
||||||
|
<string>org.rebreak.protection.dns.filter</string>
|
||||||
|
<key>PayloadType</key>
|
||||||
|
<string>com.apple.dnsSettings.managed</string>
|
||||||
|
<key>PayloadUUID</key>
|
||||||
|
<string>7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0</string>
|
||||||
|
<key>PayloadVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>DNSSettings</key>
|
||||||
|
<dict>
|
||||||
|
<key>DNSProtocol</key>
|
||||||
|
<string>HTTPS</string>
|
||||||
|
<key>ServerURL</key>
|
||||||
|
<string>https://dns.rebreak.org/dns-query</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>PayloadDisplayName</key>
|
||||||
|
<string>ReBreak Schutz</string>
|
||||||
|
<key>PayloadDescription</key>
|
||||||
|
<string>Aktiviert den ReBreak-DNS-Filter auf diesem Mac. Glücksspiel-Domains werden auf System-Ebene blockiert — gilt für alle Browser, alle Apps. Kann via Systemeinstellungen → Allgemein → Geräteverwaltung entfernt werden (Admin-Passwort erforderlich).</string>
|
||||||
|
<key>PayloadIdentifier</key>
|
||||||
|
<string>org.rebreak.protection.profile</string>
|
||||||
|
<key>PayloadOrganization</key>
|
||||||
|
<string>ReBreak</string>
|
||||||
|
<key>PayloadType</key>
|
||||||
|
<string>Configuration</string>
|
||||||
|
<key>PayloadUUID</key>
|
||||||
|
<string>8C3F9A2B-D4E5-4F87-9A12-B5C6D7E8F901</string>
|
||||||
|
<key>PayloadVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>PayloadScope</key>
|
||||||
|
<string>System</string>
|
||||||
|
<key>PayloadRemovalDisallowed</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
116
ops/strategy/mdm-productization-roadmap.md
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
# MDM-Productization Roadmap
|
||||||
|
|
||||||
|
**Stand:** 2026-05-10 (nach Phase F: persönliches Self-Binding für Chahine erfolgreich)
|
||||||
|
|
||||||
|
## Vision
|
||||||
|
|
||||||
|
ReBreak bietet als optionales Add-On (3€/mo on top auf Pro/Legend) ein vollständiges MDM-Lock-Setup für motivierte Recovery-User. Voraussetzung: Mac + USB + Bereitschaft zum factory-reset.
|
||||||
|
|
||||||
|
## Ziel-Audience
|
||||||
|
|
||||||
|
Schmaler aber motivierter Markt:
|
||||||
|
- Recovery-Community-Members nach 100000 Verzweiflungen
|
||||||
|
- DiGA-Patienten in aktiver Relapse-Prevention mit Therapie-Begleitung
|
||||||
|
- Users die "alles andere probiert haben" und maximalen Lock wollen
|
||||||
|
|
||||||
|
User-Insight (Chahine, 2026-05-10): "wenn keine nachfrage da ist haben wir nicht viel verloren — server steht eh."
|
||||||
|
|
||||||
|
## Was schon steht (Phase F done)
|
||||||
|
|
||||||
|
- NanoMDM-Server auf rebreak-mdm (178.105.101.137)
|
||||||
|
- Apple-Push-Cert via mdmcert.download
|
||||||
|
- AdGuard Home DoH @ dns.rebreak.org mit ReBreak-Blocklist
|
||||||
|
- DNS-MDM-Profile non-removable (supervised-only)
|
||||||
|
- Backend-Endpoint `/api/url-filter/blocklist.txt` als single source of truth
|
||||||
|
|
||||||
|
## Productization-Phase G (~1-2 Wochen dev)
|
||||||
|
|
||||||
|
### G.1 Enrollment-Profile-Generator
|
||||||
|
|
||||||
|
Backend-Endpoint `POST /api/mdm/enroll-profile` (Pro/Legend gated):
|
||||||
|
- Generiert per-user device-identity-cert (signed by NanoMDM CA)
|
||||||
|
- Wrapped als PKCS12
|
||||||
|
- Build .mobileconfig mit MDM-payload pointing zu `https://mdm.rebreak.org/mdm`
|
||||||
|
- DNS-payload pointing zu `https://dns.rebreak.org/dns-query`
|
||||||
|
- Returns als download
|
||||||
|
|
||||||
|
Heute manuell gemacht in `/opt/nanomdm/enrollment/` — automatisieren.
|
||||||
|
|
||||||
|
### G.2 User-Device-Link in DB
|
||||||
|
|
||||||
|
NanoMDM speichert devices in eigener DB (table `devices`). Brauchen mapping zu rebreak users:
|
||||||
|
- Neue table `rebreak.mdm_enrollments(user_id, device_id, enrolled_at, status)`
|
||||||
|
- Backend-API: `GET /api/mdm/my-status` returns enrollment-status für UI
|
||||||
|
|
||||||
|
### G.3 Lyra-Onboarding-Flow
|
||||||
|
|
||||||
|
In-App "Stärkster Schutz" Button (Pro/Legend):
|
||||||
|
1. Lyra-conversation: "Bist du sicher? Bedeutet factory-reset deines iPhones..."
|
||||||
|
2. Risiko-Aufklärung: Apps + lokale Daten (außer iCloud-Backup) verloren
|
||||||
|
3. **7-Tage-Cooldown** wie andere Schutze — User muss 7 Tage drüber schlafen
|
||||||
|
4. Nach Cooldown: Step-by-step Anleitung
|
||||||
|
5. Web-link öffnet `mdm.rebreak.org/onboarding/<user-token>`
|
||||||
|
6. Apple Configurator Wizard (Markdown-formatted instructions + screenshots)
|
||||||
|
7. Profile-Download
|
||||||
|
8. Wenn enrolled: NanoMDM pushed DNS-Profile + Restriction-Profile automatisch
|
||||||
|
|
||||||
|
### G.4 Onboarding-Web-Page
|
||||||
|
|
||||||
|
Static page (Nuxt marketing app) `mdm.rebreak.org/onboarding/<token>`:
|
||||||
|
- Step-1: Mac-requirement check
|
||||||
|
- Step-2: Apple Configurator install (App Store link)
|
||||||
|
- Step-3: factory-reset Anleitung (Settings-Pfad screenshot)
|
||||||
|
- Step-4: USB-connect + Configurator-Prepare-wizard (mit Screenshots)
|
||||||
|
- Step-5: .mobileconfig download + install via Apple Configurator
|
||||||
|
- Step-6: Bestätigung dass enrollment erfolgreich (backend-callback)
|
||||||
|
|
||||||
|
### G.5 Stripe-Add-On-Tier
|
||||||
|
|
||||||
|
- Pro: 3.99€ → mit MDM 6.99€
|
||||||
|
- Legend: 7.99€ → mit MDM 10.99€
|
||||||
|
- Stripe-Subscription-Modification API
|
||||||
|
|
||||||
|
### G.6 Per-User-Blocklist (later)
|
||||||
|
|
||||||
|
Aktuell: AdGuard pulled GLOBAL `getActiveBlocklistDomains()`.
|
||||||
|
Phase G.6: extend zu user-specific (custom-domains pro User).
|
||||||
|
Optionen:
|
||||||
|
- AdGuard-multi-DNS-server (1 pro User) — overkill
|
||||||
|
- Custom DoH-server der per-Token user-spezifische blocklist serviert
|
||||||
|
- Nicht-Priority — global blocklist ist 99% der Use-Cases
|
||||||
|
|
||||||
|
## Out-of-Scope (Apple-Hard-Limits)
|
||||||
|
|
||||||
|
- **Windows-User-Support**: Apple Configurator nur auf macOS. Windows-Pfad bräuchte custom Apple-Configurator-clone = monate dev. Skip.
|
||||||
|
- **DEP/ABM-Enrollment**: Wäre "ohne factory-reset enrollable", aber braucht DUNS + Apple-Business-Manager-Approval + nur Neu-Geräte via Reseller. Out of scope für consumer.
|
||||||
|
- **Per-App-Family-Controls-Toggle-Lock**: Apple-Platform-Limit (siehe Research Mai 2026). DNS-Layer kompensiert.
|
||||||
|
|
||||||
|
## Marginal Cost pro neuem User
|
||||||
|
|
||||||
|
- 1 row in nanomdm.devices: ~1KB
|
||||||
|
- APNS-connection: shared-pool, kosten gegen 0
|
||||||
|
- DoH-queries: paar 100 pro Tag pro User → AdGuard handhabt easy
|
||||||
|
- Storage/Bandwidth: vernachlässigbar
|
||||||
|
- **Effektiv: ~0€/mo pro MDM-User**
|
||||||
|
|
||||||
|
Bei 3€/mo Add-On = ~95% Marge.
|
||||||
|
|
||||||
|
## Risk-Assessment
|
||||||
|
|
||||||
|
- Apple könnte mdmcert.download-shared-key revoken (wenn auffällig viele Personal-MDM-Users) → fallback DNS funktioniert weiter, MDM-Push-commands brechen. Mitigation: eigener mdmcert-Account-Apply (kostenlos)
|
||||||
|
- Support-burden: jeder MDM-User wird ggf. Hilfe beim Setup brauchen. Initial-Beta: max 10-20 User, manueller Support, Lyra-led
|
||||||
|
- Liability: User locked sich aus → Recovery-Pfad via Lyra + Chahine-Manual-Override (admin-API). Cooldown verhindert impulsive enrollment.
|
||||||
|
|
||||||
|
## Decision-Points (User entscheidet)
|
||||||
|
|
||||||
|
- [ ] Phase G bauen oder warten bis 5+ User explizit nachfragen?
|
||||||
|
- [ ] Beta-Launch: stille Mail an existierende Legend-Users oder offen?
|
||||||
|
- [ ] Preisbestätigung: 3€ Add-On bestätigt oder 5-9€ wie Strategist eher empfehlen würde?
|
||||||
|
|
||||||
|
Strategist hat Pricing-Analysis pending (Task #58) — abwarten bevor finale Preis-Entscheidung.
|
||||||
|
|
||||||
|
## Source-of-Truth-Files
|
||||||
|
|
||||||
|
- Personal-Setup-Doku: `ops/mdm/PHASES.md` (Phase A-F)
|
||||||
|
- Architektur: `ops/mdm/ARCHITECTURE.md`
|
||||||
|
- Pricing-Strategy (pending): output von Strategist Task #58
|
||||||