merge: integrate upgrade/sdk-54 into main
Reconciles 20 sdk-54 commits with 10 main commits. Mobile (Expo): - KeyboardAwareSheet migrations + Snake/Tetris UI + Lyra-feedback game-over flow - Dark Theme system (Wave 1 + Wave 2 — global color-tokens) - Profile Avatar + Nickname edit-flow - Sound system for games (useSnakeSounds) - Best-score persistence + share-to-community Admin (Nuxt): - Phase 2 backend (Users + Moderation endpoints + 2 schema migrations) - Phase 2 frontend (Domains/Stats/Users/Moderation pages, responsive layout) - Lyra-Posts feature migration from legacy nuxt-rebreak Backend (kept main's versions for stability): - IMAP IDLE-daemon: GMX silent-drop fix (10min renew, 2min NOOP heartbeat) - mail/status: connect-error tracking + heartbeat fields - coach/speak: explicit voice-quota helper imports - prisma: preserved gameName field (production DB column exists) Conflict resolutions: - apps/admin/pages/index.vue: theirs (sdk-54 adds Lyra-Posts quick-link) - apps/rebreak-native/app/lyra.tsx: theirs (Dark-Theme color binding) - locales/de.json + en.json: theirs (game-rating + share strings) - GameOverScreen.tsx: theirs (full new feature, 505 vs 256 lines) - UrgeGames.tsx: theirs (consistent with new GameOverScreen props) - backend/imap-idle/index.mjs: ours (preserves GMX-fix + heartbeat) - backend/prisma/schema.prisma: ours (preserves gameName field on prod DB) - backend/server/api/coach/speak.post.ts: ours (explicit imports fix) - backend/server/api/mail/status.get.ts: ours (cleaner without type-cast) - apps/admin/start-admin-staging.sh: ours (preserves PORT-3017 override fix) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
94
.github/workflows/maestro-cloud.yml
vendored
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# Maestro Cloud — E2E template for rebreak-native.
|
||||||
|
# STATUS: TEMPLATE ONLY — not active. Requires User confirmation before enabling.
|
||||||
|
#
|
||||||
|
# Trigger: manual dispatch OR PR to main (commented out — enable after User GO).
|
||||||
|
# Requires:
|
||||||
|
# - MAESTRO_CLOUD_API_KEY in GitHub Actions secrets
|
||||||
|
# - EAS_TOKEN in GitHub Actions secrets
|
||||||
|
# - E2E_TEST_USER + E2E_TEST_PASSWORD in GitHub Actions secrets
|
||||||
|
# - Maestro Cloud account configured at mobile.dev
|
||||||
|
|
||||||
|
name: Maestro Cloud E2E (rebreak-native)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
platform:
|
||||||
|
description: "Target platform"
|
||||||
|
required: true
|
||||||
|
default: "ios"
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- ios
|
||||||
|
- android
|
||||||
|
# Uncomment to run on PRs — only after User approval:
|
||||||
|
# pull_request:
|
||||||
|
# branches: [main]
|
||||||
|
# paths:
|
||||||
|
# - "apps/rebreak-native/**"
|
||||||
|
# - "apps/rebreak-native/.maestro/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
maestro-cloud:
|
||||||
|
name: E2E (${{ inputs.platform || 'ios' }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
# Skip entirely if Maestro Cloud key is not configured —
|
||||||
|
# avoids CI failure on forks or before Cloud is set up.
|
||||||
|
if: ${{ secrets.MAESTRO_CLOUD_API_KEY != '' }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
working-directory: apps/rebreak-native
|
||||||
|
|
||||||
|
# Build app via EAS — requires EAS_TOKEN secret and eas.json configured.
|
||||||
|
# Profile "preview" must produce a .ipa (iOS) or .apk (Android).
|
||||||
|
- name: Build with EAS
|
||||||
|
uses: expo/expo-github-action@v8
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EAS_TOKEN }}
|
||||||
|
|
||||||
|
- name: EAS Build
|
||||||
|
run: |
|
||||||
|
eas build \
|
||||||
|
--platform ${{ inputs.platform || 'ios' }} \
|
||||||
|
--profile preview \
|
||||||
|
--non-interactive \
|
||||||
|
--output ./build-artifact
|
||||||
|
working-directory: apps/rebreak-native
|
||||||
|
|
||||||
|
# Install Maestro CLI
|
||||||
|
- name: Install Maestro CLI
|
||||||
|
run: curl -Ls "https://get.maestro.mobile.dev" | bash
|
||||||
|
env:
|
||||||
|
MAESTRO_VERSION: 1.39.0
|
||||||
|
|
||||||
|
- name: Add Maestro to PATH
|
||||||
|
run: echo "$HOME/.maestro/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
# Upload build + run flows on Maestro Cloud
|
||||||
|
- name: Run Maestro Cloud
|
||||||
|
run: |
|
||||||
|
maestro cloud \
|
||||||
|
--apiKey ${{ secrets.MAESTRO_CLOUD_API_KEY }} \
|
||||||
|
--app ./build-artifact \
|
||||||
|
--device ${{ inputs.platform || 'ios' }} \
|
||||||
|
--env=E2E_TEST_USER=${{ secrets.E2E_TEST_USER }} \
|
||||||
|
--env=E2E_TEST_PASSWORD=${{ secrets.E2E_TEST_PASSWORD }} \
|
||||||
|
apps/rebreak-native/.maestro/
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
@ -75,5 +75,12 @@ const quickLinks = [
|
|||||||
icon: "heroicons:flag",
|
icon: "heroicons:flag",
|
||||||
to: "/moderation",
|
to: "/moderation",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Lyra-Posts",
|
||||||
|
value: "→",
|
||||||
|
hint: "Als Lyra oder ReBreak posten",
|
||||||
|
icon: "heroicons:sparkles",
|
||||||
|
to: "/lyra",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
297
apps/admin/pages/lyra.vue
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-xl font-semibold text-white mb-1">Lyra-Posts</h1>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Community-Posts als Lyra oder ReBreak-Account erstellen (KI-generiert oder manuell).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Author Selection -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<p class="text-xs text-gray-500 mb-2 uppercase tracking-wide font-medium">Als wer posten?</p>
|
||||||
|
<div class="grid grid-cols-2 gap-3 max-w-sm">
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||||
|
:class="
|
||||||
|
author === 'lyra'
|
||||||
|
? 'border-purple-700 bg-purple-950/40'
|
||||||
|
: 'border-gray-800 bg-gray-900 hover:border-gray-700'
|
||||||
|
"
|
||||||
|
@click="selectAuthor('lyra')"
|
||||||
|
>
|
||||||
|
<div class="w-8 h-8 rounded-full bg-purple-950/60 border border-purple-800/50 flex items-center justify-center shrink-0">
|
||||||
|
<img v-if="lyraAvatar" :src="lyraAvatar" alt="Lyra" class="w-8 h-8 rounded-full object-cover" />
|
||||||
|
<UIcon v-else name="heroicons:cpu-chip" class="h-4 w-4 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-white leading-tight">{{ lyraNickname }}</p>
|
||||||
|
<p class="text-xs text-gray-500">KI-Coach</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||||
|
:class="
|
||||||
|
author === 'rebreak'
|
||||||
|
? 'border-blue-700 bg-blue-950/40'
|
||||||
|
: 'border-gray-800 bg-gray-900 hover:border-gray-700'
|
||||||
|
"
|
||||||
|
@click="selectAuthor('rebreak')"
|
||||||
|
>
|
||||||
|
<div class="w-8 h-8 rounded-full bg-blue-950/60 border border-blue-800/50 flex items-center justify-center shrink-0">
|
||||||
|
<img v-if="rebreakAvatar" :src="rebreakAvatar" alt="ReBreak" class="w-8 h-8 rounded-full object-cover" />
|
||||||
|
<UIcon v-else name="heroicons:shield-check" class="h-4 w-4 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-white leading-tight">{{ rebreakNickname }}</p>
|
||||||
|
<p class="text-xs text-gray-500">Offiziell</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Topic Selection -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<p class="text-xs text-gray-500 mb-2 uppercase tracking-wide font-medium">Thema (fuer KI-Generierung)</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="t in lyraTopics"
|
||||||
|
:key="t.value"
|
||||||
|
class="px-3 py-1.5 rounded-full text-xs font-medium transition-colors border"
|
||||||
|
:class="
|
||||||
|
lyraTopic === t.value
|
||||||
|
? 'bg-purple-600 border-purple-600 text-white'
|
||||||
|
: 'bg-gray-900 border-gray-700 text-gray-400 hover:border-gray-600 hover:text-gray-300'
|
||||||
|
"
|
||||||
|
@click="lyraTopic = t.value"
|
||||||
|
>
|
||||||
|
{{ t.emoji }} {{ t.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Context + Generate -->
|
||||||
|
<div class="flex gap-3 items-end mb-6">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="text-xs text-gray-500 uppercase tracking-wide font-medium mb-1.5 block">
|
||||||
|
Optionaler Kontext
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="lyraContext"
|
||||||
|
placeholder="z.B. 'DNS-Blocker Update verfuegbar'"
|
||||||
|
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-gray-600 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
:loading="lyraGenerating"
|
||||||
|
:disabled="lyraPosting"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="heroicons:sparkles"
|
||||||
|
@click="generateContent"
|
||||||
|
>
|
||||||
|
Generieren
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Editable Content -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center justify-between mb-1.5">
|
||||||
|
<label class="text-xs text-gray-500 uppercase tracking-wide font-medium">
|
||||||
|
Post-Text
|
||||||
|
<span class="text-gray-600 normal-case font-normal ml-1">(direkt editierbar)</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
v-if="lyraContent"
|
||||||
|
class="text-xs text-gray-600 hover:text-gray-400 transition-colors"
|
||||||
|
@click="lyraContent = ''"
|
||||||
|
>
|
||||||
|
leeren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="lyraContent"
|
||||||
|
placeholder="Text hier eingeben oder via 'Generieren' erstellen..."
|
||||||
|
rows="5"
|
||||||
|
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 resize-none focus:outline-none focus:border-gray-600 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<div v-if="lyraContent.trim()" class="mb-6 rounded-lg border border-gray-800 bg-gray-900 p-5">
|
||||||
|
<p class="text-xs text-gray-600 uppercase tracking-wide font-semibold mb-4">Vorschau</p>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="w-9 h-9 rounded-full bg-gray-800 border border-gray-700 flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
|
<img
|
||||||
|
v-if="currentAuthorAvatar"
|
||||||
|
:src="currentAuthorAvatar"
|
||||||
|
:alt="currentAuthorNickname"
|
||||||
|
class="w-9 h-9 object-cover"
|
||||||
|
/>
|
||||||
|
<UIcon
|
||||||
|
v-else
|
||||||
|
:name="author === 'rebreak' ? 'heroicons:shield-check' : 'heroicons:cpu-chip'"
|
||||||
|
class="h-4 w-4 text-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1.5">
|
||||||
|
<span class="text-sm font-semibold text-white">{{ currentAuthorNickname }}</span>
|
||||||
|
<span class="text-xs text-purple-400 bg-purple-950/60 px-1.5 py-0.5 rounded-full flex items-center gap-0.5">
|
||||||
|
<UIcon name="heroicons:cpu-chip" class="h-3 w-3" />
|
||||||
|
KI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-200 leading-relaxed whitespace-pre-wrap">{{ lyraContent }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div
|
||||||
|
v-if="lyraError"
|
||||||
|
class="mb-4 rounded-lg border border-red-900 bg-red-950/40 px-4 py-3 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<UIcon name="heroicons:exclamation-triangle" class="h-4 w-4 text-red-400 shrink-0" />
|
||||||
|
<p class="text-sm text-red-300">{{ lyraError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Button -->
|
||||||
|
<UButton
|
||||||
|
block
|
||||||
|
:loading="lyraPosting"
|
||||||
|
:disabled="!lyraContent.trim() || lyraGenerating"
|
||||||
|
icon="heroicons:paper-airplane"
|
||||||
|
@click="postAsBot"
|
||||||
|
>
|
||||||
|
Als {{ currentAuthorNickname }} posten
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<!-- Success -->
|
||||||
|
<div
|
||||||
|
v-if="lyraSuccess"
|
||||||
|
class="mt-4 rounded-lg border border-emerald-900 bg-emerald-950/40 p-4 flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<UIcon name="heroicons:check-circle" class="h-5 w-5 text-emerald-400 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-emerald-300">Post veroeffentlicht</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5">
|
||||||
|
Erscheint sofort in der Community unter dem Profil von {{ currentAuthorNickname }}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ middleware: "admin-auth" });
|
||||||
|
|
||||||
|
const lyraTopics = [
|
||||||
|
{ value: "motivation", label: "Motivation", emoji: "💪" },
|
||||||
|
{ value: "tipp", label: "CBT-Tipp", emoji: "💡" },
|
||||||
|
{ value: "zitat", label: "Zitat", emoji: "📖" },
|
||||||
|
{ value: "witzig", label: "Witzig", emoji: "😄" },
|
||||||
|
{ value: "news", label: "News/Warnung", emoji: "⚠️" },
|
||||||
|
{ value: "feature", label: "Feature", emoji: "✨" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const author = ref<"lyra" | "rebreak">("lyra");
|
||||||
|
const lyraTopic = ref<string>("motivation");
|
||||||
|
const lyraContext = ref("");
|
||||||
|
const lyraContent = ref("");
|
||||||
|
const lyraGenerating = ref(false);
|
||||||
|
const lyraPosting = ref(false);
|
||||||
|
const lyraSuccess = ref(false);
|
||||||
|
const lyraError = ref("");
|
||||||
|
|
||||||
|
const lyraNickname = ref("Lyra");
|
||||||
|
const lyraAvatar = ref<string | null>(null);
|
||||||
|
const rebreakNickname = ref("ReBreak");
|
||||||
|
const rebreakAvatar = ref<string | null>(null);
|
||||||
|
|
||||||
|
const currentAuthorNickname = computed(() =>
|
||||||
|
author.value === "rebreak" ? rebreakNickname.value : lyraNickname.value,
|
||||||
|
);
|
||||||
|
const currentAuthorAvatar = computed(() =>
|
||||||
|
author.value === "rebreak" ? rebreakAvatar.value : lyraAvatar.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadProfile(a: "lyra" | "rebreak") {
|
||||||
|
try {
|
||||||
|
const profile = await $fetch<{ nickname: string; avatar: string | null }>(
|
||||||
|
`/api/admin/lyra-profile`,
|
||||||
|
{ query: { author: a } },
|
||||||
|
);
|
||||||
|
if (a === "lyra") {
|
||||||
|
lyraNickname.value = profile.nickname;
|
||||||
|
lyraAvatar.value = profile.avatar;
|
||||||
|
} else {
|
||||||
|
rebreakNickname.value = profile.nickname;
|
||||||
|
rebreakAvatar.value = profile.avatar;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback-Namen bleiben gesetzt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadProfile("lyra"), loadProfile("rebreak")]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectAuthor(a: "lyra" | "rebreak") {
|
||||||
|
author.value = a;
|
||||||
|
lyraContent.value = "";
|
||||||
|
lyraSuccess.value = false;
|
||||||
|
lyraError.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateContent() {
|
||||||
|
lyraError.value = "";
|
||||||
|
lyraSuccess.value = false;
|
||||||
|
lyraGenerating.value = true;
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ success: boolean; content: string }>(
|
||||||
|
"/api/admin/lyra-generate",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
author: author.value,
|
||||||
|
topic: lyraTopic.value,
|
||||||
|
context: lyraContext.value || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
lyraContent.value = res.content;
|
||||||
|
} catch (err: any) {
|
||||||
|
lyraError.value =
|
||||||
|
err?.data?.statusMessage ?? err?.statusMessage ?? err?.message ?? "Fehler beim Generieren";
|
||||||
|
} finally {
|
||||||
|
lyraGenerating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postAsBot() {
|
||||||
|
if (!lyraContent.value.trim()) return;
|
||||||
|
lyraError.value = "";
|
||||||
|
lyraSuccess.value = false;
|
||||||
|
lyraPosting.value = true;
|
||||||
|
try {
|
||||||
|
await $fetch("/api/admin/lyra-post", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
author: author.value,
|
||||||
|
customContent: lyraContent.value.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
lyraSuccess.value = true;
|
||||||
|
lyraContent.value = "";
|
||||||
|
lyraContext.value = "";
|
||||||
|
} catch (err: any) {
|
||||||
|
lyraError.value =
|
||||||
|
err?.data?.statusMessage ?? err?.statusMessage ?? err?.message ?? "Fehler beim Posten";
|
||||||
|
} finally {
|
||||||
|
lyraPosting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
33
apps/admin/server/api/admin/lyra-generate.post.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// apps/admin/server/api/admin/lyra-generate.post.ts
|
||||||
|
//
|
||||||
|
// Proxy: leitet LLM-Generierungsrequest an das Backend weiter.
|
||||||
|
// Admin-Secret bleibt server-only (NIE im Client-Bundle).
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<{ success: boolean; content: string }> => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiBase = config.public.apiBase;
|
||||||
|
const adminSecret = config.adminSecret;
|
||||||
|
|
||||||
|
if (!adminSecret) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "ADMIN_SECRET nicht konfiguriert (Infisical-Var fehlt)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event).catch(() => ({}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await $fetch(`${apiBase}/api/admin/lyra-generate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-admin-secret": adminSecret },
|
||||||
|
body: body ?? {},
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: err?.statusCode ?? 502,
|
||||||
|
statusMessage:
|
||||||
|
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
33
apps/admin/server/api/admin/lyra-post.post.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// apps/admin/server/api/admin/lyra-post.post.ts
|
||||||
|
//
|
||||||
|
// Proxy: sendet manuellen Bot-Post an das Backend (generiert via LLM oder custom content).
|
||||||
|
// Admin-Secret bleibt server-only (NIE im Client-Bundle).
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<{ success: boolean; postId: string; author: string; content: string }> => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiBase = config.public.apiBase;
|
||||||
|
const adminSecret = config.adminSecret;
|
||||||
|
|
||||||
|
if (!adminSecret) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "ADMIN_SECRET nicht konfiguriert (Infisical-Var fehlt)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event).catch(() => ({}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await $fetch(`${apiBase}/api/admin/lyra-post`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-admin-secret": adminSecret },
|
||||||
|
body: body ?? {},
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: err?.statusCode ?? 502,
|
||||||
|
statusMessage:
|
||||||
|
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
33
apps/admin/server/api/admin/lyra-profile.get.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// apps/admin/server/api/admin/lyra-profile.get.ts
|
||||||
|
//
|
||||||
|
// Proxy: holt Nickname + Avatar des Lyra- bzw. ReBreak-Bot-Accounts.
|
||||||
|
// Admin-Secret bleibt server-only (NIE im Client-Bundle).
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<{ nickname: string; avatar: string | null }> => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiBase = config.public.apiBase;
|
||||||
|
const adminSecret = config.adminSecret;
|
||||||
|
|
||||||
|
if (!adminSecret) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "ADMIN_SECRET nicht konfiguriert (Infisical-Var fehlt)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = getQuery(event);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await $fetch(`${apiBase}/api/admin/lyra-profile`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { "x-admin-secret": adminSecret },
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: err?.statusCode ?? 502,
|
||||||
|
statusMessage:
|
||||||
|
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
33
apps/admin/server/api/admin/set-lyra-avatar.post.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// apps/admin/server/api/admin/set-lyra-avatar.post.ts
|
||||||
|
//
|
||||||
|
// Proxy: leitet Avatar-Upload (base64 PNG) an das Backend weiter.
|
||||||
|
// Admin-Secret bleibt server-only (NIE im Client-Bundle).
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<{ success: boolean; avatar: string }> => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiBase = config.public.apiBase;
|
||||||
|
const adminSecret = config.adminSecret;
|
||||||
|
|
||||||
|
if (!adminSecret) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "ADMIN_SECRET nicht konfiguriert (Infisical-Var fehlt)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event).catch(() => ({}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await $fetch(`${apiBase}/api/admin/set-lyra-avatar`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-admin-secret": adminSecret },
|
||||||
|
body: body ?? {},
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: err?.statusCode ?? 502,
|
||||||
|
statusMessage:
|
||||||
|
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
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 |
@ -74,27 +74,42 @@ Flow-Headern als `appId: org.rebreak.app`.
|
|||||||
|
|
||||||
Flows benoetigen Test-User-Credentials. **Nie** hardcoden — immer als Env-Vars uebergeben.
|
Flows benoetigen Test-User-Credentials. **Nie** hardcoden — immer als Env-Vars uebergeben.
|
||||||
|
|
||||||
```bash
|
### Option A: direktes `--env` Flag
|
||||||
export E2E_TEST_USER=claude-android-test
|
|
||||||
export E2E_TEST_PASSWORD=<Passwort aus Infisical>
|
|
||||||
```
|
|
||||||
|
|
||||||
Oder via Infisical:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
infisical run -- maestro test apps/rebreak-native/.maestro/auth/signin.yaml
|
maestro test \
|
||||||
|
--env=E2E_TEST_USER=admin \
|
||||||
|
--env=E2E_TEST_PASSWORD=<Passwort aus Infisical> \
|
||||||
|
apps/rebreak-native/.maestro/auth/email-signin.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Option B: Infisical Wrapper
|
||||||
|
|
||||||
|
```bash
|
||||||
|
infisical run -- maestro test apps/rebreak-native/.maestro/auth/email-signin.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Voraussetzung: Infisical-Projekt hat `E2E_TEST_USER` und `E2E_TEST_PASSWORD` als Secrets.
|
||||||
|
|
||||||
Variablen die Flows erwarten:
|
Variablen die Flows erwarten:
|
||||||
|
|
||||||
| Var | Beschreibung |
|
| Var | Beschreibung |
|
||||||
|----------------------|-----------------------------------------------|
|
|----------------------|-------------------------------------------------------|
|
||||||
| `E2E_TEST_USER` | Username ohne @rebreak.internal |
|
| `E2E_TEST_USER` | Username-Teil der E-Mail (ohne @rebreak.internal) |
|
||||||
| `E2E_TEST_PASSWORD` | Passwort des Test-Users auf Staging |
|
| `E2E_TEST_PASSWORD` | Passwort des Test-Users auf Staging |
|
||||||
|
|
||||||
Wichtig: Der Backend-Server haengt `@rebreak.internal` automatisch an den Username.
|
Wichtig: Der Backend-Server haengt `@rebreak.internal` automatisch an den Username.
|
||||||
In den Flows steht deshalb `${E2E_TEST_USER}@rebreak.internal` als E-Mail-Input.
|
In den Flows steht deshalb `${E2E_TEST_USER}@rebreak.internal` als E-Mail-Input.
|
||||||
|
|
||||||
|
### Test-Account (aktueller Stand)
|
||||||
|
|
||||||
|
- **`admin@rebreak.org`** — email-basierter Account, Passwort in Infisical als `E2E_TEST_PASSWORD`
|
||||||
|
Dann: `E2E_TEST_USER=admin`
|
||||||
|
- **`charioanouar@gmail.com`** — Google OAuth only, kein Passwort → kann NICHT fuer Maestro-Email-Login genutzt werden
|
||||||
|
- **`claude-android-test@rebreak.internal`** — dedizierter CI-Test-Account (Erstellung via Service-Role noetig wenn nicht vorhanden)
|
||||||
|
|
||||||
|
Empfehlung: `admin`-Account fuer lokale Flow-Tests nutzen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Flows ausfuehren
|
## 4. Flows ausfuehren
|
||||||
@ -195,12 +210,25 @@ Test-User muss **vorab** auf dem Staging-Backend existieren:
|
|||||||
|
|
||||||
## 8. Flow-Uebersicht
|
## 8. Flow-Uebersicht
|
||||||
|
|
||||||
| Flow | Was wird geprueft |
|
| Flow | Was wird geprueft | Stabil? |
|
||||||
|-----------------------------------|----------------------------------------------------------|
|
|---|---|---|
|
||||||
| `auth/signin.yaml` | App startet, Login funktioniert, Home-Feed sichtbar |
|
| `auth/signin.yaml` | App startet, Login via Email+Pw, Home-Feed sichtbar | Ja (text-selektoren) |
|
||||||
| `urge/start-session.yaml` | SOS-Button im Dropdown erreichbar, Lyra-Screen laedt |
|
| `auth/email-signin.yaml` | Identisch — aktuelle Version mit besseren Kommentaren | Ja |
|
||||||
| `community/post.yaml` | ComposeCard oeffnet, Text-Input funktioniert, Post sendet|
|
| `urge/start-session.yaml` | SOS im Dropdown erreichbar, Lyra-Screen laedt | Koordinaten-Fallback |
|
||||||
| `profile/view-profile.yaml` | Profil-Navigation via Dropdown, ProfileScreen laedt |
|
| `urge/sos-flow.yaml` | SOS → Lyra-Chat → "Atemübung" Chip → BreathingDrawer | LLM-abhaengig |
|
||||||
|
| `community/post.yaml` | ComposeCard, Text-Input, Submit | Ja |
|
||||||
|
| `community/create-post.yaml` | Identisch — aktuelle Version | Ja |
|
||||||
|
| `profile/view-profile.yaml` | Profil-Navigation, ProfileHeader, StatsBar | Koordinaten-Fallback |
|
||||||
|
| `profile/view-and-edit.yaml` | Profil → Edit → Nickname aendern → Speichern | Koordinaten-Fallback |
|
||||||
|
| `profile/demographics.yaml` | DemographicsAccordion toggle, WheelPicker oeffnet | Text-selektoren |
|
||||||
|
| `settings/dark-theme.yaml` | Settings → Theme → Dunkel | Native-Menu-Limitation |
|
||||||
|
|
||||||
|
**Koordinaten-Fallback** = Flow nutzt `point: "x%, y%"` fuer Avatar-Button, weil kein `testID` vorhanden.
|
||||||
|
Bricht wenn Header-Layout geaendert wird. Betroffene testIDs: `TODO_TESTIDS.md`.
|
||||||
|
|
||||||
|
**Native-Menu-Limitation** = `@react-native-menu/menu` (UIMenu auf iOS) kann Maestro moeglicherweise
|
||||||
|
nicht interagieren — Flow koennte an diesem Step haengen. Wenn `settings/dark-theme.yaml` immer
|
||||||
|
an "Systemstandard" haengt: bekanntes Problem, kein Maestro-Bug, sondern iOS-Restriktion.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
104
apps/rebreak-native/.maestro/TODO_TESTIDS.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# TODO: testID additions needed for stable Maestro selectors
|
||||||
|
|
||||||
|
These are components that need `testID="..."` added by the UI agent (rebreak-native-ui domain).
|
||||||
|
Ahmed does NOT add these — list is for coordination.
|
||||||
|
|
||||||
|
Priority: HIGH = flow currently uses coordinate fallback or is skipped.
|
||||||
|
Priority: MEDIUM = flow works via text selector but will break on i18n locale change.
|
||||||
|
Priority: LOW = nice to have, flow is stable enough without it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HIGH — Coordinate fallbacks (breaks on layout change)
|
||||||
|
|
||||||
|
| Component | File | Recommended testID | Used by flow |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Avatar Pressable (menu trigger) | `components/AppHeader.tsx` line ~109 | `header-avatar-btn` | all flows that open dropdown |
|
||||||
|
| Nickname edit Pressable in ProfileHeader | `components/profile/ProfileHeader.tsx` | `profile-edit-nickname-btn` | `profile/view-and-edit.yaml` |
|
||||||
|
| Photo/avatar area tap in ProfileHeader | `components/profile/ProfileHeader.tsx` | `profile-edit-avatar-btn` | `profile/view-and-edit.yaml` |
|
||||||
|
|
||||||
|
AppHeader snippet (line ~109):
|
||||||
|
```tsx
|
||||||
|
<Pressable
|
||||||
|
testID="header-avatar-btn" // <-- add this
|
||||||
|
onPress={() => setMenuOpen(true)}
|
||||||
|
...
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HIGH — SOS screen send button (no text, no testID)
|
||||||
|
|
||||||
|
| Component | File | Recommended testID | Used by flow |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Send/submit Pressable in chat input area | `app/urge.tsx` | `sos-send-btn` | `urge/sos-flow.yaml` |
|
||||||
|
|
||||||
|
The send icon Pressable has no text and no testID. Current flow cannot reliably tap it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HIGH — Demographics field row Pressables
|
||||||
|
|
||||||
|
Each row in DemographicsAccordion that opens a WheelPickerModal is a Pressable with no testID.
|
||||||
|
Currently matched via hardcoded German label text (stable, but fragile if labels change).
|
||||||
|
|
||||||
|
| Field | File | Recommended testID |
|
||||||
|
|---|---|---|
|
||||||
|
| Geburtsjahr row | `components/profile/DemographicsAccordion.tsx` | `demographics-birth-year-row` |
|
||||||
|
| Geschlecht row | `components/profile/DemographicsAccordion.tsx` | `demographics-gender-row` |
|
||||||
|
| Familienstand row | `components/profile/DemographicsAccordion.tsx` | `demographics-marital-row` |
|
||||||
|
| Berufsstatus row | `components/profile/DemographicsAccordion.tsx` | `demographics-employment-row` |
|
||||||
|
| Bundesland row | `components/profile/DemographicsAccordion.tsx` | `demographics-bundesland-row` |
|
||||||
|
| Stadt / Landkreis row | `components/profile/DemographicsAccordion.tsx` | `demographics-city-row` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MEDIUM — Auth screen inputs (currently matched via i18n placeholder text)
|
||||||
|
|
||||||
|
| Component | File | Recommended testID | Risk |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Email TextInput | `app/(auth)/signin.tsx` line ~139 | `auth-email-input` | breaks if de.json placeholder changes |
|
||||||
|
| Password TextInput | `app/(auth)/signin.tsx` line ~151 | `auth-password-input` | same |
|
||||||
|
| Submit Pressable | `app/(auth)/signin.tsx` line ~173 | `auth-signin-btn` | breaks if t('auth.signin') changes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MEDIUM — ProfileEdit screen nickname input
|
||||||
|
|
||||||
|
| Component | File | Recommended testID |
|
||||||
|
|---|---|---|
|
||||||
|
| Nickname TextInput | `app/profile/edit.tsx` line ~295 | `profile-nickname-input` |
|
||||||
|
| Save Pressable | `app/profile/edit.tsx` line ~154 | `profile-save-btn` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MEDIUM — ComposeCard share button
|
||||||
|
|
||||||
|
| Component | File | Recommended testID | Risk |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Share/Teilen Pressable | `components/ComposeCard.tsx` line ~165 | `compose-share-btn` | breaks if t('community.share') changes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOW — Settings screen rows
|
||||||
|
|
||||||
|
Theme selection uses @react-native-menu/menu (native iOS UIMenu).
|
||||||
|
Maestro may not interact with native UIMenu popovers — coordinate taps on the anchor
|
||||||
|
Pressable are the only option without testID. If the native menu approach proves unreliable,
|
||||||
|
a fallback custom picker with testID would be needed.
|
||||||
|
|
||||||
|
| Component | File | Recommended testID |
|
||||||
|
|---|---|---|
|
||||||
|
| Theme menu anchor Pressable | `app/settings.tsx` | `settings-theme-picker` |
|
||||||
|
| Language menu anchor Pressable | `app/settings.tsx` | `settings-language-picker` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Adding testID to a Pressable/TextInput does NOT require any logic change — it is a
|
||||||
|
metadata prop only. Safe for UI agent to add without Ahmed review.
|
||||||
|
- RiveAvatar in urge.tsx: no testID needed — Maestro cannot assert animation states.
|
||||||
|
SOS screen is verified via the chat TextInput placeholder instead.
|
||||||
|
- NotificationsDropdown: not tested in current flow suite — no testID needed yet.
|
||||||
44
apps/rebreak-native/.maestro/auth/email-signin.yaml
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# auth/email-signin.yaml
|
||||||
|
# Tests: App starts → sign-in screen loads → email+password login succeeds → Home-Feed visible.
|
||||||
|
# Pre-requisite: App installed, E2E_TEST_USER account exists on staging backend.
|
||||||
|
# Env-Vars: E2E_TEST_USER (username without @rebreak.internal), E2E_TEST_PASSWORD
|
||||||
|
# Expected outcome: "ReBreak" headline visible in AppHeader after login.
|
||||||
|
# Note: No testIDs on signin inputs — text selectors match i18n keys from de.json.
|
||||||
|
# Run with --env=E2E_LOCALE=de if CI device locale may differ.
|
||||||
|
|
||||||
|
appId: org.rebreak.app
|
||||||
|
---
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
|
||||||
|
# Splash / font-load can take a moment
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
|
||||||
|
# Signin screen must appear immediately — no auth state after clearState.
|
||||||
|
# TextInput placeholder = t('auth.emailPlaceholder') = "E-Mail" (de.json)
|
||||||
|
- assertVisible:
|
||||||
|
text: "E-Mail"
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
text: "E-Mail"
|
||||||
|
- inputText: ${E2E_TEST_USER}@rebreak.internal
|
||||||
|
|
||||||
|
# Password input placeholder = t('auth.passwordPlaceholder') = "Passwort" (de.json)
|
||||||
|
- tapOn:
|
||||||
|
text: "Passwort"
|
||||||
|
- inputText: ${E2E_TEST_PASSWORD}
|
||||||
|
|
||||||
|
# Submit button text = t('auth.signin') = "Anmelden" (de.json)
|
||||||
|
# Button is disabled until both fields have content — typing above enables it.
|
||||||
|
- tapOn:
|
||||||
|
text: "Anmelden"
|
||||||
|
|
||||||
|
# Supabase auth + /api/auth/me call — allow network round-trip
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
# AppHeader shows t('appHeader.appName') = "ReBreak" (hardcoded fallback, de.json).
|
||||||
|
# This text only appears in the authenticated Home layout.
|
||||||
|
- assertVisible:
|
||||||
|
text: "ReBreak"
|
||||||
72
apps/rebreak-native/.maestro/community/create-post.yaml
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# community/create-post.yaml
|
||||||
|
# Tests: Login → Home-Feed → ComposeCard → type text → publish → ComposeCard resets.
|
||||||
|
# Pre-requisite: App installed. Test-user exists on staging.
|
||||||
|
# Env-Vars: E2E_TEST_USER, E2E_TEST_PASSWORD
|
||||||
|
# Expected outcome: After tapping "Teilen", ComposeCard resets to idle state
|
||||||
|
# (placeholder text visible again). Post is created in staging DB.
|
||||||
|
#
|
||||||
|
# NOTE: This flow creates a real post on staging. No automatic cleanup.
|
||||||
|
# Delete via Service-Role or Supabase dashboard after test runs.
|
||||||
|
#
|
||||||
|
# ComposeCard placeholder = t('community.compose_placeholder') = "Was bewegt dich gerade?" (de.json)
|
||||||
|
# Share button = t('community.share') = "Teilen" (de.json)
|
||||||
|
|
||||||
|
appId: org.rebreak.app
|
||||||
|
---
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
|
||||||
|
# --- Auth ---
|
||||||
|
- assertVisible:
|
||||||
|
text: "E-Mail"
|
||||||
|
- tapOn:
|
||||||
|
text: "E-Mail"
|
||||||
|
- inputText: ${E2E_TEST_USER}@rebreak.internal
|
||||||
|
- tapOn:
|
||||||
|
text: "Passwort"
|
||||||
|
- inputText: ${E2E_TEST_PASSWORD}
|
||||||
|
- tapOn:
|
||||||
|
text: "Anmelden"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
# --- Home-Feed ---
|
||||||
|
- assertVisible:
|
||||||
|
text: "ReBreak"
|
||||||
|
|
||||||
|
# ComposeCard sits at top of Home feed (/(app)/index.tsx).
|
||||||
|
# Scroll up first to make sure we're at the top.
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
text: "Was bewegt dich gerade?"
|
||||||
|
direction: UP
|
||||||
|
timeout: 4000
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
text: "Was bewegt dich gerade?"
|
||||||
|
|
||||||
|
# Tap placeholder to focus the TextInput (sets focused=true, showActions=true)
|
||||||
|
- tapOn:
|
||||||
|
text: "Was bewegt dich gerade?"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 1000
|
||||||
|
|
||||||
|
# Type post content — unique enough to find in DB for cleanup
|
||||||
|
- inputText: "[E2E] Maestro-Testpost — bitte ignorieren."
|
||||||
|
|
||||||
|
# After text input: action row appears with "Teilen" button
|
||||||
|
- assertVisible:
|
||||||
|
text: "Teilen"
|
||||||
|
|
||||||
|
# Submit. API call: POST /api/community/post → returns 200 → queryClient invalidates
|
||||||
|
- tapOn:
|
||||||
|
text: "Teilen"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 8000
|
||||||
|
|
||||||
|
# After success: cancel() is called → content reset → ComposeCard shows placeholder again
|
||||||
|
- assertVisible:
|
||||||
|
text: "Was bewegt dich gerade?"
|
||||||
118
apps/rebreak-native/.maestro/profile/demographics.yaml
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
# profile/demographics.yaml
|
||||||
|
# Tests: Login → Profile → DemographicsAccordion toggle → WheelPicker opens for Geburtsjahr
|
||||||
|
# → dismiss → Gender picker opens → dismiss → verify accordion still expanded.
|
||||||
|
# Pre-requisite: App installed. Test-user exists on staging.
|
||||||
|
# Env-Vars: E2E_TEST_USER, E2E_TEST_PASSWORD
|
||||||
|
# Expected outcome: Accordion expands, pickers open without crash.
|
||||||
|
#
|
||||||
|
# LIMITATION: WheelPickerModal is a Modal + ScrollView — Maestro can tap within it
|
||||||
|
# but cannot reliably assert a specific wheel item is selected. This flow only verifies
|
||||||
|
# the UI path opens and dismisses cleanly (no crash). Full field-fill test requires
|
||||||
|
# testIDs on each picker trigger row.
|
||||||
|
#
|
||||||
|
# NOTE: The accordion header uses hardcoded text (not i18n):
|
||||||
|
# "ANONYMER BEITRAG ZUR FORSCHUNG" — safe to match as static string.
|
||||||
|
#
|
||||||
|
# BLOCKER on Avatar tap: coordinate-based (93%, 6%). See TODO_TESTIDS.md.
|
||||||
|
|
||||||
|
appId: org.rebreak.app
|
||||||
|
---
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
|
||||||
|
# --- Auth ---
|
||||||
|
- assertVisible:
|
||||||
|
text: "E-Mail"
|
||||||
|
- tapOn:
|
||||||
|
text: "E-Mail"
|
||||||
|
- inputText: ${E2E_TEST_USER}@rebreak.internal
|
||||||
|
- tapOn:
|
||||||
|
text: "Passwort"
|
||||||
|
- inputText: ${E2E_TEST_PASSWORD}
|
||||||
|
- tapOn:
|
||||||
|
text: "Anmelden"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
text: "ReBreak"
|
||||||
|
|
||||||
|
# Open dropdown → Profile
|
||||||
|
- tapOn:
|
||||||
|
point: "93%, 6%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
- assertVisible:
|
||||||
|
text: "Profil"
|
||||||
|
- tapOn:
|
||||||
|
text: "Profil"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 4000
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
text: "Profil"
|
||||||
|
|
||||||
|
# Scroll down to reach DemographicsAccordion (below StreakSection / UrgeStatsCard)
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
text: "ANONYMER BEITRAG ZUR FORSCHUNG"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 6000
|
||||||
|
|
||||||
|
# Accordion header text is hardcoded — safe to use as selector.
|
||||||
|
- assertVisible:
|
||||||
|
text: "ANONYMER BEITRAG ZUR FORSCHUNG"
|
||||||
|
|
||||||
|
# Tap accordion header to expand
|
||||||
|
- tapOn:
|
||||||
|
text: "ANONYMER BEITRAG ZUR FORSCHUNG"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 1500
|
||||||
|
|
||||||
|
# After expansion: the progress bar area and field rows appear.
|
||||||
|
# "Geburtsjahr" label is hardcoded in DemographicsAccordion.
|
||||||
|
# FRAGILE: no testID on the row Pressable. Using text selector on label.
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
text: "Geburtsjahr"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 3000
|
||||||
|
- assertVisible:
|
||||||
|
text: "Geburtsjahr"
|
||||||
|
|
||||||
|
# Tap Geburtsjahr row to open WheelPickerModal
|
||||||
|
- tapOn:
|
||||||
|
text: "Geburtsjahr"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
|
||||||
|
# WheelPickerModal is open. Dismiss by tapping the backdrop or close button.
|
||||||
|
# WheelPickerModal has no testID on the backdrop. Tap outside the picker area.
|
||||||
|
- tapOn:
|
||||||
|
point: "50%, 10%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 1000
|
||||||
|
|
||||||
|
# Tap Geschlecht (Gender) row — also hardcoded label text in DemographicsAccordion
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
text: "Geschlecht"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 3000
|
||||||
|
- tapOn:
|
||||||
|
text: "Geschlecht"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
|
||||||
|
# Dismiss gender picker
|
||||||
|
- tapOn:
|
||||||
|
point: "50%, 10%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 1000
|
||||||
|
|
||||||
|
# Verify accordion is still expanded after dismissing pickers
|
||||||
|
- assertVisible:
|
||||||
|
text: "Geburtsjahr"
|
||||||
113
apps/rebreak-native/.maestro/profile/view-and-edit.yaml
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
# profile/view-and-edit.yaml
|
||||||
|
# Tests: Login → open Header dropdown → tap "Profil" → ProfileScreen loads →
|
||||||
|
# tap edit (navigates to /profile/edit) → change nickname → tap Save.
|
||||||
|
# Pre-requisite: App installed. Test-user account exists on staging.
|
||||||
|
# Env-Vars: E2E_TEST_USER, E2E_TEST_PASSWORD
|
||||||
|
# Expected outcome: Save button (t('profile.edit_save') = "Speichern") tapped,
|
||||||
|
# screen returns to ProfileScreen without error.
|
||||||
|
#
|
||||||
|
# BLOCKER: Avatar Pressable in AppHeader has NO testID and NO accessibilityLabel.
|
||||||
|
# The dropdown open-tap uses a coordinate fallback (93%, 6%). This breaks if
|
||||||
|
# header layout changes. See TODO_TESTIDS.md — needs testID="header-avatar-btn".
|
||||||
|
|
||||||
|
appId: org.rebreak.app
|
||||||
|
---
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
|
||||||
|
# --- Auth ---
|
||||||
|
- assertVisible:
|
||||||
|
text: "E-Mail"
|
||||||
|
- tapOn:
|
||||||
|
text: "E-Mail"
|
||||||
|
- inputText: ${E2E_TEST_USER}@rebreak.internal
|
||||||
|
- tapOn:
|
||||||
|
text: "Passwort"
|
||||||
|
- inputText: ${E2E_TEST_PASSWORD}
|
||||||
|
- tapOn:
|
||||||
|
text: "Anmelden"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
# --- Home ---
|
||||||
|
- assertVisible:
|
||||||
|
text: "ReBreak"
|
||||||
|
|
||||||
|
# Open HeaderDropdownMenu via Avatar tap (top-right corner).
|
||||||
|
# FRAGILE — needs testID="header-avatar-btn" to become stable. See TODO_TESTIDS.md.
|
||||||
|
- tapOn:
|
||||||
|
point: "93%, 6%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
|
||||||
|
# Dropdown label = t('headerMenu.profile') = "Profil" (de.json)
|
||||||
|
- assertVisible:
|
||||||
|
text: "Profil"
|
||||||
|
- tapOn:
|
||||||
|
text: "Profil"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 4000
|
||||||
|
|
||||||
|
# ProfileScreen: AppHeader title is hardcoded "Profil" in ProfileScreen.
|
||||||
|
# "Posts" label in StatsBar (hardcoded, no i18n) confirms the screen rendered.
|
||||||
|
- assertVisible:
|
||||||
|
text: "Profil"
|
||||||
|
- assertVisible:
|
||||||
|
text: "Posts"
|
||||||
|
|
||||||
|
# Scroll down to make sure StatsBar area is visible before continuing
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
text: "Posts"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 3000
|
||||||
|
|
||||||
|
# Navigate to edit screen.
|
||||||
|
# ProfileHeader has two Pressables: onEditAvatar + onEditNickname — both push /profile/edit.
|
||||||
|
# The camera icon area has no testID. We scroll back up first and tap the nickname area.
|
||||||
|
# FRAGILE — needs testID="profile-edit-nickname-btn" on the nickname Pressable.
|
||||||
|
# See TODO_TESTIDS.md.
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
text: "Profil"
|
||||||
|
direction: UP
|
||||||
|
timeout: 3000
|
||||||
|
- tapOn:
|
||||||
|
point: "50%, 28%"
|
||||||
|
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
|
||||||
|
# Edit screen: title = t('profile.edit_title') — check SETUP.md for de.json value.
|
||||||
|
# Nickname TextInput has no testID. Tap and clear existing value, type new one.
|
||||||
|
# We use scrollUntilVisible to reach the nickname section.
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
text: "NICKNAME"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 3000
|
||||||
|
|
||||||
|
# Nickname TextInput has no testID. Tap the placeholder text (t('auth.nicknamePlaceholder'))
|
||||||
|
# if field is empty, or tap directly below the "NICKNAME" label.
|
||||||
|
# clearText clears whatever is in the focused input.
|
||||||
|
- tapOn:
|
||||||
|
text: "NICKNAME"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 500
|
||||||
|
# The TextInput sits directly below the NICKNAME label — Maestro focuses the last
|
||||||
|
# tapped element. If placeholder is visible, tap it instead.
|
||||||
|
- clearText
|
||||||
|
- inputText: "TestNick"
|
||||||
|
|
||||||
|
# Save button = t('profile.edit_save') = "Speichern" (de.json). Only active when hasChanges.
|
||||||
|
- tapOn:
|
||||||
|
text: "Speichern"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
|
||||||
|
# After save: router.back() → ProfileScreen. "Profil" title visible again.
|
||||||
|
- assertVisible:
|
||||||
|
text: "Profil"
|
||||||
85
apps/rebreak-native/.maestro/settings/dark-theme.yaml
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
# settings/dark-theme.yaml
|
||||||
|
# Tests: Login → Header dropdown → Settings → Theme picker → Dark mode selected.
|
||||||
|
# Pre-requisite: App installed. Test-user exists on staging.
|
||||||
|
# Env-Vars: E2E_TEST_USER, E2E_TEST_PASSWORD
|
||||||
|
# Expected outcome: After selecting "Dunkel", Settings screen reflects dark theme
|
||||||
|
# (background color changes — Maestro cannot assert CSS, but
|
||||||
|
# assertVisible on a dark-only element or the "Dunkel" value chip
|
||||||
|
# confirms the selection was persisted in local store).
|
||||||
|
#
|
||||||
|
# IMPORTANT: Theme is stored in MMKV (useThemeStore). clearState: true resets it
|
||||||
|
# to 'system' each run — so this flow always starts from system default.
|
||||||
|
#
|
||||||
|
# The theme menu is a @react-native-menu/menu MenuView (native iOS context menu).
|
||||||
|
# Maestro can trigger the anchor Pressable but may NOT be able to interact with
|
||||||
|
# the native UIMenu popover on iOS. If this step fails, it is a Maestro limitation
|
||||||
|
# with native context menus — add to SETUP.md known issues.
|
||||||
|
# Alternative: implement a test-only theme toggle button accessible via testID.
|
||||||
|
# See TODO_TESTIDS.md.
|
||||||
|
|
||||||
|
appId: org.rebreak.app
|
||||||
|
---
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
|
||||||
|
# --- Auth ---
|
||||||
|
- assertVisible:
|
||||||
|
text: "E-Mail"
|
||||||
|
- tapOn:
|
||||||
|
text: "E-Mail"
|
||||||
|
- inputText: ${E2E_TEST_USER}@rebreak.internal
|
||||||
|
- tapOn:
|
||||||
|
text: "Passwort"
|
||||||
|
- inputText: ${E2E_TEST_PASSWORD}
|
||||||
|
- tapOn:
|
||||||
|
text: "Anmelden"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
text: "ReBreak"
|
||||||
|
|
||||||
|
# Open Header dropdown → Settings
|
||||||
|
- tapOn:
|
||||||
|
point: "93%, 6%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
|
||||||
|
# t('headerMenu.settings') = "Einstellungen" (de.json)
|
||||||
|
- assertVisible:
|
||||||
|
text: "Einstellungen"
|
||||||
|
- tapOn:
|
||||||
|
text: "Einstellungen"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
|
||||||
|
# Settings screen: AppHeader title = t('settings.title') = "Einstellungen"
|
||||||
|
- assertVisible:
|
||||||
|
text: "Einstellungen"
|
||||||
|
|
||||||
|
# Theme section: t('settings.theme') = "Erscheinungsbild" (de.json) — row label.
|
||||||
|
# The current value chip shows t('settings.theme_system') = "Systemstandard" initially.
|
||||||
|
- assertVisible:
|
||||||
|
text: "Erscheinungsbild"
|
||||||
|
|
||||||
|
# Tap the value chip (MenuView anchor Pressable) to open iOS UIMenu.
|
||||||
|
# WARNING: Native UIMenu interaction may not work in Maestro — see header note.
|
||||||
|
# We tap on the current value "Systemstandard" which is inside the anchor Pressable.
|
||||||
|
- tapOn:
|
||||||
|
text: "Systemstandard"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 1500
|
||||||
|
|
||||||
|
# iOS native UIMenu: items are "Systemstandard", "Hell", "Dunkel"
|
||||||
|
# t('settings.theme_dark') = "Dunkel" (de.json)
|
||||||
|
- tapOn:
|
||||||
|
text: "Dunkel"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 1500
|
||||||
|
|
||||||
|
# After selection: the value chip in the row should now show "Dunkel"
|
||||||
|
- assertVisible:
|
||||||
|
text: "Dunkel"
|
||||||
94
apps/rebreak-native/.maestro/urge/sos-flow.yaml
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# urge/sos-flow.yaml
|
||||||
|
# Tests: Login → Header dropdown → SOS → Lyra chat screen loads → breathing exercise
|
||||||
|
# triggered → session ends → feedback rating drawer appears.
|
||||||
|
# Pre-requisite: App installed. Test-user exists on staging. Staging backend running.
|
||||||
|
# Groq API key configured (SOS streams via /api/coach/sos-stream).
|
||||||
|
# Env-Vars: E2E_TEST_USER, E2E_TEST_PASSWORD
|
||||||
|
# Expected outcome: Lyra chat input visible after SOS screen loads.
|
||||||
|
# Breathing drawer opens when "Atemübung" chip is tapped.
|
||||||
|
#
|
||||||
|
# TIMING NOTE: SOS screen boots RiveAvatar + streams first Lyra message via Groq.
|
||||||
|
# Timeout of 12s post-navigation is intentional — cold LLM response on staging.
|
||||||
|
#
|
||||||
|
# BLOCKER: Avatar button in AppHeader has no testID → coordinate fallback (93%, 6%).
|
||||||
|
# SOS-entry Pressable in HeaderDropdownMenu has no testID → text "SOS" selector works
|
||||||
|
# because "SOS" only appears inside the open dropdown.
|
||||||
|
|
||||||
|
appId: org.rebreak.app
|
||||||
|
---
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
|
||||||
|
# --- Auth ---
|
||||||
|
- assertVisible:
|
||||||
|
text: "E-Mail"
|
||||||
|
- tapOn:
|
||||||
|
text: "E-Mail"
|
||||||
|
- inputText: ${E2E_TEST_USER}@rebreak.internal
|
||||||
|
- tapOn:
|
||||||
|
text: "Passwort"
|
||||||
|
- inputText: ${E2E_TEST_PASSWORD}
|
||||||
|
- tapOn:
|
||||||
|
text: "Anmelden"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
text: "ReBreak"
|
||||||
|
|
||||||
|
# Open Header dropdown
|
||||||
|
- tapOn:
|
||||||
|
point: "93%, 6%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
|
||||||
|
# t('appHeader.sosLabel') = "SOS" (de.json) — red text, top of dropdown
|
||||||
|
- assertVisible:
|
||||||
|
text: "SOS"
|
||||||
|
- tapOn:
|
||||||
|
text: "SOS"
|
||||||
|
|
||||||
|
# SOS screen loads: RiveAvatar renders + Lyra streaming starts.
|
||||||
|
# Boot takes 6-12s on staging (Groq cold start + audio pre-cache).
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 12000
|
||||||
|
|
||||||
|
# Chat input: placeholder = t('coach.placeholder') = "Was beschäftigt dich?" (de.json).
|
||||||
|
# This placeholder ONLY exists on the SOS/Urge screen.
|
||||||
|
- assertVisible:
|
||||||
|
text: "Was beschäftigt dich?"
|
||||||
|
|
||||||
|
# Type a message to trigger Lyra response + chip suggestions
|
||||||
|
- tapOn:
|
||||||
|
text: "Was beschäftigt dich?"
|
||||||
|
- inputText: "Ich habe gerade einen starken Drang."
|
||||||
|
- tapOn:
|
||||||
|
text: "Was beschäftigt dich?"
|
||||||
|
|
||||||
|
# After typing, a send button should appear (Ionicons send icon, no text/testID).
|
||||||
|
# Tap on the text input area then look for the send button via coordinates.
|
||||||
|
# FRAGILE — needs testID="sos-send-btn" on the send Pressable. See TODO_TESTIDS.md.
|
||||||
|
# Instead of sending (which triggers another streaming round-trip),
|
||||||
|
# we verify the chip row is visible if Lyra has already responded.
|
||||||
|
# If this is the first message, wait for Lyra's response + chip appearance.
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 15000
|
||||||
|
|
||||||
|
# After Lyra responds, chipSet changes from 'start' to dynamic chips.
|
||||||
|
# "Atemübung" is a chip in CHIP_SETS.start (sosConstants.ts) — always shown first.
|
||||||
|
# Text is hardcoded in sosConstants.ts, not i18n.
|
||||||
|
# If visible: tap to open BreathingDrawer.
|
||||||
|
- assertVisible:
|
||||||
|
text: "Atemübung"
|
||||||
|
- tapOn:
|
||||||
|
text: "Atemübung"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
|
||||||
|
# BreathingDrawer opens as a bottom sheet. Header text is hardcoded
|
||||||
|
# "Atemübung" in Breathing.tsx — safe to assert.
|
||||||
|
- assertVisible:
|
||||||
|
text: "Atemübung"
|
||||||
@ -56,6 +56,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
plugins: [
|
plugins: [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-localization",
|
"expo-localization",
|
||||||
|
"expo-font",
|
||||||
|
"expo-web-browser",
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import * as Notifications from 'expo-notifications';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../../stores/auth';
|
import { useAuthStore } from '../../stores/auth';
|
||||||
import { useNotificationStore } from '../../stores/notifications';
|
import { useNotificationStore } from '../../stores/notifications';
|
||||||
import { colors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
import { NativeTabs } from '../../components/NativeTabs';
|
import { NativeTabs } from '../../components/NativeTabs';
|
||||||
import { protection } from '../../lib/protection';
|
import { protection } from '../../lib/protection';
|
||||||
import { preloadTabIcons, getTabIcon } from '../../lib/tabIcons';
|
import { preloadTabIcons, getTabIcon } from '../../lib/tabIcons';
|
||||||
@ -14,6 +14,7 @@ export default function AppLayout() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { session, loading } = useAuthStore();
|
const { session, loading } = useAuthStore();
|
||||||
|
const colors = useColors();
|
||||||
const loadNotifications = useNotificationStore((s) => s.load);
|
const loadNotifications = useNotificationStore((s) => s.load);
|
||||||
const startRealtime = useNotificationStore((s) => s.startRealtime);
|
const startRealtime = useNotificationStore((s) => s.startRealtime);
|
||||||
const stopRealtime = useNotificationStore((s) => s.stopRealtime);
|
const stopRealtime = useNotificationStore((s) => s.stopRealtime);
|
||||||
@ -143,7 +144,7 @@ export default function AppLayout() {
|
|||||||
|
|
||||||
if (loading || !session) {
|
if (loading || !session) {
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-white items-center justify-center">
|
<View style={{ flex: 1, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<ActivityIndicator color={colors.brandOrange} size="large" />
|
<ActivityIndicator color={colors.brandOrange} size="large" />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import { apiFetch } from '../../lib/api';
|
|||||||
import { AppHeader } from '../../components/AppHeader';
|
import { AppHeader } from '../../components/AppHeader';
|
||||||
import { RoomCard, type Room } from '../../components/chat/RoomCard';
|
import { RoomCard, type Room } from '../../components/chat/RoomCard';
|
||||||
import { CreateRoomSheet } from '../../components/chat/CreateRoomSheet';
|
import { CreateRoomSheet } from '../../components/chat/CreateRoomSheet';
|
||||||
import { colors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
type DmConversation = {
|
type DmConversation = {
|
||||||
partnerId: string;
|
partnerId: string;
|
||||||
@ -39,6 +39,8 @@ function formatTime(ts: string, justNowLabel: string): string {
|
|||||||
|
|
||||||
function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }) {
|
function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const styles = makeStyles(colors);
|
||||||
const hasUnread = conv.unreadCount > 0;
|
const hasUnread = conv.unreadCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -95,6 +97,8 @@ function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }
|
|||||||
export default function ChatScreen() {
|
export default function ChatScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const colors = useColors();
|
||||||
|
const styles = makeStyles(colors);
|
||||||
const [tab, setTab] = useState<'groups' | 'direct'>('groups');
|
const [tab, setTab] = useState<'groups' | 'direct'>('groups');
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
@ -199,13 +203,13 @@ export default function ChatScreen() {
|
|||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refetchingRooms}
|
refreshing={refetchingRooms}
|
||||||
onRefresh={refetchRooms}
|
onRefresh={refetchRooms}
|
||||||
tintColor={colors.brandOrange}
|
tintColor="#007AFF"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
loadingRooms ? (
|
loadingRooms ? (
|
||||||
<View style={styles.emptyBox}>
|
<View style={styles.emptyBox}>
|
||||||
<ActivityIndicator color={colors.brandOrange} />
|
<ActivityIndicator color="#007AFF" />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.emptyBox}>
|
<View style={styles.emptyBox}>
|
||||||
@ -225,13 +229,13 @@ export default function ChatScreen() {
|
|||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refetchingDms}
|
refreshing={refetchingDms}
|
||||||
onRefresh={refetchDms}
|
onRefresh={refetchDms}
|
||||||
tintColor={colors.brandOrange}
|
tintColor="#007AFF"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
loadingDms ? (
|
loadingDms ? (
|
||||||
<View style={styles.emptyBox}>
|
<View style={styles.emptyBox}>
|
||||||
<ActivityIndicator color={colors.brandOrange} />
|
<ActivityIndicator color="#007AFF" />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.emptyBox}>
|
<View style={styles.emptyBox}>
|
||||||
@ -257,15 +261,16 @@ export default function ChatScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||||
container: { flex: 1, backgroundColor: '#fafafa' },
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bg },
|
||||||
headerSection: {
|
headerSection: {
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingTop: 14,
|
paddingTop: 14,
|
||||||
paddingBottom: 10,
|
paddingBottom: 10,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: colors.bg,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
borderBottomColor: '#e5e5e5',
|
borderBottomColor: colors.border,
|
||||||
},
|
},
|
||||||
titleRow: {
|
titleRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -275,7 +280,7 @@ const styles = StyleSheet.create({
|
|||||||
title: {
|
title: {
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontFamily: 'Nunito_800ExtraBold',
|
fontFamily: 'Nunito_800ExtraBold',
|
||||||
color: '#171717',
|
color: colors.text,
|
||||||
},
|
},
|
||||||
createBtn: {
|
createBtn: {
|
||||||
width: 34,
|
width: 34,
|
||||||
@ -288,7 +293,7 @@ const styles = StyleSheet.create({
|
|||||||
tabs: {
|
tabs: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
padding: 3,
|
padding: 3,
|
||||||
},
|
},
|
||||||
@ -301,7 +306,7 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
},
|
},
|
||||||
tabActive: {
|
tabActive: {
|
||||||
backgroundColor: '#fff',
|
backgroundColor: colors.surface,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOpacity: 0.05,
|
shadowOpacity: 0.05,
|
||||||
shadowRadius: 2,
|
shadowRadius: 2,
|
||||||
@ -310,7 +315,7 @@ const styles = StyleSheet.create({
|
|||||||
tabText: {
|
tabText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
marginLeft: 5,
|
marginLeft: 5,
|
||||||
},
|
},
|
||||||
tabTextActive: {
|
tabTextActive: {
|
||||||
@ -341,25 +346,24 @@ const styles = StyleSheet.create({
|
|||||||
emptyText: {
|
emptyText: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#a3a3a3',
|
color: colors.textMuted,
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
},
|
},
|
||||||
// DM row styles
|
|
||||||
dmRow: {
|
dmRow: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 11,
|
paddingVertical: 11,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: colors.bg,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
borderBottomColor: '#f5f5f5',
|
borderBottomColor: colors.border,
|
||||||
},
|
},
|
||||||
dmAvatar: {
|
dmAvatar: {
|
||||||
width: 42,
|
width: 42,
|
||||||
height: 42,
|
height: 42,
|
||||||
borderRadius: 21,
|
borderRadius: 21,
|
||||||
backgroundColor: '#e5e5e5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@ -369,7 +373,7 @@ const styles = StyleSheet.create({
|
|||||||
dmAvatarInitials: {
|
dmAvatarInitials: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#525252',
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
dmInfo: { flex: 1, minWidth: 0 },
|
dmInfo: { flex: 1, minWidth: 0 },
|
||||||
dmHeaderRow: {
|
dmHeaderRow: {
|
||||||
@ -380,7 +384,7 @@ const styles = StyleSheet.create({
|
|||||||
dmName: {
|
dmName: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#171717',
|
color: colors.text,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
marginRight: 6,
|
marginRight: 6,
|
||||||
},
|
},
|
||||||
@ -408,3 +412,4 @@ const styles = StyleSheet.create({
|
|||||||
color: '#fff',
|
color: '#fff',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { View } from 'react-native';
|
import { View } from 'react-native';
|
||||||
import { useRouter, useFocusEffect } from 'expo-router';
|
import { useRouter, useFocusEffect } from 'expo-router';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Placeholder-Screen für den Coach-Tab.
|
* Placeholder-Screen für den Coach-Tab.
|
||||||
@ -11,6 +12,7 @@ import { useRouter, useFocusEffect } from 'expo-router';
|
|||||||
*/
|
*/
|
||||||
export default function CoachTabRedirect() {
|
export default function CoachTabRedirect() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const colors = useColors();
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@ -20,5 +22,5 @@ export default function CoachTabRedirect() {
|
|||||||
}, [router]),
|
}, [router]),
|
||||||
);
|
);
|
||||||
|
|
||||||
return <View style={{ flex: 1, backgroundColor: '#ffffff' }} />;
|
return <View style={{ flex: 1, backgroundColor: colors.bg }} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import { PostCardSkeleton } from '../../components/PostCardSkeleton';
|
|||||||
import { PostCommentsSheet } from '../../components/PostCommentsSheet';
|
import { PostCommentsSheet } from '../../components/PostCommentsSheet';
|
||||||
import { useCommunityStore, type CommunityCategory, type CommunityPost } from '../../stores/community';
|
import { useCommunityStore, type CommunityCategory, type CommunityPost } from '../../stores/community';
|
||||||
import { useCommunityRealtime } from '../../hooks/useCommunityRealtime';
|
import { useCommunityRealtime } from '../../hooks/useCommunityRealtime';
|
||||||
import { colors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
type FilterChip = {
|
type FilterChip = {
|
||||||
value: CommunityCategory;
|
value: CommunityCategory;
|
||||||
@ -30,6 +30,7 @@ type FilterChip = {
|
|||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const colors = useColors();
|
||||||
// Granular selectors: subscribing to the whole store (incl. optimisticLikes)
|
// Granular selectors: subscribing to the whole store (incl. optimisticLikes)
|
||||||
// would re-render the screen — and thus the FlatList — on every like.
|
// would re-render the screen — and thus the FlatList — on every like.
|
||||||
const activeCategory = useCommunityStore((s) => s.activeCategory);
|
const activeCategory = useCommunityStore((s) => s.activeCategory);
|
||||||
@ -79,7 +80,7 @@ export default function HomeScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-neutral-50">
|
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
@ -139,23 +140,30 @@ export default function HomeScreen() {
|
|||||||
<Pressable
|
<Pressable
|
||||||
key={f.value}
|
key={f.value}
|
||||||
onPress={() => toggleFilter(f.value)}
|
onPress={() => toggleFilter(f.value)}
|
||||||
className={`flex-row items-center gap-1.5 h-8 px-3 rounded-full border ${
|
style={({ pressed }) => ({
|
||||||
active
|
opacity: pressed ? 0.7 : 1,
|
||||||
? 'bg-rebreak-500 border-rebreak-500'
|
flexDirection: 'row',
|
||||||
: 'bg-white border-neutral-200'
|
alignItems: 'center',
|
||||||
}`}
|
gap: 6,
|
||||||
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
height: 32,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 999,
|
||||||
|
borderWidth: 1,
|
||||||
|
backgroundColor: active ? colors.brandOrange : colors.surface,
|
||||||
|
borderColor: active ? colors.brandOrange : colors.border,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={f.icon}
|
name={f.icon}
|
||||||
size={13}
|
size={13}
|
||||||
color={active ? '#fff' : '#737373'}
|
color={active ? '#fff' : colors.textMuted}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
className={`text-xs ${
|
style={{
|
||||||
active ? 'text-white' : 'text-neutral-500'
|
fontSize: 12,
|
||||||
}`}
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
style={{ fontFamily: 'Nunito_600SemiBold' }}
|
color: active ? '#fff' : colors.textMuted,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{f.label}
|
{f.label}
|
||||||
</Text>
|
</Text>
|
||||||
@ -178,9 +186,9 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
isLoading ? null : (
|
isLoading ? null : (
|
||||||
<View className="items-center py-16">
|
<View style={{ alignItems: 'center', paddingVertical: 64 }}>
|
||||||
<Ionicons name="chatbubbles-outline" size={40} color="#d4d4d4" />
|
<Ionicons name="chatbubbles-outline" size={40} color={colors.border} />
|
||||||
<Text className="text-sm text-neutral-400 mt-3 text-center" style={{ fontFamily: 'Nunito_400Regular' }}>
|
<Text style={{ fontSize: 14, color: colors.textMuted, marginTop: 12, textAlign: 'center', fontFamily: 'Nunito_400Regular' }}>
|
||||||
{t('community.no_posts')}
|
{t('community.no_posts')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -20,10 +20,12 @@ import { SuccessAlert } from '../../components/SuccessAlert';
|
|||||||
import { useMailStatus } from '../../hooks/useMailStatus';
|
import { useMailStatus } from '../../hooks/useMailStatus';
|
||||||
import { useMailDisconnect } from '../../hooks/useMailDisconnect';
|
import { useMailDisconnect } from '../../hooks/useMailDisconnect';
|
||||||
import { useUserPlan } from '../../hooks/useUserPlan';
|
import { useUserPlan } from '../../hooks/useUserPlan';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
export default function MailScreen() {
|
export default function MailScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tabBarHeight = useBottomTabBarHeight();
|
const tabBarHeight = useBottomTabBarHeight();
|
||||||
|
const colors = useColors();
|
||||||
|
|
||||||
const { plan } = useUserPlan();
|
const { plan } = useUserPlan();
|
||||||
|
|
||||||
@ -72,7 +74,7 @@ export default function MailScreen() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: '#fafafa' }}>
|
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<ActivityIndicator size="large" color="#007AFF" />
|
<ActivityIndicator size="large" color="#007AFF" />
|
||||||
@ -82,7 +84,7 @@ export default function MailScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: '#fafafa' }}>
|
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@ -118,7 +120,7 @@ export default function MailScreen() {
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 0.8,
|
letterSpacing: 0.8,
|
||||||
}}
|
}}
|
||||||
@ -129,7 +131,7 @@ export default function MailScreen() {
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#a3a3a3',
|
color: colors.textMuted,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -147,7 +149,7 @@ export default function MailScreen() {
|
|||||||
disabled={limitReached}
|
disabled={limitReached}
|
||||||
android_ripple={{ color: '#0066cc' }}
|
android_ripple={{ color: '#0066cc' }}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: limitReached ? '#e5e5e5' : '#007AFF',
|
backgroundColor: limitReached ? colors.surfaceElevated : '#007AFF',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
opacity: limitReached ? 0.7 : 1,
|
opacity: limitReached ? 0.7 : 1,
|
||||||
shadowColor: '#007AFF',
|
shadowColor: '#007AFF',
|
||||||
@ -169,14 +171,14 @@ export default function MailScreen() {
|
|||||||
<Ionicons
|
<Ionicons
|
||||||
name="add"
|
name="add"
|
||||||
size={18}
|
size={18}
|
||||||
color={limitReached ? '#737373' : '#fff'}
|
color={limitReached ? colors.textMuted : '#fff'}
|
||||||
style={{ marginRight: 6 }}
|
style={{ marginRight: 6 }}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: limitReached ? '#737373' : '#fff',
|
color: limitReached ? colors.textMuted : '#fff',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('mail.add_account')}
|
{t('mail.add_account')}
|
||||||
|
|||||||
@ -7,11 +7,12 @@ import { HeroShieldCheck } from '../../components/HeroShieldCheck';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { EmptyState } from '../../components/EmptyState';
|
import { EmptyState } from '../../components/EmptyState';
|
||||||
import { useNotificationStore, type AppNotification } from '../../stores/notifications';
|
import { useNotificationStore, type AppNotification } from '../../stores/notifications';
|
||||||
import { colors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
export default function NotificationsScreen() {
|
export default function NotificationsScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
const items = useNotificationStore((s) => s.items);
|
const items = useNotificationStore((s) => s.items);
|
||||||
const loaded = useNotificationStore((s) => s.loaded);
|
const loaded = useNotificationStore((s) => s.loaded);
|
||||||
const load = useNotificationStore((s) => s.load);
|
const load = useNotificationStore((s) => s.load);
|
||||||
@ -28,17 +29,16 @@ export default function NotificationsScreen() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-white" edges={['top']}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }} edges={['top']}>
|
||||||
<View className="flex-row items-center gap-3 px-5 pt-3 pb-3 border-b border-neutral-200">
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 20, paddingTop: 12, paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: colors.border }}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
className="w-9 h-9 rounded-full bg-neutral-100 border border-neutral-200 items-center justify-center"
|
style={{ width: 36, height: 36, borderRadius: 18, backgroundColor: colors.surfaceElevated, borderWidth: 1, borderColor: colors.border, alignItems: 'center', justifyContent: 'center' }}
|
||||||
>
|
>
|
||||||
<Ionicons name="arrow-back" size={18} color="#737373" />
|
<Ionicons name="arrow-back" size={18} color={colors.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text
|
<Text
|
||||||
className="text-neutral-900 text-lg flex-1"
|
style={{ color: colors.text, fontSize: 18, flex: 1, fontFamily: 'Nunito_700Bold' }}
|
||||||
style={{ fontFamily: 'Nunito_700Bold' }}
|
|
||||||
>
|
>
|
||||||
{t('notifications.title')}
|
{t('notifications.title')}
|
||||||
</Text>
|
</Text>
|
||||||
@ -59,7 +59,7 @@ export default function NotificationsScreen() {
|
|||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={!loaded}
|
refreshing={!loaded}
|
||||||
onRefresh={load}
|
onRefresh={load}
|
||||||
tintColor={colors.brandOrange}
|
tintColor="#007AFF"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
@ -88,6 +88,7 @@ function NotificationRow({
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
const isUnread = !notif.readAt;
|
const isUnread = !notif.readAt;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@ -103,8 +104,8 @@ function NotificationRow({
|
|||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#f5f5f5',
|
borderBottomColor: colors.border,
|
||||||
backgroundColor: isUnread ? '#fff7ed' : '#fff',
|
backgroundColor: isUnread ? colors.surface : colors.bg,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Pure-Icon — KEIN bg-Circle (User-Wunsch: kein extra Rand). */}
|
{/* Pure-Icon — KEIN bg-Circle (User-Wunsch: kein extra Rand). */}
|
||||||
@ -117,7 +118,7 @@ function NotificationRow({
|
|||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
|
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}
|
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: colors.text }}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{notif.actorName}
|
{notif.actorName}
|
||||||
@ -127,7 +128,7 @@ function NotificationRow({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#525252',
|
color: colors.textMuted,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
|
|||||||
@ -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';
|
||||||
@ -16,8 +17,10 @@ import {
|
|||||||
} from '@expo-google-fonts/nunito';
|
} from '@expo-google-fonts/nunito';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { useThemeStore } from '../stores/theme';
|
import { useThemeStore } from '../stores/theme';
|
||||||
|
import { useColors } from '../lib/theme';
|
||||||
import { useLanguageStore } from '../stores/language';
|
import { useLanguageStore } from '../stores/language';
|
||||||
import { BrandSplash } from '../components/BrandSplash';
|
import { BrandSplash } from '../components/BrandSplash';
|
||||||
|
import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet';
|
||||||
import '../lib/i18n'; // i18next-Init via Side-Effect
|
import '../lib/i18n'; // i18next-Init via Side-Effect
|
||||||
import '../global.css';
|
import '../global.css';
|
||||||
|
|
||||||
@ -44,7 +47,9 @@ const queryClient = new QueryClient({
|
|||||||
function RootLayoutInner() {
|
function RootLayoutInner() {
|
||||||
const { loading, init } = useAuthStore();
|
const { loading, init } = useAuthStore();
|
||||||
const initTheme = useThemeStore((s) => s.init);
|
const initTheme = useThemeStore((s) => s.init);
|
||||||
|
const colorScheme = useThemeStore((s) => s.colorScheme);
|
||||||
const initLanguage = useLanguageStore((s) => s.init);
|
const initLanguage = useLanguageStore((s) => s.init);
|
||||||
|
const colors = useColors();
|
||||||
const [fontsLoaded] = useFonts({
|
const [fontsLoaded] = useFonts({
|
||||||
Nunito_400Regular,
|
Nunito_400Regular,
|
||||||
Nunito_600SemiBold,
|
Nunito_600SemiBold,
|
||||||
@ -70,12 +75,13 @@ function RootLayoutInner() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StatusBar style="dark" />
|
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||||
|
<DeviceLimitReachedSheet />
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
animation: 'slide_from_right',
|
animation: 'slide_from_right',
|
||||||
contentStyle: { backgroundColor: '#ffffff' },
|
contentStyle: { backgroundColor: colors.bg },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack.Screen name="index" />
|
<Stack.Screen name="index" />
|
||||||
@ -153,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>
|
||||||
@ -160,6 +167,7 @@ export default function RootLayout() {
|
|||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</ActionSheetProvider>
|
</ActionSheetProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</KeyboardProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,10 +15,11 @@ import { useEffect } from 'react';
|
|||||||
import { View, ActivityIndicator } from 'react-native';
|
import { View, ActivityIndicator } from 'react-native';
|
||||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { colors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
export default function AuthCallback() {
|
export default function AuthCallback() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const colors = useColors();
|
||||||
const params = useLocalSearchParams<{ access_token?: string; refresh_token?: string }>();
|
const params = useLocalSearchParams<{ access_token?: string; refresh_token?: string }>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -50,7 +51,7 @@ export default function AuthCallback() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#ffffff' }}>
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg }}>
|
||||||
<ActivityIndicator size="large" color={colors.brandOrange} />
|
<ActivityIndicator size="large" color={colors.brandOrange} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,10 +3,11 @@ import { View, Text, ScrollView, Pressable } from 'react-native';
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { colors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
|
|
||||||
export default function DebugScreen() {
|
export default function DebugScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const colors = useColors();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!__DEV__) {
|
if (!__DEV__) {
|
||||||
@ -15,11 +16,11 @@ export default function DebugScreen() {
|
|||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
if (!__DEV__) {
|
if (!__DEV__) {
|
||||||
return <View style={{ flex: 1, backgroundColor: '#ffffff' }} />;
|
return <View style={{ flex: 1, backgroundColor: colors.bg }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#ffffff' }} edges={['top']}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }} edges={['top']}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
@ -48,7 +49,7 @@ export default function DebugScreen() {
|
|||||||
<Ionicons name="chevron-back" size={26} color={colors.text} />
|
<Ionicons name="chevron-back" size={26} color={colors.text} />
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={{ fontSize: 20, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
|
<Text style={{ fontSize: 20, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
|
||||||
Debug
|
Debug
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -119,10 +120,11 @@ function DebugStub({
|
|||||||
subtitle: string;
|
subtitle: string;
|
||||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||||
}) {
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#fafafa',
|
backgroundColor: colors.surface,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: 'rgba(0,0,0,0.05)',
|
borderColor: 'rgba(0,0,0,0.05)',
|
||||||
@ -139,19 +141,19 @@ function DebugStub({
|
|||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderRadius: 11,
|
borderRadius: 11,
|
||||||
backgroundColor: '#e5e7eb',
|
backgroundColor: colors.surfaceElevated,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name={icon} size={18} color="#525252" />
|
<Ionicons name={icon} size={18} color={colors.textMuted} />
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={{ fontSize: 14, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>{title}</Text>
|
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold' }}>{title}</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
marginTop: 3,
|
marginTop: 3,
|
||||||
lineHeight: 17,
|
lineHeight: 17,
|
||||||
|
|||||||
382
apps/rebreak-native/app/devices.tsx
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useColors } from '../lib/theme';
|
||||||
|
import { useDevicesStore, type UserDevice } from '../stores/devices';
|
||||||
|
import { AppHeader } from '../components/AppHeader';
|
||||||
|
|
||||||
|
function platformIcon(
|
||||||
|
platform: string
|
||||||
|
): React.ComponentProps<typeof Ionicons>['name'] {
|
||||||
|
if (platform === 'ios') return 'logo-apple';
|
||||||
|
if (platform === 'android') return 'logo-android';
|
||||||
|
return 'phone-portrait-outline';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastSeen(iso: string, t: (k: string, o?: any) => string): string {
|
||||||
|
const ms = Date.now() - new Date(iso).getTime();
|
||||||
|
const min = Math.floor(ms / 60_000);
|
||||||
|
if (min < 1) return t('settings.devices_just_now');
|
||||||
|
if (min < 60) return t('settings.devices_mins_ago', { count: min });
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return t('settings.devices_hours_ago', { count: hr });
|
||||||
|
const day = Math.floor(hr / 24);
|
||||||
|
if (day < 30) return t('settings.devices_days_ago', { count: day });
|
||||||
|
return new Date(iso).toLocaleDateString(Platform.OS === 'ios' ? undefined : 'de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSince(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString(Platform.OS === 'ios' ? undefined : 'de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceRow({
|
||||||
|
device,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
device: UserDevice;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
|
||||||
|
function confirmRemove() {
|
||||||
|
Alert.alert(
|
||||||
|
t('settings.devices_remove_title'),
|
||||||
|
t('settings.devices_remove_desc'),
|
||||||
|
[
|
||||||
|
{ text: t('common.cancel'), style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: t('settings.devices_remove_confirm'),
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => onRemove(device.id),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={platformIcon(device.platform)}
|
||||||
|
size={20}
|
||||||
|
color={colors.text}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
flexShrink: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{device.name ?? device.model ?? device.platform}
|
||||||
|
</Text>
|
||||||
|
{device.isCurrent ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: 'rgba(249,115,22,0.12)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: colors.brandOrange,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.devices_this_device')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{device.model &&
|
||||||
|
device.name &&
|
||||||
|
!device.name.includes(device.model) ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginTop: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{device.model}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Ionicons name="time-outline" size={11} color={colors.textMuted} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatLastSeen(device.lastSeenAt, t)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Ionicons name="link-outline" size={11} color={colors.textMuted} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.devices_since')} {formatSince(device.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!device.isCurrent ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={confirmRemove}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={18} color={colors.error} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevicesScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const { devices, maxDevices, plan, loading, loadDevices, removeDevice } =
|
||||||
|
useDevicesStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDevices();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const atLimit = devices.length >= maxDevices;
|
||||||
|
const fillRatio = Math.min(1, devices.length / Math.max(1, maxDevices));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
||||||
|
<AppHeader showBack title={t('settings.devices_page_title')} />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: insets.bottom + 40,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Slot counter card */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.04,
|
||||||
|
shadowRadius: 3,
|
||||||
|
elevation: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.devices_slots')}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: atLimit
|
||||||
|
? 'rgba(239,68,68,0.3)'
|
||||||
|
: 'rgba(0,0,0,0.08)',
|
||||||
|
backgroundColor: atLimit
|
||||||
|
? 'rgba(239,68,68,0.08)'
|
||||||
|
: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
color: atLimit ? colors.error : colors.textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{devices.length} / {maxDevices}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.devices_slots_desc', { plan: plan.toUpperCase() })}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 5,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.06)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 5,
|
||||||
|
borderRadius: 4,
|
||||||
|
width: `${fillRatio * 100}%`,
|
||||||
|
backgroundColor: atLimit
|
||||||
|
? colors.error
|
||||||
|
: fillRatio >= 0.8
|
||||||
|
? '#f59e0b'
|
||||||
|
: colors.brandOrange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Device list card */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 14,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.04,
|
||||||
|
shadowRadius: 3,
|
||||||
|
elevation: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingVertical: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator color={colors.brandOrange} />
|
||||||
|
</View>
|
||||||
|
) : devices.length === 0 ? (
|
||||||
|
<View style={{ paddingVertical: 32, alignItems: 'center' }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.devices_empty')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
devices.map((device, i) => (
|
||||||
|
<View
|
||||||
|
key={device.id}
|
||||||
|
style={{
|
||||||
|
borderBottomWidth: i < devices.length - 1 ? 1 : 0,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeviceRow device={device} onRemove={removeDevice} />
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginTop: 12,
|
||||||
|
marginHorizontal: 4,
|
||||||
|
lineHeight: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.devices_hint')}
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -18,9 +18,10 @@ 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 { colors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
|
|
||||||
type DmHistoryResponse = {
|
type DmHistoryResponse = {
|
||||||
partner: {
|
partner: {
|
||||||
@ -52,6 +53,8 @@ export default function DmScreen() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const colors = useColors();
|
||||||
|
const styles = makeStyles(colors);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const flatRef = useRef<FlatList>(null);
|
const flatRef = useRef<FlatList>(null);
|
||||||
const [myUserId, setMyUserId] = useState<string | undefined>(undefined);
|
const [myUserId, setMyUserId] = useState<string | undefined>(undefined);
|
||||||
@ -234,12 +237,12 @@ export default function DmScreen() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable style={styles.backBtn} onPress={() => router.back()} hitSlop={8}>
|
<Pressable style={styles.backBtn} onPress={() => router.back()} hitSlop={8}>
|
||||||
<Ionicons name="chevron-back" size={22} color="#0a0a0a" />
|
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<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()}
|
||||||
@ -260,7 +263,7 @@ export default function DmScreen() {
|
|||||||
>
|
>
|
||||||
{isLoading && messages.length === 0 ? (
|
{isLoading && messages.length === 0 ? (
|
||||||
<View style={styles.loadingBox}>
|
<View style={styles.loadingBox}>
|
||||||
<ActivityIndicator color={colors.brandOrange} />
|
<ActivityIndicator color="#007AFF" />
|
||||||
</View>
|
</View>
|
||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<View style={styles.loadingBox}>
|
<View style={styles.loadingBox}>
|
||||||
@ -302,23 +305,24 @@ export default function DmScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||||
container: { flex: 1, backgroundColor: '#fafafa' },
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bg },
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: colors.bg,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
borderBottomColor: '#e5e5e5',
|
borderBottomColor: colors.border,
|
||||||
},
|
},
|
||||||
backBtn: {
|
backBtn: {
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
@ -333,7 +337,7 @@ const styles = StyleSheet.create({
|
|||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
backgroundColor: '#e5e5e5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@ -343,12 +347,12 @@ const styles = StyleSheet.create({
|
|||||||
headerAvatarInitials: {
|
headerAvatarInitials: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
headerName: {
|
headerName: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#171717',
|
color: colors.text,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
loadingBox: {
|
loadingBox: {
|
||||||
@ -359,7 +363,8 @@ const styles = StyleSheet.create({
|
|||||||
emptyText: {
|
emptyText: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#a3a3a3',
|
color: colors.textMuted,
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
TetrisGame,
|
TetrisGame,
|
||||||
} from '../components/urge/UrgeGames';
|
} from '../components/urge/UrgeGames';
|
||||||
import { GameCard } from '../components/games/GameCard';
|
import { GameCard } from '../components/games/GameCard';
|
||||||
import { colors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
type GameStat = { avgStars: number; count: number };
|
type GameStat = { avgStars: number; count: number };
|
||||||
@ -31,6 +31,7 @@ type LastScore = { game: GameType; score: number } | null;
|
|||||||
export default function GamesScreen() {
|
export default function GamesScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
const [active, setActive] = useState<GameType | null>(null);
|
const [active, setActive] = useState<GameType | null>(null);
|
||||||
const [lastScore, setLastScore] = useState<LastScore>(null);
|
const [lastScore, setLastScore] = useState<LastScore>(null);
|
||||||
const [gameStats, setGameStats] = useState<GameStats>(EMPTY_STATS);
|
const [gameStats, setGameStats] = useState<GameStats>(EMPTY_STATS);
|
||||||
@ -70,7 +71,7 @@ export default function GamesScreen() {
|
|||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#ffffff' }} edges={['top']}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }} edges={['top']}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
@ -79,7 +80,7 @@ export default function GamesScreen() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: 'rgba(0,0,0,0.06)',
|
borderBottomColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
@ -102,9 +103,9 @@ export default function GamesScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
|
{/* 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>
|
||||||
|
|
||||||
@ -127,7 +128,7 @@ export default function GamesScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#ffffff' }} edges={['top']}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }} edges={['top']}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
@ -137,7 +138,7 @@ export default function GamesScreen() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: 'rgba(0,0,0,0.06)',
|
borderBottomColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
@ -156,7 +157,7 @@ export default function GamesScreen() {
|
|||||||
<Ionicons name="chevron-back" size={26} color={colors.text} />
|
<Ionicons name="chevron-back" size={26} color={colors.text} />
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={{ fontSize: 20, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
|
<Text style={{ fontSize: 20, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
|
||||||
{t('games.title')}
|
{t('games.title')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -169,7 +170,7 @@ export default function GamesScreen() {
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
lineHeight: 19,
|
lineHeight: 19,
|
||||||
marginBottom: 18,
|
marginBottom: 18,
|
||||||
@ -232,7 +233,7 @@ export default function GamesScreen() {
|
|||||||
style={{
|
style={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: '#a3a3a3',
|
color: colors.textMuted,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
marginTop: 24,
|
marginTop: 24,
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
|
|||||||
@ -23,23 +23,17 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
|||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { Audio } from 'expo-av';
|
import { Audio } from 'expo-av';
|
||||||
import * as FileSystem from 'expo-file-system';
|
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import Constants from 'expo-constants';
|
import Constants from 'expo-constants';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { RiveAvatar, type Emotion } from '../components/RiveAvatar';
|
import { RiveAvatar, type Emotion } from '../components/RiveAvatar';
|
||||||
import { useCoachStore, type Message } from '../stores/coach';
|
import { useCoachStore, type Message } from '../stores/coach';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { colors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
|
import { useThemeStore } from '../stores/theme';
|
||||||
const EMPATHY_RE = /schwer|rückfall|traurig|schlimm|hoffnungslos|hilf|verloren|scham|schuld|verzweifelt/i;
|
import { detectEmotion } from '../lib/lyraResponse';
|
||||||
const HAPPY_RE = /toll|super|geschafft|stark|glückwunsch|stolz|fantastisch|weiter so|prima|gut gemacht/i;
|
|
||||||
|
|
||||||
function detectEmotion(text: string): Emotion {
|
|
||||||
if (HAPPY_RE.test(text)) return 'happy';
|
|
||||||
if (EMPATHY_RE.test(text)) return 'empathy';
|
|
||||||
return 'idle';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(s: number): string {
|
function formatDuration(s: number): string {
|
||||||
const m = Math.floor(s / 60);
|
const m = Math.floor(s / 60);
|
||||||
@ -55,6 +49,7 @@ function formatTimestamp(date: Date): string {
|
|||||||
// Standard-Spinner — kein zweiter Rive-Avatar (der ist bereits im topBar oben).
|
// Standard-Spinner — kein zweiter Rive-Avatar (der ist bereits im topBar oben).
|
||||||
|
|
||||||
function LoadingPulse() {
|
function LoadingPulse() {
|
||||||
|
const colors = useColors();
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
||||||
<ActivityIndicator size="large" color={colors.textMuted} />
|
<ActivityIndicator size="large" color={colors.textMuted} />
|
||||||
@ -65,6 +60,7 @@ function LoadingPulse() {
|
|||||||
// ── Thinking dots ─────────────────────────────────────────────────────────────
|
// ── Thinking dots ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ThinkingDots() {
|
function ThinkingDots() {
|
||||||
|
const colors = useColors();
|
||||||
const anim = useRef([new Animated.Value(0), new Animated.Value(0), new Animated.Value(0)]).current;
|
const anim = useRef([new Animated.Value(0), new Animated.Value(0), new Animated.Value(0)]).current;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -88,7 +84,7 @@ function ThinkingDots() {
|
|||||||
key={i}
|
key={i}
|
||||||
style={[
|
style={[
|
||||||
styles.thinkingDot,
|
styles.thinkingDot,
|
||||||
{ transform: [{ translateY: a.interpolate({ inputRange: [0, 1], outputRange: [0, -5] }) }] },
|
{ backgroundColor: colors.border, transform: [{ translateY: a.interpolate({ inputRange: [0, 1], outputRange: [0, -5] }) }] },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -137,13 +133,14 @@ function MessageRow({
|
|||||||
item: MessageWithMeta;
|
item: MessageWithMeta;
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
}) {
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
const isUser = item.role === 'user';
|
const isUser = item.role === 'user';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.msgRow, isUser ? styles.msgRowUser : styles.msgRowAssistant]}>
|
<View style={[styles.msgRow, isUser ? styles.msgRowUser : styles.msgRowAssistant]}>
|
||||||
<View style={[styles.bubbleCol, isUser ? styles.bubbleColUser : styles.bubbleColAssistant]}>
|
<View style={[styles.bubbleCol, isUser ? styles.bubbleColUser : styles.bubbleColAssistant]}>
|
||||||
<View style={[styles.bubble, isUser ? styles.bubbleUser : styles.bubbleAssistant]}>
|
<View style={[styles.bubble, isUser ? styles.bubbleUser : [styles.bubbleAssistant, { backgroundColor: colors.surfaceElevated }]]}>
|
||||||
<Text style={[styles.bubbleText, isUser ? styles.bubbleTextUser : styles.bubbleTextAssistant]}>
|
<Text style={[styles.bubbleText, isUser ? styles.bubbleTextUser : [styles.bubbleTextAssistant, { color: colors.text }]]}>
|
||||||
{item.content}
|
{item.content}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -151,11 +148,11 @@ function MessageRow({
|
|||||||
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
|
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
|
||||||
{item.feedbackSaved && (
|
{item.feedbackSaved && (
|
||||||
<>
|
<>
|
||||||
<Ionicons name="checkmark-circle" size={11} color="#16a34a" />
|
<Ionicons name="checkmark-circle" size={11} color={colors.success} />
|
||||||
<Text style={styles.feedbackText}>{t('coach.feedback_saved')}</Text>
|
<Text style={[styles.feedbackText, { color: colors.success }]}>{t('coach.feedback_saved')}</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Text style={styles.timestampText}>{formatTimestamp(item.timestamp)}</Text>
|
<Text style={[styles.timestampText, { color: colors.textMuted }]}>{formatTimestamp(item.timestamp)}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@ -169,6 +166,8 @@ export default function CoachScreen() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const flatRef = useRef<FlatList>(null);
|
const flatRef = useRef<FlatList>(null);
|
||||||
|
const colors = useColors();
|
||||||
|
const colorScheme = useThemeStore((s) => s.colorScheme);
|
||||||
|
|
||||||
// Reaktive Slices — nur was UI-relevant ist (Re-Render bei diesen).
|
// Reaktive Slices — nur was UI-relevant ist (Re-Render bei diesen).
|
||||||
const messages = useCoachStore((s) => s.messages);
|
const messages = useCoachStore((s) => s.messages);
|
||||||
@ -357,14 +356,17 @@ export default function CoachScreen() {
|
|||||||
try {
|
try {
|
||||||
const apiBase = Constants.expoConfig?.extra?.apiUrl as string;
|
const apiBase = Constants.expoConfig?.extra?.apiUrl as string;
|
||||||
const session = (await supabase.auth.getSession()).data.session;
|
const session = (await supabase.auth.getSession()).data.session;
|
||||||
const ttsRes = await fetch(`${apiBase}/api/coach/speak-openai`, {
|
const speakUrl = `${apiBase}/api/coach/speak`;
|
||||||
|
console.log('[tts] POST', speakUrl, 'text-len:', res.message.length);
|
||||||
|
const ttsRes = await fetch(speakUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}),
|
...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ text: res.message, locale: i18n.language }),
|
body: JSON.stringify({ text: res.message, mode: 'chat' }),
|
||||||
});
|
});
|
||||||
|
console.log('[tts] response status:', ttsRes.status, 'content-type:', ttsRes.headers.get('content-type'));
|
||||||
if (ttsRes.ok) {
|
if (ttsRes.ok) {
|
||||||
// Backend streamt audio/mpeg direkt — als ArrayBuffer holen, base64-encoden, in tmp-File schreiben.
|
// Backend streamt audio/mpeg direkt — als ArrayBuffer holen, base64-encoden, in tmp-File schreiben.
|
||||||
const buffer = await ttsRes.arrayBuffer();
|
const buffer = await ttsRes.arrayBuffer();
|
||||||
@ -538,17 +540,17 @@ export default function CoachScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={[styles.container, { backgroundColor: colors.bg }]} edges={['top']}>
|
||||||
{/* Backdrop hinter topBar — verhindert dass Chat-Texte hinter dem Avatar */}
|
{/* Backdrop hinter topBar — verhindert dass Chat-Texte hinter dem Avatar */}
|
||||||
{/* sichtbar werden ("kollidieren mit Lyra schriftzug"). */}
|
{/* sichtbar werden ("kollidieren mit Lyra schriftzug"). */}
|
||||||
<View
|
<View
|
||||||
style={[styles.topBarBackdrop, { height: insets.top + 170 }]}
|
style={[styles.topBarBackdrop, { height: insets.top + 170, backgroundColor: colors.bg }]}
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Floating header — no bar, avatar + 2 icon buttons hover over chat */}
|
{/* Floating header — no bar, avatar + 2 icon buttons hover over chat */}
|
||||||
<View style={[styles.topBar, { top: insets.top + 6 }]}>
|
<View style={[styles.topBar, { top: insets.top + 6 }]}>
|
||||||
<Pressable style={styles.backBtn} onPress={() => router.replace('/(app)' as never)} hitSlop={12}>
|
<Pressable style={[styles.backBtn, { backgroundColor: colorScheme === 'dark' ? 'rgba(44,44,46,0.92)' : 'rgba(255,255,255,0.92)' }]} onPress={() => router.replace('/(app)' as never)} hitSlop={12}>
|
||||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
@ -558,7 +560,7 @@ export default function CoachScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.avatarMeta}>
|
<View style={styles.avatarMeta}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
<Text style={styles.avatarName}>{t('coach.lyra')}</Text>
|
<Text style={[styles.avatarName, { color: colors.text }]}>{t('coach.lyra')}</Text>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
@ -585,7 +587,7 @@ export default function CoachScreen() {
|
|||||||
<View style={styles.speakingRow}>
|
<View style={styles.speakingRow}>
|
||||||
<VoiceBars count={5} baseColor={colors.brandOrange} />
|
<VoiceBars count={5} baseColor={colors.brandOrange} />
|
||||||
<Text style={[styles.speakingLabel, { color: colors.brandOrange }]}>{t('coach.speaking')}</Text>
|
<Text style={[styles.speakingLabel, { color: colors.brandOrange }]}>{t('coach.speaking')}</Text>
|
||||||
<Pressable style={styles.stopBtn} onPress={stopSpeaking} hitSlop={6}>
|
<Pressable style={[styles.stopBtn, { backgroundColor: colors.surfaceElevated }]} onPress={stopSpeaking} hitSlop={6}>
|
||||||
<Ionicons name="square" size={10} color={colors.brandOrange} />
|
<Ionicons name="square" size={10} color={colors.brandOrange} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@ -593,7 +595,7 @@ export default function CoachScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Pressable style={styles.newChatBtn} onPress={handleNewChat} hitSlop={12}>
|
<Pressable style={[styles.newChatBtn, { backgroundColor: colorScheme === 'dark' ? 'rgba(44,44,46,0.92)' : 'rgba(255,255,255,0.92)' }]} onPress={handleNewChat} hitSlop={12}>
|
||||||
<Ionicons name="refresh-outline" size={22} color={colors.textMuted} />
|
<Ionicons name="refresh-outline" size={22} color={colors.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@ -625,7 +627,7 @@ export default function CoachScreen() {
|
|||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
thinking ? (
|
thinking ? (
|
||||||
<View style={styles.msgRowAssistant}>
|
<View style={styles.msgRowAssistant}>
|
||||||
<View style={styles.bubbleAssistant}>
|
<View style={[styles.bubbleAssistant, { backgroundColor: colors.surfaceElevated }]}>
|
||||||
<ThinkingDots />
|
<ThinkingDots />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@ -643,7 +645,7 @@ export default function CoachScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Input bar */}
|
{/* Input bar */}
|
||||||
<View style={[styles.inputBar, { paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom) }]}>
|
<View style={[styles.inputBar, { paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom), backgroundColor: colors.bg, borderTopColor: colors.border }]}>
|
||||||
{isRecording ? (
|
{isRecording ? (
|
||||||
<View style={styles.recordingContainer}>
|
<View style={styles.recordingContainer}>
|
||||||
<Pressable style={styles.cancelBtn} onPress={cancelRecording}>
|
<Pressable style={styles.cancelBtn} onPress={cancelRecording}>
|
||||||
@ -658,13 +660,13 @@ export default function CoachScreen() {
|
|||||||
) : isTranscribing ? (
|
) : isTranscribing ? (
|
||||||
<View style={styles.transcribingRow}>
|
<View style={styles.transcribingRow}>
|
||||||
<Ionicons name="sync" size={16} color={colors.textMuted} />
|
<Ionicons name="sync" size={16} color={colors.textMuted} />
|
||||||
<Text style={styles.transcribingText}>{t('coach.transcribing')}</Text>
|
<Text style={[styles.transcribingText, { color: colors.textMuted }]}>{t('coach.transcribing')}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.textInput}
|
style={[styles.textInput, { backgroundColor: colors.surfaceElevated, color: colors.text }]}
|
||||||
placeholder={t('coach.placeholder')}
|
placeholder={t('coach.placeholder')}
|
||||||
placeholderTextColor="#a3a3a3"
|
placeholderTextColor={colors.textMuted}
|
||||||
value={input}
|
value={input}
|
||||||
onChangeText={handleInputChange}
|
onChangeText={handleInputChange}
|
||||||
multiline
|
multiline
|
||||||
@ -676,7 +678,7 @@ export default function CoachScreen() {
|
|||||||
|
|
||||||
{!isTranscribing && (
|
{!isTranscribing && (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.micBtn, isRecording && styles.micBtnActive, thinking && styles.micBtnDisabled]}
|
style={[styles.micBtn, { backgroundColor: colors.surfaceElevated }, isRecording && styles.micBtnActive, thinking && styles.micBtnDisabled]}
|
||||||
onPressIn={onMicDown}
|
onPressIn={onMicDown}
|
||||||
onPressOut={onMicUp}
|
onPressOut={onMicUp}
|
||||||
disabled={thinking}
|
disabled={thinking}
|
||||||
@ -763,7 +765,7 @@ const styles = StyleSheet.create({
|
|||||||
statusLabel: {
|
statusLabel: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: colors.textMuted,
|
color: '#737373',
|
||||||
},
|
},
|
||||||
speakingRow: {
|
speakingRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { View, Text, ScrollView, Pressable, Image } from 'react-native';
|
|||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { colors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
import { resolveAvatar } from '../../lib/resolveAvatar';
|
import { resolveAvatar } from '../../lib/resolveAvatar';
|
||||||
import type { Plan } from '../../hooks/useUserPlan';
|
import type { Plan } from '../../hooks/useUserPlan';
|
||||||
|
|
||||||
@ -52,13 +52,14 @@ type StatProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function ForeignStat({ value, label }: StatProps) {
|
function ForeignStat({ value, label }: StatProps) {
|
||||||
|
const colors = useColors();
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: colors.bg,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e5e5',
|
borderColor: colors.border,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -85,6 +86,7 @@ function ForeignStat({ value, label }: StatProps) {
|
|||||||
export default function ForeignProfileScreen() {
|
export default function ForeignProfileScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const colors = useColors();
|
||||||
const { userId } = useLocalSearchParams<{ userId: string }>();
|
const { userId } = useLocalSearchParams<{ userId: string }>();
|
||||||
const [imageFailed, setImageFailed] = useState(false);
|
const [imageFailed, setImageFailed] = useState(false);
|
||||||
const [isFollowing, setIsFollowing] = useState(DUMMY_FOREIGN.isFollowing);
|
const [isFollowing, setIsFollowing] = useState(DUMMY_FOREIGN.isFollowing);
|
||||||
@ -99,13 +101,13 @@ export default function ForeignProfileScreen() {
|
|||||||
const planStyle = planColors[profile.plan];
|
const planStyle = planColors[profile.plan];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: '#ffffff' }}>
|
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingTop: insets.top,
|
paddingTop: insets.top,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: colors.bg,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#e5e5e5',
|
borderBottomColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@ -147,7 +149,7 @@ export default function ForeignProfileScreen() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: showImage ? '#fafafa' : colors.brandOrange,
|
backgroundColor: showImage ? colors.surface : colors.brandOrange,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showImage ? (
|
{showImage ? (
|
||||||
@ -215,9 +217,9 @@ export default function ForeignProfileScreen() {
|
|||||||
<View style={{
|
<View style={{
|
||||||
paddingVertical: 11,
|
paddingVertical: 11,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: isFollowing ? '#f5f5f5' : colors.brandOrange,
|
backgroundColor: isFollowing ? colors.surfaceElevated : colors.brandOrange,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: isFollowing ? '#e5e5e5' : colors.brandOrange,
|
borderColor: isFollowing ? colors.border : colors.brandOrange,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}>
|
}}>
|
||||||
<Text
|
<Text
|
||||||
@ -244,9 +246,9 @@ export default function ForeignProfileScreen() {
|
|||||||
<View style={{
|
<View style={{
|
||||||
paddingVertical: 11,
|
paddingVertical: 11,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: colors.bg,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e5e5',
|
borderColor: colors.border,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}>
|
}}>
|
||||||
<Text
|
<Text
|
||||||
@ -292,9 +294,9 @@ export default function ForeignProfileScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: colors.bg,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e5e5',
|
borderColor: colors.border,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
padding: 16,
|
padding: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
320
apps/rebreak-native/app/profile/edit.tsx
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Image,
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
// TODO(sdk54): migrate to new expo-file-system class-based API — see Task #14
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
import { HERO_AVATARS, getAvatarUrl } from '../../lib/avatars';
|
||||||
|
import { resolveAvatar } from '../../lib/resolveAvatar';
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
|
import { useMe } from '../../hooks/useMe';
|
||||||
|
|
||||||
|
export default function ProfileEditScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const { me, reload } = useMe();
|
||||||
|
|
||||||
|
const INPUT_STYLE = {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 22,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderRadius: 12,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const [nickname, setNickname] = useState(me?.nickname ?? '');
|
||||||
|
const [avatarId, setAvatarId] = useState<string | null>(me?.avatar ?? null);
|
||||||
|
const [photoUri, setPhotoUri] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const displayAvatar = photoUri ?? avatarId;
|
||||||
|
|
||||||
|
async function pickPhoto() {
|
||||||
|
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
Alert.alert(
|
||||||
|
t('profile.edit_photo_perm_title'),
|
||||||
|
t('profile.edit_photo_perm_desc'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [1, 1],
|
||||||
|
quality: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || !result.assets[0]) return;
|
||||||
|
|
||||||
|
const uri = result.assets[0].uri;
|
||||||
|
setPhotoUri(uri);
|
||||||
|
setAvatarId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!nickname.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let finalAvatar: string | null = avatarId;
|
||||||
|
|
||||||
|
if (photoUri) {
|
||||||
|
setUploading(true);
|
||||||
|
const base64 = await FileSystem.readAsStringAsync(photoUri, {
|
||||||
|
encoding: FileSystem.EncodingType.Base64,
|
||||||
|
});
|
||||||
|
const ext = photoUri.toLowerCase().endsWith('.png') ? 'png' : 'jpeg';
|
||||||
|
const dataUrl = `data:image/${ext};base64,${base64}`;
|
||||||
|
const res = await apiFetch<{ url: string }>('/api/avatar/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { dataUrl },
|
||||||
|
});
|
||||||
|
finalAvatar = res.url;
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiFetch('/api/auth/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: {
|
||||||
|
nickname: nickname.trim(),
|
||||||
|
...(finalAvatar !== me?.avatar ? { avatar: finalAvatar } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
reload();
|
||||||
|
router.back();
|
||||||
|
} catch (err: any) {
|
||||||
|
setUploading(false);
|
||||||
|
console.error('[profile/edit] save failed:', err?.message ?? err);
|
||||||
|
Alert.alert(t('common.error'), err?.message ?? t('common.unknown_error'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPreview = photoUri
|
||||||
|
? photoUri
|
||||||
|
: resolveAvatar(avatarId, nickname || (me?.nickname ?? ''));
|
||||||
|
|
||||||
|
const hasChanges =
|
||||||
|
nickname.trim() !== (me?.nickname ?? '') ||
|
||||||
|
photoUri !== null ||
|
||||||
|
avatarId !== me?.avatar;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ flex: 1, backgroundColor: colors.bg }}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: insets.top + 8,
|
||||||
|
paddingBottom: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.back()}
|
||||||
|
hitSlop={10}
|
||||||
|
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, marginRight: 12 })}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={{ flex: 1, fontSize: 17, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
|
||||||
|
{t('profile.edit_title')}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={save}
|
||||||
|
disabled={saving || !hasChanges || !nickname.trim()}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
opacity: pressed || saving || !hasChanges || !nickname.trim() ? 0.4 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.brandOrange} />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.brandOrange,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('profile.edit_save')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ paddingBottom: insets.bottom + 32 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Avatar preview + pick-photo button */}
|
||||||
|
<View style={{ alignItems: 'center', paddingTop: 32, paddingBottom: 24 }}>
|
||||||
|
<View style={{ position: 'relative' }}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: resolvedPreview }}
|
||||||
|
style={{
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
borderRadius: 48,
|
||||||
|
backgroundColor: '#e5e5e5',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.border,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{uploading ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
borderRadius: 48,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.45)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={pickPhoto}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
marginTop: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
opacity: pressed ? 0.5 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="camera-outline" size={18} color={colors.brandOrange} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.brandOrange,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('profile.edit_photo_cta')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Preset avatars */}
|
||||||
|
<View style={{ paddingHorizontal: 16, marginBottom: 24 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('profile.edit_preset_label').toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
|
||||||
|
{HERO_AVATARS.map((avatar) => {
|
||||||
|
const isSelected = !photoUri && avatarId === avatar.id;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={avatar.id}
|
||||||
|
onPress={() => {
|
||||||
|
setAvatarId(avatar.id);
|
||||||
|
setPhotoUri(null);
|
||||||
|
}}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: getAvatarUrl(avatar.id) }}
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
borderWidth: isSelected ? 3 : 1.5,
|
||||||
|
borderColor: isSelected ? colors.brandOrange : colors.border,
|
||||||
|
opacity: isSelected ? 1 : 0.55,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<View style={{ height: 1, backgroundColor: colors.border, marginHorizontal: 16, marginBottom: 24 }} />
|
||||||
|
|
||||||
|
{/* Nickname */}
|
||||||
|
<View style={{ paddingHorizontal: 16 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('profile.edit_nickname_label').toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={INPUT_STYLE}
|
||||||
|
value={nickname}
|
||||||
|
onChangeText={setNickname}
|
||||||
|
placeholder={t('auth.nicknamePlaceholder')}
|
||||||
|
placeholderTextColor="#a3a3a3"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
maxLength={32}
|
||||||
|
returnKeyType="done"
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('profile.edit_nickname_hint')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,99 +1,29 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { View, ScrollView, Text, Alert, findNodeHandle, UIManager } from 'react-native';
|
import { View, ScrollView, Text, Alert, findNodeHandle, UIManager } from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
import { AppHeader } from '../../components/AppHeader';
|
import { AppHeader } from '../../components/AppHeader';
|
||||||
import { ProfileHeader, type AuthProvider } from '../../components/profile/ProfileHeader';
|
import { ProfileHeader, type AuthProvider } from '../../components/profile/ProfileHeader';
|
||||||
import { StatsBar } from '../../components/profile/StatsBar';
|
import { StatsBar } from '../../components/profile/StatsBar';
|
||||||
import { ApprovedDomainsList, type ApprovedDomain } from '../../components/profile/ApprovedDomainsList';
|
import { ApprovedDomainsList } from '../../components/profile/ApprovedDomainsList';
|
||||||
import { StreakSection, type CooldownEntry } from '../../components/profile/StreakSection';
|
import { StreakSection, type CooldownEntry } from '../../components/profile/StreakSection';
|
||||||
import { UrgeStatsCard, type HelpedByEntry } from '../../components/profile/UrgeStatsCard';
|
import { UrgeStatsCard, type HelpedByEntry } from '../../components/profile/UrgeStatsCard';
|
||||||
import { DemographicsAccordion, type Demographics } from '../../components/profile/DemographicsAccordion';
|
import { DemographicsAccordion, type Demographics } from '../../components/profile/DemographicsAccordion';
|
||||||
import { DigaMissionBanner } from '../../components/profile/DigaMissionBanner';
|
import { DigaMissionBanner } from '../../components/profile/DigaMissionBanner';
|
||||||
import { colors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
import type { Plan } from '../../hooks/useUserPlan';
|
import type { Plan } from '../../hooks/useUserPlan';
|
||||||
import { useMe } from '../../hooks/useMe';
|
import { useMe } from '../../hooks/useMe';
|
||||||
import { useAuthStore } from '../../stores/auth';
|
import { useAuthStore } from '../../stores/auth';
|
||||||
|
import {
|
||||||
|
useSocialStats,
|
||||||
|
useApprovedDomains,
|
||||||
|
useCooldownHistory,
|
||||||
|
useSosInsights,
|
||||||
|
useDemographics,
|
||||||
|
} from '../../hooks/useProfileData';
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
|
|
||||||
// TODO Phase C: GET /api/profile/me — aggregate endpoint (profile + stats + streak +
|
const EMPTY_COOLDOWNS: CooldownEntry[] = [];
|
||||||
// recentCooldowns + demographics + sosInsights). Until backend live:
|
|
||||||
// - Core User-Felder (nickname/email/avatar/plan) kommen aus useMe-Hook (live)
|
|
||||||
// - Stats/Streak/Cooldowns/Demographics bleiben dummy
|
|
||||||
const DUMMY_PROFILE_FALLBACK = {
|
|
||||||
memberSince: 'April 2026', // TODO Phase C: aus profile.created_at
|
|
||||||
provider: 'email' as AuthProvider, // TODO Phase C: aus user.app_metadata.provider
|
|
||||||
};
|
|
||||||
|
|
||||||
const DUMMY_STATS = {
|
|
||||||
postsCount: 12,
|
|
||||||
followersCount: 47,
|
|
||||||
// Approved Domains = Community-Beitrag (KEIN Plan-Slot, kein Cap).
|
|
||||||
// Source: domain_submissions WHERE userId=me AND status='approved'.
|
|
||||||
// TODO Phase C: GET /api/profile/me/approved-domains (Endpoint existiert noch NICHT
|
|
||||||
// — gefunden wurden nur admin-side aggregate counts in
|
|
||||||
// backend/server/api/admin/stats.get.ts und backend/server/api/blocklist/stats.get.ts).
|
|
||||||
// Neuer Endpoint nötig: GET /api/profile/me/approved-domains → { count, list[] }.
|
|
||||||
approvedDomainsCount: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DUMMY_STREAK = {
|
|
||||||
currentDays: 23,
|
|
||||||
longestDays: 41,
|
|
||||||
startDate: '14. April 2026',
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: GET /api/profile/me/cooldown-history?cursor=...
|
|
||||||
const DUMMY_COOLDOWNS: CooldownEntry[] = [
|
|
||||||
{
|
|
||||||
id: 'c1',
|
|
||||||
startedAt: '06.05.',
|
|
||||||
durationLabel: '24h',
|
|
||||||
status: 'active',
|
|
||||||
reason: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c2',
|
|
||||||
startedAt: '02.05.',
|
|
||||||
durationLabel: '4h',
|
|
||||||
status: 'cancelled',
|
|
||||||
reason: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c3',
|
|
||||||
startedAt: '18.04.',
|
|
||||||
durationLabel: '16h',
|
|
||||||
status: 'resolved',
|
|
||||||
reason: 'Stress nach Arbeit',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// TODO: GET /api/profile/me/approved-domains
|
|
||||||
const DUMMY_APPROVED_DOMAINS: ApprovedDomain[] = [
|
|
||||||
{ domain: 'tipico.de', approvedAt: '12.04.' },
|
|
||||||
{ domain: 'bwin.com', approvedAt: '15.04.' },
|
|
||||||
{ domain: 'merkur24.com', approvedAt: '20.04.' },
|
|
||||||
{ domain: 'sunmaker.com', approvedAt: '28.04.' },
|
|
||||||
{ domain: 'lottoland.com', approvedAt: '02.05.' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// TODO: GET /api/profile/me/sos-insights
|
|
||||||
const DUMMY_HELPED_BY: HelpedByEntry[] = [
|
|
||||||
{ key: 'breathing', label: 'Atemübung', count: 3 },
|
|
||||||
{ key: 'game', label: 'Spiel', count: 1 },
|
|
||||||
{ key: 'talk', label: 'Reden mit Lyra', count: 1 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// TODO: GET /api/profile/me/demographics — gehört zur me-aggregat-response
|
|
||||||
const DUMMY_DEMOGRAPHICS: Demographics = {
|
|
||||||
birthYear: 1989,
|
|
||||||
gender: 'diverse',
|
|
||||||
maritalStatus: null,
|
|
||||||
employmentStatus: null,
|
|
||||||
shiftWork: null,
|
|
||||||
industry: null,
|
|
||||||
jobTenure: null,
|
|
||||||
bundesland: 'BY',
|
|
||||||
city: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function isDemographicsComplete(d: Demographics): boolean {
|
function isDemographicsComplete(d: Demographics): boolean {
|
||||||
const base =
|
const base =
|
||||||
@ -114,32 +44,90 @@ function isDemographicsComplete(d: Demographics): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_DEMOGRAPHICS: Demographics = {
|
||||||
|
birthYear: null,
|
||||||
|
gender: null,
|
||||||
|
maritalStatus: null,
|
||||||
|
employmentStatus: null,
|
||||||
|
shiftWork: null,
|
||||||
|
industry: null,
|
||||||
|
jobTenure: null,
|
||||||
|
bundesland: null,
|
||||||
|
city: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatMemberSince(isoString: string | undefined): string {
|
||||||
|
if (!isoString) return '';
|
||||||
|
const d = new Date(isoString);
|
||||||
|
return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStreakStartDate(isoString: string | undefined): string {
|
||||||
|
if (!isoString) return '';
|
||||||
|
const d = new Date(isoString);
|
||||||
|
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapHelpedBy(helpedBy: {
|
||||||
|
breathing: number;
|
||||||
|
game: number;
|
||||||
|
talk: number;
|
||||||
|
other: number;
|
||||||
|
}): HelpedByEntry[] {
|
||||||
|
const entries: HelpedByEntry[] = [
|
||||||
|
{ key: 'breathing', label: 'Atemübung', count: helpedBy.breathing },
|
||||||
|
{ key: 'game', label: 'Spiel', count: helpedBy.game },
|
||||||
|
{ key: 'talk', label: 'Reden mit Lyra', count: helpedBy.talk },
|
||||||
|
{ key: 'other', label: 'Sonstiges', count: helpedBy.other },
|
||||||
|
];
|
||||||
|
return entries.filter((e) => e.count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const colors = useColors();
|
||||||
const [bannerDismissed, setBannerDismissed] = useState(false);
|
const [bannerDismissed, setBannerDismissed] = useState(false);
|
||||||
const [demographics, setDemographics] = useState<Demographics>(DUMMY_DEMOGRAPHICS);
|
|
||||||
const [demographicsExpanded, setDemographicsExpanded] = useState(false);
|
const [demographicsExpanded, setDemographicsExpanded] = useState(false);
|
||||||
const { me } = useMe();
|
const { me } = useMe();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
|
const { stats: socialStats } = useSocialStats(me?.id);
|
||||||
|
const { domains: approvedDomainsData } = useApprovedDomains();
|
||||||
|
const { cooldownHistory } = useCooldownHistory();
|
||||||
|
const { sosInsights } = useSosInsights();
|
||||||
|
const {
|
||||||
|
demographics: serverDemographics,
|
||||||
|
withdrawnAt,
|
||||||
|
reload: reloadDemographics,
|
||||||
|
} = useDemographics();
|
||||||
|
|
||||||
|
const demographics: Demographics = serverDemographics ?? EMPTY_DEMOGRAPHICS;
|
||||||
|
|
||||||
const scrollViewRef = useRef<ScrollView | null>(null);
|
const scrollViewRef = useRef<ScrollView | null>(null);
|
||||||
const demographicsAnchorRef = useRef<View | null>(null);
|
const demographicsAnchorRef = useRef<View | null>(null);
|
||||||
|
|
||||||
// Live-Daten aus DB (für Avatar / Nickname / Plan / Email).
|
|
||||||
// Provider-Detection: user.app_metadata.provider vom Supabase-OAuth-Flow.
|
|
||||||
const provider: AuthProvider =
|
const provider: AuthProvider =
|
||||||
((user?.app_metadata as { provider?: string } | undefined)?.provider as AuthProvider) ?? 'email';
|
((user?.app_metadata as { provider?: string } | undefined)?.provider as AuthProvider) ?? 'email';
|
||||||
|
|
||||||
const profile = {
|
const profile = {
|
||||||
nickname: me?.nickname ?? user?.email?.split('@')[0] ?? 'User',
|
nickname: me?.nickname ?? user?.email?.split('@')[0] ?? 'User',
|
||||||
email: user?.email ?? '',
|
email: user?.email ?? '',
|
||||||
avatar: me?.avatar ?? null,
|
avatar: me?.avatar ?? null,
|
||||||
plan: ((me as { plan?: string } | null | undefined)?.plan ?? 'free') as Plan,
|
plan: ((me as { plan?: string } | null | undefined)?.plan ?? 'free') as Plan,
|
||||||
memberSince: DUMMY_PROFILE_FALLBACK.memberSince,
|
memberSince: formatMemberSince(me?.created_at),
|
||||||
provider,
|
provider,
|
||||||
};
|
};
|
||||||
|
|
||||||
const showDigaBanner = DUMMY_STREAK.currentDays >= 30 && !bannerDismissed;
|
const currentStreak = me?.streak ?? 0;
|
||||||
const demoComplete = isDemographicsComplete(demographics);
|
// TODO(backend): longestDays + streakStartDate fehlen in /api/auth/me.
|
||||||
|
// Backend-Agent: Profile-Tabelle braucht longestStreak:Int + streakStartedAt:DateTime.
|
||||||
|
// Tracking: streakStartedAt wird bei jedem Streak-Reset auf NOW() gesetzt.
|
||||||
|
const longestDays = currentStreak;
|
||||||
|
const streakStartDate = formatStreakStartDate(me?.created_at);
|
||||||
|
|
||||||
|
const showDigaBanner = currentStreak >= 30 && !bannerDismissed;
|
||||||
|
const demoComplete = !withdrawnAt && isDemographicsComplete(demographics);
|
||||||
|
|
||||||
function scrollToDemographics() {
|
function scrollToDemographics() {
|
||||||
const node = demographicsAnchorRef.current;
|
const node = demographicsAnchorRef.current;
|
||||||
@ -151,9 +139,7 @@ export default function ProfileScreen() {
|
|||||||
UIManager.measureLayout(
|
UIManager.measureLayout(
|
||||||
handle,
|
handle,
|
||||||
scrollHandle,
|
scrollHandle,
|
||||||
() => {
|
() => {},
|
||||||
// measure failure — silent
|
|
||||||
},
|
|
||||||
(_x, y) => {
|
(_x, y) => {
|
||||||
scroll.scrollTo({ y: Math.max(0, y - 16), animated: true });
|
scroll.scrollTo({ y: Math.max(0, y - 16), animated: true });
|
||||||
},
|
},
|
||||||
@ -166,7 +152,7 @@ export default function ProfileScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: '#fafafa' }}>
|
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
||||||
<AppHeader showBack title="Profil" />
|
<AppHeader showBack title="Profil" />
|
||||||
<ScrollView
|
<ScrollView
|
||||||
ref={scrollViewRef}
|
ref={scrollViewRef}
|
||||||
@ -184,50 +170,34 @@ export default function ProfileScreen() {
|
|||||||
demographicsComplete={demoComplete}
|
demographicsComplete={demoComplete}
|
||||||
showDemographicsHint={!demoComplete}
|
showDemographicsHint={!demoComplete}
|
||||||
onDemographicsHintPress={openDemographics}
|
onDemographicsHintPress={openDemographics}
|
||||||
onEditAvatar={() => {
|
onEditAvatar={() => router.push('/profile/edit')}
|
||||||
Alert.alert(
|
onEditNickname={() => router.push('/profile/edit')}
|
||||||
'Avatar bearbeiten',
|
|
||||||
'Hero-Auswahl + Foto-Upload kommt in der nächsten Iteration.',
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onEditNickname={() => {
|
|
||||||
Alert.alert(
|
|
||||||
'Nickname bearbeiten',
|
|
||||||
'Inline-Edit + Save kommt in der nächsten Iteration.',
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: 'rgba(0,0,0,0.06)',
|
backgroundColor: colors.border,
|
||||||
marginHorizontal: 16,
|
marginHorizontal: 16,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={{ marginTop: 16 }}>
|
<View style={{ marginTop: 16 }}>
|
||||||
<StatsBar
|
<StatsBar
|
||||||
postsCount={DUMMY_STATS.postsCount}
|
postsCount={socialStats?.postsCount ?? 0}
|
||||||
followersCount={DUMMY_STATS.followersCount}
|
followersCount={socialStats?.followersCount ?? 0}
|
||||||
approvedDomainsCount={DUMMY_STATS.approvedDomainsCount}
|
approvedDomainsCount={approvedDomainsData?.count ?? 0}
|
||||||
onPostsPress={() => {
|
onPostsPress={() => {}}
|
||||||
// TODO: Phase C — navigate to user's own posts list
|
onFollowersPress={() => {}}
|
||||||
}}
|
onApprovedDomainsPress={() => {}}
|
||||||
onFollowersPress={() => {
|
|
||||||
// TODO: Phase C — open FollowersSheet
|
|
||||||
}}
|
|
||||||
onApprovedDomainsPress={() => {
|
|
||||||
// TODO: Phase C — scroll to ApprovedDomainsList + auto-expand
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{showDigaBanner ? (
|
{showDigaBanner ? (
|
||||||
<DigaMissionBanner
|
<DigaMissionBanner
|
||||||
onDismiss={() => {
|
onDismiss={() => {
|
||||||
// TODO: AsyncStorage persist `diga_banner_dismissed_at`
|
|
||||||
setBannerDismissed(true);
|
setBannerDismissed(true);
|
||||||
|
apiFetch('/api/profile/me/diga-banner-dismiss', { method: 'POST' }).catch(() => {});
|
||||||
}}
|
}}
|
||||||
onContribute={() => {
|
onContribute={() => {
|
||||||
setBannerDismissed(true);
|
setBannerDismissed(true);
|
||||||
@ -237,53 +207,69 @@ export default function ProfileScreen() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<StreakSection
|
<StreakSection
|
||||||
currentDays={DUMMY_STREAK.currentDays}
|
currentDays={currentStreak}
|
||||||
longestDays={DUMMY_STREAK.longestDays}
|
longestDays={longestDays}
|
||||||
startDate={DUMMY_STREAK.startDate}
|
startDate={streakStartDate}
|
||||||
cooldowns={DUMMY_COOLDOWNS}
|
cooldowns={cooldownHistory?.items ?? EMPTY_COOLDOWNS}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UrgeStatsCard
|
<UrgeStatsCard
|
||||||
sessions={5}
|
sessions={sosInsights?.last30Days.sessions ?? 0}
|
||||||
overcome={4}
|
overcome={sosInsights?.last30Days.overcome ?? 0}
|
||||||
helpedBy={DUMMY_HELPED_BY}
|
helpedBy={
|
||||||
topEmotion="Stress"
|
sosInsights
|
||||||
|
? mapHelpedBy(sosInsights.helpedBy)
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
topEmotion={sosInsights?.topEmotion ?? null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Anchor: Hint-Tap im Header scrollt hierhin */}
|
|
||||||
<View ref={demographicsAnchorRef} collapsable={false}>
|
<View ref={demographicsAnchorRef} collapsable={false}>
|
||||||
<DemographicsAccordion
|
<DemographicsAccordion
|
||||||
demographics={demographics}
|
demographics={demographics}
|
||||||
plan={profile.plan}
|
plan={profile.plan}
|
||||||
expanded={demographicsExpanded}
|
expanded={demographicsExpanded}
|
||||||
onChange={(next) => {
|
onChange={async (next) => {
|
||||||
// TODO Phase C: PATCH /api/profile/me/demographics — Body: next
|
try {
|
||||||
// Endpoint: profile.demographics_consent_at = NOW() bei erstem Save (DSGVO-Audit-Trail).
|
const result = await apiFetch<{ trialAwarded: boolean; expiresAt: string | null }>(
|
||||||
// Plan-Trial-Trigger: wenn alle 6 Felder gefüllt + plan='free' → server setzt
|
'/api/profile/me/demographics',
|
||||||
// pro_trial_started_at + pro_trial_expires_at + pro_trial_source='demographics_complete'.
|
{ method: 'PATCH', body: next },
|
||||||
setDemographics(next);
|
);
|
||||||
|
reloadDemographics();
|
||||||
|
if (result.trialAwarded) {
|
||||||
|
Alert.alert(
|
||||||
|
'Pro-Woche freigeschaltet',
|
||||||
|
'Danke fur deine DiGA-Daten. Du hast 7 Tage Pro kostenlos erhalten.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// write failed — optimistic update not applied, server state preserved
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onRevokeConsent={() => {
|
onRevokeConsent={() => {
|
||||||
// TODO: Phase C — DELETE /api/profile/me/demographics, confirm-alert first
|
Alert.alert(
|
||||||
|
'Daten zuruckziehen',
|
||||||
|
'Alle demografischen Angaben werden geloscht. Fortfahren?',
|
||||||
|
[
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Loschen',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
apiFetch('/api/profile/me/demographics', { method: 'DELETE' })
|
||||||
|
.then(() => reloadDemographics())
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* ApprovedDomains ans Ende — User-Direktive 2026-05-08 */}
|
<ApprovedDomainsList domains={approvedDomainsData?.list ?? []} />
|
||||||
<ApprovedDomainsList domains={DUMMY_APPROVED_DOMAINS} />
|
|
||||||
|
|
||||||
<View style={{ height: 24 }} />
|
<View style={{ height: 24 }} />
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
fontSize: 11,
|
|
||||||
color: colors.textMuted,
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
opacity: 0.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Profil-Skeleton (dummy data) — Backend wired in Phase C
|
|
||||||
</Text>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -20,13 +20,14 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import * as FileSystem from 'expo-file-system';
|
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
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 { ChatInput, type SendPayload } from '../components/chat/ChatInput';
|
import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
|
||||||
import { useRoomRealtime } from '../hooks/useChatRealtime';
|
import { useRoomRealtime } from '../hooks/useChatRealtime';
|
||||||
import { colors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
|
|
||||||
const GROUP_GAP_MS = 5 * 60 * 1000;
|
const GROUP_GAP_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
@ -63,6 +64,8 @@ export default function RoomScreen() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const colors = useColors();
|
||||||
|
const styles = makeStyles(colors);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const flatRef = useRef<FlatList>(null);
|
const flatRef = useRef<FlatList>(null);
|
||||||
const [myUserId, setMyUserId] = useState<string | undefined>();
|
const [myUserId, setMyUserId] = useState<string | undefined>();
|
||||||
@ -297,7 +300,7 @@ export default function RoomScreen() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable style={styles.iconBtn} onPress={() => router.back()} hitSlop={8}>
|
<Pressable style={styles.iconBtn} onPress={() => router.back()} hitSlop={8}>
|
||||||
<Ionicons name="chevron-back" size={22} color="#0a0a0a" />
|
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.headerCenter}>
|
<View style={styles.headerCenter}>
|
||||||
<View style={styles.headerAvatar}>
|
<View style={styles.headerAvatar}>
|
||||||
@ -319,7 +322,7 @@ export default function RoomScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Pressable style={styles.iconBtn} onPress={() => setSettingsOpen(true)} hitSlop={8}>
|
<Pressable style={styles.iconBtn} onPress={() => setSettingsOpen(true)} hitSlop={8}>
|
||||||
<Ionicons name="ellipsis-horizontal" size={20} color="#0a0a0a" />
|
<Ionicons name="ellipsis-horizontal" size={20} color={colors.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@ -429,6 +432,8 @@ function RoomSettingsModal({
|
|||||||
roomId: string;
|
roomId: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const modal = makeModalStyles(colors);
|
||||||
const [pendingRequests, setPendingRequests] = useState<any[]>([]);
|
const [pendingRequests, setPendingRequests] = useState<any[]>([]);
|
||||||
const [loadingReqs, setLoadingReqs] = useState(false);
|
const [loadingReqs, setLoadingReqs] = useState(false);
|
||||||
|
|
||||||
@ -636,22 +641,23 @@ function RoomSettingsModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||||
container: { flex: 1, backgroundColor: '#fafafa' },
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bg },
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: colors.bg,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
borderBottomColor: '#e5e5e5',
|
borderBottomColor: colors.border,
|
||||||
},
|
},
|
||||||
iconBtn: {
|
iconBtn: {
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
@ -665,7 +671,7 @@ const styles = StyleSheet.create({
|
|||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
backgroundColor: '#e5e5e5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@ -675,17 +681,17 @@ const styles = StyleSheet.create({
|
|||||||
headerAvatarInitials: {
|
headerAvatarInitials: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
headerName: {
|
headerName: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#171717',
|
color: colors.text,
|
||||||
},
|
},
|
||||||
headerSub: {
|
headerSub: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: 'Nunito_500Medium',
|
fontFamily: 'Nunito_500Medium',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
marginTop: 1,
|
marginTop: 1,
|
||||||
},
|
},
|
||||||
loadingBox: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
loadingBox: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||||
@ -698,20 +704,20 @@ const styles = StyleSheet.create({
|
|||||||
joinTitle: {
|
joinTitle: {
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#171717',
|
color: colors.text,
|
||||||
marginTop: 14,
|
marginTop: 14,
|
||||||
},
|
},
|
||||||
joinDesc: {
|
joinDesc: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: 'Nunito_500Medium',
|
fontFamily: 'Nunito_500Medium',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
joinHint: {
|
joinHint: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_500Medium',
|
fontFamily: 'Nunito_500Medium',
|
||||||
color: '#a3a3a3',
|
color: colors.textMuted,
|
||||||
marginTop: 18,
|
marginTop: 18,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
@ -745,22 +751,24 @@ const styles = StyleSheet.create({
|
|||||||
marginLeft: 6,
|
marginLeft: 6,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const modal = StyleSheet.create({
|
function makeModalStyles(colors: ReturnType<typeof useColors>) {
|
||||||
container: { flex: 1, backgroundColor: '#fafafa' },
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bg },
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: colors.bg,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
borderBottomColor: '#e5e5e5',
|
borderBottomColor: colors.border,
|
||||||
},
|
},
|
||||||
title: { fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#171717' },
|
title: { fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text },
|
||||||
section: {
|
section: {
|
||||||
backgroundColor: '#fff',
|
backgroundColor: colors.surface,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
padding: 14,
|
padding: 14,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
@ -768,7 +776,7 @@ const modal = StyleSheet.create({
|
|||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
@ -776,7 +784,7 @@ const modal = StyleSheet.create({
|
|||||||
avatarWrap: { alignSelf: 'center', marginBottom: 10 },
|
avatarWrap: { alignSelf: 'center', marginBottom: 10 },
|
||||||
avatar: { width: 80, height: 80, borderRadius: 40 },
|
avatar: { width: 80, height: 80, borderRadius: 40 },
|
||||||
avatarPlaceholder: {
|
avatarPlaceholder: {
|
||||||
backgroundColor: '#e5e5e5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
@ -789,20 +797,20 @@ const modal = StyleSheet.create({
|
|||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
backgroundColor: '#007AFF',
|
backgroundColor: '#007AFF',
|
||||||
borderWidth: 3,
|
borderWidth: 3,
|
||||||
borderColor: '#fff',
|
borderColor: colors.bg,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
roomName: {
|
roomName: {
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#171717',
|
color: colors.text,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
roomDesc: {
|
roomDesc: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_500Medium',
|
fontFamily: 'Nunito_500Medium',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
@ -811,13 +819,13 @@ const modal = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
borderBottomColor: '#f5f5f5',
|
borderBottomColor: colors.border,
|
||||||
},
|
},
|
||||||
memberAvatar: {
|
memberAvatar: {
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
backgroundColor: '#e5e5e5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@ -827,17 +835,17 @@ const modal = StyleSheet.create({
|
|||||||
memberInitials: {
|
memberInitials: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
memberName: { fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#171717' },
|
memberName: { fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text },
|
||||||
memberRole: { fontSize: 11, color: '#a3a3a3', marginTop: 1, textTransform: 'capitalize' },
|
memberRole: { fontSize: 11, color: colors.textMuted, marginTop: 1, textTransform: 'capitalize' },
|
||||||
actionBtn: {
|
actionBtn: {
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 5,
|
paddingVertical: 5,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
},
|
},
|
||||||
actionText: { fontSize: 11, fontFamily: 'Nunito_700Bold' },
|
actionText: { fontSize: 11, fontFamily: 'Nunito_700Bold' },
|
||||||
emptyText: { fontSize: 12, color: '#a3a3a3' },
|
emptyText: { fontSize: 12, color: colors.textMuted },
|
||||||
leaveBtn: {
|
leaveBtn: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -854,3 +862,4 @@ const modal = StyleSheet.create({
|
|||||||
marginLeft: 6,
|
marginLeft: 6,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -6,13 +6,15 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useNativeActionSheet } from '../lib/useNativeActionSheet';
|
import { MenuView, type MenuAction } from '@react-native-menu/menu';
|
||||||
|
import { TrueSheet } from '@lodev09/react-native-true-sheet';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { colors } from '../lib/theme';
|
import { LanguageIcon } from '../components/icons/LanguageIcon';
|
||||||
|
import { useColors } from '../lib/theme';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { useThemeStore, type ThemeMode } from '../stores/theme';
|
import { useThemeStore, type ThemeMode } from '../stores/theme';
|
||||||
import { useLanguageStore, type AppLanguage } from '../stores/language';
|
import { useLanguageStore, type AppLanguage } from '../stores/language';
|
||||||
@ -24,13 +26,20 @@ import { AppHeader } from '../components/AppHeader';
|
|||||||
type PickerOption<T extends string> = { value: T; label: string };
|
type PickerOption<T extends string> = { value: T; label: string };
|
||||||
|
|
||||||
type SectionRow = {
|
type SectionRow = {
|
||||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
/** Ionicons-name ODER eigenes SVG-Component (für custom icons wie LanguageIcon) */
|
||||||
|
icon: React.ComponentProps<typeof Ionicons>['name'] | React.ComponentType<{ size?: number; color?: string }>;
|
||||||
label: string;
|
label: string;
|
||||||
sublabel: string;
|
sublabel: string;
|
||||||
soon?: boolean;
|
soon?: boolean;
|
||||||
destructive?: boolean;
|
destructive?: boolean;
|
||||||
value?: string;
|
value?: string;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
|
/** Wenn gesetzt, wrappt UIMenu (anchored Pull-Down) statt onPress-trigger */
|
||||||
|
menu?: {
|
||||||
|
title: string;
|
||||||
|
actions: MenuAction[];
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type Section = {
|
type Section = {
|
||||||
@ -47,7 +56,7 @@ export default function SettingsScreen() {
|
|||||||
const { mode: themeMode, setMode: setThemeMode } = useThemeStore();
|
const { mode: themeMode, setMode: setThemeMode } = useThemeStore();
|
||||||
const { language, setLanguage } = useLanguageStore();
|
const { language, setLanguage } = useLanguageStore();
|
||||||
const { plan } = useUserPlan();
|
const { plan } = useUserPlan();
|
||||||
const { showActionSheetWithOptions } = useNativeActionSheet();
|
const colors = useColors();
|
||||||
|
|
||||||
// Lyra Voice: hardcoded ElevenLabs voice IDs (expandable by user later)
|
// Lyra Voice: hardcoded ElevenLabs voice IDs (expandable by user later)
|
||||||
// Backend endpoint PATCH /api/profile/me/demographics does NOT accept lyraVoiceId.
|
// Backend endpoint PATCH /api/profile/me/demographics does NOT accept lyraVoiceId.
|
||||||
@ -55,24 +64,8 @@ export default function SettingsScreen() {
|
|||||||
// For now: picker is wired to local state only, changes are NOT persisted.
|
// For now: picker is wired to local state only, changes are NOT persisted.
|
||||||
const [selectedVoice, setSelectedVoice] = useState('EXAVITQu4vr4xnSDxMaL');
|
const [selectedVoice, setSelectedVoice] = useState('EXAVITQu4vr4xnSDxMaL');
|
||||||
|
|
||||||
function pickFromOptions<T extends string>(
|
// TrueSheet ref for Lyra-Voice picker (UISheetPresentationController bottom-sheet)
|
||||||
title: string,
|
const voiceSheetRef = useRef<TrueSheet>(null);
|
||||||
options: PickerOption<T>[],
|
|
||||||
onPick: (value: T) => void,
|
|
||||||
) {
|
|
||||||
const labels = options.map((o) => o.label);
|
|
||||||
showActionSheetWithOptions(
|
|
||||||
{
|
|
||||||
title,
|
|
||||||
options: [...labels, t('common.cancel')],
|
|
||||||
cancelButtonIndex: labels.length,
|
|
||||||
},
|
|
||||||
(idx) => {
|
|
||||||
if (idx === undefined || idx === labels.length) return;
|
|
||||||
onPick(options[idx].value);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSignOut() {
|
async function handleSignOut() {
|
||||||
Alert.alert(t('auth.signOut'), '', [
|
Alert.alert(t('auth.signOut'), '', [
|
||||||
@ -128,20 +121,32 @@ export default function SettingsScreen() {
|
|||||||
label: t('settings.theme'),
|
label: t('settings.theme'),
|
||||||
sublabel: t('settings.theme_desc'),
|
sublabel: t('settings.theme_desc'),
|
||||||
value: themeLabel,
|
value: themeLabel,
|
||||||
onPress: () =>
|
menu: {
|
||||||
pickFromOptions<ThemeMode>(t('settings.theme'), themeOptions, (v) =>
|
title: t('settings.theme'),
|
||||||
setThemeMode(v),
|
// Bewusst KEINE `image`-Props (SF-Symbols) — sonst rendert UIMenu mit
|
||||||
),
|
// Icon-Slot reserviert und das Menu wird breiter/höher als bei Sprache.
|
||||||
|
actions: themeOptions.map((opt) => ({
|
||||||
|
id: opt.value,
|
||||||
|
title: opt.label,
|
||||||
|
state: opt.value === themeMode ? 'on' : 'off',
|
||||||
|
})),
|
||||||
|
onSelect: (id) => setThemeMode(id as ThemeMode),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'language-outline',
|
icon: LanguageIcon,
|
||||||
label: t('settings.language'),
|
label: t('settings.language'),
|
||||||
sublabel: t('settings.language_desc'),
|
sublabel: t('settings.language_desc'),
|
||||||
value: language === 'de' ? t('settings.language_de') : t('settings.language_en'),
|
value: language === 'de' ? t('settings.language_de') : t('settings.language_en'),
|
||||||
onPress: () =>
|
menu: {
|
||||||
pickFromOptions<AppLanguage>(t('settings.language'), langOptions, (v) =>
|
title: t('settings.language'),
|
||||||
setLanguage(v),
|
actions: langOptions.map((opt) => ({
|
||||||
),
|
id: opt.value,
|
||||||
|
title: opt.label,
|
||||||
|
state: opt.value === language ? 'on' : 'off',
|
||||||
|
})),
|
||||||
|
onSelect: (id) => setLanguage(id as AppLanguage),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -171,7 +176,7 @@ export default function SettingsScreen() {
|
|||||||
icon: 'phone-portrait-outline',
|
icon: 'phone-portrait-outline',
|
||||||
label: t('settings.devices'),
|
label: t('settings.devices'),
|
||||||
sublabel: t('settings.devices_desc'),
|
sublabel: t('settings.devices_desc'),
|
||||||
soon: true,
|
onPress: () => router.push('/devices'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'star-outline',
|
icon: 'star-outline',
|
||||||
@ -196,14 +201,7 @@ export default function SettingsScreen() {
|
|||||||
// Voice picker is wired but changes are local-only until
|
// Voice picker is wired but changes are local-only until
|
||||||
// PATCH /api/profile/me/lyra-voice endpoint is added by backend-agent.
|
// PATCH /api/profile/me/lyra-voice endpoint is added by backend-agent.
|
||||||
onPress:
|
onPress:
|
||||||
plan === 'legend'
|
plan === 'legend' ? () => voiceSheetRef.current?.present() : undefined,
|
||||||
? () =>
|
|
||||||
pickFromOptions<string>(
|
|
||||||
t('settings.lyra_voice'),
|
|
||||||
voiceOptions,
|
|
||||||
(v) => setSelectedVoice(v),
|
|
||||||
)
|
|
||||||
: undefined,
|
|
||||||
soon: plan !== 'legend',
|
soon: plan !== 'legend',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -251,7 +249,7 @@ export default function SettingsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: '#fafafa' }}>
|
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
||||||
<AppHeader showBack title={t('settings.title')} />
|
<AppHeader showBack title={t('settings.title')} />
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@ -268,7 +266,7 @@ export default function SettingsScreen() {
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: '#a3a3a3',
|
color: colors.textMuted,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
@ -280,7 +278,7 @@ export default function SettingsScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: colors.surface,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
@ -290,32 +288,21 @@ export default function SettingsScreen() {
|
|||||||
elevation: 1,
|
elevation: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{section.rows.map((row, i) => (
|
{section.rows.map((row, i) => {
|
||||||
<Pressable
|
// Visual content of the row (icon + label + sublabel)
|
||||||
key={row.label}
|
const iconColor = row.destructive ? colors.error : colors.textMuted;
|
||||||
onPress={row.soon ? undefined : row.onPress}
|
const IconComponent = typeof row.icon === 'string' ? null : row.icon;
|
||||||
disabled={row.soon}
|
const rowLeft = (
|
||||||
style={({ pressed }) => ({
|
<>
|
||||||
opacity: row.soon ? 0.5 : pressed ? 0.7 : 1,
|
{IconComponent ? (
|
||||||
})}
|
<IconComponent size={18} color={iconColor} />
|
||||||
>
|
) : (
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 12,
|
|
||||||
minHeight: 56,
|
|
||||||
borderBottomWidth: i < section.rows.length - 1 ? 1 : 0,
|
|
||||||
borderBottomColor: 'rgba(0,0,0,0.04)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={row.icon}
|
name={row.icon as React.ComponentProps<typeof Ionicons>['name']}
|
||||||
size={18}
|
size={18}
|
||||||
color={row.destructive ? colors.error : colors.textMuted}
|
color={iconColor}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
@ -341,11 +328,90 @@ export default function SettingsScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const containerStyle = {
|
||||||
|
flexDirection: 'row' as const,
|
||||||
|
alignItems: 'center' as const,
|
||||||
|
gap: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
minHeight: 56,
|
||||||
|
borderBottomWidth: i < section.rows.length - 1 ? 1 : 0,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
opacity: row.soon ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Row mit Menu: Label-Bereich nicht tappable, MenuView nur am End-Anchor
|
||||||
|
if (row.menu) {
|
||||||
|
return (
|
||||||
|
<View key={row.label} style={containerStyle}>
|
||||||
|
{rowLeft}
|
||||||
|
<MenuView
|
||||||
|
title={row.menu.title}
|
||||||
|
actions={row.menu.actions}
|
||||||
|
onPressAction={({ nativeEvent: { event } }) =>
|
||||||
|
row.menu!.onSelect(event)
|
||||||
|
}
|
||||||
|
shouldOpenOnLongPress={false}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.value ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{row.value}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-forward"
|
||||||
|
size={14}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</MenuView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard-Row: ganze Pressable als Tap-Target
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={row.label}
|
||||||
|
onPress={row.soon ? undefined : row.onPress}
|
||||||
|
disabled={row.soon}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
opacity: row.soon ? 0.5 : pressed ? 0.7 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View style={containerStyle}>
|
||||||
|
{rowLeft}
|
||||||
{row.soon ? (
|
{row.soon ? (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: '#a3a3a3',
|
color: colors.textMuted,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
@ -369,12 +435,13 @@ export default function SettingsScreen() {
|
|||||||
<Ionicons
|
<Ionicons
|
||||||
name="chevron-forward"
|
name="chevron-forward"
|
||||||
size={16}
|
size={16}
|
||||||
color="#d4d4d8"
|
color={colors.border}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@ -383,7 +450,7 @@ export default function SettingsScreen() {
|
|||||||
style={{
|
style={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: '#a3a3a3',
|
color: colors.textMuted,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
@ -395,7 +462,7 @@ export default function SettingsScreen() {
|
|||||||
style={{
|
style={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: '#a3a3a3',
|
color: colors.textMuted,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
@ -404,6 +471,75 @@ export default function SettingsScreen() {
|
|||||||
{Platform.OS}
|
{Platform.OS}
|
||||||
</Text>
|
</Text>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
<TrueSheet
|
||||||
|
ref={voiceSheetRef}
|
||||||
|
detents={['auto', 1]}
|
||||||
|
cornerRadius={20}
|
||||||
|
grabber
|
||||||
|
backgroundColor={colors.surface}
|
||||||
|
>
|
||||||
|
<View style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 24, backgroundColor: colors.surface }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.lyra_voice')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginBottom: 20,
|
||||||
|
lineHeight: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.lyra_voice_desc')}
|
||||||
|
</Text>
|
||||||
|
{voiceOptions.map((opt, idx) => {
|
||||||
|
const isSelected = opt.value === selectedVoice;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={opt.value}
|
||||||
|
onPress={() => {
|
||||||
|
setSelectedVoice(opt.value);
|
||||||
|
voiceSheetRef.current?.dismiss();
|
||||||
|
}}
|
||||||
|
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderBottomWidth: idx < voiceOptions.length - 1 ? 1 : 0,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: isSelected ? 'Nunito_700Bold' : 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Text>
|
||||||
|
{isSelected ? (
|
||||||
|
<Ionicons name="checkmark" size={20} color={colors.brandOrange} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</TrueSheet>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,13 +8,14 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
|||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av';
|
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av';
|
||||||
import * as FileSystem from 'expo-file-system';
|
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import Constants from 'expo-constants';
|
import Constants from 'expo-constants';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { RiveAvatar } from '../components/RiveAvatar';
|
import { RiveAvatar } from '../components/RiveAvatar';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { colors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
import {
|
import {
|
||||||
type GameType, GAME_META, MemoryGame, TicTacToeGame, SnakeGame, TetrisGame,
|
type GameType, GAME_META, MemoryGame, TicTacToeGame, SnakeGame, TetrisGame,
|
||||||
} from '../components/urge/UrgeGames';
|
} from '../components/urge/UrgeGames';
|
||||||
@ -40,6 +41,8 @@ export default function SOSScreen() {
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const colors = useColors();
|
||||||
|
const st = makeStyles(colors);
|
||||||
const flatRef = useRef<FlatList>(null);
|
const flatRef = useRef<FlatList>(null);
|
||||||
|
|
||||||
const [messages, setMessages] = useState<SosMsg[]>([]);
|
const [messages, setMessages] = useState<SosMsg[]>([]);
|
||||||
@ -237,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: {
|
||||||
@ -441,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) => {
|
||||||
@ -628,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) => {
|
||||||
@ -1088,7 +1091,7 @@ export default function SOSScreen() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={[st.topBar, { top: insets.top + 6 }]}>
|
<View style={[st.topBar, { top: insets.top + 6 }]}>
|
||||||
<Pressable style={st.actionBtn} onPress={attemptExit} hitSlop={12}>
|
<Pressable style={st.actionBtn} onPress={attemptExit} hitSlop={12}>
|
||||||
<Ionicons name="close" size={22} color="#374151" />
|
<Ionicons name="close" size={22} color={colors.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={st.avatarCenter}>
|
<View style={st.avatarCenter}>
|
||||||
<RiveAvatar emotion={emotion} size="md" />
|
<RiveAvatar emotion={emotion} size="md" />
|
||||||
@ -1148,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>
|
||||||
) : (
|
) : (
|
||||||
@ -1308,23 +1311,24 @@ export default function SOSScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const st = StyleSheet.create({
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||||
container: { flex: 1, backgroundColor: '#ffffff' },
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bg },
|
||||||
topBar: { position: 'absolute', left: 0, right: 0, zIndex: 10, flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', paddingHorizontal: 12 },
|
topBar: { position: 'absolute', left: 0, right: 0, zIndex: 10, flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', paddingHorizontal: 12 },
|
||||||
topBarBackdrop: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 9, backgroundColor: '#ffffff' },
|
topBarBackdrop: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 9, backgroundColor: colors.bg },
|
||||||
actionBtn: { width: 40, height: 40, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.92)', alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 6, elevation: 4 },
|
actionBtn: { width: 40, height: 40, borderRadius: 20, backgroundColor: colors.surface, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 6, elevation: 4 },
|
||||||
avatarCenter: { flex: 1, alignItems: 'center', gap: 4 },
|
avatarCenter: { flex: 1, alignItems: 'center', gap: 4 },
|
||||||
avatarMeta: { alignItems: 'center', gap: 2 },
|
avatarMeta: { alignItems: 'center', gap: 2 },
|
||||||
avatarName: { fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' },
|
avatarName: { fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text },
|
||||||
speakingRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
speakingRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||||
stopBtn: { width: 18, height: 18, borderRadius: 9, backgroundColor: '#f5f5f5', alignItems: 'center', justifyContent: 'center' },
|
stopBtn: { width: 18, height: 18, borderRadius: 9, backgroundColor: colors.surfaceElevated, alignItems: 'center', justifyContent: 'center' },
|
||||||
listContent: { paddingHorizontal: 12, paddingBottom: 4 },
|
listContent: { paddingHorizontal: 12, paddingBottom: 4 },
|
||||||
scrollDownBtn: { position: 'absolute', bottom: 8, right: 16, width: 36, height: 36, borderRadius: 18, backgroundColor: '#374151', alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4, elevation: 4 },
|
scrollDownBtn: { position: 'absolute', bottom: 8, right: 16, width: 36, height: 36, borderRadius: 18, backgroundColor: '#374151', alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4, elevation: 4 },
|
||||||
chip: {
|
chip: {
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
borderColor: '#9ca3af', // sichtbarer Ring (medium-grau gegen weiß)
|
borderColor: colors.border,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: colors.bg,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 11,
|
paddingVertical: 11,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
@ -1334,13 +1338,14 @@ const st = StyleSheet.create({
|
|||||||
elevation: 3,
|
elevation: 3,
|
||||||
},
|
},
|
||||||
chipPressed: {
|
chipPressed: {
|
||||||
backgroundColor: '#f3f4f6',
|
backgroundColor: colors.surfaceElevated,
|
||||||
borderColor: '#6b7280', // dunkler beim Press → spürbares Feedback
|
borderColor: colors.textMuted,
|
||||||
transform: [{ scale: 0.97 }],
|
transform: [{ scale: 0.97 }],
|
||||||
shadowOpacity: 0.05,
|
shadowOpacity: 0.05,
|
||||||
},
|
},
|
||||||
chipText: { fontFamily: 'Nunito_600SemiBold', fontSize: 14, color: '#334155' },
|
chipText: { fontFamily: 'Nunito_600SemiBold', fontSize: 14, color: colors.textMuted },
|
||||||
inputBar: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 12, paddingTop: 8, borderTopWidth: 1, borderTopColor: '#f3f4f6', backgroundColor: '#fff', gap: 8 },
|
inputBar: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 12, paddingTop: 8, borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.bg, gap: 8 },
|
||||||
textInput: { flex: 1, minHeight: 40, maxHeight: 120, backgroundColor: '#f3f4f6', borderRadius: 20, paddingHorizontal: 14, paddingVertical: 10, fontSize: 15, fontFamily: 'Nunito_400Regular', color: '#111827' },
|
textInput: { flex: 1, minHeight: 40, maxHeight: 120, backgroundColor: colors.surfaceElevated, borderRadius: 20, paddingHorizontal: 14, paddingVertical: 10, fontSize: 15, fontFamily: 'Nunito_400Regular', color: colors.text },
|
||||||
sendBtn: { width: 38, height: 38, borderRadius: 19, backgroundColor: '#007AFF', alignItems: 'center', justifyContent: 'center' },
|
sendBtn: { width: 38, height: 38, borderRadius: 19, backgroundColor: '#007AFF', alignItems: 'center', justifyContent: 'center' },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { useNotificationStore } from '../stores/notifications';
|
import { useNotificationStore } from '../stores/notifications';
|
||||||
import { resolveAvatar } from '../lib/resolveAvatar';
|
import { resolveAvatar } from '../lib/resolveAvatar';
|
||||||
|
import { useColors } from '../lib/theme';
|
||||||
import { useMe } from '../hooks/useMe';
|
import { useMe } from '../hooks/useMe';
|
||||||
import { NotificationsDropdown } from './NotificationsDropdown';
|
import { NotificationsDropdown } from './NotificationsDropdown';
|
||||||
import { HeaderDropdownMenu } from './header/HeaderDropdownMenu';
|
import { HeaderDropdownMenu } from './header/HeaderDropdownMenu';
|
||||||
@ -22,6 +23,7 @@ export function AppHeader({ notifCount, showBack, title }: Props = {}) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
|
const colors = useColors();
|
||||||
const { me } = useMe();
|
const { me } = useMe();
|
||||||
const storeUnread = useNotificationStore((s) => s.unread);
|
const storeUnread = useNotificationStore((s) => s.unread);
|
||||||
const badge = notifCount ?? storeUnread;
|
const badge = notifCount ?? storeUnread;
|
||||||
@ -47,8 +49,12 @@ export function AppHeader({ notifCount, showBack, title }: Props = {}) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="bg-white border-b border-neutral-200"
|
style={{
|
||||||
style={{ paddingTop: insets.top }}
|
paddingTop: insets.top,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<View className="h-14 flex-row items-center justify-between px-5">
|
<View className="h-14 flex-row items-center justify-between px-5">
|
||||||
<View className="flex-row items-center" style={{ gap: 8 }}>
|
<View className="flex-row items-center" style={{ gap: 8 }}>
|
||||||
@ -56,14 +62,21 @@ export function AppHeader({ notifCount, showBack, title }: Props = {}) {
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
hitSlop={10}
|
hitSlop={10}
|
||||||
className="w-9 h-9 rounded-full items-center justify-center"
|
style={({ pressed }) => ({
|
||||||
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1, marginLeft: -8 })}
|
opacity: pressed ? 0.6 : 1,
|
||||||
|
marginLeft: -8,
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
})}
|
||||||
accessibilityLabel="Zurück"
|
accessibilityLabel="Zurück"
|
||||||
>
|
>
|
||||||
<Ionicons name="chevron-back" size={22} color="#0a0a0a" />
|
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
<Text className="text-lg text-midnight-900 tracking-tight" style={{ fontFamily: 'Nunito_700Bold' }}>
|
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 18, color: colors.text, letterSpacing: -0.3 }}>
|
||||||
{title ?? t('appHeader.appName')}
|
{title ?? t('appHeader.appName')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -72,10 +85,17 @@ export function AppHeader({ notifCount, showBack, title }: Props = {}) {
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setNotifOpen(true)}
|
onPress={() => setNotifOpen(true)}
|
||||||
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
|
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
|
||||||
className="w-9 h-9 rounded-full bg-white items-center justify-center"
|
style={({ pressed }) => ({
|
||||||
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<Ionicons name="notifications-outline" size={22} color="#737373" />
|
<Ionicons name="notifications-outline" size={22} color={colors.textMuted} />
|
||||||
{badge > 0 && (
|
{badge > 0 && (
|
||||||
<View className="absolute top-0 right-0 w-4 h-4 rounded-full bg-rebreak-500 items-center justify-center">
|
<View className="absolute top-0 right-0 w-4 h-4 rounded-full bg-rebreak-500 items-center justify-center">
|
||||||
<Text className="text-white text-[9px]" style={{ fontFamily: 'Nunito_700Bold' }}>
|
<Text className="text-white text-[9px]" style={{ fontFamily: 'Nunito_700Bold' }}>
|
||||||
@ -89,8 +109,16 @@ export function AppHeader({ notifCount, showBack, title }: Props = {}) {
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setMenuOpen(true)}
|
onPress={() => setMenuOpen(true)}
|
||||||
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
|
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
|
||||||
className={`w-9 h-9 rounded-full items-center justify-center overflow-hidden ${showAvatarImage ? 'bg-neutral-100' : 'bg-rebreak-500'}`}
|
style={({ pressed }) => ({
|
||||||
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: showAvatarImage ? colors.surfaceElevated : colors.brandOrange,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{showAvatarImage ? (
|
{showAvatarImage ? (
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@ -11,12 +11,13 @@ import {
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import * as FileSystem from 'expo-file-system';
|
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
import { resolveAvatar } from '../lib/resolveAvatar';
|
import { resolveAvatar } from '../lib/resolveAvatar';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { colors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onPosted?: () => void;
|
onPosted?: () => void;
|
||||||
@ -24,6 +25,7 @@ type Props = {
|
|||||||
|
|
||||||
export function ComposeCard({ onPosted }: Props) {
|
export function ComposeCard({ onPosted }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
@ -100,7 +102,7 @@ export function ComposeCard({ onPosted }: Props) {
|
|||||||
const showActions = focused || content.length > 0;
|
const showActions = focused || content.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="bg-white border border-neutral-200 rounded-2xl p-4 mb-4">
|
<View style={{ backgroundColor: colors.bg, borderWidth: 1, borderColor: colors.border, borderRadius: 16, padding: 16, marginBottom: 16 }}>
|
||||||
<View className="flex-row items-start gap-3">
|
<View className="flex-row items-start gap-3">
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: avatarUrl }}
|
source={{ uri: avatarUrl }}
|
||||||
@ -113,10 +115,10 @@ export function ComposeCard({ onPosted }: Props) {
|
|||||||
onChangeText={setContent}
|
onChangeText={setContent}
|
||||||
onFocus={() => setFocused(true)}
|
onFocus={() => setFocused(true)}
|
||||||
placeholder={t('community.compose_placeholder')}
|
placeholder={t('community.compose_placeholder')}
|
||||||
placeholderTextColor="#a3a3a3"
|
placeholderTextColor={colors.textMuted}
|
||||||
multiline
|
multiline
|
||||||
className="text-sm text-neutral-900 leading-5 min-h-[40px]"
|
className="text-sm leading-5 min-h-[40px]"
|
||||||
style={{ textAlignVertical: 'top', fontFamily: 'Nunito_400Regular' }}
|
style={{ textAlignVertical: 'top', fontFamily: 'Nunito_400Regular', color: colors.text }}
|
||||||
/>
|
/>
|
||||||
{imageUri && (
|
{imageUri && (
|
||||||
<View className="relative mt-2">
|
<View className="relative mt-2">
|
||||||
|
|||||||
238
apps/rebreak-native/components/DeviceLimitReachedSheet.tsx
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
import { ActivityIndicator, Platform, Pressable, Text, View } from 'react-native';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { TrueSheet, type SheetDetent } from '@lodev09/react-native-true-sheet';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useColors } from '../lib/theme';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
import { useDeviceLimitStore, type DeviceLimitDevice } from '../stores/deviceLimit';
|
||||||
|
|
||||||
|
function platformIcon(
|
||||||
|
platform: string
|
||||||
|
): React.ComponentProps<typeof Ionicons>['name'] {
|
||||||
|
if (platform === 'ios') return 'logo-apple';
|
||||||
|
if (platform === 'android') return 'logo-android';
|
||||||
|
return 'phone-portrait-outline';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastSeen(iso: string, t: (k: string, o?: any) => string): string {
|
||||||
|
const ms = Date.now() - new Date(iso).getTime();
|
||||||
|
const min = Math.floor(ms / 60_000);
|
||||||
|
if (min < 1) return t('settings.devices_just_now');
|
||||||
|
if (min < 60) return t('settings.devices_mins_ago', { count: min });
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return t('settings.devices_hours_ago', { count: hr });
|
||||||
|
const day = Math.floor(hr / 24);
|
||||||
|
if (day < 30) return t('settings.devices_days_ago', { count: day });
|
||||||
|
return new Date(iso).toLocaleDateString(
|
||||||
|
Platform.OS === 'ios' ? undefined : 'de-DE',
|
||||||
|
{ day: '2-digit', month: 'short', year: 'numeric' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceLimitRow({
|
||||||
|
device,
|
||||||
|
removing,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
device: DeviceLimitDevice;
|
||||||
|
removing: boolean;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.04)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={platformIcon(device.platform)} size={20} color={colors.text} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
flexShrink: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{device.name ?? device.model ?? device.platform}
|
||||||
|
</Text>
|
||||||
|
{device.model && device.name && !device.name.includes(device.model) ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginTop: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{device.model}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}>
|
||||||
|
<Ionicons name="time-outline" size={11} color={colors.textMuted} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatLastSeen(device.lastSeenAt, t)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{removing ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.error} />
|
||||||
|
) : (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onRemove(device.id)}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={18} color={colors.error} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceLimitReachedSheet() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const sheetRef = useRef<TrueSheet>(null);
|
||||||
|
const { visible, devices, max, plan, hide, removeDevice } = useDeviceLimitStore();
|
||||||
|
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
sheetRef.current?.present();
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
async function handleRemove(id: string) {
|
||||||
|
setRemovingId(id);
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/devices/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
skipDeviceHeader: true,
|
||||||
|
});
|
||||||
|
removeDevice(id);
|
||||||
|
|
||||||
|
const remaining = useDeviceLimitStore.getState().devices;
|
||||||
|
if (remaining.length < max) {
|
||||||
|
sheetRef.current?.dismiss();
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setRemovingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TrueSheet
|
||||||
|
ref={sheetRef}
|
||||||
|
detents={['auto', 1] satisfies SheetDetent[]}
|
||||||
|
cornerRadius={20}
|
||||||
|
grabber
|
||||||
|
onDidDismiss={hide}
|
||||||
|
>
|
||||||
|
<View style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 32 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(239,68,68,0.1)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="phone-portrait-outline" size={20} color={colors.error} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('device_limit.title')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginBottom: 20,
|
||||||
|
lineHeight: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('device_limit.subtitle', { count: devices.length, max, plan: plan.toUpperCase() })}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
borderRadius: 14,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(239,68,68,0.12)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{devices.map((device, i) => (
|
||||||
|
<View
|
||||||
|
key={device.id}
|
||||||
|
style={{
|
||||||
|
borderBottomWidth: i < devices.length - 1 ? 1 : 0,
|
||||||
|
borderBottomColor: 'rgba(0,0,0,0.04)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeviceLimitRow
|
||||||
|
device={device}
|
||||||
|
removing={removingId === device.id}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginTop: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('device_limit.hint')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TrueSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useNotificationStore, type AppNotification } from '../stores/notifications';
|
import { useNotificationStore, type AppNotification } from '../stores/notifications';
|
||||||
import { resolveAvatar } from '../lib/resolveAvatar';
|
import { resolveAvatar } from '../lib/resolveAvatar';
|
||||||
import { HeroShieldCheck } from './HeroShieldCheck';
|
import { HeroShieldCheck } from './HeroShieldCheck';
|
||||||
|
import { useColors } from '../lib/theme';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@ -16,6 +17,7 @@ type Props = {
|
|||||||
|
|
||||||
export function NotificationsDropdown({ visible, onClose, topOffset }: Props) {
|
export function NotificationsDropdown({ visible, onClose, topOffset }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const items = useNotificationStore((s) => s.items);
|
const items = useNotificationStore((s) => s.items);
|
||||||
const loaded = useNotificationStore((s) => s.loaded);
|
const loaded = useNotificationStore((s) => s.loaded);
|
||||||
@ -71,7 +73,7 @@ export function NotificationsDropdown({ visible, onClose, topOffset }: Props) {
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: topOffset + 6,
|
top: topOffset + 6,
|
||||||
right: 12,
|
right: 12,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: colors.bg,
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 8 },
|
shadowOffset: { width: 0, height: 8 },
|
||||||
@ -93,11 +95,11 @@ export function NotificationsDropdown({ visible, onClose, topOffset }: Props) {
|
|||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#f0f0f0',
|
borderBottomColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{ flex: 1, fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}
|
style={{ flex: 1, fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}
|
||||||
>
|
>
|
||||||
{t('notifications.title')}
|
{t('notifications.title')}
|
||||||
</Text>
|
</Text>
|
||||||
@ -114,13 +116,13 @@ export function NotificationsDropdown({ visible, onClose, topOffset }: Props) {
|
|||||||
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<View style={{ paddingVertical: 32, alignItems: 'center' }}>
|
<View style={{ paddingVertical: 32, alignItems: 'center' }}>
|
||||||
<Ionicons name="notifications-off-outline" size={28} color="#a3a3a3" />
|
<Ionicons name="notifications-off-outline" size={28} color={colors.textMuted} />
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#525252',
|
color: colors.textMuted,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('notifications.empty_title')}
|
{t('notifications.empty_title')}
|
||||||
@ -130,7 +132,7 @@ export function NotificationsDropdown({ visible, onClose, topOffset }: Props) {
|
|||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#a3a3a3',
|
color: colors.textMuted,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
}}
|
}}
|
||||||
@ -221,6 +223,7 @@ function NotificationRow({
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
t: (k: string, opts?: any) => string;
|
t: (k: string, opts?: any) => string;
|
||||||
}) {
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
const isUnread = !notif.readAt;
|
const isUnread = !notif.readAt;
|
||||||
const { icon, color, bg } = notifIcon(notif.type);
|
const { icon, color, bg } = notifIcon(notif.type);
|
||||||
const isSocial =
|
const isSocial =
|
||||||
@ -249,8 +252,8 @@ function NotificationRow({
|
|||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 11,
|
paddingVertical: 11,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#f5f5f5',
|
borderBottomColor: colors.border,
|
||||||
backgroundColor: isUnread ? '#fff7ed' : '#ffffff',
|
backgroundColor: isUnread ? colors.surface : colors.bg,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Avatar-Logik:
|
{/* Avatar-Logik:
|
||||||
@ -297,7 +300,7 @@ function NotificationRow({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#0a0a0a',
|
color: colors.text,
|
||||||
lineHeight: 16,
|
lineHeight: 16,
|
||||||
}}
|
}}
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
@ -308,7 +311,7 @@ function NotificationRow({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#a3a3a3',
|
color: colors.textMuted,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -22,7 +22,7 @@ import {
|
|||||||
Easing,
|
Easing,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { colors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
|
|
||||||
type Option<T> = {
|
type Option<T> = {
|
||||||
value: T;
|
value: T;
|
||||||
@ -51,6 +51,7 @@ export function OptionsBottomSheet<T extends string | number>({
|
|||||||
onClose,
|
onClose,
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const colors = useColors();
|
||||||
const translateY = useRef(new Animated.Value(400)).current;
|
const translateY = useRef(new Animated.Value(400)).current;
|
||||||
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { formatRelativeTime } from '../lib/formatTime';
|
|||||||
import { useCommunityStore, type CommunityPost } from '../stores/community';
|
import { useCommunityStore, type CommunityPost } from '../stores/community';
|
||||||
import { RiveAvatar } from './RiveAvatar';
|
import { RiveAvatar } from './RiveAvatar';
|
||||||
import { HeroShieldCheck } from './HeroShieldCheck';
|
import { HeroShieldCheck } from './HeroShieldCheck';
|
||||||
|
import { useColors } from '../lib/theme';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
post: CommunityPost;
|
post: CommunityPost;
|
||||||
@ -17,6 +18,7 @@ type Props = {
|
|||||||
|
|
||||||
function PostCardImpl({ post, onCommentPress }: Props) {
|
function PostCardImpl({ post, onCommentPress }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
// Granular selectors — subscribing to the whole store would re-render every
|
// Granular selectors — subscribing to the whole store would re-render every
|
||||||
// PostCard whenever any user likes any post (optimisticLikes mutates).
|
// PostCard whenever any user likes any post (optimisticLikes mutates).
|
||||||
@ -162,7 +164,7 @@ function PostCardImpl({ post, onCommentPress }: Props) {
|
|||||||
}, [isLiking, localLike, localCount, post.id, post.userLike, post.likesCount, applyOptimisticLike, clearOptimisticLike, revertOptimisticLike, queryClient, triggerHeartPop]);
|
}, [isLiking, localLike, localCount, post.id, post.userLike, post.likesCount, applyOptimisticLike, clearOptimisticLike, revertOptimisticLike, queryClient, triggerHeartPop]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="bg-white border border-neutral-200 rounded-2xl p-3 mb-3">
|
<View style={{ backgroundColor: colors.bg, borderWidth: 1, borderColor: colors.border, borderRadius: 16, padding: 12, marginBottom: 12 }}>
|
||||||
{/* Repost header */}
|
{/* Repost header */}
|
||||||
{post.repostOf && (
|
{post.repostOf && (
|
||||||
<View className="flex-row items-center gap-1.5 mb-3">
|
<View className="flex-row items-center gap-1.5 mb-3">
|
||||||
@ -194,35 +196,35 @@ function PostCardImpl({ post, onCommentPress }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View className="flex-1 min-w-0">
|
<View className="flex-1 min-w-0">
|
||||||
<Text className="text-sm text-neutral-900" numberOfLines={1} style={{ fontFamily: 'Nunito_600SemiBold' }}>
|
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_600SemiBold' }} numberOfLines={1}>
|
||||||
{authorLabel}
|
{authorLabel}
|
||||||
</Text>
|
</Text>
|
||||||
{authorDescription !== undefined && (
|
{authorDescription !== undefined && (
|
||||||
<Text className="text-xs text-neutral-400" style={{ fontFamily: 'Nunito_400Regular' }}>{authorDescription}</Text>
|
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>{authorDescription}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-xs text-neutral-400 shrink-0 ml-2 mt-0.5" style={{ fontFamily: 'Nunito_400Regular' }}>
|
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular', flexShrink: 0, marginLeft: 8, marginTop: 2 }}>
|
||||||
{formatRelativeTime(post.createdAt)}
|
{formatRelativeTime(post.createdAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Content — hidden for domain_vote (replaced by poll below) */}
|
{/* Content — hidden for domain_vote (replaced by poll below) */}
|
||||||
{!!displayContent && post.category !== 'domain_vote' && (
|
{!!displayContent && post.category !== 'domain_vote' && (
|
||||||
<Text className="text-sm text-neutral-800 leading-relaxed mb-0" style={{ fontFamily: 'Nunito_400Regular' }}>
|
<Text style={{ fontSize: 14, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 21 }}>
|
||||||
{displayContent}
|
{displayContent}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* domain_approved: favicon + domain name + shield badge */}
|
{/* domain_approved: favicon + domain name + shield badge */}
|
||||||
{post.category === 'domain_approved' && !!approvedDomain && (
|
{post.category === 'domain_approved' && !!approvedDomain && (
|
||||||
<View className="flex-row items-center gap-2.5 mt-3 rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2">
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 12, borderRadius: 12, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, paddingHorizontal: 12, paddingVertical: 8 }}>
|
||||||
<DomainFavicon domain={approvedDomain} size={24} />
|
<DomainFavicon domain={approvedDomain} size={24} />
|
||||||
<View className="flex-1 min-w-0">
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text className="text-xs font-semibold text-neutral-900 truncate" style={{ fontFamily: 'Nunito_700Bold' }}>
|
<Text style={{ fontSize: 12, color: colors.text, fontFamily: 'Nunito_700Bold' }} numberOfLines={1}>
|
||||||
{approvedDomain}
|
{approvedDomain}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xs text-neutral-400" style={{ fontFamily: 'Nunito_400Regular' }}>
|
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
|
||||||
{t('community.domain_added_to_blocklist')}
|
{t('community.domain_added_to_blocklist')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -1,18 +1,20 @@
|
|||||||
import { View } from 'react-native';
|
import { View } from 'react-native';
|
||||||
|
import { useColors } from '../lib/theme';
|
||||||
|
|
||||||
export function PostCardSkeleton() {
|
export function PostCardSkeleton() {
|
||||||
|
const colors = useColors();
|
||||||
return (
|
return (
|
||||||
<View className="bg-white border border-neutral-200 rounded-2xl p-4 mb-3">
|
<View style={{ backgroundColor: colors.bg, borderWidth: 1, borderColor: colors.border, borderRadius: 16, padding: 16, marginBottom: 12 }}>
|
||||||
<View className="flex-row items-center gap-3 mb-3">
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||||
<View className="w-9 h-9 rounded-full bg-neutral-200" />
|
<View style={{ width: 36, height: 36, borderRadius: 18, backgroundColor: colors.surfaceElevated }} />
|
||||||
<View className="flex-1 gap-1.5">
|
<View style={{ flex: 1, gap: 6 }}>
|
||||||
<View className="h-3 bg-neutral-200 rounded w-1/3" />
|
<View style={{ height: 12, backgroundColor: colors.surfaceElevated, borderRadius: 6, width: '33%' }} />
|
||||||
<View className="h-2.5 bg-neutral-100 rounded w-1/4" />
|
<View style={{ height: 10, backgroundColor: colors.surface, borderRadius: 6, width: '25%' }} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className="h-3 bg-neutral-200 rounded w-full mb-2" />
|
<View style={{ height: 12, backgroundColor: colors.surfaceElevated, borderRadius: 6, width: '100%', marginBottom: 8 }} />
|
||||||
<View className="h-3 bg-neutral-200 rounded w-3/4 mb-2" />
|
<View style={{ height: 12, backgroundColor: colors.surfaceElevated, borderRadius: 6, width: '75%', marginBottom: 8 }} />
|
||||||
<View className="h-3 bg-neutral-100 rounded w-1/2" />
|
<View style={{ height: 12, backgroundColor: colors.surface, borderRadius: 6, width: '50%' }} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
import { formatRelativeTime } from '../lib/formatTime';
|
import { formatRelativeTime } from '../lib/formatTime';
|
||||||
import { colors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
import type { CommunityComment } from '../stores/community';
|
import type { CommunityComment } from '../stores/community';
|
||||||
|
|
||||||
const EMOJIS = ['❤️', '🙌', '🔥', '👏', '😢', '😍', '😮', '😂'];
|
const EMOJIS = ['❤️', '🙌', '🔥', '👏', '😢', '😍', '😮', '😂'];
|
||||||
@ -33,6 +33,7 @@ type Props = {
|
|||||||
|
|
||||||
export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
@ -230,7 +231,7 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: colors.bg,
|
||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: 24,
|
||||||
borderTopRightRadius: 24,
|
borderTopRightRadius: 24,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@ -255,7 +256,7 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
width: 36,
|
width: 36,
|
||||||
height: 5,
|
height: 5,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
backgroundColor: '#d4d4d8',
|
backgroundColor: colors.border,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@ -268,10 +269,10 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
paddingTop: 6,
|
paddingTop: 6,
|
||||||
paddingBottom: 12,
|
paddingBottom: 12,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#e5e5e5',
|
borderBottomColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
{t('community.comments_title')}
|
{t('community.comments_title')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -323,7 +324,7 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: '#f5f5f5',
|
borderTopColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{EMOJIS.map((e) => (
|
{EMOJIS.map((e) => (
|
||||||
@ -342,10 +343,10 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
backgroundColor: '#fafafa',
|
backgroundColor: colors.surface,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: 12, color: '#737373', fontFamily: 'Nunito_400Regular' }}>
|
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
|
||||||
{t('community.reply_to')}{' '}
|
{t('community.reply_to')}{' '}
|
||||||
<Text style={{ fontFamily: 'Nunito_700Bold' }}>@{replyTarget.nickname}</Text>
|
<Text style={{ fontFamily: 'Nunito_700Bold' }}>@{replyTarget.nickname}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
@ -366,7 +367,7 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
// sonst Safe-Area
|
// sonst Safe-Area
|
||||||
paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom),
|
paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom),
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: '#e5e5e5',
|
borderTopColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
@ -374,18 +375,18 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
value={text}
|
value={text}
|
||||||
onChangeText={setText}
|
onChangeText={setText}
|
||||||
placeholder={t('community.comment_placeholder')}
|
placeholder={t('community.comment_placeholder')}
|
||||||
placeholderTextColor="#a3a3a3"
|
placeholderTextColor={colors.textMuted}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#fafafa',
|
backgroundColor: colors.surface,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e5e5',
|
borderColor: colors.border,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#0a0a0a',
|
color: colors.text,
|
||||||
marginRight: 8,
|
marginRight: 8,
|
||||||
}}
|
}}
|
||||||
returnKeyType="send"
|
returnKeyType="send"
|
||||||
@ -430,6 +431,7 @@ type CommentRowProps = {
|
|||||||
|
|
||||||
function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowProps) {
|
function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
const heartScale = useRef(new Animated.Value(1)).current;
|
const heartScale = useRef(new Animated.Value(1)).current;
|
||||||
const handleLikeWithPop = useCallback(() => {
|
const handleLikeWithPop = useCallback(() => {
|
||||||
heartScale.setValue(1);
|
heartScale.setValue(1);
|
||||||
@ -447,26 +449,26 @@ function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowPro
|
|||||||
width: isReply ? 24 : 32,
|
width: isReply ? 24 : 32,
|
||||||
height: isReply ? 24 : 32,
|
height: isReply ? 24 : 32,
|
||||||
borderRadius: isReply ? 12 : 16,
|
borderRadius: isReply ? 12 : 16,
|
||||||
backgroundColor: '#e5e5e5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: isReply ? 9 : 11, fontFamily: 'Nunito_700Bold', color: '#737373' }}>
|
<Text style={{ fontSize: isReply ? 9 : 11, fontFamily: 'Nunito_700Bold', color: colors.textMuted }}>
|
||||||
{(comment.authorNickname ?? 'AN').slice(0, 2).toUpperCase()}
|
{(comment.authorNickname ?? 'AN').slice(0, 2).toUpperCase()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ flex: 1, minWidth: 0 }}>
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text style={{ fontSize: 12, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
|
<Text style={{ fontSize: 12, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
{comment.authorNickname ?? t('community.anonymous_label')}
|
{comment.authorNickname ?? t('community.anonymous_label')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#404040',
|
color: colors.textMuted,
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
@ -474,12 +476,12 @@ function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowPro
|
|||||||
{comment.content}
|
{comment.content}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16, marginTop: 6 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16, marginTop: 6 }}>
|
||||||
<Text style={{ fontSize: 10, color: '#a3a3a3', fontFamily: 'Nunito_400Regular' }}>
|
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
|
||||||
{formatRelativeTime(comment.createdAt)}
|
{formatRelativeTime(comment.createdAt)}
|
||||||
</Text>
|
</Text>
|
||||||
{!isReply && onReply && (
|
{!isReply && onReply && (
|
||||||
<Pressable onPress={onReply}>
|
<Pressable onPress={onReply}>
|
||||||
<Text style={{ fontSize: 11, color: '#a3a3a3', fontFamily: 'Nunito_600SemiBold' }}>
|
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
{t('community.reply')}
|
{t('community.reply')}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@ -39,18 +39,21 @@ function preloadRiveAsset(): Promise<string | null> {
|
|||||||
// Render bereits die cached URI nutzt (außer im allerersten App-Start).
|
// Render bereits die cached URI nutzt (außer im allerersten App-Start).
|
||||||
preloadRiveAsset();
|
preloadRiveAsset();
|
||||||
|
|
||||||
export type Emotion = 'idle' | 'happy' | 'thinking' | 'empathy';
|
// Supported emotions sind durch state-machine im .riv-file definiert.
|
||||||
|
// Neue states: nur EMOTION_ANIMATIONS + EMOTION_LABELS erweitern, kein weiterer Code-Change nötig.
|
||||||
|
export type SupportedEmotion = 'idle' | 'happy' | 'thinking' | 'empathy';
|
||||||
|
export type Emotion = SupportedEmotion | (string & {});
|
||||||
|
|
||||||
// Direkte Animation-Namen aus der .riv-Datei (1:1 mit Nuxt BenAvatar.vue).
|
// Direkte Animation-Namen aus der .riv-Datei (1:1 mit Nuxt BenAvatar.vue).
|
||||||
// "happy" hat zwei Phasen: erst Übergangs-Animation, dann Loop.
|
// "happy" hat zwei Phasen: erst Übergangs-Animation, dann Loop.
|
||||||
const EMOTION_ANIMATIONS: Record<Emotion, string> = {
|
const EMOTION_ANIMATIONS: Record<string, string> = {
|
||||||
idle: 'Idle Loop',
|
idle: 'Idle Loop',
|
||||||
happy: 'idle to Pose 1',
|
happy: 'idle to Pose 1',
|
||||||
thinking: 'WALK',
|
thinking: 'WALK',
|
||||||
empathy: '01 Wave 1',
|
empathy: '01 Wave 1',
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMOTION_LABELS: Record<Emotion, string> = {
|
const EMOTION_LABELS: Record<string, string> = {
|
||||||
idle: 'bereit',
|
idle: 'bereit',
|
||||||
happy: 'froh für dich',
|
happy: 'froh für dich',
|
||||||
thinking: 'überlegt ...',
|
thinking: 'überlegt ...',
|
||||||
@ -67,13 +70,16 @@ type Props = {
|
|||||||
emotion: Emotion;
|
emotion: Emotion;
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
|
fallback?: Emotion;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RiveAvatar({ emotion, size = 'md', showLabel = false }: Props) {
|
export function RiveAvatar({ emotion, size = 'md', showLabel = false, fallback = 'idle' }: Props) {
|
||||||
const px = SIZE_PX[size];
|
const px = SIZE_PX[size];
|
||||||
|
|
||||||
|
const resolvedEmotion = EMOTION_ANIMATIONS[emotion] !== undefined ? emotion : fallback;
|
||||||
|
|
||||||
// Aktuelle Animation als deklarativer State (kein imperatives ref.play()).
|
// Aktuelle Animation als deklarativer State (kein imperatives ref.play()).
|
||||||
const [currentAnim, setCurrentAnim] = useState<string>(EMOTION_ANIMATIONS.idle);
|
const [currentAnim, setCurrentAnim] = useState<string>(EMOTION_ANIMATIONS[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle);
|
||||||
|
|
||||||
// Lokale URI für die .riv-Datei — geht über expo-asset damit der File
|
// Lokale URI für die .riv-Datei — geht über expo-asset damit der File
|
||||||
// im App-Sandbox gecached wird statt jedes Mal von Metro zu streamen.
|
// im App-Sandbox gecached wird statt jedes Mal von Metro zu streamen.
|
||||||
@ -92,14 +98,14 @@ export function RiveAvatar({ emotion, size = 'md', showLabel = false }: Props) {
|
|||||||
}, [riveUri]);
|
}, [riveUri]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (emotion === 'happy') {
|
if (resolvedEmotion === 'happy') {
|
||||||
// 2-Phasen-Flow: Übergang (~900ms) → Loop (1:1 wie Nuxt-BenAvatar)
|
// 2-Phasen-Flow: Übergang (~900ms) → Loop (1:1 wie Nuxt-BenAvatar)
|
||||||
setCurrentAnim('idle to Pose 1');
|
setCurrentAnim('idle to Pose 1');
|
||||||
const t = setTimeout(() => setCurrentAnim('Pose 1 loop'), 900);
|
const t = setTimeout(() => setCurrentAnim('Pose 1 loop'), 900);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}
|
}
|
||||||
setCurrentAnim(EMOTION_ANIMATIONS[emotion]);
|
setCurrentAnim(EMOTION_ANIMATIONS[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle);
|
||||||
}, [emotion]);
|
}, [resolvedEmotion]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ alignItems: 'center', gap: 4 }}>
|
<View style={{ alignItems: 'center', gap: 4 }}>
|
||||||
@ -158,7 +164,7 @@ export function RiveAvatar({ emotion, size = 'md', showLabel = false }: Props) {
|
|||||||
|
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<Text style={{ fontSize: 11, color: '#737373', letterSpacing: 0.3 }}>
|
<Text style={{ fontSize: 11, color: '#737373', letterSpacing: 0.3 }}>
|
||||||
{EMOTION_LABELS[emotion]}
|
{EMOTION_LABELS[resolvedEmotion] ?? EMOTION_LABELS[fallback] ?? ''}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { View, Text } from 'react-native';
|
import { View, Text } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { colors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
days: number;
|
days: number;
|
||||||
@ -16,14 +16,18 @@ const sizeMap = {
|
|||||||
|
|
||||||
export function StreakBadge({ days, size = 'md' }: Props) {
|
export function StreakBadge({ days, size = 'md' }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
const s = sizeMap[size];
|
const s = sizeMap[size];
|
||||||
return (
|
return (
|
||||||
<View className={`items-center bg-white border border-neutral-200 rounded-3xl ${s.padding}`}>
|
<View
|
||||||
|
className={`items-center rounded-3xl ${s.padding}`}
|
||||||
|
style={{ backgroundColor: colors.bg, borderWidth: 1, borderColor: colors.border }}
|
||||||
|
>
|
||||||
<View className="flex-row items-center gap-2 mb-1">
|
<View className="flex-row items-center gap-2 mb-1">
|
||||||
<Ionicons name="flame" size={s.icon} color={colors.brandOrange} />
|
<Ionicons name="flame" size={s.icon} color={colors.brandOrange} />
|
||||||
<Text className={`${s.number} text-neutral-900 tabular-nums`} style={{ fontFamily: 'Nunito_800ExtraBold' }}>{days}</Text>
|
<Text className={`${s.number} tabular-nums`} style={{ fontFamily: 'Nunito_800ExtraBold', color: colors.text }}>{days}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className={`${s.label} text-neutral-500 tracking-wide uppercase`} style={{ fontFamily: 'Nunito_600SemiBold' }}>
|
<Text className={`${s.label} tracking-wide uppercase`} style={{ fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
|
||||||
{days === 1 ? t('streak.label_one') : t('streak.label_other')} {t('streak.label_suffix')}
|
{days === 1 ? t('streak.label_one') : t('streak.label_other')} {t('streak.label_suffix')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Modal, View, Text, Pressable } from 'react-native';
|
import { Modal, View, Text, Pressable } from 'react-native';
|
||||||
import { Picker } from '@react-native-picker/picker';
|
import { Picker } from '@react-native-picker/picker';
|
||||||
import { colors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
|
|
||||||
type Option<T> = { value: T; label: string };
|
type Option<T> = { value: T; label: string };
|
||||||
|
|
||||||
@ -35,6 +35,7 @@ export function WheelPickerModal<T extends string | number>({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onClose,
|
onClose,
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
|
const colors = useColors();
|
||||||
// Tracks the wheel's current selection (separate from confirmed value).
|
// Tracks the wheel's current selection (separate from confirmed value).
|
||||||
// Initialized from `value` prop on each open.
|
// Initialized from `value` prop on each open.
|
||||||
const [tempValue, setTempValue] = useState<T | null>(value);
|
const [tempValue, setTempValue] = useState<T | null>(value);
|
||||||
@ -64,14 +65,15 @@ export function WheelPickerModal<T extends string | number>({
|
|||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
// Kein darkening (User-Regel) — backdrop nur als Tap-to-close-Layer
|
||||||
|
backgroundColor: 'transparent',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pressable onPress={() => {}}>
|
<Pressable onPress={() => {}}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: colors.bg,
|
||||||
borderTopLeftRadius: 18,
|
borderTopLeftRadius: 18,
|
||||||
borderTopRightRadius: 18,
|
borderTopRightRadius: 18,
|
||||||
paddingBottom: 24,
|
paddingBottom: 24,
|
||||||
@ -86,7 +88,7 @@ export function WheelPickerModal<T extends string | number>({
|
|||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#e5e5e5',
|
borderBottomColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pressable onPress={onClose} hitSlop={10}>
|
<Pressable onPress={onClose} hitSlop={10}>
|
||||||
|
|||||||
@ -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';
|
||||||
@ -21,9 +15,10 @@ import {
|
|||||||
normalizeDomain,
|
normalizeDomain,
|
||||||
type Tier,
|
type Tier,
|
||||||
} from '../../hooks/useCustomDomains';
|
} from '../../hooks/useCustomDomains';
|
||||||
|
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;
|
||||||
@ -34,6 +29,7 @@ type Props = {
|
|||||||
|
|
||||||
export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [confirmPermanent, setConfirmPermanent] = useState(false);
|
const [confirmPermanent, setConfirmPermanent] = useState(false);
|
||||||
@ -43,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);
|
||||||
@ -96,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: '#fff',
|
|
||||||
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: '#d4d4d4' }} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -147,20 +78,29 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
paddingTop: 6,
|
paddingTop: 6,
|
||||||
paddingBottom: 12,
|
paddingBottom: 12,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#f0f0f0',
|
borderBottomColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pressable onPress={close} hitSlop={10}>
|
<Pressable onPress={close} hitSlop={10}>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
{t('blocker.add_sheet_title')}
|
{t('blocker.add_sheet_title')}
|
||||||
</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>
|
||||||
@ -168,7 +108,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#525252',
|
color: colors.textMuted,
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -181,7 +121,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
placeholder={t('blocker.add_sheet_placeholder')}
|
placeholder={t('blocker.add_sheet_placeholder')}
|
||||||
placeholderTextColor="#a3a3a3"
|
placeholderTextColor={colors.textMuted}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
autoFocus
|
autoFocus
|
||||||
@ -189,13 +129,13 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
onSubmitEditing={handleAdd}
|
onSubmitEditing={handleAdd}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#0a0a0a',
|
color: colors.text,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{input && !valid && (
|
{input && !valid && (
|
||||||
@ -220,7 +160,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 10,
|
gap: 10,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -235,7 +175,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#0a0a0a',
|
color: colors.text,
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
@ -289,8 +229,8 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
height: 22,
|
height: 22,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
borderColor: confirmPermanent ? '#16a34a' : '#d4d4d4',
|
borderColor: confirmPermanent ? colors.success : colors.border,
|
||||||
backgroundColor: confirmPermanent ? '#16a34a' : '#fff',
|
backgroundColor: confirmPermanent ? colors.success : colors.bg,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginTop: 1,
|
marginTop: 1,
|
||||||
@ -303,7 +243,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#0a0a0a',
|
color: colors.text,
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -314,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>
|
||||||
)}
|
)}
|
||||||
@ -332,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" />
|
||||||
) : (
|
) : (
|
||||||
@ -348,8 +288,6 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAwareSheet>
|
||||||
</Animated.View>
|
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { View, Text, Pressable, ActivityIndicator } from 'react-native';
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
remainingFormatted: string; // "23:59:42"
|
remainingFormatted: string; // "23:59:42"
|
||||||
@ -10,6 +11,7 @@ type Props = {
|
|||||||
|
|
||||||
export function CooldownBanner({ remainingFormatted, onCancel }: Props) {
|
export function CooldownBanner({ remainingFormatted, onCancel }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
const [cancelling, setCancelling] = useState(false);
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
|
||||||
async function handleCancel() {
|
async function handleCancel() {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Modal, View, Text, Pressable, ScrollView, ActionSheetIOS, Platform, Ale
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@ -26,6 +27,7 @@ export function DeactivationExplainerSheet({
|
|||||||
onStartCooldown,
|
onStartCooldown,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
function showFinalConfirm() {
|
function showFinalConfirm() {
|
||||||
@ -74,7 +76,7 @@ export function DeactivationExplainerSheet({
|
|||||||
presentationStyle="pageSheet"
|
presentationStyle="pageSheet"
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
>
|
>
|
||||||
<View style={{ flex: 1, backgroundColor: '#fff' }}>
|
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -85,29 +87,29 @@ export function DeactivationExplainerSheet({
|
|||||||
paddingTop: 14,
|
paddingTop: 14,
|
||||||
paddingBottom: 12,
|
paddingBottom: 12,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#f0f0f0',
|
borderBottomColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pressable onPress={onClose} hitSlop={10}>
|
<Pressable onPress={onClose} hitSlop={10}>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||||||
{t('common.back')}
|
{t('common.back')}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
{t('blocker.deactivation_heading')}
|
{t('blocker.deactivation_heading')}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ width: 50 }} />
|
<View style={{ width: 50 }} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={{ padding: 20, gap: 18 }}>
|
<ScrollView contentContainerStyle={{ padding: 20, gap: 18 }}>
|
||||||
<Text style={{ fontSize: 22, fontFamily: 'Nunito_800ExtraBold', color: '#0a0a0a' }}>
|
<Text style={{ fontSize: 22, fontFamily: 'Nunito_800ExtraBold', color: colors.text }}>
|
||||||
{t('blocker.deactivation_title')}
|
{t('blocker.deactivation_title')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#404040',
|
color: colors.textMuted,
|
||||||
lineHeight: 22,
|
lineHeight: 22,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -195,6 +197,7 @@ function BulletRow({
|
|||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
}) {
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
return (
|
return (
|
||||||
<View style={{ flexDirection: 'row', gap: 12 }}>
|
<View style={{ flexDirection: 'row', gap: 12 }}>
|
||||||
<View
|
<View
|
||||||
@ -202,22 +205,22 @@ function BulletRow({
|
|||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name={icon} size={18} color="#525252" />
|
<Ionicons name={icon} size={18} color={colors.textMuted} />
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
|
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#525252',
|
color: colors.textMuted,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
lineHeight: 17,
|
lineHeight: 17,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { SuccessAlert } from '../SuccessAlert';
|
import { SuccessAlert } from '../SuccessAlert';
|
||||||
import { ConfirmAlert } from '../ConfirmAlert';
|
import { ConfirmAlert } from '../ConfirmAlert';
|
||||||
import type { CustomDomain, Tier } from '../../hooks/useCustomDomains';
|
import type { CustomDomain, Tier } from '../../hooks/useCustomDomains';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -65,6 +66,7 @@ const STATUS_PRIORITY: Record<string, number> = {
|
|||||||
|
|
||||||
export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Props) {
|
export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
// Slot-relevante Domains (alles außer approved). Sortiert nach Status-Priority,
|
// Slot-relevante Domains (alles außer approved). Sortiert nach Status-Priority,
|
||||||
// innerhalb gleicher Priority dann newest-first by addedAt.
|
// innerhalb gleicher Priority dann newest-first by addedAt.
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
@ -85,7 +87,7 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
|
|||||||
<View style={{ gap: 12 }}>
|
<View style={{ gap: 12 }}>
|
||||||
{/* Header: Section-Title + Slot-Counter + Add-Button (inline, neben SlotPill) */}
|
{/* Header: Section-Title + Slot-Counter + Add-Button (inline, neben SlotPill) */}
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
|
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
{t('blocker.domain_section_title')}
|
{t('blocker.domain_section_title')}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
@ -122,7 +124,7 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
|
|||||||
const pct = (tier.usedSlots / tier.domainLimit) * 100;
|
const pct = (tier.usedSlots / tier.domainLimit) * 100;
|
||||||
const barColor = pct >= 90 ? '#dc2626' : pct >= 60 ? '#f59e0b' : '#16a34a';
|
const barColor = pct >= 90 ? '#dc2626' : pct >= 60 ? '#f59e0b' : '#16a34a';
|
||||||
return (
|
return (
|
||||||
<View style={{ height: 4, borderRadius: 2, backgroundColor: '#f0f0f0', overflow: 'hidden' }}>
|
<View style={{ height: 4, borderRadius: 2, backgroundColor: colors.surfaceElevated, overflow: 'hidden' }}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@ -174,16 +176,16 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
|
|||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: 'dashed',
|
borderStyle: 'dashed',
|
||||||
borderColor: '#d4d4d4',
|
borderColor: colors.border,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="globe-outline" size={28} color="#a3a3a3" />
|
<Ionicons name="globe-outline" size={28} color={colors.textMuted} />
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
@ -205,8 +207,9 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
|
|||||||
// ─── SlotPill ─────────────────────────────────────────────────────────────
|
// ─── SlotPill ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SlotPill({ tier }: { tier: Tier }) {
|
function SlotPill({ tier }: { tier: Tier }) {
|
||||||
const bg = tier.atLimit ? '#fee2e2' : '#f5f5f5';
|
const colors = useColors();
|
||||||
const fg = tier.atLimit ? '#dc2626' : '#525252';
|
const bg = tier.atLimit ? '#fee2e2' : colors.surfaceElevated;
|
||||||
|
const fg = tier.atLimit ? '#dc2626' : colors.textMuted;
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -258,6 +261,7 @@ function DomainTile({
|
|||||||
onSubmit?: (id: string) => Promise<{ ok: boolean }>;
|
onSubmit?: (id: string) => Promise<{ ok: boolean }>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
const [successVisible, setSuccessVisible] = useState(false);
|
const [successVisible, setSuccessVisible] = useState(false);
|
||||||
@ -346,9 +350,9 @@ function DomainTile({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#fff',
|
backgroundColor: colors.bg,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e5e5',
|
borderColor: colors.border,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
padding: 8,
|
padding: 8,
|
||||||
// KEIN aspectRatio:1 mehr — der hat den Button auf 0 Höhe gepresst.
|
// KEIN aspectRatio:1 mehr — der hat den Button auf 0 Höhe gepresst.
|
||||||
@ -417,7 +421,7 @@ function DomainTile({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#0a0a0a',
|
color: colors.text,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { View, Text, Switch, Pressable, ActivityIndicator } from 'react-native';
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { ProtectionState } from '../../lib/protection';
|
import type { ProtectionState } from '../../lib/protection';
|
||||||
import { colors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
state: ProtectionState;
|
state: ProtectionState;
|
||||||
@ -15,6 +15,7 @@ type Props = {
|
|||||||
|
|
||||||
export function ProtectionCard({ state, loading, onActivate, onPressSettings }: Props) {
|
export function ProtectionCard({ state, loading, onActivate, onPressSettings }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
const isActive = state.phase === 'active' || state.phase === 'cooldownActive';
|
const isActive = state.phase === 'active' || state.phase === 'cooldownActive';
|
||||||
const isCooldown = state.phase === 'cooldownActive';
|
const isCooldown = state.phase === 'cooldownActive';
|
||||||
|
|
||||||
@ -30,8 +31,8 @@ export function ProtectionCard({ state, loading, onActivate, onPressSettings }:
|
|||||||
return t('blocker.protection_subtitle_pro');
|
return t('blocker.protection_subtitle_pro');
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const cardBg = isCooldown ? '#fef3c7' : isActive ? '#dcfce7' : '#ffffff';
|
const cardBg = isCooldown ? '#fef3c7' : isActive ? '#dcfce7' : colors.bg;
|
||||||
const cardBorder = isCooldown ? '#fcd34d' : isActive ? '#86efac' : '#e5e5e5';
|
const cardBorder = isCooldown ? '#fcd34d' : isActive ? '#86efac' : colors.border;
|
||||||
const iconBg = isCooldown ? '#fde68a' : isActive ? '#bbf7d0' : '#f5f5f5';
|
const iconBg = isCooldown ? '#fde68a' : isActive ? '#bbf7d0' : '#f5f5f5';
|
||||||
const iconColor = isCooldown ? '#d97706' : isActive ? '#16a34a' : '#a3a3a3';
|
const iconColor = isCooldown ? '#d97706' : isActive ? '#16a34a' : '#a3a3a3';
|
||||||
|
|
||||||
@ -67,7 +68,7 @@ export function ProtectionCard({ state, loading, onActivate, onPressSettings }:
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#0a0a0a',
|
color: colors.text,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('blocker.protection_card_title')}
|
{t('blocker.protection_card_title')}
|
||||||
@ -76,7 +77,7 @@ export function ProtectionCard({ state, loading, onActivate, onPressSettings }:
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#525252',
|
color: colors.textMuted,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -100,7 +101,7 @@ export function ProtectionCard({ state, loading, onActivate, onPressSettings }:
|
|||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: colors.surface,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
@ -108,7 +109,7 @@ export function ProtectionCard({ state, loading, onActivate, onPressSettings }:
|
|||||||
shadowOpacity: 0.05,
|
shadowOpacity: 0.05,
|
||||||
shadowRadius: 2,
|
shadowRadius: 2,
|
||||||
}}>
|
}}>
|
||||||
<Ionicons name="settings-outline" size={18} color="#525252" />
|
<Ionicons name="settings-outline" size={18} color={colors.textMuted} />
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
@ -146,18 +147,19 @@ export function ProtectionCard({ state, loading, onActivate, onPressSettings }:
|
|||||||
function Stat({
|
function Stat({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
valueColor = '#0a0a0a',
|
valueColor,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
valueColor?: string;
|
valueColor?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, alignItems: 'center' }}>
|
<View style={{ flex: 1, alignItems: 'center' }}>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color: valueColor }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color: valueColor ?? colors.text }}>
|
||||||
{value}
|
{value}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#737373' }}>
|
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import Svg, { Path, Circle } from 'react-native-svg';
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
import type { ProtectionState } from '../../lib/protection';
|
import type { ProtectionState } from '../../lib/protection';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@ -55,6 +56,7 @@ export function ProtectionDetailsSheet({
|
|||||||
onRequestDeactivation,
|
onRequestDeactivation,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
const localeTag = i18n.language === 'de' ? 'de-DE' : 'en-US';
|
const localeTag = i18n.language === 'de' ? 'de-DE' : 'en-US';
|
||||||
|
|
||||||
const sheetHeight = useRef(new Animated.Value(DEFAULT_HEIGHT)).current;
|
const sheetHeight = useRef(new Animated.Value(DEFAULT_HEIGHT)).current;
|
||||||
@ -162,7 +164,7 @@ export function ProtectionDetailsSheet({
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: colors.bg,
|
||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: 24,
|
||||||
borderTopRightRadius: 24,
|
borderTopRightRadius: 24,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@ -178,7 +180,7 @@ export function ProtectionDetailsSheet({
|
|||||||
{...panResponder.panHandlers}
|
{...panResponder.panHandlers}
|
||||||
style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 6 }}
|
style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 6 }}
|
||||||
>
|
>
|
||||||
<View style={{ width: 36, height: 5, borderRadius: 3, backgroundColor: '#d4d4d8' }} />
|
<View style={{ width: 36, height: 5, borderRadius: 3, backgroundColor: colors.border }} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -192,15 +194,15 @@ export function ProtectionDetailsSheet({
|
|||||||
paddingTop: 4,
|
paddingTop: 4,
|
||||||
paddingBottom: 12,
|
paddingBottom: 12,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#f0f0f0',
|
borderBottomColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ width: 50 }} />
|
<View style={{ width: 50 }} />
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
{t('blocker.details_title')}
|
{t('blocker.details_title')}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable onPress={handleClose} hitSlop={10} style={{ width: 50, alignItems: 'flex-end' }}>
|
<Pressable onPress={handleClose} hitSlop={10} style={{ width: 50, alignItems: 'flex-end' }}>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||||||
{t('blocker.details_done')}
|
{t('blocker.details_done')}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@ -249,16 +251,16 @@ export function ProtectionDetailsSheet({
|
|||||||
padding: 18,
|
padding: 18,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e5e5',
|
borderColor: colors.border,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: colors.bg,
|
||||||
gap: 8,
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
<Text style={{ fontSize: 13, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
|
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
|
||||||
{t('blocker.kpi_submissions_title')}
|
{t('blocker.kpi_submissions_title')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: 11, color: '#737373', fontFamily: 'Nunito_400Regular', marginTop: 2 }}>
|
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular', marginTop: 2 }}>
|
||||||
{t('blocker.kpi_submissions_subtitle')}
|
{t('blocker.kpi_submissions_subtitle')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -323,14 +325,14 @@ export function ProtectionDetailsSheet({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('blocker.faq_heading')}
|
{t('blocker.faq_heading')}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons name="help-circle-outline" size={18} color="#737373" />
|
<Ionicons name="help-circle-outline" size={18} color={colors.textMuted} />
|
||||||
</View>
|
</View>
|
||||||
{[1, 2, 3, 4].map((n) => (
|
{[1, 2, 3, 4].map((n) => (
|
||||||
<FaqItem
|
<FaqItem
|
||||||
@ -355,7 +357,7 @@ export function ProtectionDetailsSheet({
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
borderColor: HERO_COLOR,
|
borderColor: HERO_COLOR,
|
||||||
backgroundColor: '#fff7ed',
|
backgroundColor: colors.surface,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@ -479,6 +481,7 @@ function KpiCard({
|
|||||||
decimals?: number;
|
decimals?: number;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -486,14 +489,14 @@ function KpiCard({
|
|||||||
padding: 12,
|
padding: 12,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e5e5',
|
borderColor: colors.border,
|
||||||
backgroundColor: '#fafafa',
|
backgroundColor: colors.surface,
|
||||||
gap: 6,
|
gap: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
<Ionicons name={icon} size={14} color="#737373" />
|
<Ionicons name={icon} size={14} color={colors.textMuted} />
|
||||||
<Text style={{ flex: 1, fontSize: 10, color: '#737373', fontFamily: 'Nunito_400Regular', lineHeight: 13 }}>
|
<Text style={{ flex: 1, fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 13 }}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -502,10 +505,10 @@ function KpiCard({
|
|||||||
value={value}
|
value={value}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
decimals={decimals}
|
decimals={decimals}
|
||||||
style={{ fontSize: 18, fontFamily: 'Nunito_900Black', color: '#0a0a0a', letterSpacing: -0.3 }}
|
style={{ fontSize: 18, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.3 }}
|
||||||
/>
|
/>
|
||||||
{suffix ? (
|
{suffix ? (
|
||||||
<Text style={{ fontSize: 10, color: '#737373', fontFamily: 'Nunito_700Bold' }}>{suffix}</Text>
|
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_700Bold' }}>{suffix}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@ -522,13 +525,14 @@ function LegendItem({
|
|||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
}) {
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
return (
|
return (
|
||||||
<View style={{ alignItems: 'center', gap: 4 }}>
|
<View style={{ alignItems: 'center', gap: 4 }}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 5 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 5 }}>
|
||||||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color }} />
|
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color }} />
|
||||||
<Text style={{ fontSize: 11, color: '#525252', fontFamily: 'Nunito_700Bold' }}>{value}</Text>
|
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_700Bold' }}>{value}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={{ fontSize: 10, color: '#737373', fontFamily: 'Nunito_400Regular' }}>{label}</Text>
|
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>{label}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -543,6 +547,7 @@ function HalfDonut({
|
|||||||
centerValue: number;
|
centerValue: number;
|
||||||
centerLabel: string;
|
centerLabel: string;
|
||||||
}) {
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
const total = Math.max(1, segments.reduce((s, x) => s + x.value, 0));
|
const total = Math.max(1, segments.reduce((s, x) => s + x.value, 0));
|
||||||
|
|
||||||
const W = 220;
|
const W = 220;
|
||||||
@ -582,7 +587,7 @@ function HalfDonut({
|
|||||||
{/* Background track */}
|
{/* Background track */}
|
||||||
<Path
|
<Path
|
||||||
d={arcPath(cx, cy, r, 180, 360)}
|
d={arcPath(cx, cy, r, 180, 360)}
|
||||||
stroke="#f0f0f0"
|
stroke={colors.surfaceElevated}
|
||||||
strokeWidth={stroke}
|
strokeWidth={stroke}
|
||||||
fill="none"
|
fill="none"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@ -618,10 +623,10 @@ function HalfDonut({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: 30, fontFamily: 'Nunito_900Black', color: '#0a0a0a', letterSpacing: -0.5 }}>
|
<Text style={{ fontSize: 30, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.5 }}>
|
||||||
{centerValue}
|
{centerValue}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: 10, color: '#737373', fontFamily: 'Nunito_400Regular', marginTop: -2 }}>
|
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular', marginTop: -2 }}>
|
||||||
{centerLabel}
|
{centerLabel}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -643,6 +648,7 @@ function polar(cx: number, cy: number, r: number, angleDeg: number) {
|
|||||||
|
|
||||||
// ─── FAQ Item (chevron AT END of header row, on right) ─────────────────────
|
// ─── FAQ Item (chevron AT END of header row, on right) ─────────────────────
|
||||||
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||||
|
const colors = useColors();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const rotateAnim = useRef(new Animated.Value(0)).current;
|
const rotateAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
@ -664,10 +670,10 @@ function FaqItem({ question, answer }: { question: string; answer: string }) {
|
|||||||
style={{
|
style={{
|
||||||
alignSelf: 'stretch',
|
alignSelf: 'stretch',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e5e5',
|
borderColor: colors.border,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: '#fff',
|
backgroundColor: colors.bg,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
@ -678,7 +684,7 @@ function FaqItem({ question, answer }: { question: string; answer: string }) {
|
|||||||
>
|
>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14 }}>
|
||||||
<View style={{ flex: 1, paddingRight: 12 }}>
|
<View style={{ flex: 1, paddingRight: 12 }}>
|
||||||
<Text style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#0a0a0a', lineHeight: 18 }}>
|
<Text style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: colors.text, lineHeight: 18 }}>
|
||||||
{question}
|
{question}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -687,19 +693,19 @@ function FaqItem({ question, answer }: { question: string; answer: string }) {
|
|||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
transform: [{ rotate }],
|
transform: [{ rotate }],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="chevron-down" size={16} color="#525252" />
|
<Ionicons name="chevron-down" size={16} color={colors.textMuted} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{open && (
|
{open && (
|
||||||
<View style={{ paddingHorizontal: 14, paddingBottom: 14, paddingTop: 0 }}>
|
<View style={{ paddingHorizontal: 14, paddingBottom: 14, paddingTop: 0 }}>
|
||||||
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: '#525252', lineHeight: 19 }}>
|
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 19 }}>
|
||||||
{answer}
|
{answer}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { View, Text, Pressable } from 'react-native';
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { ProtectionState } from '../../lib/protection';
|
import type { ProtectionState } from '../../lib/protection';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
state: ProtectionState;
|
state: ProtectionState;
|
||||||
@ -16,6 +17,7 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
export function ProtectionLockedCard({ state, onPressSettings }: Props) {
|
export function ProtectionLockedCard({ state, onPressSettings }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
const isCooldown = state.phase === 'cooldownActive';
|
const isCooldown = state.phase === 'cooldownActive';
|
||||||
const cardBg = isCooldown ? '#fef3c7' : '#dcfce7';
|
const cardBg = isCooldown ? '#fef3c7' : '#dcfce7';
|
||||||
const cardBorder = isCooldown ? '#fcd34d' : '#86efac';
|
const cardBorder = isCooldown ? '#fcd34d' : '#86efac';
|
||||||
@ -57,14 +59,14 @@ export function ProtectionLockedCard({ state, onPressSettings }: Props) {
|
|||||||
<Ionicons name="shield-checkmark" size={22} color={iconColor} />
|
<Ionicons name="shield-checkmark" size={22} color={iconColor} />
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
|
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
{t('blocker.protection_card_locked_title')}
|
{t('blocker.protection_card_locked_title')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#525252',
|
color: colors.textMuted,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -84,7 +86,7 @@ export function ProtectionLockedCard({ state, onPressSettings }: Props) {
|
|||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: colors.surface,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
@ -92,7 +94,7 @@ export function ProtectionLockedCard({ state, onPressSettings }: Props) {
|
|||||||
shadowOpacity: 0.05,
|
shadowOpacity: 0.05,
|
||||||
shadowRadius: 2,
|
shadowRadius: 2,
|
||||||
}}>
|
}}>
|
||||||
<Ionicons name="settings-outline" size={18} color="#525252" />
|
<Ionicons name="settings-outline" size={18} color={colors.textMuted} />
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@ -118,11 +120,12 @@ export function ProtectionLockedCard({ state, onPressSettings }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value, valueColor = '#0a0a0a' }: { label: string; value: string; valueColor?: string }) {
|
function Stat({ label, value, valueColor }: { label: string; value: string; valueColor?: string }) {
|
||||||
|
const colors = useColors();
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, alignItems: 'center' }}>
|
<View style={{ flex: 1, alignItems: 'center' }}>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color: valueColor }}>{value}</Text>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color: valueColor ?? colors.text }}>{value}</Text>
|
||||||
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#737373' }}>{label}</Text>
|
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>{label}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import * as Clipboard from 'expo-clipboard';
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { resolveAvatar } from '../../lib/resolveAvatar';
|
import { resolveAvatar } from '../../lib/resolveAvatar';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
export type ChatMsg = {
|
export type ChatMsg = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -63,6 +64,8 @@ export function ChatBubble({
|
|||||||
onOpenImage,
|
onOpenImage,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const styles = makeStyles(colors);
|
||||||
const [actionsOpen, setActionsOpen] = useState(false);
|
const [actionsOpen, setActionsOpen] = useState(false);
|
||||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@ -323,7 +326,8 @@ export function ChatBubble({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||||
|
return StyleSheet.create({
|
||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
@ -337,7 +341,7 @@ const styles = StyleSheet.create({
|
|||||||
width: 26,
|
width: 26,
|
||||||
height: 26,
|
height: 26,
|
||||||
borderRadius: 13,
|
borderRadius: 13,
|
||||||
backgroundColor: '#e5e5e5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
},
|
},
|
||||||
bubbleCol: {
|
bubbleCol: {
|
||||||
maxWidth: '78%',
|
maxWidth: '78%',
|
||||||
@ -362,9 +366,9 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#007AFF',
|
backgroundColor: '#007AFF',
|
||||||
},
|
},
|
||||||
bubbleOther: {
|
bubbleOther: {
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: colors.surface,
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
borderColor: '#e5e5e5',
|
borderColor: colors.border,
|
||||||
},
|
},
|
||||||
replyPreview: {
|
replyPreview: {
|
||||||
borderLeftWidth: 3,
|
borderLeftWidth: 3,
|
||||||
@ -381,7 +385,7 @@ const styles = StyleSheet.create({
|
|||||||
image: {
|
image: {
|
||||||
width: 220,
|
width: 220,
|
||||||
height: 220,
|
height: 220,
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
},
|
},
|
||||||
imageTimeOverlay: {
|
imageTimeOverlay: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@ -412,7 +416,7 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
},
|
},
|
||||||
sheet: {
|
sheet: {
|
||||||
backgroundColor: '#fff',
|
backgroundColor: colors.bg,
|
||||||
borderTopLeftRadius: 20,
|
borderTopLeftRadius: 20,
|
||||||
borderTopRightRadius: 20,
|
borderTopRightRadius: 20,
|
||||||
padding: 8,
|
padding: 8,
|
||||||
@ -422,7 +426,7 @@ const styles = StyleSheet.create({
|
|||||||
width: 36,
|
width: 36,
|
||||||
height: 4,
|
height: 4,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
backgroundColor: '#d4d4d4',
|
backgroundColor: colors.border,
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
},
|
},
|
||||||
@ -436,7 +440,8 @@ const styles = StyleSheet.create({
|
|||||||
sheetText: {
|
sheetText: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#171717',
|
color: colors.text,
|
||||||
marginLeft: 12,
|
marginLeft: 12,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -11,10 +11,12 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import * as FileSystem from 'expo-file-system';
|
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
type ReplyTo = { id: string; nickname: string; content: string };
|
type ReplyTo = { id: string; nickname: string; content: string };
|
||||||
|
|
||||||
@ -44,6 +46,7 @@ export function ChatInput({
|
|||||||
onCancelReply,
|
onCancelReply,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [attachment, setAttachment] = useState<{
|
const [attachment, setAttachment] = useState<{
|
||||||
uri: string;
|
uri: string;
|
||||||
@ -136,6 +139,8 @@ export function ChatInput({
|
|||||||
setAttachment(null);
|
setAttachment(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = makeStyles(colors);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Reply preview */}
|
{/* Reply preview */}
|
||||||
@ -230,18 +235,19 @@ function decodeBase64(base64: string): Uint8Array {
|
|||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||||
|
return StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: colors.bg,
|
||||||
borderTopWidth: StyleSheet.hairlineWidth,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
borderTopColor: '#e5e5e5',
|
borderTopColor: colors.border,
|
||||||
},
|
},
|
||||||
replyBar: {
|
replyBar: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
backgroundColor: '#eff6ff',
|
backgroundColor: colors.surface,
|
||||||
borderLeftWidth: 3,
|
borderLeftWidth: 3,
|
||||||
borderLeftColor: '#007AFF',
|
borderLeftColor: '#007AFF',
|
||||||
marginHorizontal: 8,
|
marginHorizontal: 8,
|
||||||
@ -256,7 +262,7 @@ const styles = StyleSheet.create({
|
|||||||
replyContent: {
|
replyContent: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#525252',
|
color: colors.textMuted,
|
||||||
marginTop: 1,
|
marginTop: 1,
|
||||||
},
|
},
|
||||||
attachBar: {
|
attachBar: {
|
||||||
@ -264,7 +270,7 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
backgroundColor: '#fafafa',
|
backgroundColor: colors.surface,
|
||||||
marginHorizontal: 8,
|
marginHorizontal: 8,
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
@ -279,7 +285,7 @@ const styles = StyleSheet.create({
|
|||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
backgroundColor: '#e5e5e5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginRight: 8,
|
marginRight: 8,
|
||||||
@ -288,7 +294,7 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#171717',
|
color: colors.text,
|
||||||
},
|
},
|
||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -307,7 +313,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
inputWrap: {
|
inputWrap: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
borderRadius: 22,
|
borderRadius: 22,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
minHeight: 36,
|
minHeight: 36,
|
||||||
@ -318,7 +324,7 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
lineHeight: 19,
|
lineHeight: 19,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#171717',
|
color: colors.text,
|
||||||
paddingVertical: Platform.OS === 'ios' ? 8 : 4,
|
paddingVertical: Platform.OS === 'ios' ? 8 : 4,
|
||||||
},
|
},
|
||||||
sendBtn: {
|
sendBtn: {
|
||||||
@ -330,3 +336,4 @@ const styles = StyleSheet.create({
|
|||||||
marginLeft: 6,
|
marginLeft: 6,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,17 +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 { KeyboardAwareSheet } from '../KeyboardAwareSheet';
|
||||||
|
|
||||||
|
const COLLAPSED_HEIGHT = 480;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@ -21,6 +22,8 @@ type Props = {
|
|||||||
|
|
||||||
export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
|
export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const styles = makeStyles(colors);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [isPublic, setIsPublic] = useState(true);
|
const [isPublic, setIsPublic] = useState(true);
|
||||||
@ -34,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;
|
||||||
@ -59,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
|
||||||
@ -84,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]} />
|
||||||
@ -119,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" />
|
||||||
@ -139,47 +147,27 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
|
|||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</View>
|
||||||
</Pressable>
|
</KeyboardAwareSheet>
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||||
backdrop: {
|
return StyleSheet.create({
|
||||||
flex: 1,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
},
|
|
||||||
sheet: {
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderTopLeftRadius: 22,
|
|
||||||
borderTopRightRadius: 22,
|
|
||||||
padding: 18,
|
|
||||||
paddingBottom: Platform.OS === 'ios' ? 32 : 18,
|
|
||||||
},
|
|
||||||
grabber: {
|
|
||||||
width: 36,
|
|
||||||
height: 4,
|
|
||||||
borderRadius: 2,
|
|
||||||
backgroundColor: '#d4d4d4',
|
|
||||||
alignSelf: 'center',
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
title: {
|
title: {
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#171717',
|
color: colors.text,
|
||||||
marginBottom: 14,
|
marginBottom: 14,
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#171717',
|
color: colors.text,
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
},
|
},
|
||||||
toggleRow: {
|
toggleRow: {
|
||||||
@ -192,13 +180,13 @@ const styles = StyleSheet.create({
|
|||||||
toggleLabel: {
|
toggleLabel: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#171717',
|
color: colors.text,
|
||||||
},
|
},
|
||||||
toggle: {
|
toggle: {
|
||||||
width: 46,
|
width: 46,
|
||||||
height: 28,
|
height: 28,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
backgroundColor: '#e5e5e5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
padding: 2,
|
padding: 2,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
@ -209,7 +197,7 @@ const styles = StyleSheet.create({
|
|||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: colors.bg,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOpacity: 0.15,
|
shadowOpacity: 0.15,
|
||||||
shadowRadius: 2,
|
shadowRadius: 2,
|
||||||
@ -222,7 +210,7 @@ const styles = StyleSheet.create({
|
|||||||
subLabel: {
|
subLabel: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
},
|
},
|
||||||
modeRow: {
|
modeRow: {
|
||||||
@ -233,29 +221,30 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e5e5',
|
borderColor: colors.border,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginRight: 6,
|
marginRight: 6,
|
||||||
},
|
},
|
||||||
modeBtnActive: {
|
modeBtnActive: {
|
||||||
backgroundColor: '#eff6ff',
|
backgroundColor: colors.surface,
|
||||||
borderColor: '#007AFF',
|
borderColor: '#007AFF',
|
||||||
},
|
},
|
||||||
modeBtnText: {
|
modeBtnText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
modeBtnTextActive: {
|
modeBtnTextActive: {
|
||||||
color: '#007AFF',
|
color: '#007AFF',
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
marginTop: 20,
|
marginTop: 4,
|
||||||
|
marginBottom: 10,
|
||||||
},
|
},
|
||||||
cancelBtn: {
|
cancelBtn: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -264,7 +253,7 @@ const styles = StyleSheet.create({
|
|||||||
cancelText: {
|
cancelText: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#171717',
|
color: colors.text,
|
||||||
},
|
},
|
||||||
createBtn: {
|
createBtn: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -280,3 +269,4 @@ const styles = StyleSheet.create({
|
|||||||
color: '#fff',
|
color: '#fff',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { View, Text, Pressable, Image, StyleSheet } from 'react-native';
|
import { View, Text, Pressable, Image, StyleSheet } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
export type Room = {
|
export type Room = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -29,6 +30,8 @@ function formatTime(ts: string, justNow: string) {
|
|||||||
|
|
||||||
export function RoomCard({ room, onPress }: Props) {
|
export function RoomCard({ room, onPress }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const styles = makeStyles(colors);
|
||||||
const initials = room.name
|
const initials = room.name
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
@ -42,7 +45,7 @@ export function RoomCard({ room, onPress }: Props) {
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.avatar,
|
styles.avatar,
|
||||||
{ backgroundColor: room.isPublic ? '#eff6ff' : '#e5e5e5' },
|
{ backgroundColor: room.isPublic ? colors.surface : colors.surfaceElevated },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{room.avatarUrl ? (
|
{room.avatarUrl ? (
|
||||||
@ -102,16 +105,17 @@ export function RoomCard({ room, onPress }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||||
|
return StyleSheet.create({
|
||||||
row: {
|
row: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 11,
|
paddingVertical: 11,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: colors.bg,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
borderBottomColor: '#f5f5f5',
|
borderBottomColor: colors.border,
|
||||||
},
|
},
|
||||||
avatar: {
|
avatar: {
|
||||||
width: 42,
|
width: 42,
|
||||||
@ -129,7 +133,7 @@ const styles = StyleSheet.create({
|
|||||||
avatarInitials: {
|
avatarInitials: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#525252',
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -155,19 +159,19 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#171717',
|
color: colors.text,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
defaultBadge: {
|
defaultBadge: {
|
||||||
marginLeft: 6,
|
marginLeft: 6,
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 1,
|
paddingVertical: 1,
|
||||||
backgroundColor: '#eff6ff',
|
backgroundColor: colors.surface,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
},
|
},
|
||||||
defaultBadgeText: {
|
defaultBadgeText: {
|
||||||
@ -178,12 +182,12 @@ const styles = StyleSheet.create({
|
|||||||
lastMessage: {
|
lastMessage: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#a3a3a3',
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
right: {
|
right: {
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
@ -192,13 +196,13 @@ const styles = StyleSheet.create({
|
|||||||
memberCount: {
|
memberCount: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
marginLeft: 3,
|
marginLeft: 3,
|
||||||
},
|
},
|
||||||
time: {
|
time: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: 'Nunito_500Medium',
|
fontFamily: 'Nunito_500Medium',
|
||||||
color: '#a3a3a3',
|
color: colors.textMuted,
|
||||||
marginLeft: 'auto',
|
marginLeft: 'auto',
|
||||||
paddingLeft: 6,
|
paddingLeft: 6,
|
||||||
},
|
},
|
||||||
@ -206,7 +210,7 @@ const styles = StyleSheet.create({
|
|||||||
marginLeft: 6,
|
marginLeft: 6,
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
paddingVertical: 3,
|
paddingVertical: 3,
|
||||||
backgroundColor: '#eff6ff',
|
backgroundColor: colors.surface,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
},
|
},
|
||||||
joinBadgeText: {
|
joinBadgeText: {
|
||||||
@ -215,3 +219,4 @@ const styles = StyleSheet.create({
|
|||||||
color: '#007AFF',
|
color: '#007AFF',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,256 +1,505 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Animated, Pressable, Text, View } from 'react-native';
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Keyboard,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { RiveAvatar } from '../RiveAvatar';
|
||||||
|
import { StarRating } from './StarRating';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
|
|
||||||
export type GameOverScreenProps = {
|
export type GameOverScreenProps = {
|
||||||
score: number;
|
score: number;
|
||||||
bestScore: number;
|
bestScore: number;
|
||||||
gameName: string;
|
gameName: string;
|
||||||
|
scoreLabel?: string;
|
||||||
|
goodScore?: number;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
isNewBest?: boolean;
|
isNewBest?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MOTIVATIONAL_KEYS = [
|
function lyraMsg(
|
||||||
'gameOver.motivational_0',
|
gameName: string,
|
||||||
'gameOver.motivational_1',
|
score: number,
|
||||||
'gameOver.motivational_2',
|
goodScore: number,
|
||||||
'gameOver.motivational_3',
|
isNewBest: boolean,
|
||||||
'gameOver.motivational_4',
|
t: (k: string) => string
|
||||||
];
|
): { title: string; body: string } {
|
||||||
|
if (isNewBest) return { title: t('gameOver.lyra_title_record'), body: t('gameOver.lyra_body_record') };
|
||||||
|
if (score >= goodScore) return { title: t('gameOver.lyra_title_good'), body: t('gameOver.lyra_body_good') };
|
||||||
|
if (score > 0) return { title: t('gameOver.lyra_title_ok'), body: t('gameOver.lyra_body_ok') };
|
||||||
|
return { title: t('gameOver.lyra_title_low'), body: t('gameOver.lyra_body_low') };
|
||||||
|
}
|
||||||
|
|
||||||
export function GameOverScreen({
|
export function GameOverScreen({
|
||||||
score,
|
score,
|
||||||
bestScore,
|
bestScore,
|
||||||
gameName,
|
gameName,
|
||||||
|
scoreLabel,
|
||||||
|
goodScore = 5,
|
||||||
onRetry,
|
onRetry,
|
||||||
onExit,
|
onExit,
|
||||||
isNewBest = false,
|
isNewBest = false,
|
||||||
}: GameOverScreenProps) {
|
}: GameOverScreenProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const slideAnim = useRef(new Animated.Value(40)).current;
|
// Slide-In Spring für den Sheet-Auftritt (eigene Bouncy-Animation behalten)
|
||||||
const fadeAnim = useRef(new Animated.Value(0)).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 [feedback, setFeedback] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
const [shareSectionOpen, setShareSectionOpen] = useState(false);
|
||||||
|
const [shareText, setShareText] = useState('');
|
||||||
|
const [shareTextLoading, setShareTextLoading] = useState(false);
|
||||||
|
const [sharing, setSharing] = useState(false);
|
||||||
|
const [posted, setPosted] = useState(false);
|
||||||
|
const [postError, setPostError] = useState(false);
|
||||||
|
|
||||||
|
const emotion = isNewBest || score >= goodScore ? 'happy' : 'empathy';
|
||||||
|
const msg = lyraMsg(gameName, score, goodScore, isNewBest, t);
|
||||||
|
const displayScore = score;
|
||||||
|
const displayBest = Math.max(score, bestScore);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||||
Animated.parallel([
|
Animated.spring(slideAnim, {
|
||||||
Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, tension: 60, friction: 10 }),
|
toValue: 0,
|
||||||
Animated.timing(fadeAnim, { toValue: 1, duration: 220, useNativeDriver: true }),
|
useNativeDriver: true,
|
||||||
]).start();
|
damping: 22,
|
||||||
|
stiffness: 200,
|
||||||
|
mass: 0.8,
|
||||||
|
}).start();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const motivationalKey = MOTIVATIONAL_KEYS[score % MOTIVATIONAL_KEYS.length]!;
|
// Negativer Lift — translateY -keyboardHeight schiebt Sheet nach oben.
|
||||||
const fmt = (n: number) => String(n).padStart(5, '0');
|
const keyboardLiftY = Animated.multiply(keyboardLift, -1);
|
||||||
|
|
||||||
|
function handleExit() {
|
||||||
|
Animated.timing(slideAnim, {
|
||||||
|
toValue: 500,
|
||||||
|
duration: 220,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => onExit());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRating() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/games/rating', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
gameName: gameName.toLowerCase(),
|
||||||
|
stars: rating,
|
||||||
|
feedback: feedback.trim() || null,
|
||||||
|
score,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setSaved(true);
|
||||||
|
} catch {
|
||||||
|
// endpoint not yet live — silent
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openShareSection() {
|
||||||
|
setShareTextLoading(true);
|
||||||
|
setShareSectionOpen(true);
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<{ text: string }>('/api/games/share-text', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
gameName: gameName.toLowerCase(),
|
||||||
|
score,
|
||||||
|
scoreLabel,
|
||||||
|
bestScore,
|
||||||
|
isNewRecord: score > bestScore,
|
||||||
|
mode: 'game',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setShareText(data.text || `${gameName}: ${score} ${scoreLabel ?? 'Punkte'}\n${t('gameOver.share_challenge')}`);
|
||||||
|
} catch {
|
||||||
|
setShareText(`${gameName}: ${score} ${scoreLabel ?? 'Punkte'}\n${t('gameOver.share_challenge')}`);
|
||||||
|
} finally {
|
||||||
|
setShareTextLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCommunityPost() {
|
||||||
|
if (!shareText.trim()) return;
|
||||||
|
setSharing(true);
|
||||||
|
setPostError(false);
|
||||||
|
try {
|
||||||
|
const scoreLine = `${scoreLabel ?? 'Score'}: ${score}`;
|
||||||
|
await apiFetch('/api/community/post', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
category: 'game_share',
|
||||||
|
content: `${gameName}\n${scoreLine}\n${shareText.trim()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setPosted(true);
|
||||||
|
setShareSectionOpen(false);
|
||||||
|
setTimeout(() => handleExit(), 1500);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[gameover/post] failed:', err);
|
||||||
|
setPostError(true);
|
||||||
|
} finally {
|
||||||
|
setSharing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pillBg = colors.surfaceElevated;
|
||||||
|
const pillText = colors.text;
|
||||||
|
const pillMuted = colors.textMuted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Modal visible transparent animationType="none" onRequestClose={handleExit}>
|
||||||
|
<View style={{ flex: 1, justifyContent: 'flex-end' }}>
|
||||||
|
<TouchableOpacity onPress={handleExit} activeOpacity={1} style={{ flex: 1 }} />
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
transform: [
|
||||||
inset: 0,
|
{ translateY: slideAnim },
|
||||||
justifyContent: 'center',
|
{ translateY: keyboardLiftY },
|
||||||
alignItems: 'center',
|
],
|
||||||
paddingHorizontal: 16,
|
backgroundColor: colors.surface,
|
||||||
paddingVertical: 24,
|
borderTopLeftRadius: 28,
|
||||||
opacity: fadeAnim,
|
borderTopRightRadius: 28,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: insets.bottom + 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Backdrop */}
|
{/* Grab-handle */}
|
||||||
<Pressable
|
<View
|
||||||
onPress={onExit}
|
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
alignSelf: 'center',
|
||||||
inset: 0,
|
width: 36,
|
||||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
height: 5,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: colors.textMuted,
|
||||||
|
opacity: 0.3,
|
||||||
|
marginBottom: 16,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Card */}
|
<ScrollView
|
||||||
<Animated.View
|
keyboardShouldPersistTaps="handled"
|
||||||
style={{
|
showsVerticalScrollIndicator={false}
|
||||||
transform: [{ translateY: slideAnim }],
|
contentContainerStyle={{ gap: 16, paddingBottom: 8 }}
|
||||||
width: '100%',
|
|
||||||
maxWidth: 340,
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
borderRadius: 20,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
paddingTop: 24,
|
|
||||||
paddingBottom: 20,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 8 },
|
|
||||||
shadowOpacity: 0.18,
|
|
||||||
shadowRadius: 20,
|
|
||||||
elevation: 12,
|
|
||||||
gap: 16,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Title row */}
|
{/* Lyra avatar + message */}
|
||||||
<View style={{ alignItems: 'center', gap: 4 }}>
|
<View style={{ alignItems: 'center', gap: 8 }}>
|
||||||
<Text
|
<RiveAvatar emotion={emotion} size="md" />
|
||||||
style={{
|
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
fontFamily: 'Nunito_800ExtraBold',
|
Lyra
|
||||||
fontSize: 22,
|
|
||||||
color: colors.text,
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('gameOver.title')}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 18, color: colors.text, textAlign: 'center' }}>
|
||||||
style={{
|
{msg.title}
|
||||||
fontFamily: 'Nunito_400Regular',
|
</Text>
|
||||||
fontSize: 13,
|
<Text style={{ fontFamily: 'Nunito_400Regular', fontSize: 13, color: colors.textMuted, textAlign: 'center', lineHeight: 18, paddingHorizontal: 4 }}>
|
||||||
color: colors.textMuted,
|
{msg.body}
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{gameName}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Score row */}
|
{/* Score pills */}
|
||||||
<View
|
<View style={{ flexDirection: 'row', justifyContent: 'center', gap: 10 }}>
|
||||||
style={{
|
<View style={{ flex: 1, backgroundColor: pillBg, borderRadius: 14, paddingVertical: 12, paddingHorizontal: 8, alignItems: 'center', gap: 2 }}>
|
||||||
flexDirection: 'row',
|
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 20, color: pillText }}>
|
||||||
justifyContent: 'center',
|
{displayScore}
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Score */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: colors.surfaceElevated,
|
|
||||||
borderRadius: 14,
|
|
||||||
paddingVertical: 12,
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontFamily: 'Courier New' as any,
|
|
||||||
fontSize: 22,
|
|
||||||
color: '#00e680',
|
|
||||||
letterSpacing: 2,
|
|
||||||
fontVariant: ['tabular-nums'],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{fmt(score)}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text style={{ fontSize: 10, color: pillMuted, textTransform: 'uppercase', letterSpacing: 1, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
style={{
|
{scoreLabel ?? t('gameOver.score')}
|
||||||
fontSize: 10,
|
|
||||||
color: colors.textMuted,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 1,
|
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('gameOver.score')}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Best */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: isNewBest ? '#fef3c7' : colors.surfaceElevated,
|
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
|
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 20, color: isNewBest ? '#0051d4' : pillMuted }}>
|
||||||
style={{
|
{displayBest}
|
||||||
fontFamily: 'Courier New' as any,
|
|
||||||
fontSize: 22,
|
|
||||||
color: isNewBest ? '#d97706' : colors.textMuted,
|
|
||||||
letterSpacing: 2,
|
|
||||||
fontVariant: ['tabular-nums'],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{fmt(Math.max(score, bestScore))}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text style={{ fontSize: 10, color: isNewBest ? '#0051d4' : pillMuted, textTransform: 'uppercase', letterSpacing: 1, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
style={{
|
|
||||||
fontSize: 10,
|
|
||||||
color: isNewBest ? '#d97706' : colors.textMuted,
|
|
||||||
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>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Motivational text */}
|
{/* Star rating */}
|
||||||
<Text
|
<View style={{ alignItems: 'center', gap: 6 }}>
|
||||||
|
<StarRating
|
||||||
|
value={rating}
|
||||||
|
size="lg"
|
||||||
|
interactive={!saved}
|
||||||
|
filledColor="#007AFF"
|
||||||
|
onChange={(v) => { if (!saved) setRating(v); }}
|
||||||
|
/>
|
||||||
|
{saved ? (
|
||||||
|
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
|
||||||
|
{t('gameOver.rating_saved')}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Feedback textarea + save */}
|
||||||
|
{rating > 0 && !saved ? (
|
||||||
|
<View style={{ gap: 8 }}>
|
||||||
|
<TextInput
|
||||||
|
value={feedback}
|
||||||
|
onChangeText={setFeedback}
|
||||||
|
placeholder={t('gameOver.feedback_placeholder')}
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
multiline
|
||||||
|
numberOfLines={2}
|
||||||
style={{
|
style={{
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: colors.textMuted,
|
|
||||||
textAlign: 'center',
|
|
||||||
lineHeight: 19,
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
paddingHorizontal: 4,
|
color: colors.text,
|
||||||
|
minHeight: 56,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={submitRating}
|
||||||
|
disabled={saving}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#007AFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
minHeight: 40,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
opacity: saving ? 0.65 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t(motivationalKey)}
|
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#ffffff' }}>
|
||||||
|
{saving ? t('common.loading') : t('gameOver.save_rating')}
|
||||||
</Text>
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Buttons */}
|
{/* Primary action row */}
|
||||||
<View style={{ flexDirection: 'row', gap: 10 }}>
|
<View style={{ flexDirection: 'row', gap: 12 }}>
|
||||||
<Pressable
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||||
onRetry();
|
onRetry();
|
||||||
}}
|
}}
|
||||||
style={({ pressed }) => ({
|
activeOpacity={0.85}
|
||||||
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingVertical: 13,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
borderRadius: 12,
|
|
||||||
backgroundColor: '#007AFF',
|
backgroundColor: '#007AFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
minHeight: 40,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
opacity: pressed ? 0.75 : 1,
|
justifyContent: 'center',
|
||||||
})}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: '#ffffff' }}>
|
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#ffffff' }}>
|
||||||
{t('gameOver.retry')}
|
{t('gameOver.retry')}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<Pressable
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||||
onExit();
|
handleExit();
|
||||||
}}
|
}}
|
||||||
style={({ pressed }) => ({
|
activeOpacity={0.75}
|
||||||
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingVertical: 13,
|
backgroundColor: '#e5e7eb',
|
||||||
paddingHorizontal: 16,
|
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: colors.surfaceElevated,
|
minHeight: 40,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 20,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
opacity: pressed ? 0.75 : 1,
|
justifyContent: 'center',
|
||||||
})}
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(0,0,0,0.08)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: colors.textMuted }}>
|
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#374151' }}>
|
||||||
{t('gameOver.exit')}
|
{t('gameOver.exit')}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Share section */}
|
||||||
|
{posted ? (
|
||||||
|
<View style={{ alignItems: 'center', paddingVertical: 4, flexDirection: 'row', justifyContent: 'center', gap: 6 }}>
|
||||||
|
<Ionicons name="checkmark-circle" size={15} color={colors.success} />
|
||||||
|
<Text style={{ fontSize: 13, color: colors.success, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
|
{t('gameOver.posted')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : !shareSectionOpen ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={openShareSection}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
style={{ alignItems: 'center', paddingVertical: 4 }}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Ionicons name="people-outline" size={15} color={colors.textMuted} />
|
||||||
|
<Text style={{ fontSize: 13, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
|
{t('gameOver.share_result')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<View style={{ gap: 10 }}>
|
||||||
|
{shareTextLoading ? (
|
||||||
|
<View style={{ alignItems: 'center', paddingVertical: 12 }}>
|
||||||
|
<ActivityIndicator size="small" color={colors.textMuted} />
|
||||||
|
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular', marginTop: 6 }}>
|
||||||
|
{t('gameOver.share_loading')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<TextInput
|
||||||
|
value={shareText}
|
||||||
|
onChangeText={setShareText}
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 14,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: colors.text,
|
||||||
|
minHeight: 100,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{postError ? (
|
||||||
|
<Text style={{ fontSize: 12, color: colors.error, fontFamily: 'Nunito_600SemiBold', textAlign: 'center' }}>
|
||||||
|
{t('gameOver.post_error')}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', gap: 12 }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => { setShareSectionOpen(false); setShareText(''); setPostError(false); }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#e5e7eb',
|
||||||
|
borderRadius: 12,
|
||||||
|
minHeight: 40,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(0,0,0,0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#374151' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={submitCommunityPost}
|
||||||
|
disabled={!shareText.trim() || sharing || shareTextLoading}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#007AFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
minHeight: 40,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 6,
|
||||||
|
opacity: sharing || !shareText.trim() || shareTextLoading ? 0.55 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sharing ? (
|
||||||
|
<ActivityIndicator size="small" color="#ffffff" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="paper-plane-outline" size={16} color="#ffffff" />
|
||||||
|
)}
|
||||||
|
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#ffffff' }}>
|
||||||
|
{t('gameOver.post_to_community')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Animated.View>
|
</View>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { useRouter, type RelativePathString } from 'expo-router';
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../../stores/auth';
|
import { useAuthStore } from '../../stores/auth';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
// Controlled-Modal-Pattern. Trigger ist NICHT in dieser Komponente — der
|
// Controlled-Modal-Pattern. Trigger ist NICHT in dieser Komponente — der
|
||||||
// Avatar im AppHeader öffnet das Modal via `visible`-Prop (User-Anweisung
|
// Avatar im AppHeader öffnet das Modal via `visible`-Prop (User-Anweisung
|
||||||
@ -33,6 +34,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { signOut } = useAuthStore();
|
const { signOut } = useAuthStore();
|
||||||
|
const colors = useColors();
|
||||||
|
|
||||||
function nav(path: RelativePathString) {
|
function nav(path: RelativePathString) {
|
||||||
onClose();
|
onClose();
|
||||||
@ -93,7 +95,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: topOffset,
|
top: topOffset,
|
||||||
right: 12,
|
right: 12,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: colors.surface,
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 8 },
|
shadowOffset: { width: 0, height: 8 },
|
||||||
@ -148,7 +150,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#a3a3a3',
|
color: colors.textMuted,
|
||||||
marginTop: 1,
|
marginTop: 1,
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
@ -156,11 +158,11 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
|
|||||||
{t('appHeader.sosTagline')}
|
{t('appHeader.sosTagline')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Ionicons name="chevron-forward" size={16} color="#d4d4d8" />
|
<Ionicons name="chevron-forward" size={16} color={colors.border} />
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
|
<View style={{ height: 1, backgroundColor: colors.border }} />
|
||||||
|
|
||||||
{/* Profile · Settings · Games · [Debug DEV] */}
|
{/* Profile · Settings · Games · [Debug DEV] */}
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
@ -170,7 +172,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
|
|||||||
onClose();
|
onClose();
|
||||||
void item.onSelect();
|
void item.onSelect();
|
||||||
}}
|
}}
|
||||||
android_ripple={{ color: '#e5e7eb' }}
|
android_ripple={{ color: colors.surfaceElevated }}
|
||||||
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@ -184,14 +186,14 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
|
|||||||
<Ionicons
|
<Ionicons
|
||||||
name={item.icon}
|
name={item.icon}
|
||||||
size={18}
|
size={18}
|
||||||
color="#737373"
|
color={colors.textMuted}
|
||||||
style={{ marginRight: 14 }}
|
style={{ marginRight: 14 }}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#0a0a0a',
|
color: colors.text,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@ -200,12 +202,12 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
|
<View style={{ height: 1, backgroundColor: colors.border }} />
|
||||||
|
|
||||||
{/* Abmelden — neutral, nicht rot */}
|
{/* Abmelden — neutral, nicht rot */}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleLogout}
|
onPress={handleLogout}
|
||||||
android_ripple={{ color: '#e5e7eb' }}
|
android_ripple={{ color: colors.surfaceElevated }}
|
||||||
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@ -219,14 +221,14 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
|
|||||||
<Ionicons
|
<Ionicons
|
||||||
name="log-out-outline"
|
name="log-out-outline"
|
||||||
size={18}
|
size={18}
|
||||||
color="#737373"
|
color={colors.textMuted}
|
||||||
style={{ marginRight: 14 }}
|
style={{ marginRight: 14 }}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#0a0a0a',
|
color: colors.text,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('headerMenu.logout')}
|
{t('headerMenu.logout')}
|
||||||
|
|||||||
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,
|
||||||
@ -18,9 +12,10 @@ 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, detectProvider, type MailProvider } from '../../hooks/useMailConnect';
|
import { useMailConnect, detectProvider, type MailProvider } from '../../hooks/useMailConnect';
|
||||||
|
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;
|
||||||
@ -97,6 +92,7 @@ const PROVIDERS: ProviderConfig[] = [
|
|||||||
*/
|
*/
|
||||||
export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { connect, connecting, error: connectError } = useMailConnect();
|
const { connect, connecting, error: connectError } = useMailConnect();
|
||||||
|
|
||||||
@ -107,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);
|
||||||
@ -178,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: '#fff',
|
|
||||||
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: '#d4d4d4' }} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -228,33 +161,39 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
paddingTop: 6,
|
paddingTop: 6,
|
||||||
paddingBottom: 12,
|
paddingBottom: 12,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#f0f0f0',
|
borderBottomColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{view === 'form' ? (
|
{view === 'form' ? (
|
||||||
<Pressable onPress={handleBack} hitSlop={10}>
|
<Pressable onPress={handleBack} hitSlop={10}>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||||||
{t('common.back')}
|
{t('common.back')}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
<Pressable onPress={handleClose} hitSlop={10}>
|
<Pressable onPress={handleClose} hitSlop={10}>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
|
|
||||||
{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} />
|
||||||
) : (
|
) : (
|
||||||
@ -274,9 +213,7 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAwareSheet>
|
||||||
</Animated.View>
|
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -293,6 +230,7 @@ function ProviderGrid({
|
|||||||
onSelect: (p: ProviderConfig) => void;
|
onSelect: (p: ProviderConfig) => void;
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
}) {
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
@ -303,7 +241,7 @@ function ProviderGrid({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
}}
|
}}
|
||||||
@ -325,9 +263,9 @@ function ProviderGrid({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 10,
|
gap: 10,
|
||||||
backgroundColor: '#f9f9f9',
|
backgroundColor: colors.surface,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e5e5',
|
borderColor: colors.border,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
padding: 14,
|
padding: 14,
|
||||||
}}>
|
}}>
|
||||||
@ -345,13 +283,13 @@ function ProviderGrid({
|
|||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}
|
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: colors.text }}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{t(p.labelKey)}
|
{t(p.labelKey)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Ionicons name="chevron-forward" size={14} color="#d4d4d4" />
|
<Ionicons name="chevron-forward" size={14} color={colors.border} />
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
@ -394,6 +332,7 @@ function FormView({
|
|||||||
insets,
|
insets,
|
||||||
t,
|
t,
|
||||||
}: FormViewProps) {
|
}: FormViewProps) {
|
||||||
|
const colors = useColors();
|
||||||
const canConnect = email.trim().length > 0 && password.trim().length > 0 && !connecting;
|
const canConnect = email.trim().length > 0 && password.trim().length > 0 && !connecting;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -461,7 +400,7 @@ function FormView({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#525252',
|
color: colors.textMuted,
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -471,19 +410,19 @@ function FormView({
|
|||||||
value={email}
|
value={email}
|
||||||
onChangeText={onEmailChange}
|
onChangeText={onEmailChange}
|
||||||
placeholder={t('mail.form_email_placeholder')}
|
placeholder={t('mail.form_email_placeholder')}
|
||||||
placeholderTextColor="#a3a3a3"
|
placeholderTextColor={colors.textMuted}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
returnKeyType="next"
|
returnKeyType="next"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#0a0a0a',
|
color: colors.text,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@ -494,7 +433,7 @@ function FormView({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#525252',
|
color: colors.textMuted,
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -505,21 +444,21 @@ function FormView({
|
|||||||
value={password}
|
value={password}
|
||||||
onChangeText={onPasswordChange}
|
onChangeText={onPasswordChange}
|
||||||
placeholder={t('mail.form_password_placeholder')}
|
placeholder={t('mail.form_password_placeholder')}
|
||||||
placeholderTextColor="#a3a3a3"
|
placeholderTextColor={colors.textMuted}
|
||||||
secureTextEntry={!passwordVisible}
|
secureTextEntry={!passwordVisible}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
onSubmitEditing={onConnect}
|
onSubmitEditing={onConnect}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
paddingRight: 46,
|
paddingRight: 46,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#0a0a0a',
|
color: colors.text,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@ -1,24 +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 { 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;
|
||||||
@ -33,38 +22,19 @@ 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 insets = useSafeAreaInsets();
|
const colors = useColors();
|
||||||
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()) {
|
||||||
@ -74,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: '#fff',
|
|
||||||
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: '#d4d4d4' }} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -129,26 +61,34 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
|
|||||||
paddingTop: 6,
|
paddingTop: 6,
|
||||||
paddingBottom: 12,
|
paddingBottom: 12,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#f0f0f0',
|
borderBottomColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pressable onPress={onClose} hitSlop={10}>
|
<Pressable onPress={handleClose} hitSlop={8}>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
|
<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: '#0a0a0a' }}>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
{t('mail.edit_account_title')}
|
{t('mail.edit_account_title')}
|
||||||
</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,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -159,13 +99,13 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
|
|||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: colors.surfaceElevated,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
gap: 10,
|
gap: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="lock-closed-outline" size={16} color="#a3a3a3" />
|
<Ionicons name="lock-closed-outline" size={16} color={colors.textMuted} />
|
||||||
<TextInput
|
<TextInput
|
||||||
value={password}
|
value={password}
|
||||||
onChangeText={(v) => {
|
onChangeText={(v) => {
|
||||||
@ -173,7 +113,7 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
|
|||||||
setFormError(null);
|
setFormError(null);
|
||||||
}}
|
}}
|
||||||
placeholder={t('mail.app_password_placeholder')}
|
placeholder={t('mail.app_password_placeholder')}
|
||||||
placeholderTextColor="#a3a3a3"
|
placeholderTextColor={colors.textMuted}
|
||||||
secureTextEntry={!passwordVisible}
|
secureTextEntry={!passwordVisible}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
@ -182,7 +122,7 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
|
|||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#0a0a0a',
|
color: colors.text,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Pressable onPress={() => setPasswordVisible((p) => !p)} hitSlop={8}>
|
<Pressable onPress={() => setPasswordVisible((p) => !p)} hitSlop={8}>
|
||||||
@ -214,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>
|
||||||
)}
|
)}
|
||||||
@ -227,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" />
|
||||||
) : (
|
) : (
|
||||||
@ -242,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)}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Pressable, Text, View } from 'react-native';
|
import { Pressable, Text, View } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onConnectPress: () => void;
|
onConnectPress: () => void;
|
||||||
@ -12,14 +13,15 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
export function MailEmptyState({ onConnectPress }: Props) {
|
export function MailEmptyState({ onConnectPress }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#fff',
|
backgroundColor: colors.bg,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e5e5',
|
borderColor: colors.border,
|
||||||
padding: 28,
|
padding: 28,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
@ -45,7 +47,7 @@ export function MailEmptyState({ onConnectPress }: Props) {
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontFamily: 'Nunito_700Bold',
|
fontFamily: 'Nunito_700Bold',
|
||||||
color: '#0a0a0a',
|
color: colors.text,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
@ -57,7 +59,7 @@ export function MailEmptyState({ onConnectPress }: Props) {
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
lineHeight: 19,
|
lineHeight: 19,
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
@ -71,7 +73,7 @@ export function MailEmptyState({ onConnectPress }: Props) {
|
|||||||
{(['privacy_1', 'privacy_2', 'privacy_3'] as const).map((key) => (
|
{(['privacy_1', 'privacy_2', 'privacy_3'] as const).map((key) => (
|
||||||
<View key={key} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
<View key={key} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
<Ionicons name="checkmark-circle" size={15} color="#16a34a" />
|
<Ionicons name="checkmark-circle" size={15} color="#16a34a" />
|
||||||
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#525252', flex: 1 }}>
|
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, flex: 1 }}>
|
||||||
{t(`mail.${key}`)}
|
{t(`mail.${key}`)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||