feat(mobile): add German/English i18n across the whole app

- i18next + react-i18next + expo-localization: device-locale detection,
  an MMKV-persisted DE/EN/Automatisch override (mirrors web's cookie-only
  locale, no backend field needed), and a language switcher on the Pets
  tab next to the theme toggle
- every screen and shared component converted from hardcoded German
  strings to translation keys; de.json/en.json kept in exact key-parity
  (218 keys each, verified programmatically)
- backend: src/trpc/init.ts now accepts an x-locale header as a fallback
  locale source (cookie still wins, web unaffected) since mobile has no
  cookie jar to set NEXT_LOCALE in; mobile's tRPC client sends the
  current language on every request
- fixes a latent bug: species/breed pickers were hardcoded to locale
  "de" while every other pet query resolved names via ctx.locale's
  default (English, since mobile never sent a locale cookie) — both now
  use the same current-language value
- expo-localization is a new native module (config plugin) and needs a
  native rebuild to actually link; deviceLocale() falls back to English
  instead of crashing when it isn't linked yet, so the app stays usable
  before that rebuild happens

Needs a NAS deploy (src/trpc/init.ts is shared backend code) before the
x-locale fallback and the species/breed locale fix take effect for mobile.
This commit is contained in:
admin committed 2026-09-06 17:31:41 +02:00
1 parent 0c6421c8de
commit 462f295caa
46 files changed
+1179 -285

No files matched your search

+66 -6
View File
@@ -18,8 +18,9 @@ stories (tray/creation/viewer), video playback, search, health tracking
(consent gate + weight/vet/vaccine logs), milestones, and message
sending/receiving are done and verified end-to-end against the live backend.
Push notifications are fully working — see "Push notifications" below.
Sign-up, first-pet onboarding, and a light/dark/auto theme are also done —
see their own sections below. Android is the priority platform; iOS
Sign-up, first-pet onboarding, a light/dark/auto theme, and German/English
i18n are also done — see their own sections below. Android is the priority
platform; iOS
builds need a Mac + Apple Developer account and come later (same
codebase, no separate source tree — see "Android-first, then iOS" below).
@@ -199,8 +200,10 @@ mobile/
lib/format.ts # pet display name + relative-time formatting
lib/sponsor.ts # active-sponsor check
lib/upload.ts # direct PUT of a picked image to a presigned Supabase URL
lib/milestone-meta.ts # MilestoneType → German label/icon map
lib/milestone-meta.ts # MilestoneType → i18n key/icon map, see "Internationalization"
theme/tokens.ts # design tokens (light `colors` + dark `darkColors`), see "Dark mode"
i18n/index.ts # i18next setup, device-locale detection, language preference, see "Internationalization"
i18n/locales/{en,de}.json # translation catalogs (kept in exact key-parity, see "Internationalization")
components/ # Button, TextField, Card, PostCard, PawButton, PetAvatar,
# PetIdentityBadges, FollowButton, StoryTray, PawRing, VideoPlayer,
# OptionPicker (species/breed dropdown, shared with Explore + onboarding)
@@ -242,14 +245,13 @@ screen is focused since messages.ts has no realtime mechanism) are done
and verified end-to-end against the live backend on a real Android
emulator. Still missing, roughly in the order they'd unblock the most:
- No i18n — the whole app is hardcoded German text, matching the sign-in screen
- Web's Explore "Trending" tab (algorithmic hot-posts grid + trending
hashtags, `explore.getTrending`) — mobile's Explore only has the
species/breed-filtered pet grid (`explore.listPets`), not this second tab.
Onboarding (`/onboarding/pet` — guidelines → upload consent →
Arachnophobie-Helfer opt-in → pet form → avatar), sign-up, and dark mode
are now built — see their own sections below.
Arachnophobie-Helfer opt-in → pet form → avatar), sign-up, dark mode, and
i18n are now built — see their own sections below.
### Onboarding and pet management
@@ -326,6 +328,64 @@ pet profile/edit/followers/following. They'll render correctly in light
mode regardless of the device/account theme setting until someone
applies the same `makeStyles` conversion to them.
### Internationalization (i18n)
German and English, matching web's supported locale set exactly
(`src/i18n/request.ts`'s `LOCALES`) — mobile intentionally didn't scope
this to the wider EN/ES/IT/FR list mentioned in older planning notes, since
web itself only ships EN/DE today. `i18next` + `react-i18next` (not
`next-intl`, which is Next.js-specific) with one flat "translation"
namespace per locale (`src/i18n/locales/{en,de}.json`), addressed by
dotted keys (`t("petForm.createButton")`) — mirrors next-intl's own
per-namespace-object shape closely enough that terminology was copied
verbatim from `../messages/{en,de}.json` wherever the same concept already
existed there (e.g. `MilestoneTypes`, `PetForm`).
- **Preference resolution** (`src/i18n/index.ts`) mirrors web's own
cookie-only approach: `getLanguagePreference()`/`setLanguagePreference()`
persist a `"de" | "en" | null` override to MMKV (`null` = follow device
language, via `expo-localization`'s `getLocales()`) — there's no backend
field for it, same as web's locale living only in the `NEXT_LOCALE`
cookie, never on the `Owner` row. The switcher lives right below the
Darstellung (theme) section on the Pets tab, same three-button pattern
(Deutsch/English/Automatisch), and calls `queryClient.invalidateQueries()`
on change so already-cached species/breed names and dates refetch under
the new language instead of staying stale.
- **`react-i18next`'s `useTranslation()` needs no theme-style `makeStyles`
workaround** — unlike colors, text updates on every render automatically
once `i18n.changeLanguage()` fires, so switching language re-labels the
entire app instantly with no per-screen conversion debt (contrast with
"Dark mode" above, which very much has that debt).
- **Backend locale resolution for mobile** (`src/trpc/init.ts`, root
`CLAUDE.md`'s `createTRPCContext`): procedures that resolve
DB-stored translatable text (Species/Breed names, via `ctx.locale`) read
a `NEXT_LOCALE` cookie that a React Native `fetch`/`httpBatchLink`
request can't set. Mobile's tRPC client (`src/api/trpc.ts`) sends an
`x-locale: <current language>` header instead; the backend checks the
cookie first (web unaffected), then this header (mobile-only fallback).
`pets.listSpecies`/`listBreeds` take `locale` as an explicit input rather
than reading `ctx.locale` — those call sites (`explore.tsx`,
`PetCreateForm.tsx`) pass `i18n.language` directly instead of a
hardcoded `"de"`, which was the actual state before this session (a
latent bug: species/breed *pickers* were German-only while the *rest* of
a pet's data was already resolving to English via `ctx.locale`'s
default, since mobile requests never carried a cookie at all).
- **Needs a NAS deploy**, unlike most `mobile/`-only work — the
`x-locale` header fallback is a shared backend file
(`src/trpc/init.ts`), and mobile's live default `EXPO_PUBLIC_API_URL`
points at the deployed site, not local dev (see
[[project_mobile_deployment_target]]).
- **`expo-localization` requires a native rebuild** (`expo prebuild` /
`expo run:android`) to actually link — it was added as a new native
module via config plugin, the same category of change as the FCM
credentials step in "Push notifications" below. Until that rebuild
happens on a given device, `Localization.getLocales()` throws `Cannot
find native module 'ExpoLocalization'`; `deviceLocale()` in
`src/i18n/index.ts` catches that and falls back to `defaultLocale`
("en") rather than crashing the app at module-load time — "Automatisch"
silently behaves like "English" on an un-rebuilt dev client instead of
actually reading the device's system language.
### Push notifications
`usePushNotifications` (in `app/_layout.tsx`) requests notification
+2 -1
View File
@@ -50,7 +50,8 @@
"icon": "./assets/android-icon-monochrome.png",
"color": "#f97316"
}
]
],
"expo-localization"
],
"extra": {
"router": {},
+6 -4
View File
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { Tabs, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Home, Compass, Bell, Heart, MessageCircle, PawPrint, Plus, Search } from "lucide-react-native";
import { useTRPC } from "../../../src/api/trpc";
@@ -18,6 +19,7 @@ import { fontFamily, type ColorTokens } from "../../../src/theme/tokens";
* for actions that need one.
*/
export default function TabsLayout() {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
@@ -44,14 +46,14 @@ export default function TabsLayout() {
<MessagesHeaderButton />
</View>
),
tabBarLabel: "Feed",
tabBarLabel: t("nav.feedTab"),
tabBarIcon: ({ color, size }) => <Home color={color} size={size} />,
}}
/>
<Tabs.Screen
name="explore"
options={{
title: "Explore",
title: t("nav.exploreTitle"),
headerRight: () => (
<View style={styles.headerButtons}>
<SearchHeaderButton />
@@ -63,12 +65,12 @@ export default function TabsLayout() {
/>
<Tabs.Screen
name="notifications"
options={{ title: "Benachrichtigungen", tabBarIcon: NotificationsTabIcon }}
options={{ title: t("nav.notificationsTitle"), tabBarIcon: NotificationsTabIcon }}
/>
<Tabs.Screen
name="pets"
options={{
title: "Meine Tiere",
title: t("nav.petsTitle"),
headerRight: () => (
<View style={styles.headerButtons}>
<AddPetHeaderButton />
+15 -7
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { View, Text, FlatList, Pressable, ScrollView, ActivityIndicator, StyleSheet } from "react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useTRPC } from "../../../src/api/trpc";
import { useTheme } from "../../../src/context/ThemeContext";
@@ -19,6 +20,7 @@ import type { ExplorePet } from "../../../src/api/types";
* yet.
*/
export default function ExploreScreen() {
const { t, i18n } = useTranslation();
const trpc = useTRPC();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
@@ -27,7 +29,7 @@ export default function ExploreScreen() {
const [breedId, setBreedId] = useState<string | null>(null);
const [breedName, setBreedName] = useState<string | null>(null);
const { data: speciesList = [] } = useQuery(trpc.pets.listSpecies.queryOptions({ locale: "de" }));
const { data: speciesList = [] } = useQuery(trpc.pets.listSpecies.queryOptions({ locale: i18n.language }));
const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, error } = useInfiniteQuery(
trpc.explore.listPets.infiniteQueryOptions(
@@ -73,7 +75,7 @@ export default function ExploreScreen() {
ListHeaderComponent={
<View style={styles.filters}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.speciesTabs}>
<SpeciesTab label="Alle" active={!speciesId} onPress={() => selectSpecies(null)} />
<SpeciesTab label={t("explore.allTab")} active={!speciesId} onPress={() => selectSpecies(null)} />
{speciesList.map((s) => (
<SpeciesTab key={s.id} label={s.name} active={speciesId === s.id} onPress={() => selectSpecies(s)} />
))}
@@ -99,7 +101,11 @@ export default function ExploreScreen() {
!isLoading ? (
<View style={styles.center}>
<Text style={getTypography(colors).body}>
{breedName ? `Noch keine ${breedName}.` : speciesName ? `Noch keine ${speciesName}.` : "Keine Tiere gefunden."}
{breedName
? t("explore.noPetsForSpecies", { name: breedName })
: speciesName
? t("explore.noPetsForSpecies", { name: speciesName })
: t("explore.noPetsAtAll")}
</Text>
</View>
) : null
@@ -127,22 +133,23 @@ function BreedFilter({
selectedName: string | null;
onSelect: (b: PickerOption | null) => void;
}) {
const { t, i18n } = useTranslation();
const trpc = useTRPC();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const [open, setOpen] = useState(false);
const { data: breeds = [], isLoading } = useQuery(trpc.pets.listBreeds.queryOptions({ speciesId, locale: "de" }));
const { data: breeds = [], isLoading } = useQuery(trpc.pets.listBreeds.queryOptions({ speciesId, locale: i18n.language }));
if (!isLoading && breeds.length === 0) return null;
const allOption: PickerOption = { id: "__all__", name: "Alle Rassen" };
const allOption: PickerOption = { id: "__all__", name: t("explore.allBreeds") };
return (
<View style={styles.breedFilter}>
<OptionPicker
open={open}
onToggle={() => setOpen((v) => !v)}
selectedName={selectedName}
placeholder="Alle Rassen"
placeholder={t("explore.allBreeds")}
isLoading={isLoading}
options={[allOption, ...breeds]}
onSelect={(o) => {
@@ -155,6 +162,7 @@ function BreedFilter({
}
function ExplorePetCard({ pet }: { pet: ExplorePet }) {
const { t } = useTranslation();
const router = useRouter();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
@@ -165,7 +173,7 @@ function ExplorePetCard({ pet }: { pet: ExplorePet }) {
<Text style={styles.name} numberOfLines={1}>
{formatPetName(pet)}
</Text>
<Text style={styles.followers}>{pet._count.followers} Follower</Text>
<Text style={styles.followers}>{t("explore.followerCount", { count: pet._count.followers })}</Text>
</Card>
</Pressable>
);
+3 -1
View File
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { View, Text, FlatList, ActivityIndicator, RefreshControl, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useTRPC } from "../../../src/api/trpc";
import { useActivePet } from "../../../src/context/ActivePetContext";
@@ -15,6 +16,7 @@ import type { FeedPost } from "../../../src/api/types";
* "chrono") toggle yet.
*/
export default function FeedScreen() {
const { t } = useTranslation();
const trpc = useTRPC();
const { activePetId, isLoading: isActivePetLoading } = useActivePet();
const { colors, typography } = useTheme();
@@ -67,7 +69,7 @@ export default function FeedScreen() {
ListFooterComponent={isFetchingNextPage ? <ActivityIndicator style={styles.footer} color={colors.marigoldOrange} /> : null}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>Noch keine Beiträge im Feed.</Text>
<Text style={typography.body}>{t("feed.empty")}</Text>
</View>
}
/>
+21 -16
View File
@@ -1,6 +1,8 @@
import { useCallback, useMemo } from "react";
import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useFocusEffect } from "expo-router";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "../../../src/api/trpc";
import { useActivePet } from "../../../src/context/ActivePetContext";
@@ -10,31 +12,34 @@ import { formatPetName, formatRelativeTime } from "../../../src/lib/format";
import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
import type { NotificationItem } from "../../../src/api/types";
const ACTOR_MESSAGES: Partial<Record<NotificationItem["type"], (actor: string) => string>> = {
FOLLOW: (actor) => `${actor} folgt dir jetzt.`,
REACTION: (actor) => `${actor} hat deinen Beitrag mit einer Pfote versehen.`,
COMMENT: (actor) => `${actor} hat deinen Beitrag kommentiert.`,
MESSAGE: (actor) => `${actor} hat dir eine Nachricht geschickt.`,
MENTION: (actor) => `${actor} hat dich erwähnt.`,
PAW_BACK: (actor) => `${actor} hat zurückgepfotet.`,
const ACTOR_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
FOLLOW: "notifications.follow",
REACTION: "notifications.reaction",
COMMENT: "notifications.comment",
MESSAGE: "notifications.message",
MENTION: "notifications.mention",
PAW_BACK: "notifications.pawBack",
};
// BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the
// backend never sets actorPetId for them (see src/app/api/cron/anniversaries
// on web), so there's no actor to name. Wording ported from messages/de.json.
const SELF_MESSAGES: Partial<Record<NotificationItem["type"], string>> = {
BIRTHDAY: "🎂 Alles Gute zum Geburtstag!",
ADOPTION_DAY: "🏠 Heute ist der Adoptionstag!",
// on web), so there's no actor to name.
const SELF_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
BIRTHDAY: "notifications.birthday",
ADOPTION_DAY: "notifications.adoptionDay",
};
function getNotificationText(item: NotificationItem): string {
if (SELF_MESSAGES[item.type]) return SELF_MESSAGES[item.type]!;
if (item.actorPet) return ACTOR_MESSAGES[item.type]?.(formatPetName(item.actorPet)) ?? item.type;
function getNotificationText(item: NotificationItem, t: TFunction): string {
const selfKey = SELF_MESSAGE_KEYS[item.type];
if (selfKey) return t(selfKey);
const actorKey = ACTOR_MESSAGE_KEYS[item.type];
if (actorKey && item.actorPet) return t(actorKey, { actor: formatPetName(item.actorPet) });
return item.type;
}
/** Port of web's notifications list — mark-read on tap, no deep-linking to the target post/comment yet. */
export default function NotificationsScreen() {
const { t } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
@@ -116,14 +121,14 @@ export default function NotificationsScreen() {
</View>
)}
<View style={styles.rowText}>
<Text style={styles.bodyText}>{getNotificationText(item)}</Text>
<Text style={styles.bodyText}>{getNotificationText(item, t)}</Text>
<Text style={styles.timestamp}>{formatRelativeTime(item.createdAt)}</Text>
</View>
</Pressable>
)}
ListEmptyComponent={
<View style={styles.center}>
<Text style={styles.bodyText}>Keine Benachrichtigungen.</Text>
<Text style={styles.bodyText}>{t("notifications.empty")}</Text>
</View>
}
/>
+73 -12
View File
@@ -1,9 +1,12 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useRouter } from "expo-router";
import { Check, Sun, Moon, SunMoon } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Check, Sun, Moon, SunMoon, Languages } from "lucide-react-native";
import { useActivePet } from "../../../src/context/ActivePetContext";
import { useTheme } from "../../../src/context/ThemeContext";
import { getLanguagePreference, setLanguagePreference, type Locale } from "../../../src/i18n";
import { PetAvatar } from "../../../src/components/PetAvatar";
import { PetIdentityBadges } from "../../../src/components/PetIdentityBadges";
import { Card } from "../../../src/components/Card";
@@ -23,6 +26,7 @@ import type { Pet } from "../../../src/api/types";
* on web (re-toggleable on /pets, not just at onboarding).
*/
export default function PetsScreen() {
const { t } = useTranslation();
const router = useRouter();
const { pets, activePetId, setActivePetId, isLoading } = useActivePet();
const { colors } = useTheme();
@@ -52,10 +56,15 @@ export default function PetsScreen() {
)}
ListEmptyComponent={
<View style={styles.center}>
<Text style={getTypography(colors).body}>Noch keine Tiere angelegt.</Text>
<Text style={getTypography(colors).body}>{t("pets.empty")}</Text>
</View>
}
ListFooterComponent={<AppearanceSection />}
ListFooterComponent={
<>
<AppearanceSection />
<LanguageSection />
</>
}
/>
);
}
@@ -103,21 +112,22 @@ function PetRow({
);
}
const APPEARANCE_OPTIONS = [
{ value: "LIGHT" as const, label: "Hell", icon: Sun },
{ value: "DARK" as const, label: "Dunkel", icon: Moon },
{ value: null, label: "Automatisch", icon: SunMoon },
];
function AppearanceSection() {
const { t } = useTranslation();
const { colors, preference, setPreference } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const appearanceOptions = [
{ value: "LIGHT" as const, label: t("pets.appearanceLight"), icon: Sun },
{ value: "DARK" as const, label: t("pets.appearanceDark"), icon: Moon },
{ value: null, label: t("pets.appearanceAuto"), icon: SunMoon },
];
return (
<View style={styles.appearanceSection}>
<Text style={styles.appearanceTitle}>Darstellung</Text>
<Text style={styles.appearanceTitle}>{t("pets.appearanceTitle")}</Text>
<View style={styles.appearanceRow}>
{APPEARANCE_OPTIONS.map((option) => {
{appearanceOptions.map((option) => {
const active = preference === option.value;
const Icon = option.icon;
return (
@@ -138,6 +148,57 @@ function AppearanceSection() {
);
}
/**
* Local-only preference (mirrors web: locale lives only in the NEXT_LOCALE
* cookie, never on the Owner row — see src/i18n/index.ts), so this needs no
* mutation, just MMKV + i18next's changeLanguage(). Full cache invalidation
* on change is deliberate: pet/post data cached before the switch (species
* and breed names in particular) was fetched under the old x-locale header
* and won't relabel itself otherwise.
*/
function LanguageSection() {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const queryClient = useQueryClient();
const [preference, setPreferenceState] = useState<Locale | null>(() => getLanguagePreference());
const languageOptions = [
{ value: "de" as const, label: t("pets.languageGerman") },
{ value: "en" as const, label: t("pets.languageEnglish") },
{ value: null, label: t("pets.languageAuto") },
];
const handleSelect = (value: Locale | null) => {
setPreferenceState(value);
setLanguagePreference(value);
queryClient.invalidateQueries();
};
return (
<View style={styles.appearanceSection}>
<Text style={styles.appearanceTitle}>{t("pets.languageTitle")}</Text>
<View style={styles.appearanceRow}>
{languageOptions.map((option) => {
const active = preference === option.value;
return (
<Pressable
key={option.label}
style={[styles.appearanceOption, active && styles.appearanceOptionActive]}
onPress={() => handleSelect(option.value)}
>
<Languages color={active ? colors.pureWhite : colors.ink} size={18} />
<Text style={[styles.appearanceOptionText, active && styles.appearanceOptionTextActive]}>
{option.label}
</Text>
</Pressable>
);
})}
</View>
</View>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
+15 -13
View File
@@ -1,6 +1,7 @@
import { Pressable } from "react-native";
import { Stack, useRouter } from "expo-router";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { useTheme } from "../../src/context/ThemeContext";
import { fontFamily } from "../../src/theme/tokens";
@@ -15,6 +16,7 @@ function CloseModalButton() {
}
export default function AppLayout() {
const { t } = useTranslation();
const { colors } = useTheme();
// Plain prop objects, not StyleSheet.create — safe to recompute on every
// render, unlike the module-level StyleSheet.create calls most other
@@ -29,21 +31,21 @@ export default function AppLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="messages/index" options={{ ...headerOptions, title: "Nachrichten" }} />
<Stack.Screen name="pets/[petId]/index" options={{ ...headerOptions, title: "Profil" }} />
<Stack.Screen name="pets/[petId]/edit" options={{ ...headerOptions, title: "Profil bearbeiten" }} />
<Stack.Screen name="pets/[petId]/followers" options={{ ...headerOptions, title: "Follower" }} />
<Stack.Screen name="pets/[petId]/following" options={{ ...headerOptions, title: "Folgt" }} />
<Stack.Screen name="pets/[petId]/health/index" options={{ ...headerOptions, title: "Gesundheit" }} />
<Stack.Screen name="pets/[petId]/milestones" options={{ ...headerOptions, title: "Meilensteine" }} />
<Stack.Screen name="posts/[postId]/comments" options={{ ...headerOptions, title: "Kommentare" }} />
<Stack.Screen name="messages/[conversationId]" options={{ ...headerOptions, title: "Nachricht" }} />
<Stack.Screen name="search" options={{ ...headerOptions, title: "Suche" }} />
<Stack.Screen name="messages/index" options={{ ...headerOptions, title: t("screens.messages") }} />
<Stack.Screen name="pets/[petId]/index" options={{ ...headerOptions, title: t("screens.profile") }} />
<Stack.Screen name="pets/[petId]/edit" options={{ ...headerOptions, title: t("screens.editProfile") }} />
<Stack.Screen name="pets/[petId]/followers" options={{ ...headerOptions, title: t("screens.followers") }} />
<Stack.Screen name="pets/[petId]/following" options={{ ...headerOptions, title: t("screens.following") }} />
<Stack.Screen name="pets/[petId]/health/index" options={{ ...headerOptions, title: t("screens.health") }} />
<Stack.Screen name="pets/[petId]/milestones" options={{ ...headerOptions, title: t("screens.milestones") }} />
<Stack.Screen name="posts/[postId]/comments" options={{ ...headerOptions, title: t("screens.comments") }} />
<Stack.Screen name="messages/[conversationId]" options={{ ...headerOptions, title: t("screens.conversation") }} />
<Stack.Screen name="search" options={{ ...headerOptions, title: t("screens.search") }} />
<Stack.Screen
name="post-new"
options={{
...headerOptions,
title: "Neuer Beitrag",
title: t("screens.newPost"),
presentation: "modal",
headerLeft: () => <CloseModalButton />,
}}
@@ -52,7 +54,7 @@ export default function AppLayout() {
name="story-new"
options={{
...headerOptions,
title: "Neue Story",
title: t("screens.newStory"),
presentation: "modal",
headerLeft: () => <CloseModalButton />,
}}
@@ -61,7 +63,7 @@ export default function AppLayout() {
name="milestone-new"
options={{
...headerOptions,
title: "Neuer Meilenstein",
title: t("screens.newMilestone"),
presentation: "modal",
headerLeft: () => <CloseModalButton />,
}}
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { View, Text, FlatList, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "../../../src/api/trpc";
import { useActivePet } from "../../../src/context/ActivePetContext";
@@ -18,6 +19,7 @@ const POLL_INTERVAL_MS = 4000;
* this screen is focused rather than a subscription.
*/
export default function ConversationScreen() {
const { t } = useTranslation();
const { conversationId } = useLocalSearchParams<{ conversationId: string }>();
const trpc = useTRPC();
const queryClient = useQueryClient();
@@ -73,7 +75,7 @@ export default function ConversationScreen() {
renderItem={({ item }) => <MessageBubble message={item} isOwn={item.senderPet.id === activePetId} />}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>Noch keine Nachrichten. Schreib die erste!</Text>
<Text style={typography.body}>{t("messages.empty")}</Text>
</View>
}
/>
@@ -81,14 +83,14 @@ export default function ConversationScreen() {
<View style={styles.inputRow}>
<TextField
placeholder="Nachricht schreiben…"
placeholder={t("messages.placeholder")}
value={body}
onChangeText={setBody}
maxLength={1000}
style={styles.input}
/>
<Button
title={sendMutation.isPending ? "…" : "Senden"}
title={sendMutation.isPending ? "…" : t("messages.send")}
onPress={handleSubmit}
disabled={!body.trim() || sendMutation.isPending}
style={styles.sendButton}
+3 -1
View File
@@ -1,6 +1,7 @@
import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useTRPC } from "../../../src/api/trpc";
import { useActivePet } from "../../../src/context/ActivePetContext";
import { PetAvatar } from "../../../src/components/PetAvatar";
@@ -10,6 +11,7 @@ import type { Conversation } from "../../../src/api/types";
/** Conversations list (conversations.list) — taps a row to open the chat detail/compose screen. */
export default function MessagesScreen() {
const { t } = useTranslation();
const trpc = useTRPC();
const router = useRouter();
const { activePetId } = useActivePet();
@@ -46,7 +48,7 @@ export default function MessagesScreen() {
)}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>Noch keine Unterhaltungen.</Text>
<Text style={typography.body}>{t("messages.noConversations")}</Text>
</View>
}
/>
+10 -8
View File
@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
import { View, Text, Image as RNImage, Pressable, ScrollView, ActivityIndicator, StyleSheet, Alert } from "react-native";
import { useRouter } from "expo-router";
import * as ImagePicker from "expo-image-picker";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ImagePlus } from "lucide-react-native";
import { useTRPC } from "../../src/api/trpc";
@@ -21,6 +22,7 @@ const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
* server-side (follows.ts) and never shown in this picker.
*/
export default function NewMilestoneScreen() {
const { t } = useTranslation();
const router = useRouter();
const trpc = useTRPC();
const queryClient = useQueryClient();
@@ -47,7 +49,7 @@ export default function NewMilestoneScreen() {
const pickImage = useCallback(async () => {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
Alert.alert("Zugriff auf Fotos wurde nicht erlaubt.");
Alert.alert(t("onboarding.galleryPermissionDenied"));
return;
}
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.8 });
@@ -77,7 +79,7 @@ export default function NewMilestoneScreen() {
imageKey,
});
} catch {
setError("Upload fehlgeschlagen — bitte erneut versuchen.");
setError(t("onboarding.uploadError"));
}
}, [activePetId, asset, milestoneType, notes, getPresignedUrlMutation, createMutation]);
@@ -85,7 +87,7 @@ export default function NewMilestoneScreen() {
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.label}>Art des Meilensteins</Text>
<Text style={styles.label}>{t("milestones.typeLabel")}</Text>
<View style={styles.chipRow}>
{USER_INITIATED_MILESTONE_TYPES.map((type) => {
const meta = MILESTONE_META[type];
@@ -98,7 +100,7 @@ export default function NewMilestoneScreen() {
onPress={() => setMilestoneType(type)}
>
<Icon color={active ? colors.pureWhite : colors.marigoldOrange} size={16} />
<Text style={[styles.chipText, active && styles.chipTextActive]}>{meta.label}</Text>
<Text style={[styles.chipText, active && styles.chipTextActive]}>{t(meta.labelKey)}</Text>
</Pressable>
);
})}
@@ -107,17 +109,17 @@ export default function NewMilestoneScreen() {
{asset ? (
<Pressable onPress={() => setAsset(null)}>
<RNImage source={{ uri: asset.uri }} style={styles.preview} />
<Text style={styles.previewHint}>Antippen, um das Foto zu entfernen</Text>
<Text style={styles.previewHint}>{t("milestones.removePhotoHint")}</Text>
</Pressable>
) : (
<Pressable style={styles.pickerButton} onPress={pickImage}>
<ImagePlus color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>Foto hinzufügen (optional)</Text>
<Text style={styles.pickerText}>{t("milestones.addPhoto")}</Text>
</Pressable>
)}
<TextField
placeholder="Notizen (optional)"
placeholder={t("health.notesPlaceholder")}
value={notes}
onChangeText={setNotes}
maxLength={500}
@@ -127,7 +129,7 @@ export default function NewMilestoneScreen() {
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button title={isSubmitting ? "Wird geteilt…" : "Meilenstein teilen"} onPress={handleSubmit} disabled={isSubmitting} />
<Button title={isSubmitting ? t("milestones.sharing") : t("milestones.shareButton")} onPress={handleSubmit} disabled={isSubmitting} />
{isSubmitting && <ActivityIndicator color={colors.marigoldOrange} />}
</ScrollView>
);
+12 -10
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { View, Text, Pressable, ScrollView, ActivityIndicator, Alert, StyleSheet } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react-native";
import { useTRPC } from "../../../../src/api/trpc";
@@ -15,6 +16,7 @@ import { colors, radii, typography } from "../../../../src/theme/tokens";
* visibility/birthday/avatar upload aren't editable here yet.
*/
export default function EditPetScreen() {
const { t } = useTranslation();
const { petId } = useLocalSearchParams<{ petId: string }>();
const router = useRouter();
const trpc = useTRPC();
@@ -60,11 +62,11 @@ export default function EditPetScreen() {
const confirmDelete = () => {
Alert.alert(
"Tier wirklich löschen?",
`${pet?.name ?? "Dieses Tier"} und alle zugehörigen Beiträge, Kommentare und Follower-Beziehungen werden unwiderruflich gelöscht.`,
t("editPet.deleteConfirmTitle"),
t("editPet.deleteConfirmMessage", { name: pet?.name ?? t("editPet.deleteConfirmMessageFallback") }),
[
{ text: "Abbrechen", style: "cancel" },
{ text: "Endgültig löschen", style: "destructive", onPress: () => deleteMutation.mutate({ petId }) },
{ text: t("common.cancel"), style: "cancel" },
{ text: t("editPet.deletePermanently"), style: "destructive", onPress: () => deleteMutation.mutate({ petId }) },
]
);
};
@@ -79,16 +81,16 @@ export default function EditPetScreen() {
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.label}>Name</Text>
<Text style={styles.label}>{t("editPet.nameLabel")}</Text>
<TextField value={name} onChangeText={setName} maxLength={30} />
<Text style={styles.label}>Rufname</Text>
<Text style={styles.label}>{t("editPet.nicknameLabel")}</Text>
<TextField value={nickname} onChangeText={setNickname} maxLength={30} />
<Text style={styles.label}>Bio</Text>
<Text style={styles.label}>{t("editPet.bioLabel")}</Text>
<TextField value={bio} onChangeText={setBio} maxLength={150} multiline style={styles.multiline} />
<Text style={styles.label}>Adoptionsgeschichte</Text>
<Text style={styles.label}>{t("petForm.adoptionStory")}</Text>
<TextField
value={adoptionStory}
onChangeText={setAdoptionStory}
@@ -100,7 +102,7 @@ export default function EditPetScreen() {
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title="Speichern"
title={t("common.save")}
disabled={updateMutation.isPending}
onPress={() =>
updateMutation.mutate({
@@ -115,7 +117,7 @@ export default function EditPetScreen() {
<Pressable style={styles.deleteRow} onPress={confirmDelete} disabled={deleteMutation.isPending}>
<Trash2 color={colors.alertRed} size={18} />
<Text style={styles.deleteText}>Tier löschen</Text>
<Text style={styles.deleteText}>{t("editPet.deletePet")}</Text>
</Pressable>
</ScrollView>
);
+3 -1
View File
@@ -1,5 +1,6 @@
import { View, Text, FlatList, ActivityIndicator, StyleSheet } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Pressable } from "react-native";
import { useTRPC } from "../../../../src/api/trpc";
@@ -8,6 +9,7 @@ import { formatPetName } from "../../../../src/lib/format";
import { colors, typography } from "../../../../src/theme/tokens";
export default function FollowersScreen() {
const { t } = useTranslation();
const { petId } = useLocalSearchParams<{ petId: string }>();
const router = useRouter();
const trpc = useTRPC();
@@ -35,7 +37,7 @@ export default function FollowersScreen() {
)}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>Noch keine Follower.</Text>
<Text style={typography.body}>{t("petProfile.noFollowers")}</Text>
</View>
}
/>
+3 -1
View File
@@ -1,5 +1,6 @@
import { View, Text, FlatList, ActivityIndicator, StyleSheet } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Pressable } from "react-native";
import { useTRPC } from "../../../../src/api/trpc";
@@ -8,6 +9,7 @@ import { formatPetName } from "../../../../src/lib/format";
import { colors, typography } from "../../../../src/theme/tokens";
export default function FollowingScreen() {
const { t } = useTranslation();
const { petId } = useLocalSearchParams<{ petId: string }>();
const router = useRouter();
const trpc = useTRPC();
@@ -35,7 +37,7 @@ export default function FollowingScreen() {
)}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>Folgt noch niemandem.</Text>
<Text style={typography.body}>{t("petProfile.noFollowing")}</Text>
</View>
}
/>
+11 -9
View File
@@ -1,5 +1,6 @@
import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation } from "@tanstack/react-query";
import { HeartPulse, Trophy, MessageCircle } from "lucide-react-native";
import { useTRPC } from "../../../../src/api/trpc";
@@ -20,6 +21,7 @@ import { colors, typography } from "../../../../src/theme/tokens";
* in-page tab) — that's a web-only layout choice, not a scope gap.
*/
export default function PetProfileScreen() {
const { t } = useTranslation();
const { petId } = useLocalSearchParams<{ petId: string }>();
const router = useRouter();
const trpc = useTRPC();
@@ -49,7 +51,7 @@ export default function PetProfileScreen() {
if (error || !pet) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error?.message ?? "Tier nicht gefunden."}</Text>
<Text style={styles.errorText}>{error?.message ?? t("petProfile.notFound")}</Text>
</View>
);
}
@@ -79,16 +81,16 @@ export default function PetProfileScreen() {
<View style={styles.statsRow}>
<Pressable style={styles.stat} onPress={() => router.push(`/pets/${petId}/followers`)}>
<Text style={styles.statCount}>{followers?.items.length ?? 0}</Text>
<Text style={styles.statLabel}>Follower</Text>
<Text style={styles.statLabel}>{t("petProfile.followerStat")}</Text>
</Pressable>
<Pressable style={styles.stat} onPress={() => router.push(`/pets/${petId}/following`)}>
<Text style={styles.statCount}>{following?.items.length ?? 0}</Text>
<Text style={styles.statLabel}>Folgt</Text>
<Text style={styles.statLabel}>{t("petProfile.followingStat")}</Text>
</Pressable>
</View>
{isOwnPet ? (
<Button title="Bearbeiten" variant="outline" onPress={() => router.push(`/pets/${petId}/edit`)} />
<Button title={t("common.edit")} variant="outline" onPress={() => router.push(`/pets/${petId}/edit`)} />
) : (
activePetId && <FollowButton followerPetId={activePetId} followeePetId={petId} />
)}
@@ -97,12 +99,12 @@ export default function PetProfileScreen() {
{isOwnPet && (
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/health`)}>
<HeartPulse color={colors.ink} size={18} />
<Text style={styles.iconButtonText}>Gesundheit</Text>
<Text style={styles.iconButtonText}>{t("petProfile.healthButton")}</Text>
</Pressable>
)}
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/milestones`)}>
<Trophy color={colors.ink} size={18} />
<Text style={styles.iconButtonText}>Meilensteine</Text>
<Text style={styles.iconButtonText}>{t("petProfile.milestonesButton")}</Text>
</Pressable>
{!isOwnPet && activePetId && (
<Pressable
@@ -110,7 +112,7 @@ export default function PetProfileScreen() {
onPress={() => getOrCreateConversation.mutate({ senderPetId: activePetId, recipientPetId: petId })}
>
<MessageCircle color={colors.ink} size={18} />
<Text style={styles.iconButtonText}>Nachricht</Text>
<Text style={styles.iconButtonText}>{t("petProfile.messageButton")}</Text>
</Pressable>
)}
</View>
@@ -118,7 +120,7 @@ export default function PetProfileScreen() {
{pet.bio ? <Text style={styles.section}>{pet.bio}</Text> : null}
{pet.adoptionStory ? (
<View>
<Text style={styles.sectionTitle}>Adoptionsgeschichte</Text>
<Text style={styles.sectionTitle}>{t("petProfile.adoptionStoryTitle")}</Text>
<Text style={styles.section}>{pet.adoptionStory}</Text>
</View>
) : null}
@@ -128,7 +130,7 @@ export default function PetProfileScreen() {
}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>Noch keine Beiträge.</Text>
<Text style={typography.body}>{t("petProfile.noPosts")}</Text>
</View>
}
/>
+7 -4
View File
@@ -1,5 +1,6 @@
import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { Plus } from "lucide-react-native";
@@ -15,6 +16,7 @@ import type { MilestonePost } from "../../../../src/api/types";
* milestone posts, not a separate data model — see src/trpc/routers/milestones.ts.
*/
export default function MilestonesScreen() {
const { t } = useTranslation();
const { petId } = useLocalSearchParams<{ petId: string }>();
const router = useRouter();
const { activePetId } = useActivePet();
@@ -40,14 +42,14 @@ export default function MilestonesScreen() {
renderItem={({ item }) => <MilestoneRow post={item} />}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>Noch keine Meilensteine.</Text>
<Text style={typography.body}>{t("milestones.empty")}</Text>
</View>
}
ListFooterComponent={
isOwnPet ? (
<Pressable style={styles.addButton} onPress={() => router.push("/milestone-new")}>
<Plus color={colors.marigoldOrange} size={18} />
<Text style={styles.addButtonText}>Meilenstein hinzufügen</Text>
<Text style={styles.addButtonText}>{t("milestones.addButton")}</Text>
</Pressable>
) : null
}
@@ -56,6 +58,7 @@ export default function MilestonesScreen() {
}
function MilestoneRow({ post }: { post: MilestonePost }) {
const { t, i18n } = useTranslation();
if (!post.milestone) return null;
const meta = MILESTONE_META[post.milestone.milestoneType as MilestoneType];
const Icon = meta.icon;
@@ -67,8 +70,8 @@ function MilestoneRow({ post }: { post: MilestonePost }) {
<Icon color={colors.marigoldOrange} size={20} />
</View>
<View style={styles.rowText}>
<Text style={styles.rowTitle}>{meta.label}</Text>
<Text style={styles.rowDate}>{new Date(post.createdAt).toLocaleDateString("de-DE")}</Text>
<Text style={styles.rowTitle}>{t(meta.labelKey)}</Text>
<Text style={styles.rowDate}>{new Date(post.createdAt).toLocaleDateString(i18n.language)}</Text>
{post.milestone.notes ? <Text style={styles.rowNotes}>{post.milestone.notes}</Text> : null}
</View>
{image && <Image source={{ uri: getMediaUrl(image.storageKey) }} style={styles.thumb} contentFit="cover" />}
+10 -8
View File
@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
import { View, Text, ScrollView, Image as RNImage, Pressable, ActivityIndicator, StyleSheet, Alert } from "react-native";
import { useRouter } from "expo-router";
import * as ImagePicker from "expo-image-picker";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { X, ImagePlus, Camera } from "lucide-react-native";
import { useTRPC } from "../../src/api/trpc";
@@ -31,6 +32,7 @@ type PickedImage = {
* currently-plain-text captions.
*/
export default function NewPostScreen() {
const { t } = useTranslation();
const router = useRouter();
const trpc = useTRPC();
const queryClient = useQueryClient();
@@ -82,7 +84,7 @@ export default function NewPostScreen() {
(assets: ImagePicker.ImagePickerAsset[]) => {
const available = MAX_IMAGES - images.length;
if (available <= 0) {
Alert.alert("Maximal 10 Fotos pro Beitrag.");
Alert.alert(t("post.maxImages", { max: MAX_IMAGES }));
return;
}
@@ -108,7 +110,7 @@ export default function NewPostScreen() {
const pickFromGallery = useCallback(async () => {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
Alert.alert("Zugriff auf Fotos wurde nicht erlaubt.");
Alert.alert(t("onboarding.galleryPermissionDenied"));
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
@@ -123,7 +125,7 @@ export default function NewPostScreen() {
const takePhoto = useCallback(async () => {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (!permission.granted) {
Alert.alert("Zugriff auf die Kamera wurde nicht erlaubt.");
Alert.alert(t("onboarding.cameraPermissionDenied"));
return;
}
const result = await ImagePicker.launchCameraAsync({ quality: 0.8 });
@@ -162,7 +164,7 @@ export default function NewPostScreen() {
)}
{img.status === "error" && (
<View style={[styles.thumbOverlay, styles.thumbError]}>
<Text style={styles.thumbErrorText}>Fehler</Text>
<Text style={styles.thumbErrorText}>{t("post.thumbError")}</Text>
</View>
)}
{img.status !== "uploading" && (
@@ -179,17 +181,17 @@ export default function NewPostScreen() {
<View style={styles.pickerRow}>
<Pressable style={styles.pickerButton} onPress={pickFromGallery}>
<ImagePlus color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>Galerie</Text>
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
</Pressable>
<Pressable style={styles.pickerButton} onPress={takePhoto}>
<Camera color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>Kamera</Text>
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
</Pressable>
</View>
)}
<TextField
placeholder="Schreibe eine Bildunterschrift…"
placeholder={t("post.captionPlaceholder")}
value={caption}
onChangeText={setCaption}
maxLength={500}
@@ -200,7 +202,7 @@ export default function NewPostScreen() {
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title={createMutation.isPending ? "Wird geteilt…" : "Teilen"}
title={createMutation.isPending ? t("milestones.sharing") : t("post.shareButton")}
onPress={handleSubmit}
disabled={!canSubmit}
/>
+6 -4
View File
@@ -1,6 +1,7 @@
import { useCallback, useRef, useState } from "react";
import { View, Text, FlatList, ActivityIndicator, Pressable, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react-native";
import { useTRPC } from "../../../../src/api/trpc";
@@ -19,6 +20,7 @@ import type { PostComment } from "../../../../src/api/types";
* (the image already has its own full-view route, image-viewer.tsx).
*/
export default function CommentsScreen() {
const { t } = useTranslation();
const { postId } = useLocalSearchParams<{ postId: string }>();
const router = useRouter();
const trpc = useTRPC();
@@ -79,7 +81,7 @@ export default function CommentsScreen() {
contentContainerStyle={styles.list}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>Noch keine Kommentare.</Text>
<Text style={typography.body}>{t("comments.empty")}</Text>
</View>
}
renderItem={({ item }) => (
@@ -95,7 +97,7 @@ export default function CommentsScreen() {
<View style={[styles.row, styles.pending]}>
<PetAvatar name="…" size="sm" />
<View style={styles.rowBody}>
<Text style={styles.pendingLabel}>Wird gesendet…</Text>
<Text style={styles.pendingLabel}>{t("comments.sending")}</Text>
<Text style={typography.body}>{pendingBody}</Text>
</View>
</View>
@@ -107,14 +109,14 @@ export default function CommentsScreen() {
{activePetId && (
<View style={styles.inputRow}>
<TextField
placeholder="Kommentar schreiben…"
placeholder={t("comments.placeholder")}
value={body}
onChangeText={setBody}
maxLength={500}
style={styles.input}
/>
<Button
title={createMutation.isPending ? "…" : "Senden"}
title={createMutation.isPending ? "…" : t("messages.send")}
onPress={handleSubmit}
disabled={!body.trim() || createMutation.isPending}
style={styles.sendButton}
+13 -11
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { View, Text, Pressable, FlatList, ActivityIndicator, StyleSheet } from "react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Search as SearchIcon, Layers2 } from "lucide-react-native";
import { Image } from "expo-image";
@@ -30,6 +31,7 @@ type SearchPet = {
* either, so there's no existing mobile pattern to match here.
*/
export default function SearchScreen() {
const { t } = useTranslation();
const trpc = useTRPC();
const router = useRouter();
const { activePetId } = useActivePet();
@@ -59,7 +61,7 @@ export default function SearchScreen() {
<View style={styles.searchBar}>
<SearchIcon size={16} color={colors.quietGray} style={styles.searchIcon} />
<TextField
placeholder="Suchen…"
placeholder={t("search.placeholder")}
value={query}
onChangeText={setQuery}
autoFocus
@@ -68,14 +70,14 @@ export default function SearchScreen() {
</View>
<View style={styles.tabs}>
<TabButton label="Tiere" active={tab === "pets"} onPress={() => setTab("pets")} />
<TabButton label="Personen" active={tab === "people"} onPress={() => setTab("people")} />
<TabButton label="Hashtags" active={tab === "tags"} onPress={() => setTab("tags")} />
<TabButton label={t("search.petsTab")} active={tab === "pets"} onPress={() => setTab("pets")} />
<TabButton label={t("search.peopleTab")} active={tab === "people"} onPress={() => setTab("people")} />
<TabButton label={t("search.tagsTab")} active={tab === "tags"} onPress={() => setTab("tags")} />
</View>
{tab === "pets" &&
(!enabled ? (
<EmptyState text="Mindestens 2 Zeichen eingeben." />
<EmptyState text={t("search.minChars")} />
) : loadingPets ? (
<Loading />
) : (
@@ -83,7 +85,7 @@ export default function SearchScreen() {
data={petResults?.pets ?? []}
keyExtractor={(pet) => pet.id}
contentContainerStyle={styles.list}
ListEmptyComponent={<EmptyState text={`Keine Tiere für "${trimmed}" gefunden.`} />}
ListEmptyComponent={<EmptyState text={t("search.noPetsFor", { query: trimmed })} />}
renderItem={({ item }) => (
<PetRow pet={item} activePetId={activePetId} onPress={() => router.push(`/pets/${item.id}`)} />
)}
@@ -92,7 +94,7 @@ export default function SearchScreen() {
{tab === "people" &&
(!enabled ? (
<EmptyState text="Mindestens 2 Zeichen eingeben." />
<EmptyState text={t("search.minChars")} />
) : loadingPeople ? (
<Loading />
) : (
@@ -100,11 +102,11 @@ export default function SearchScreen() {
data={ownerResults?.owners ?? []}
keyExtractor={(owner) => owner.id}
contentContainerStyle={styles.list}
ListEmptyComponent={<EmptyState text={`Keine Personen für "${trimmed}" gefunden.`} />}
ListEmptyComponent={<EmptyState text={t("search.noPeopleFor", { query: trimmed })} />}
renderItem={({ item: owner }) => {
const displayName = owner.username
? `@${owner.username}`
: [owner.firstName, owner.lastName].filter(Boolean).join(" ") || "Unbekannt";
: [owner.firstName, owner.lastName].filter(Boolean).join(" ") || t("search.unknownOwner");
return (
<View style={styles.ownerBlock}>
<Text style={styles.ownerName}>{displayName}</Text>
@@ -125,7 +127,7 @@ export default function SearchScreen() {
{tab === "tags" &&
(normalizedTag.length < 1 ? (
<EmptyState text="Hashtag eingeben." />
<EmptyState text={t("search.enterHashtag")} />
) : loadingTags ? (
<Loading />
) : (
@@ -134,7 +136,7 @@ export default function SearchScreen() {
keyExtractor={(post) => post.id}
numColumns={3}
contentContainerStyle={styles.tagGrid}
ListEmptyComponent={<EmptyState text={`Keine Beiträge für #${normalizedTag}.`} />}
ListEmptyComponent={<EmptyState text={t("search.noPostsForTag", { tag: normalizedTag })} />}
renderItem={({ item: post }) => {
const firstImage = post.images[0];
const isCarousel = post.images.length > 1;
+9 -7
View File
@@ -2,6 +2,7 @@ import { useState, useCallback } from "react";
import { View, Text, Image as RNImage, Pressable, ActivityIndicator, StyleSheet, Alert } from "react-native";
import { useRouter } from "expo-router";
import * as ImagePicker from "expo-image-picker";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ImagePlus, Camera } from "lucide-react-native";
import { useTRPC } from "../../src/api/trpc";
@@ -19,6 +20,7 @@ const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
* unlike post-new.tsx's multi-image picker — a story is one photo.
*/
export default function NewStoryScreen() {
const { t } = useTranslation();
const router = useRouter();
const trpc = useTRPC();
const queryClient = useQueryClient();
@@ -43,7 +45,7 @@ export default function NewStoryScreen() {
const pickFromGallery = useCallback(async () => {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
Alert.alert("Zugriff auf Fotos wurde nicht erlaubt.");
Alert.alert(t("onboarding.galleryPermissionDenied"));
return;
}
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.8 });
@@ -53,7 +55,7 @@ export default function NewStoryScreen() {
const takePhoto = useCallback(async () => {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (!permission.granted) {
Alert.alert("Zugriff auf die Kamera wurde nicht erlaubt.");
Alert.alert(t("onboarding.cameraPermissionDenied"));
return;
}
const result = await ImagePicker.launchCameraAsync({ quality: 0.8 });
@@ -74,7 +76,7 @@ export default function NewStoryScreen() {
await uploadImageToSupabase(asset.uri, presignedUrl, mimeType);
createMutation.mutate({ petId: activePetId, storageKey: key, blurhash: null });
} catch {
setError("Upload fehlgeschlagen — bitte erneut versuchen.");
setError(t("onboarding.uploadError"));
}
}, [asset, activePetId, getPresignedUrlMutation, createMutation]);
@@ -85,17 +87,17 @@ export default function NewStoryScreen() {
{asset ? (
<Pressable style={styles.preview} onPress={() => setAsset(null)}>
<RNImage source={{ uri: asset.uri }} style={styles.previewImage} />
<Text style={styles.previewHint}>Antippen, um ein anderes Foto zu wählen</Text>
<Text style={styles.previewHint}>{t("story.changePhotoHint")}</Text>
</Pressable>
) : (
<View style={styles.pickerRow}>
<Pressable style={styles.pickerButton} onPress={pickFromGallery}>
<ImagePlus color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>Galerie</Text>
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
</Pressable>
<Pressable style={styles.pickerButton} onPress={takePhoto}>
<Camera color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>Kamera</Text>
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
</Pressable>
</View>
)}
@@ -103,7 +105,7 @@ export default function NewStoryScreen() {
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title={isSubmitting ? "Wird geteilt…" : "Story teilen"}
title={isSubmitting ? t("milestones.sharing") : t("story.shareButton")}
onPress={handleSubmit}
disabled={!asset || isSubmitting}
/>
+12 -12
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { useSignIn } from "@clerk/expo";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Button } from "../../src/components/Button";
import { TextField } from "../../src/components/TextField";
import { colors, typography } from "../../src/theme/tokens";
@@ -20,6 +21,7 @@ import { colors, typography } from "../../src/theme/tokens";
* MFA setting — see signIn.mfa.sendEmailCode()/verifyEmailCode() below.
*/
export default function SignInScreen() {
const { t } = useTranslation();
const { signIn, fetchStatus } = useSignIn();
const router = useRouter();
const [emailAddress, setEmailAddress] = useState("");
@@ -30,7 +32,7 @@ export default function SignInScreen() {
const finalizeIfComplete = async () => {
if (signIn.status !== "complete") {
setError(`Anmeldung unvollständig — Status: ${signIn.status}`);
setError(t("auth.signInIncompleteError", { status: signIn.status }));
return;
}
await signIn.finalize();
@@ -71,47 +73,45 @@ export default function SignInScreen() {
if (step === "verify-device") {
return (
<View style={styles.container}>
<Text style={styles.title}>Gerät bestätigen</Text>
<Text style={styles.hint}>
Wir haben einen Code an {emailAddress} geschickt, um dieses Gerät zu bestätigen.
</Text>
<Text style={styles.title}>{t("auth.verifyDeviceTitle")}</Text>
<Text style={styles.hint}>{t("auth.verifyDeviceHint", { email: emailAddress })}</Text>
<TextField
autoCapitalize="none"
keyboardType="number-pad"
placeholder="Code"
placeholder={t("auth.codePlaceholder")}
value={code}
onChangeText={setCode}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button title="Bestätigen" disabled={fetchStatus === "fetching"} onPress={onVerifyPress} />
<Button title={t("auth.confirmButton")} disabled={fetchStatus === "fetching"} onPress={onVerifyPress} />
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.title}>PawFeed Login</Text>
<Text style={styles.title}>{t("auth.signInTitle")}</Text>
<TextField
autoCapitalize="none"
keyboardType="email-address"
placeholder="E-Mail"
placeholder={t("auth.emailPlaceholder")}
value={emailAddress}
onChangeText={setEmailAddress}
/>
<TextField
placeholder="Passwort"
placeholder={t("auth.passwordPlaceholder")}
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title="Anmelden"
title={t("auth.signInButton")}
disabled={fetchStatus === "fetching"}
onPress={onSignInPress}
/>
<Pressable onPress={() => router.replace("/(auth)/sign-up")} hitSlop={8}>
<Text style={styles.link}>Noch kein Account? Registrieren</Text>
<Text style={styles.link}>{t("auth.noAccountLink")}</Text>
</Pressable>
</View>
);
+12 -12
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { useSignUp } from "@clerk/expo";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Button } from "../../src/components/Button";
import { TextField } from "../../src/components/TextField";
import { colors, typography } from "../../src/theme/tokens";
@@ -17,6 +18,7 @@ import { colors, typography } from "../../src/theme/tokens";
* assuming an older signUp.create()/setActive() shape from training data.
*/
export default function SignUpScreen() {
const { t } = useTranslation();
const { signUp, fetchStatus } = useSignUp();
const router = useRouter();
const [emailAddress, setEmailAddress] = useState("");
@@ -27,7 +29,7 @@ export default function SignUpScreen() {
const finalizeIfComplete = async () => {
if (signUp.status !== "complete") {
setError(`Registrierung unvollständig — Status: ${signUp.status}`);
setError(t("auth.signUpIncompleteError", { status: signUp.status }));
return;
}
await signUp.finalize();
@@ -68,47 +70,45 @@ export default function SignUpScreen() {
if (step === "verify-email") {
return (
<View style={styles.container}>
<Text style={styles.title}>E-Mail bestätigen</Text>
<Text style={styles.hint}>
Wir haben einen Code an {emailAddress} geschickt, um deine E-Mail-Adresse zu bestätigen.
</Text>
<Text style={styles.title}>{t("auth.verifyEmailTitle")}</Text>
<Text style={styles.hint}>{t("auth.verifyEmailHint", { email: emailAddress })}</Text>
<TextField
autoCapitalize="none"
keyboardType="number-pad"
placeholder="Code"
placeholder={t("auth.codePlaceholder")}
value={code}
onChangeText={setCode}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button title="Bestätigen" disabled={fetchStatus === "fetching"} onPress={onVerifyPress} />
<Button title={t("auth.confirmButton")} disabled={fetchStatus === "fetching"} onPress={onVerifyPress} />
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.title}>PawFeed Registrierung</Text>
<Text style={styles.title}>{t("auth.signUpTitle")}</Text>
<TextField
autoCapitalize="none"
keyboardType="email-address"
placeholder="E-Mail"
placeholder={t("auth.emailPlaceholder")}
value={emailAddress}
onChangeText={setEmailAddress}
/>
<TextField
placeholder="Passwort"
placeholder={t("auth.passwordPlaceholder")}
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title="Registrieren"
title={t("auth.signUpButton")}
disabled={fetchStatus === "fetching"}
onPress={onSignUpPress}
/>
<Pressable onPress={() => router.replace("/(auth)/sign-in")} hitSlop={8}>
<Text style={styles.link}>Schon registriert? Anmelden</Text>
<Text style={styles.link}>{t("auth.hasAccountLink")}</Text>
</Pressable>
</View>
);
+3 -1
View File
@@ -1,6 +1,7 @@
import { Pressable } from "react-native";
import { Stack, useRouter } from "expo-router";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { useTheme } from "../../src/context/ThemeContext";
import { fontFamily } from "../../src/theme/tokens";
@@ -25,6 +26,7 @@ function CloseButton() {
}
export default function OnboardingLayout() {
const { t } = useTranslation();
const { colors } = useTheme();
return (
<Stack
@@ -34,7 +36,7 @@ export default function OnboardingLayout() {
headerLeft: () => <CloseButton />,
}}
>
<Stack.Screen name="pet" options={{ title: "Tier hinzufügen" }} />
<Stack.Screen name="pet" options={{ title: t("onboarding.addPetTitle") }} />
</Stack>
);
}
+86
View File
@@ -23,15 +23,18 @@
"expo-image": "~57.0.4",
"expo-image-picker": "~57.0.16",
"expo-linking": "~57.0.8",
"expo-localization": "~57.0.1",
"expo-notifications": "~57.0.17",
"expo-router": "^5.1.11",
"expo-secure-store": "~57.0.2",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.3",
"expo-video": "~57.0.3",
"i18next": "^26.4.2",
"lucide-react-native": "^1.41.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-i18next": "^17.0.13",
"react-native": "0.86.3",
"react-native-mmkv": "^4.3.2",
"react-native-nitro-modules": "^0.37.1",
@@ -5859,6 +5862,19 @@
"react-native": "*"
}
},
"node_modules/expo-localization": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-57.0.1.tgz",
"integrity": "sha512-8Ffl4UTbOsQeGT0v5fxMbyPHyPMPnhSPDFQJa8p9rjJrthFoAtNi+fL6Ssmrvf1/7dmPq1mVY52MEt0TMEfgjA==",
"license": "MIT",
"dependencies": {
"rtl-detect": "^1.0.2"
},
"peerDependencies": {
"expo": "*",
"react": "*"
}
},
"node_modules/expo-manifests": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-57.0.1.tgz",
@@ -6598,6 +6614,15 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/html-parse-stringify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
"license": "MIT",
"funding": {
"url": "https://locize.com"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -6650,6 +6675,34 @@
"ms": "^2.0.0"
}
},
"node_modules/i18next": {
"version": "26.4.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/idb-keyval": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
@@ -8682,6 +8735,33 @@
"react": ">=17.0.0"
}
},
"node_modules/react-i18next": {
"version": "17.0.13",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.13.tgz",
"integrity": "sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"html-parse-stringify": "^4.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -9052,6 +9132,12 @@
}
}
},
"node_modules/rtl-detect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz",
"integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==",
"license": "BSD-3-Clause"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+3
View File
@@ -18,15 +18,18 @@
"expo-image": "~57.0.4",
"expo-image-picker": "~57.0.16",
"expo-linking": "~57.0.8",
"expo-localization": "~57.0.1",
"expo-notifications": "~57.0.17",
"expo-router": "^5.1.11",
"expo-secure-store": "~57.0.2",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.3",
"expo-video": "~57.0.3",
"i18next": "^26.4.2",
"lucide-react-native": "^1.41.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-i18next": "^17.0.13",
"react-native": "0.86.3",
"react-native-mmkv": "^4.3.2",
"react-native-nitro-modules": "^0.37.1",
+9 -1
View File
@@ -7,6 +7,7 @@ import { QueryClient } from "@tanstack/react-query";
// (mobile/ is nested inside the OnlyPets repo, not a separate checkout).
import type { AppRouter } from "../../../src/trpc/routers/_app";
import { env } from "../lib/env";
import i18n from "../i18n";
export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
@@ -35,7 +36,14 @@ export function createMobileTRPCClient(getToken: () => Promise<string | null>) {
url: `${env.apiUrl}/api/trpc`,
async headers() {
const token = await getToken();
return token ? { authorization: `Bearer ${token}` } : {};
// Backend has no cookie to read locale from on mobile requests
// (see src/trpc/init.ts's x-locale fallback) — this is the only
// way species/breed translations and any future locale-aware
// procedure resolve the right language.
return {
...(token ? { authorization: `Bearer ${token}` } : {}),
"x-locale": i18n.language,
};
},
}),
],
+3 -1
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "../api/trpc";
import { Button } from "./Button";
@@ -16,6 +17,7 @@ type Props = {
* now that a second call site (search results) needs the same toggle.
*/
export function FollowButton({ followerPetId, followeePetId, compact }: Props) {
const { t } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
@@ -41,7 +43,7 @@ export function FollowButton({ followerPetId, followeePetId, compact }: Props) {
return (
<Button
title={isFollowing ? "Entfolgen" : "Folgen"}
title={isFollowing ? t("social.unfollow") : t("social.follow")}
variant={isFollowing ? "outline" : "primary"}
disabled={pending}
onPress={() => {
+3 -1
View File
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { View, Text, Pressable, ScrollView, ActivityIndicator, StyleSheet } from "react-native";
import { ChevronDown } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { useTheme } from "../context/ThemeContext";
import { radii, getTypography, type ColorTokens, type Typography } from "../theme/tokens";
@@ -50,6 +51,7 @@ export function OptionPicker({
emptyLabel,
onSelect,
}: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
@@ -64,7 +66,7 @@ export function OptionPicker({
{isLoading ? (
<ActivityIndicator color={colors.marigoldOrange} style={styles.loading} />
) : options.length === 0 ? (
<Text style={styles.empty}>{emptyLabel ?? "Keine Einträge gefunden."}</Text>
<Text style={styles.empty}>{emptyLabel ?? t("common.noResultsFound")}</Text>
) : (
<ScrollView style={styles.scrollView} nestedScrollEnabled>
{options.map((option) => (
+3 -1
View File
@@ -1,5 +1,6 @@
import { View, Text, Pressable, StyleSheet, useWindowDimensions } from "react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Image } from "expo-image";
import { PawPrint, Video, Trophy } from "lucide-react-native";
import { PetAvatar } from "./PetAvatar";
@@ -21,6 +22,7 @@ type Props = {
* DESIGN.md's "hairline rings not shadows").
*/
export function RepostCard({ post }: Props) {
const { t } = useTranslation();
const router = useRouter();
const { width } = useWindowDimensions();
const cardWidth = width - 32;
@@ -35,7 +37,7 @@ export function RepostCard({ post }: Props) {
<View style={styles.card}>
<Pressable style={styles.attribution} onPress={() => router.push(`/pets/${post.pet.id}`)}>
<PawPrint size={14} color={colors.marigoldOrange} />
<Text style={styles.attributionText}>Paw-Back von {post.pet.name}</Text>
<Text style={styles.attributionText}>{t("social.repostAttribution", { name: post.pet.name })}</Text>
</Pressable>
<Pressable style={styles.header} onPress={() => router.push(`/pets/${origPet.id}`)}>
+3 -1
View File
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { ScrollView, View, Text, Pressable, StyleSheet } from "react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { PlusCircle } from "lucide-react-native";
import { useTRPC } from "../api/trpc";
@@ -21,6 +22,7 @@ const AVATAR_SIZE = 56;
* story upload/playback (mirrors mobile's post-creation scope decision).
*/
export function StoryTray() {
const { t } = useTranslation();
const trpc = useTRPC();
const router = useRouter();
const { activePetId } = useActivePet();
@@ -39,7 +41,7 @@ export function StoryTray() {
<PlusCircle color={colors.pureWhite} size={26} />
</View>
<Text style={styles.label} numberOfLines={1}>
Story
{t("feed.storyLabel")}
</Text>
</Pressable>
+4 -2
View File
@@ -1,5 +1,6 @@
import { useEffect } from "react";
import { View, Text, ActivityIndicator, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle } from "lucide-react-native";
import { useVideoPlayer, VideoView } from "expo-video";
@@ -22,6 +23,7 @@ type Props = {
* separate player SDK needed the way web needs @mux/mux-player-react.
*/
export function VideoPlayer({ postId, initialStatus, initialPlaybackId, width }: Props) {
const { t } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
@@ -47,7 +49,7 @@ export function VideoPlayer({ postId, initialStatus, initialPlaybackId, width }:
return (
<View style={[styles.box, { width }]}>
<ActivityIndicator color={colors.marigoldOrange} />
<Text style={styles.label}>Video wird verarbeitet…</Text>
<Text style={styles.label}>{t("video.processing")}</Text>
</View>
);
}
@@ -56,7 +58,7 @@ export function VideoPlayer({ postId, initialStatus, initialPlaybackId, width }:
return (
<View style={[styles.box, { width }]}>
<AlertCircle color={colors.alertRed} size={28} />
<Text style={styles.label}>Video konnte nicht verarbeitet werden.</Text>
<Text style={styles.label}>{t("video.error")}</Text>
</View>
);
}
@@ -1,5 +1,6 @@
import { useState, type ReactNode } from "react";
import { View, Text, Pressable, ActivityIndicator, Linking, Alert, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { HeartPulse, ShieldOff, Check } from "lucide-react-native";
@@ -17,11 +18,10 @@ type Props = {
* Port of web's src/components/health/HealthConsentGate.tsx. Health-Tracking
* data (weight, vet visits, vaccines) is gated behind explicit, un-pre-checked
* consent per Art. 9 Abs. 2 lit. a DSGVO — this is the UI, health.ts's
* assertHealthConsent is the actual enforcement boundary. Text copied
* verbatim from messages/de.json's Health.consent namespace (mobile has no
* i18n system yet).
* assertHealthConsent is the actual enforcement boundary.
*/
export function HealthConsentGate({ petId, children }: Props) {
const { t } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
const router = useRouter();
@@ -39,25 +39,25 @@ export function HealthConsentGate({ petId, children }: Props) {
const giveMutation = useMutation(
trpc.health.giveConsent.mutationOptions({
onSuccess: invalidateHealthQueries,
onError: () => Alert.alert("Einwilligung konnte nicht gespeichert werden."),
onError: () => Alert.alert(t("health.consentSaveError")),
})
);
const revokeMutation = useMutation(
trpc.health.revokeConsent.mutationOptions({
onSuccess: invalidateHealthQueries,
onError: () => Alert.alert("Einwilligung konnte nicht widerrufen werden."),
onError: () => Alert.alert(t("health.consentRevokeError")),
})
);
const confirmRevoke = () => {
Alert.alert(
"Einwilligung wirklich widerrufen?",
"Dadurch werden alle Gewichtseinträge, Tierarztbesuche und Impfungen dieses Haustiers unwiderruflich gelöscht.",
t("health.revokeConfirmTitle"),
t("health.revokeConfirmMessage"),
[
{ text: "Abbrechen", style: "cancel" },
{ text: t("common.cancel"), style: "cancel" },
{
text: "Endgültig widerrufen und löschen",
text: t("health.revokeConfirmButton"),
style: "destructive",
onPress: () => revokeMutation.mutate({ petId }),
},
@@ -78,32 +78,25 @@ export function HealthConsentGate({ petId, children }: Props) {
<View style={styles.consentContainer}>
<View style={styles.titleRow}>
<HeartPulse color={colors.marigoldOrange} size={20} />
<Text style={styles.title}>Einwilligung für Gesundheitsdaten</Text>
<Text style={styles.title}>{t("health.consentTitle")}</Text>
</View>
<Text style={styles.subtitle}>
Bevor Sie den Health-Tracking-Bereich nutzen können, benötigen wir Ihre ausdrückliche Einwilligung zur
Verarbeitung der Gesundheitsdaten Ihres Haustiers.
</Text>
<Text style={styles.subtitle}>{t("health.consentSubtitle")}</Text>
<Pressable style={styles.checkboxRow} onPress={() => setChecked((v) => !v)}>
<View style={[styles.checkbox, checked && styles.checkboxChecked]}>
{checked && <Check color={colors.pureWhite} size={14} />}
</View>
<Text style={styles.checkboxLabel}>
Ich willige ausdrücklich ein, dass Gesundheitsdaten meines Haustiers (Gewicht, Tierarztbesuche,
Impfungen) gespeichert und verarbeitet werden. Ich kann diese Einwilligung jederzeit widerrufen; danach
werden alle Gesundheitsdaten dieses Haustiers automatisch gelöscht.
</Text>
<Text style={styles.checkboxLabel}>{t("health.consentCheckboxLabel")}</Text>
</Pressable>
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/datenschutz`)}>
<Text style={styles.privacyLink}>Weitere Informationen in unserer Datenschutzerklärung</Text>
<Text style={styles.privacyLink}>{t("health.consentPrivacyLink")}</Text>
</Pressable>
<View style={styles.ctaRow}>
<Button title="Abbrechen" variant="outline" style={styles.ctaButton} onPress={() => router.back()} />
<Button title={t("common.cancel")} variant="outline" style={styles.ctaButton} onPress={() => router.back()} />
<Button
title={giveMutation.isPending ? "…" : "Zustimmen und fortfahren"}
title={giveMutation.isPending ? "…" : t("health.consentAcceptButton")}
style={styles.ctaButton}
disabled={!checked || giveMutation.isPending}
onPress={() => giveMutation.mutate({ petId })}
@@ -119,13 +112,11 @@ export function HealthConsentGate({ petId, children }: Props) {
<View style={styles.manageBox}>
<ShieldOff color={colors.alertRed} size={18} />
<View style={styles.manageText}>
<Text style={styles.manageTitle}>Einwilligung verwalten</Text>
<Text style={styles.manageDesc}>
Ein Widerruf löscht sofort und unwiderruflich alle Gesundheitsdaten dieses Haustiers.
</Text>
<Text style={styles.manageTitle}>{t("health.manageTitle")}</Text>
<Text style={styles.manageDesc}>{t("health.manageDesc")}</Text>
</View>
<Pressable onPress={confirmRevoke} disabled={revokeMutation.isPending}>
<Text style={styles.revokeLink}>Widerrufen</Text>
<Text style={styles.revokeLink}>{t("health.revokeLink")}</Text>
</Pressable>
</View>
</>
@@ -1,5 +1,6 @@
import { useState } from "react";
import { View, Text, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
@@ -12,6 +13,7 @@ const today = () => new Date().toISOString().slice(0, 10);
/** Port of web's health.ts vaccine CRUD — see health/page.tsx's Impfungen tab. */
export function VaccineSection({ petId }: { petId: string }) {
const { t, i18n } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
@@ -45,7 +47,7 @@ export function VaccineSection({ petId }: { petId: string }) {
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Impfungen</Text>
<Text style={styles.sectionTitle}>{t("health.vaccineTitle")}</Text>
<Pressable onPress={() => setShowForm((v) => !v)} hitSlop={8}>
<Plus color={colors.marigoldOrange} size={20} />
</Pressable>
@@ -53,12 +55,12 @@ export function VaccineSection({ petId }: { petId: string }) {
{showForm && (
<Card style={styles.form}>
<TextField placeholder="Impfstoff, z.B. Tollwut, DHPP" value={name} onChangeText={setName} maxLength={100} />
<TextField placeholder="Gegeben am (JJJJ-MM-TT)" value={dateGiven} onChangeText={setDateGiven} />
<TextField placeholder="Nächste Fälligkeit (optional, JJJJ-MM-TT)" value={nextDueDate} onChangeText={setNextDueDate} />
<TextField placeholder="Notizen (optional)" value={notes} onChangeText={setNotes} maxLength={200} />
<TextField placeholder={t("health.vaccineNamePlaceholder")} value={name} onChangeText={setName} maxLength={100} />
<TextField placeholder={t("health.vaccineGivenPlaceholder")} value={dateGiven} onChangeText={setDateGiven} />
<TextField placeholder={t("health.vaccineNextDuePlaceholder")} value={nextDueDate} onChangeText={setNextDueDate} />
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={200} />
<Button
title={addMutation.isPending ? "…" : "Speichern"}
title={addMutation.isPending ? "…" : t("common.save")}
disabled={!canSubmit}
onPress={() =>
addMutation.mutate({
@@ -76,16 +78,20 @@ export function VaccineSection({ petId }: { petId: string }) {
{isLoading ? (
<ActivityIndicator color={colors.marigoldOrange} />
) : vaccines.length === 0 ? (
<Text style={styles.empty}>Noch keine Impfungen eingetragen</Text>
<Text style={styles.empty}>{t("health.vaccineEmpty")}</Text>
) : (
vaccines.map((vaccine) => (
<View key={vaccine.id} style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.rowPrimary}>{vaccine.name}</Text>
<Text style={styles.rowSecondary}>Gegeben {new Date(vaccine.dateGiven).toLocaleDateString("de-DE")}</Text>
<Text style={styles.rowSecondary}>
{t("health.vaccineGiven", { date: new Date(vaccine.dateGiven).toLocaleDateString(i18n.language) })}
</Text>
{vaccine.nextDueDate && (
<Text style={[styles.rowSecondary, isOverdue(vaccine.nextDueDate) && styles.overdue]}>
{isOverdue(vaccine.nextDueDate) ? "Überfällig" : `Fällig ${new Date(vaccine.nextDueDate).toLocaleDateString("de-DE")}`}
{isOverdue(vaccine.nextDueDate)
? t("health.vaccineOverdue")
: t("health.vaccineDue", { date: new Date(vaccine.nextDueDate).toLocaleDateString(i18n.language) })}
</Text>
)}
</View>
@@ -1,5 +1,6 @@
import { useState } from "react";
import { View, Text, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
@@ -12,6 +13,7 @@ const today = () => new Date().toISOString().slice(0, 10);
/** Port of web's health.ts vetVisit CRUD — see health/page.tsx's Tierarzt tab. */
export function VetVisitSection({ petId }: { petId: string }) {
const { t, i18n } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
@@ -44,7 +46,7 @@ export function VetVisitSection({ petId }: { petId: string }) {
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Tierarzt</Text>
<Text style={styles.sectionTitle}>{t("health.vetTitle")}</Text>
<Pressable onPress={() => setShowForm((v) => !v)} hitSlop={8}>
<Plus color={colors.marigoldOrange} size={20} />
</Pressable>
@@ -52,12 +54,12 @@ export function VetVisitSection({ petId }: { petId: string }) {
{showForm && (
<Card style={styles.form}>
<TextField placeholder="JJJJ-MM-TT" value={date} onChangeText={setDate} />
<TextField placeholder="Grund, z.B. Jahresuntersuchung" value={reason} onChangeText={setReason} maxLength={100} />
<TextField placeholder="Tierarzt / Klinik (optional)" value={vetName} onChangeText={setVetName} maxLength={100} />
<TextField placeholder="Notizen (optional)" value={notes} onChangeText={setNotes} maxLength={500} multiline />
<TextField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
<TextField placeholder={t("health.vetReasonPlaceholder")} value={reason} onChangeText={setReason} maxLength={100} />
<TextField placeholder={t("health.vetNamePlaceholder")} value={vetName} onChangeText={setVetName} maxLength={100} />
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={500} multiline />
<Button
title={addMutation.isPending ? "…" : "Speichern"}
title={addMutation.isPending ? "…" : t("common.save")}
disabled={!canSubmit}
onPress={() =>
addMutation.mutate({
@@ -75,14 +77,14 @@ export function VetVisitSection({ petId }: { petId: string }) {
{isLoading ? (
<ActivityIndicator color={colors.marigoldOrange} />
) : visits.length === 0 ? (
<Text style={styles.empty}>Noch keine Tierarztbesuche</Text>
<Text style={styles.empty}>{t("health.vetEmpty")}</Text>
) : (
visits.map((visit) => (
<View key={visit.id} style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.rowPrimary}>{visit.reason}</Text>
<Text style={styles.rowSecondary}>
{new Date(visit.date).toLocaleDateString("de-DE")}
{new Date(visit.date).toLocaleDateString(i18n.language)}
{visit.vetName ? ` · ${visit.vetName}` : ""}
</Text>
{visit.notes ? <Text style={styles.rowNotes}>{visit.notes}</Text> : null}
@@ -1,5 +1,6 @@
import { useState } from "react";
import { View, Text, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
@@ -12,6 +13,7 @@ const today = () => new Date().toISOString().slice(0, 10);
/** Port of web's health.ts weightLog CRUD — see src/app/(app)/pets/[petId]/health/page.tsx's Gewicht tab. */
export function WeightLogSection({ petId }: { petId: string }) {
const { t, i18n } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
@@ -43,7 +45,7 @@ export function WeightLogSection({ petId }: { petId: string }) {
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Gewicht</Text>
<Text style={styles.sectionTitle}>{t("health.weightTitle")}</Text>
<Pressable onPress={() => setShowForm((v) => !v)} hitSlop={8}>
<Plus color={colors.marigoldOrange} size={20} />
</Pressable>
@@ -51,11 +53,11 @@ export function WeightLogSection({ petId }: { petId: string }) {
{showForm && (
<Card style={styles.form}>
<TextField placeholder="JJJJ-MM-TT" value={date} onChangeText={setDate} />
<TextField placeholder="Gewicht (kg), z.B. 4,2" value={weightKg} onChangeText={setWeightKg} keyboardType="decimal-pad" />
<TextField placeholder="Notizen (optional)" value={notes} onChangeText={setNotes} maxLength={200} />
<TextField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
<TextField placeholder={t("health.weightKgPlaceholder")} value={weightKg} onChangeText={setWeightKg} keyboardType="decimal-pad" />
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={200} />
<Button
title={addMutation.isPending ? "…" : "Speichern"}
title={addMutation.isPending ? "…" : t("common.save")}
disabled={!canSubmit}
onPress={() => addMutation.mutate({ petId, date, weightKg: parsedWeight, notes: notes.trim() || undefined })}
/>
@@ -65,14 +67,14 @@ export function WeightLogSection({ petId }: { petId: string }) {
{isLoading ? (
<ActivityIndicator color={colors.marigoldOrange} />
) : logs.length === 0 ? (
<Text style={styles.empty}>Noch keine Gewichtseinträge</Text>
<Text style={styles.empty}>{t("health.weightEmpty")}</Text>
) : (
logs.map((log) => (
<View key={log.id} style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.rowPrimary}>{(log.weightGrams / 1000).toLocaleString("de-DE")} kg</Text>
<Text style={styles.rowPrimary}>{(log.weightGrams / 1000).toLocaleString(i18n.language)} kg</Text>
<Text style={styles.rowSecondary}>
{new Date(log.date).toLocaleDateString("de-DE")}
{new Date(log.date).toLocaleDateString(i18n.language)}
{log.notes ? ` · ${log.notes}` : ""}
</Text>
</View>
@@ -1,5 +1,6 @@
import { useState } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { Bug, Check } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
@@ -14,6 +15,7 @@ type Props = { onContinue: () => void };
* on the Pets tab, matching web's HideSpidersToggle).
*/
export function ArachnophobiaOptIn({ onContinue }: Props) {
const { t } = useTranslation();
const trpc = useTRPC();
const [hideSpiders, setHideSpiders] = useState(false);
@@ -28,24 +30,21 @@ export function ArachnophobiaOptIn({ onContinue }: Props) {
<View style={styles.container}>
<View style={styles.titleRow}>
<Bug color={colors.marigoldOrange} size={20} />
<Text style={styles.title}>Arachnophobie-Helfer</Text>
<Text style={styles.title}>{t("onboarding.arachnophobiaTitle")}</Text>
</View>
<Text style={styles.subtitle}>
PawFeed ist jetzt auch für Spinnen-Halter da. Wenn dir vor Spinnenbildern graut, blenden wir sie
für dich komplett aus — im Feed, bei Explore und in der Suche.
</Text>
<Text style={styles.subtitle}>{t("onboarding.arachnophobiaSubtitle")}</Text>
<Pressable style={styles.checkboxRow} onPress={() => setHideSpiders((v) => !v)}>
<View style={[styles.checkbox, hideSpiders && styles.checkboxChecked]}>
{hideSpiders && <Check color={colors.pureWhite} size={14} />}
</View>
<Text style={styles.checkboxLabel}>Ja, bitte Spinnen-Inhalte für mich ausblenden.</Text>
<Text style={styles.checkboxLabel}>{t("onboarding.arachnophobiaCheckboxLabel")}</Text>
</Pressable>
<Text style={styles.hint}>Du kannst das jederzeit unter "Meine Tiere" wieder ändern.</Text>
<Text style={styles.hint}>{t("onboarding.arachnophobiaHint")}</Text>
<Button
title={setPreferenceMutation.isPending ? "…" : "Weiter"}
title={setPreferenceMutation.isPending ? "…" : t("common.continue")}
disabled={setPreferenceMutation.isPending}
onPress={() => setPreferenceMutation.mutate({ hideSpiders })}
style={styles.cta}
@@ -1,6 +1,7 @@
import { useCallback, useState } from "react";
import { View, Text, Image as RNImage, Pressable, ActivityIndicator, StyleSheet, Alert } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { ImagePlus, Camera } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
@@ -25,6 +26,7 @@ type Props = {
* required to finish onboarding.
*/
export function AvatarStep({ petId, petName, onDone }: Props) {
const { t } = useTranslation();
const trpc = useTRPC();
const [asset, setAsset] = useState<ImagePicker.ImagePickerAsset | null>(null);
const [uploadedKey, setUploadedKey] = useState<string | null>(null);
@@ -52,7 +54,7 @@ export function AvatarStep({ petId, petName, onDone }: Props) {
await confirmUploadMutation.mutateAsync({ petId, key });
setUploadedKey(key);
} catch {
setError("Upload fehlgeschlagen — bitte erneut versuchen.");
setError(t("onboarding.uploadError"));
}
},
[petId, getPresignedUrlMutation, confirmUploadMutation]
@@ -61,7 +63,7 @@ export function AvatarStep({ petId, petName, onDone }: Props) {
const pickFromGallery = useCallback(async () => {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
Alert.alert("Zugriff auf Fotos wurde nicht erlaubt.");
Alert.alert(t("onboarding.galleryPermissionDenied"));
return;
}
await pickAndUpload(() => ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.8 }));
@@ -70,7 +72,7 @@ export function AvatarStep({ petId, petName, onDone }: Props) {
const takePhoto = useCallback(async () => {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (!permission.granted) {
Alert.alert("Zugriff auf die Kamera wurde nicht erlaubt.");
Alert.alert(t("onboarding.cameraPermissionDenied"));
return;
}
await pickAndUpload(() => ImagePicker.launchCameraAsync({ quality: 0.8 }));
@@ -80,8 +82,8 @@ export function AvatarStep({ petId, petName, onDone }: Props) {
return (
<View style={styles.container}>
<Text style={styles.title}>Profilfoto hinzufügen</Text>
<Text style={styles.subtitle}>Gib deinem Tier ein Gesicht auf PawFeed. Du kannst das jederzeit ändern.</Text>
<Text style={styles.title}>{t("onboarding.avatarTitle")}</Text>
<Text style={styles.subtitle}>{t("onboarding.avatarSubtitle")}</Text>
{asset ? (
<View style={styles.previewWrap}>
@@ -101,18 +103,18 @@ export function AvatarStep({ petId, petName, onDone }: Props) {
<View style={styles.pickerRow}>
<Pressable style={styles.pickerButton} onPress={pickFromGallery} disabled={isUploading}>
<ImagePlus color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>Galerie</Text>
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
</Pressable>
<Pressable style={styles.pickerButton} onPress={takePhoto} disabled={isUploading}>
<Camera color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>Kamera</Text>
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
</Pressable>
</View>
<Button title="Fertig" onPress={onDone} disabled={isUploading} style={styles.cta} />
<Button title={t("common.done")} onPress={onDone} disabled={isUploading} style={styles.cta} />
{!uploadedKey && (
<Pressable onPress={onDone} disabled={isUploading} hitSlop={8}>
<Text style={styles.skipLink}>Jetzt überspringen</Text>
<Text style={styles.skipLink}>{t("onboarding.skipLink")}</Text>
</Pressable>
)}
</View>
@@ -1,5 +1,6 @@
import { View, Text, Pressable, ActivityIndicator, Linking, StyleSheet } from "react-native";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ScrollText } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
@@ -16,6 +17,7 @@ type Props = { onContinue: () => void };
* LegalDocumentVersion content, not a hardcoded first-run screen.
*/
export function OnboardingGuidelines({ onContinue }: Props) {
const { t } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
@@ -49,9 +51,9 @@ export function OnboardingGuidelines({ onContinue }: Props) {
<View style={styles.container}>
<View style={styles.titleRow}>
<ScrollText color={colors.marigoldOrange} size={20} />
<Text style={styles.title}>Bevor es losgeht</Text>
<Text style={styles.title}>{t("onboarding.guidelinesTitle")}</Text>
</View>
<Text style={styles.subtitle}>Ein kurzer Überblick über unsere Community-Richtlinien.</Text>
<Text style={styles.subtitle}>{t("onboarding.guidelinesSubtitle")}</Text>
<View style={styles.card}>
{pending!.map((version) => (
@@ -62,14 +64,14 @@ export function OnboardingGuidelines({ onContinue }: Props) {
</View>
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/nutzungsbedingungen`)}>
<Text style={styles.link}>Alle Details in unseren Nutzungsbedingungen</Text>
<Text style={styles.link}>{t("onboarding.termsLink")}</Text>
</Pressable>
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/datenschutz`)}>
<Text style={styles.link}>Datenschutzerklärung (inkl. Cookies)</Text>
<Text style={styles.link}>{t("onboarding.privacyLink")}</Text>
</Pressable>
<Button
title={acknowledgeMutation.isPending ? "…" : "Verstanden, weiter"}
title={acknowledgeMutation.isPending ? "…" : t("onboarding.guidelinesContinue")}
disabled={acknowledgeMutation.isPending}
onPress={() => acknowledgeMutation.mutate()}
style={styles.cta}
@@ -1,5 +1,6 @@
import { useState } from "react";
import { View, Text, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useTRPC } from "../../api/trpc";
import { TextField } from "../TextField";
@@ -16,6 +17,7 @@ type Props = { onSuccess: (pet: { id: string; name: string }) => void };
* pet exists (see onboarding/pet.tsx), matching web's two-phase flow.
*/
export function PetCreateForm({ onSuccess }: Props) {
const { t } = useTranslation();
const trpc = useTRPC();
const [name, setName] = useState("");
const [nickname, setNickname] = useState("");
@@ -38,16 +40,16 @@ export function PetCreateForm({ onSuccess }: Props) {
return (
<View style={styles.container}>
<Text style={styles.title}>Füge dein erstes Tier hinzu</Text>
<Text style={styles.subtitle}>Dein Tier wird sein eigener Star auf PawFeed.</Text>
<Text style={styles.title}>{t("petForm.createTitle")}</Text>
<Text style={styles.subtitle}>{t("petForm.createSubtitle")}</Text>
<Text style={styles.label}>Name des Tieres</Text>
<TextField placeholder="Name eingeben" value={name} onChangeText={setName} maxLength={30} />
<Text style={styles.label}>{t("petForm.name")}</Text>
<TextField placeholder={t("petForm.namePlaceholder")} value={name} onChangeText={setName} maxLength={30} />
<Text style={styles.label}>Rufname / Spitzname (optional)</Text>
<TextField placeholder="z. B. Rexi" value={nickname} onChangeText={setNickname} maxLength={30} />
<Text style={styles.label}>{t("petForm.nickname")}</Text>
<TextField placeholder={t("petForm.nicknamePlaceholder")} value={nickname} onChangeText={setNickname} maxLength={30} />
<Text style={styles.label}>Tierart</Text>
<Text style={styles.label}>{t("petForm.species")}</Text>
<SpeciesPicker
selectedName={speciesName}
onSelect={(option) => {
@@ -58,12 +60,12 @@ export function PetCreateForm({ onSuccess }: Props) {
}}
/>
<Text style={styles.label}>Rasse</Text>
<Text style={styles.label}>{t("petForm.breed")}</Text>
<BreedPicker speciesId={speciesId} selectedName={breedName} onSelect={(o) => { setBreedId(o.id); setBreedName(o.name); }} />
<Text style={styles.label}>Biografie</Text>
<Text style={styles.label}>{t("petForm.bio")}</Text>
<TextField
placeholder="Erzähl uns von deinem Tier…"
placeholder={t("petForm.bioPlaceholder")}
value={bio}
onChangeText={setBio}
maxLength={150}
@@ -71,9 +73,9 @@ export function PetCreateForm({ onSuccess }: Props) {
style={styles.multiline}
/>
<Text style={styles.label}>Adoptionsgeschichte</Text>
<Text style={styles.label}>{t("petForm.adoptionStory")}</Text>
<TextField
placeholder="Wie kam dein Tier zu deiner Familie?"
placeholder={t("petForm.adoptionStoryPlaceholder")}
value={adoptionStory}
onChangeText={setAdoptionStory}
maxLength={500}
@@ -84,7 +86,7 @@ export function PetCreateForm({ onSuccess }: Props) {
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title={createMutation.isPending ? "…" : "Tier anlegen"}
title={createMutation.isPending ? "…" : t("petForm.createButton")}
disabled={!canSubmit}
onPress={() =>
createMutation.mutate({
@@ -103,16 +105,17 @@ export function PetCreateForm({ onSuccess }: Props) {
}
function SpeciesPicker({ selectedName, onSelect }: { selectedName: string | null; onSelect: (o: PickerOption) => void }) {
const { t, i18n } = useTranslation();
const trpc = useTRPC();
const [open, setOpen] = useState(false);
const { data: species = [], isLoading } = useQuery(trpc.pets.listSpecies.queryOptions({ locale: "de" }));
const { data: species = [], isLoading } = useQuery(trpc.pets.listSpecies.queryOptions({ locale: i18n.language }));
return (
<OptionPicker
open={open}
onToggle={() => setOpen((v) => !v)}
selectedName={selectedName}
placeholder="Tierart auswählen"
placeholder={t("petForm.selectSpecies")}
isLoading={isLoading}
options={species}
onSelect={(o) => {
@@ -132,10 +135,11 @@ function BreedPicker({
selectedName: string | null;
onSelect: (o: PickerOption) => void;
}) {
const { t, i18n } = useTranslation();
const trpc = useTRPC();
const [open, setOpen] = useState(false);
const { data: breeds = [], isLoading } = useQuery({
...trpc.pets.listBreeds.queryOptions({ speciesId: speciesId ?? "", locale: "de" }),
...trpc.pets.listBreeds.queryOptions({ speciesId: speciesId ?? "", locale: i18n.language }),
enabled: !!speciesId,
});
@@ -145,10 +149,10 @@ function BreedPicker({
onToggle={() => speciesId && setOpen((v) => !v)}
disabled={!speciesId}
selectedName={selectedName}
placeholder={speciesId ? "Rasse auswählen (optional)" : "Wähle zuerst eine Tierart aus"}
placeholder={speciesId ? t("petForm.selectBreed") : t("petForm.selectSpeciesFirst")}
isLoading={isLoading}
options={breeds}
emptyLabel="Keine Rassen gefunden."
emptyLabel={t("petForm.noBreedsFound")}
onSelect={(o) => {
onSelect(o);
setOpen(false);
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { View, Text, Pressable, ActivityIndicator, Linking, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ShieldCheck, Check } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
@@ -16,6 +17,7 @@ type Props = { onContinue: () => void };
* automatically if already consented (legal.getUploadConsentStatus).
*/
export function UploadConsent({ onContinue }: Props) {
const { t } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
const [contentRightsChecked, setContentRightsChecked] = useState(false);
@@ -53,29 +55,27 @@ export function UploadConsent({ onContinue }: Props) {
<View style={styles.container}>
<View style={styles.titleRow}>
<ShieldCheck color={colors.marigoldOrange} size={20} />
<Text style={styles.title}>Zwei kurze Bestätigungen</Text>
<Text style={styles.title}>{t("onboarding.consentTitle")}</Text>
</View>
<Text style={styles.subtitle}>
Bevor du dein erstes Tier anlegst, brauchen wir deine ausdrückliche Zustimmung zu Folgendem.
</Text>
<Text style={styles.subtitle}>{t("onboarding.consentSubtitle")}</Text>
<ConsentCheckbox
checked={contentRightsChecked}
onToggle={() => setContentRightsChecked((v) => !v)}
label="Ich bestätige, dass ich alle Rechte an den von mir hochgeladenen Bildern und Videos besitze und dass diese kein fremdes Urheberrecht verletzen."
label={t("onboarding.contentRightsLabel")}
/>
<ConsentCheckbox
checked={dataProcessingChecked}
onToggle={() => setDataProcessingChecked((v) => !v)}
label="Ich willige ein, dass meine Daten zur Bereitstellung des Dienstes an Clerk (Authentifizierung), Cloudflare (Medienspeicherung), Mux (Videoverarbeitung) und Supabase (Datenbank) übermittelt werden."
label={t("onboarding.dataProcessingLabel")}
/>
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/datenschutz`)}>
<Text style={styles.link}>Details zu den eingesetzten Dienstleistern in unserer Datenschutzerklärung</Text>
<Text style={styles.link}>{t("onboarding.consentPrivacyLink")}</Text>
</Pressable>
<Button
title={acknowledgeMutation.isPending ? "…" : "Bestätigen und weiter"}
title={acknowledgeMutation.isPending ? "…" : t("onboarding.consentContinue")}
disabled={!canContinue || acknowledgeMutation.isPending}
onPress={() => acknowledgeMutation.mutate()}
style={styles.cta}
+69
View File
@@ -0,0 +1,69 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import * as Localization from "expo-localization";
import { storage } from "../lib/storage";
import en from "./locales/en.json";
import de from "./locales/de.json";
// Mirrors the web app's supported locales (src/i18n/request.ts) — keep these
// two in sync if a locale is ever added on either side.
export const LOCALES = ["en", "de"] as const;
export type Locale = (typeof LOCALES)[number];
export const defaultLocale: Locale = "en";
const LANGUAGE_PREFERENCE_KEY = "language_preference";
function isLocale(value: string | null | undefined): value is Locale {
return !!value && (LOCALES as readonly string[]).includes(value);
}
/**
* First supported locale among the device's language list, else defaultLocale.
* expo-localization is a native module added via config plugin — on a
* dev-client binary built before this change, it isn't linked yet and
* getLocales() throws "Cannot find native module 'ExpoLocalization'" rather
* than returning an empty array. Falling back to defaultLocale here (instead
* of letting that throw during i18next.init's module-load-time call) is what
* keeps the app bootable on an un-rebuilt dev client — see mobile/README.md.
*/
function deviceLocale(): Locale {
try {
const match = Localization.getLocales().find((l) => isLocale(l.languageCode));
return isLocale(match?.languageCode) ? match.languageCode : defaultLocale;
} catch {
return defaultLocale;
}
}
/** The stored account-local override — null means "follow device language". */
export function getLanguagePreference(): Locale | null {
const stored = storage.getString(LANGUAGE_PREFERENCE_KEY);
return isLocale(stored) ? stored : null;
}
/**
* There is no backend language field (mirrors web: locale lives only in the
* NEXT_LOCALE cookie, never in the Owner row) — persisting to MMKV and
* flipping i18next's active language is the whole operation.
*/
export function setLanguagePreference(preference: Locale | null): void {
if (preference === null) {
storage.remove(LANGUAGE_PREFERENCE_KEY);
} else {
storage.set(LANGUAGE_PREFERENCE_KEY, preference);
}
i18n.changeLanguage(preference ?? deviceLocale());
}
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
de: { translation: de },
},
lng: getLanguagePreference() ?? deviceLocale(),
fallbackLng: defaultLocale,
interpolation: { escapeValue: false },
returnNull: false,
});
export default i18n;
+266
View File
@@ -0,0 +1,266 @@
{
"relativeTime": {
"justNow": "gerade eben",
"minutesAgo": "vor {{count}} Min.",
"hoursAgo": "vor {{count}} Std.",
"daysAgo_one": "vor {{count}} Tag",
"daysAgo_other": "vor {{count}} Tagen"
},
"common": {
"confirm": "Bestätigen",
"cancel": "Abbrechen",
"save": "Speichern",
"delete": "Löschen",
"loading": "Wird geladen…",
"retry": "Erneut versuchen",
"close": "Schließen",
"back": "Zurück",
"continue": "Weiter",
"skip": "Überspringen",
"done": "Fertig",
"optional": "optional",
"noResultsFound": "Keine Einträge gefunden.",
"edit": "Bearbeiten"
},
"social": {
"follow": "Folgen",
"unfollow": "Entfolgen",
"repostAttribution": "Paw-Back von {{name}}"
},
"video": {
"processing": "Video wird verarbeitet…",
"error": "Video konnte nicht verarbeitet werden."
},
"feed": {
"empty": "Noch keine Beiträge im Feed.",
"storyLabel": "Story"
},
"explore": {
"allTab": "Alle",
"allBreeds": "Alle Rassen",
"noPetsForSpecies": "Noch keine {{name}}.",
"noPetsAtAll": "Keine Tiere gefunden.",
"followerCount_one": "{{count}} Follower",
"followerCount_other": "{{count}} Follower"
},
"editPet": {
"nameLabel": "Name",
"nicknameLabel": "Rufname",
"bioLabel": "Bio",
"deleteConfirmTitle": "Tier wirklich löschen?",
"deleteConfirmMessage": "{{name}} und alle zugehörigen Beiträge, Kommentare und Follower-Beziehungen werden unwiderruflich gelöscht.",
"deleteConfirmMessageFallback": "Dieses Tier",
"deletePermanently": "Endgültig löschen",
"deletePet": "Tier löschen"
},
"comments": {
"empty": "Noch keine Kommentare.",
"sending": "Wird gesendet…",
"placeholder": "Kommentar schreiben…"
},
"post": {
"maxImages": "Maximal {{max}} Fotos pro Beitrag.",
"thumbError": "Fehler",
"captionPlaceholder": "Schreibe eine Bildunterschrift…",
"shareButton": "Teilen"
},
"story": {
"changePhotoHint": "Antippen, um ein anderes Foto zu wählen",
"shareButton": "Story teilen"
},
"milestoneTypes": {
"BIRTHDAY": "Geburtstag",
"ADOPTION_DAY": "Adoptionstag",
"FIRST_OUTING": "Erster Ausflug",
"VET_VISIT": "Tierarztbesuch",
"LITTER": "Neuer Wurf",
"FOLLOWERS_500": "500 Follower",
"FOLLOWERS_1K": "1.000 Follower",
"FOLLOWERS_2_5K": "2.500 Follower",
"FOLLOWERS_5K": "5.000 Follower",
"FOLLOWERS_10K": "10.000 Follower",
"FOLLOWERS_25K": "25.000 Follower",
"FOLLOWERS_50K": "50.000 Follower",
"FOLLOWERS_100K": "100.000 Follower",
"FOLLOWERS_250K": "250.000 Follower",
"FOLLOWERS_500K": "500.000 Follower",
"FOLLOWERS_1M": "1.000.000 Follower"
},
"milestones": {
"empty": "Noch keine Meilensteine.",
"addButton": "Meilenstein hinzufügen",
"typeLabel": "Art des Meilensteins",
"removePhotoHint": "Antippen, um das Foto zu entfernen",
"addPhoto": "Foto hinzufügen (optional)",
"sharing": "Wird geteilt…",
"shareButton": "Meilenstein teilen"
},
"health": {
"consentSaveError": "Einwilligung konnte nicht gespeichert werden.",
"consentRevokeError": "Einwilligung konnte nicht widerrufen werden.",
"revokeConfirmTitle": "Einwilligung wirklich widerrufen?",
"revokeConfirmMessage": "Dadurch werden alle Gewichtseinträge, Tierarztbesuche und Impfungen dieses Haustiers unwiderruflich gelöscht.",
"revokeConfirmButton": "Endgültig widerrufen und löschen",
"consentTitle": "Einwilligung für Gesundheitsdaten",
"consentSubtitle": "Bevor Sie den Health-Tracking-Bereich nutzen können, benötigen wir Ihre ausdrückliche Einwilligung zur Verarbeitung der Gesundheitsdaten Ihres Haustiers.",
"consentCheckboxLabel": "Ich willige ausdrücklich ein, dass Gesundheitsdaten meines Haustiers (Gewicht, Tierarztbesuche, Impfungen) gespeichert und verarbeitet werden. Ich kann diese Einwilligung jederzeit widerrufen; danach werden alle Gesundheitsdaten dieses Haustiers automatisch gelöscht.",
"consentPrivacyLink": "Weitere Informationen in unserer Datenschutzerklärung",
"consentAcceptButton": "Zustimmen und fortfahren",
"manageTitle": "Einwilligung verwalten",
"manageDesc": "Ein Widerruf löscht sofort und unwiderruflich alle Gesundheitsdaten dieses Haustiers.",
"revokeLink": "Widerrufen",
"weightTitle": "Gewicht",
"weightDatePlaceholder": "JJJJ-MM-TT",
"weightKgPlaceholder": "Gewicht (kg), z.B. 4,2",
"notesPlaceholder": "Notizen (optional)",
"weightEmpty": "Noch keine Gewichtseinträge",
"vetTitle": "Tierarzt",
"vetReasonPlaceholder": "Grund, z.B. Jahresuntersuchung",
"vetNamePlaceholder": "Tierarzt / Klinik (optional)",
"vetEmpty": "Noch keine Tierarztbesuche",
"vaccineTitle": "Impfungen",
"vaccineNamePlaceholder": "Impfstoff, z.B. Tollwut, DHPP",
"vaccineGivenPlaceholder": "Gegeben am (JJJJ-MM-TT)",
"vaccineNextDuePlaceholder": "Nächste Fälligkeit (optional, JJJJ-MM-TT)",
"vaccineEmpty": "Noch keine Impfungen eingetragen",
"vaccineGiven": "Gegeben {{date}}",
"vaccineDue": "Fällig {{date}}",
"vaccineOverdue": "Überfällig"
},
"search": {
"placeholder": "Suchen…",
"petsTab": "Tiere",
"peopleTab": "Personen",
"tagsTab": "Hashtags",
"minChars": "Mindestens 2 Zeichen eingeben.",
"noPetsFor": "Keine Tiere für \"{{query}}\" gefunden.",
"noPeopleFor": "Keine Personen für \"{{query}}\" gefunden.",
"unknownOwner": "Unbekannt",
"enterHashtag": "Hashtag eingeben.",
"noPostsForTag": "Keine Beiträge für #{{tag}}."
},
"messages": {
"noConversations": "Noch keine Unterhaltungen.",
"empty": "Noch keine Nachrichten. Schreib die erste!",
"placeholder": "Nachricht schreiben…",
"send": "Senden"
},
"petProfile": {
"notFound": "Tier nicht gefunden.",
"noPosts": "Noch keine Beiträge.",
"followerStat": "Follower",
"followingStat": "Folgt",
"healthButton": "Gesundheit",
"milestonesButton": "Meilensteine",
"messageButton": "Nachricht",
"adoptionStoryTitle": "Adoptionsgeschichte",
"noFollowers": "Noch keine Follower.",
"noFollowing": "Folgt noch niemandem."
},
"notifications": {
"follow": "{{actor}} folgt dir jetzt.",
"reaction": "{{actor}} hat deinen Beitrag mit einer Pfote versehen.",
"comment": "{{actor}} hat deinen Beitrag kommentiert.",
"message": "{{actor}} hat dir eine Nachricht geschickt.",
"mention": "{{actor}} hat dich erwähnt.",
"pawBack": "{{actor}} hat zurückgepfotet.",
"birthday": "🎂 Alles Gute zum Geburtstag!",
"adoptionDay": "🏠 Heute ist der Adoptionstag!",
"empty": "Keine Benachrichtigungen."
},
"pets": {
"empty": "Noch keine Tiere angelegt.",
"appearanceTitle": "Darstellung",
"appearanceLight": "Hell",
"appearanceDark": "Dunkel",
"appearanceAuto": "Automatisch",
"languageTitle": "Sprache",
"languageGerman": "Deutsch",
"languageEnglish": "English",
"languageAuto": "Automatisch"
},
"auth": {
"signInTitle": "PawFeed Login",
"signUpTitle": "PawFeed Registrierung",
"emailPlaceholder": "E-Mail",
"passwordPlaceholder": "Passwort",
"codePlaceholder": "Code",
"signInButton": "Anmelden",
"signUpButton": "Registrieren",
"confirmButton": "Bestätigen",
"noAccountLink": "Noch kein Account? Registrieren",
"hasAccountLink": "Schon registriert? Anmelden",
"verifyDeviceTitle": "Gerät bestätigen",
"verifyDeviceHint": "Wir haben einen Code an {{email}} geschickt, um dieses Gerät zu bestätigen.",
"verifyEmailTitle": "E-Mail bestätigen",
"verifyEmailHint": "Wir haben einen Code an {{email}} geschickt, um deine E-Mail-Adresse zu bestätigen.",
"signInIncompleteError": "Anmeldung unvollständig — Status: {{status}}",
"signUpIncompleteError": "Registrierung unvollständig — Status: {{status}}"
},
"onboarding": {
"addPetTitle": "Tier hinzufügen",
"guidelinesTitle": "Bevor es losgeht",
"guidelinesSubtitle": "Ein kurzer Überblick über unsere Community-Richtlinien.",
"termsLink": "Alle Details in unseren Nutzungsbedingungen",
"privacyLink": "Datenschutzerklärung (inkl. Cookies)",
"guidelinesContinue": "Verstanden, weiter",
"consentTitle": "Zwei kurze Bestätigungen",
"consentSubtitle": "Bevor du dein erstes Tier anlegst, brauchen wir deine ausdrückliche Zustimmung zu Folgendem.",
"contentRightsLabel": "Ich bestätige, dass ich alle Rechte an den von mir hochgeladenen Bildern und Videos besitze und dass diese kein fremdes Urheberrecht verletzen.",
"dataProcessingLabel": "Ich willige ein, dass meine Daten zur Bereitstellung des Dienstes an Clerk (Authentifizierung), Cloudflare (Medienspeicherung), Mux (Videoverarbeitung) und Supabase (Datenbank) übermittelt werden.",
"consentPrivacyLink": "Details zu den eingesetzten Dienstleistern in unserer Datenschutzerklärung",
"consentContinue": "Bestätigen und weiter",
"arachnophobiaTitle": "Arachnophobie-Helfer",
"arachnophobiaSubtitle": "PawFeed ist jetzt auch für Spinnen-Halter da. Wenn dir vor Spinnenbildern graut, blenden wir sie für dich komplett aus — im Feed, bei Explore und in der Suche.",
"arachnophobiaCheckboxLabel": "Ja, bitte Spinnen-Inhalte für mich ausblenden.",
"arachnophobiaHint": "Du kannst das jederzeit unter \"Meine Tiere\" wieder ändern.",
"avatarTitle": "Profilfoto hinzufügen",
"avatarSubtitle": "Gib deinem Tier ein Gesicht auf PawFeed. Du kannst das jederzeit ändern.",
"galleryButton": "Galerie",
"cameraButton": "Kamera",
"skipLink": "Jetzt überspringen",
"uploadError": "Upload fehlgeschlagen — bitte erneut versuchen.",
"galleryPermissionDenied": "Zugriff auf Fotos wurde nicht erlaubt.",
"cameraPermissionDenied": "Zugriff auf die Kamera wurde nicht erlaubt."
},
"petForm": {
"createTitle": "Füge dein erstes Tier hinzu",
"createSubtitle": "Dein Tier wird sein eigener Star auf PawFeed.",
"name": "Name des Tieres",
"namePlaceholder": "Name eingeben",
"nickname": "Rufname / Spitzname (optional)",
"nicknamePlaceholder": "z. B. Rexi",
"species": "Tierart",
"selectSpecies": "Tierart auswählen",
"breed": "Rasse",
"selectBreed": "Rasse auswählen (optional)",
"selectSpeciesFirst": "Wähle zuerst eine Tierart aus",
"noBreedsFound": "Keine Rassen gefunden.",
"bio": "Biografie",
"bioPlaceholder": "Erzähl uns von deinem Tier…",
"adoptionStory": "Adoptionsgeschichte",
"adoptionStoryPlaceholder": "Wie kam dein Tier zu deiner Familie?",
"createButton": "Tier anlegen"
},
"nav": {
"feedTab": "Feed",
"exploreTitle": "Explore",
"notificationsTitle": "Benachrichtigungen",
"petsTitle": "Meine Tiere"
},
"screens": {
"messages": "Nachrichten",
"profile": "Profil",
"editProfile": "Profil bearbeiten",
"followers": "Follower",
"following": "Folgt",
"health": "Gesundheit",
"milestones": "Meilensteine",
"comments": "Kommentare",
"conversation": "Nachricht",
"search": "Suche",
"newPost": "Neuer Beitrag",
"newStory": "Neue Story",
"newMilestone": "Neuer Meilenstein"
}
}
+266
View File
@@ -0,0 +1,266 @@
{
"relativeTime": {
"justNow": "just now",
"minutesAgo": "{{count}} min ago",
"hoursAgo": "{{count}}h ago",
"daysAgo_one": "{{count}} day ago",
"daysAgo_other": "{{count}} days ago"
},
"common": {
"confirm": "Confirm",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"loading": "Loading…",
"retry": "Try again",
"close": "Close",
"back": "Back",
"continue": "Continue",
"skip": "Skip",
"done": "Done",
"optional": "optional",
"noResultsFound": "No entries found.",
"edit": "Edit"
},
"social": {
"follow": "Follow",
"unfollow": "Unfollow",
"repostAttribution": "Paw-Back from {{name}}"
},
"video": {
"processing": "Video is processing…",
"error": "Video could not be processed."
},
"feed": {
"empty": "No posts in your feed yet.",
"storyLabel": "Story"
},
"explore": {
"allTab": "All",
"allBreeds": "All breeds",
"noPetsForSpecies": "No {{name}} yet.",
"noPetsAtAll": "No pets found.",
"followerCount_one": "{{count}} follower",
"followerCount_other": "{{count}} followers"
},
"editPet": {
"nameLabel": "Name",
"nicknameLabel": "Nickname",
"bioLabel": "Bio",
"deleteConfirmTitle": "Delete this pet?",
"deleteConfirmMessage": "{{name}} and all associated posts, comments, and follower relationships will be permanently deleted.",
"deleteConfirmMessageFallback": "This pet",
"deletePermanently": "Delete permanently",
"deletePet": "Delete pet"
},
"comments": {
"empty": "No comments yet.",
"sending": "Sending…",
"placeholder": "Write a comment…"
},
"post": {
"maxImages": "Maximum {{max}} photos per post.",
"thumbError": "Error",
"captionPlaceholder": "Write a caption…",
"shareButton": "Share"
},
"story": {
"changePhotoHint": "Tap to choose a different photo",
"shareButton": "Share story"
},
"milestoneTypes": {
"BIRTHDAY": "Birthday",
"ADOPTION_DAY": "Adoption Day",
"FIRST_OUTING": "First Outing",
"VET_VISIT": "Vet Visit",
"LITTER": "New Litter",
"FOLLOWERS_500": "500 Followers",
"FOLLOWERS_1K": "1,000 Followers",
"FOLLOWERS_2_5K": "2,500 Followers",
"FOLLOWERS_5K": "5,000 Followers",
"FOLLOWERS_10K": "10,000 Followers",
"FOLLOWERS_25K": "25,000 Followers",
"FOLLOWERS_50K": "50,000 Followers",
"FOLLOWERS_100K": "100,000 Followers",
"FOLLOWERS_250K": "250,000 Followers",
"FOLLOWERS_500K": "500,000 Followers",
"FOLLOWERS_1M": "1,000,000 Followers"
},
"milestones": {
"empty": "No milestones yet.",
"addButton": "Add milestone",
"typeLabel": "Milestone type",
"removePhotoHint": "Tap to remove the photo",
"addPhoto": "Add photo (optional)",
"sharing": "Sharing…",
"shareButton": "Share milestone"
},
"health": {
"consentSaveError": "Consent could not be saved.",
"consentRevokeError": "Consent could not be revoked.",
"revokeConfirmTitle": "Really revoke consent?",
"revokeConfirmMessage": "This will permanently delete all weight logs, vet visits, and vaccinations for this pet.",
"revokeConfirmButton": "Revoke and delete permanently",
"consentTitle": "Health data consent",
"consentSubtitle": "Before you can use the health tracking area, we need your explicit consent to process your pet's health data.",
"consentCheckboxLabel": "I explicitly consent to my pet's health data (weight, vet visits, vaccinations) being stored and processed. I can revoke this consent at any time; all health data for this pet will then be automatically deleted.",
"consentPrivacyLink": "More information in our Privacy Policy",
"consentAcceptButton": "Agree and continue",
"manageTitle": "Manage consent",
"manageDesc": "Revoking immediately and permanently deletes all health data for this pet.",
"revokeLink": "Revoke",
"weightTitle": "Weight",
"weightDatePlaceholder": "YYYY-MM-DD",
"weightKgPlaceholder": "Weight (kg), e.g. 4.2",
"notesPlaceholder": "Notes (optional)",
"weightEmpty": "No weight entries yet",
"vetTitle": "Vet",
"vetReasonPlaceholder": "Reason, e.g. annual checkup",
"vetNamePlaceholder": "Vet / clinic (optional)",
"vetEmpty": "No vet visits yet",
"vaccineTitle": "Vaccinations",
"vaccineNamePlaceholder": "Vaccine, e.g. rabies, DHPP",
"vaccineGivenPlaceholder": "Given on (YYYY-MM-DD)",
"vaccineNextDuePlaceholder": "Next due (optional, YYYY-MM-DD)",
"vaccineEmpty": "No vaccinations recorded yet",
"vaccineGiven": "Given {{date}}",
"vaccineDue": "Due {{date}}",
"vaccineOverdue": "Overdue"
},
"search": {
"placeholder": "Search…",
"petsTab": "Pets",
"peopleTab": "People",
"tagsTab": "Hashtags",
"minChars": "Enter at least 2 characters.",
"noPetsFor": "No pets found for \"{{query}}\".",
"noPeopleFor": "No people found for \"{{query}}\".",
"unknownOwner": "Unknown",
"enterHashtag": "Enter a hashtag.",
"noPostsForTag": "No posts for #{{tag}}."
},
"messages": {
"noConversations": "No conversations yet.",
"empty": "No messages yet. Write the first one!",
"placeholder": "Write a message…",
"send": "Send"
},
"petProfile": {
"notFound": "Pet not found.",
"noPosts": "No posts yet.",
"followerStat": "Followers",
"followingStat": "Following",
"healthButton": "Health",
"milestonesButton": "Milestones",
"messageButton": "Message",
"adoptionStoryTitle": "Adoption story",
"noFollowers": "No followers yet.",
"noFollowing": "Not following anyone yet."
},
"notifications": {
"follow": "{{actor}} is now following you.",
"reaction": "{{actor}} pawed your post.",
"comment": "{{actor}} commented on your post.",
"message": "{{actor}} sent you a message.",
"mention": "{{actor}} mentioned you.",
"pawBack": "{{actor}} paw-backed your post.",
"birthday": "🎂 Happy birthday!",
"adoptionDay": "🏠 Today is adoption day!",
"empty": "No notifications."
},
"pets": {
"empty": "No pets added yet.",
"appearanceTitle": "Appearance",
"appearanceLight": "Light",
"appearanceDark": "Dark",
"appearanceAuto": "Automatic",
"languageTitle": "Language",
"languageGerman": "Deutsch",
"languageEnglish": "English",
"languageAuto": "Automatic"
},
"auth": {
"signInTitle": "PawFeed Login",
"signUpTitle": "PawFeed Sign Up",
"emailPlaceholder": "Email",
"passwordPlaceholder": "Password",
"codePlaceholder": "Code",
"signInButton": "Sign in",
"signUpButton": "Sign up",
"confirmButton": "Confirm",
"noAccountLink": "No account yet? Sign up",
"hasAccountLink": "Already have an account? Sign in",
"verifyDeviceTitle": "Confirm device",
"verifyDeviceHint": "We sent a code to {{email}} to confirm this device.",
"verifyEmailTitle": "Confirm email",
"verifyEmailHint": "We sent a code to {{email}} to confirm your email address.",
"signInIncompleteError": "Sign-in incomplete — status: {{status}}",
"signUpIncompleteError": "Sign-up incomplete — status: {{status}}"
},
"onboarding": {
"addPetTitle": "Add pet",
"guidelinesTitle": "Before you get started",
"guidelinesSubtitle": "A quick overview of our community guidelines.",
"termsLink": "See the full details in our Terms of Service",
"privacyLink": "Privacy Policy (incl. cookies)",
"guidelinesContinue": "Got it, continue",
"consentTitle": "Two quick confirmations",
"consentSubtitle": "Before you create your first pet, we need your explicit consent to the following.",
"contentRightsLabel": "I confirm that I own all rights to the images and videos I upload and that they don't infringe on anyone else's copyright.",
"dataProcessingLabel": "I consent to my data being shared with Clerk (authentication), Cloudflare (media storage), Mux (video processing), and Supabase (database) to provide the service.",
"consentPrivacyLink": "Details on the service providers we use are in our Privacy Policy",
"consentContinue": "Confirm and continue",
"arachnophobiaTitle": "Arachnophobia helper",
"arachnophobiaSubtitle": "PawFeed now welcomes spider owners too. If spider photos give you the creeps, we'll hide them completely for you — in the feed, on Explore, and in search.",
"arachnophobiaCheckboxLabel": "Yes, please hide spider content for me.",
"arachnophobiaHint": "You can change this anytime under \"My Pets\".",
"avatarTitle": "Add a profile photo",
"avatarSubtitle": "Give your pet a face on PawFeed. You can change this anytime.",
"galleryButton": "Gallery",
"cameraButton": "Camera",
"skipLink": "Skip for now",
"uploadError": "Upload failed — please try again.",
"galleryPermissionDenied": "Access to photos was not granted.",
"cameraPermissionDenied": "Access to the camera was not granted."
},
"petForm": {
"createTitle": "Add your first pet",
"createSubtitle": "Your pet will be its own star on PawFeed.",
"name": "Pet name",
"namePlaceholder": "Enter pet name",
"nickname": "Nickname (optional)",
"nicknamePlaceholder": "e.g. Rexy",
"species": "Species",
"selectSpecies": "Select species",
"breed": "Breed",
"selectBreed": "Select breed (optional)",
"selectSpeciesFirst": "Select a species first",
"noBreedsFound": "No breeds found.",
"bio": "Bio",
"bioPlaceholder": "Tell us about your pet…",
"adoptionStory": "Adoption story",
"adoptionStoryPlaceholder": "How did your pet join your family?",
"createButton": "Add pet"
},
"nav": {
"feedTab": "Feed",
"exploreTitle": "Explore",
"notificationsTitle": "Notifications",
"petsTitle": "My Pets"
},
"screens": {
"messages": "Messages",
"profile": "Profile",
"editProfile": "Edit profile",
"followers": "Followers",
"following": "Following",
"health": "Health",
"milestones": "Milestones",
"comments": "Comments",
"conversation": "Message",
"search": "Search",
"newPost": "New post",
"newStory": "New story",
"newMilestone": "New milestone"
}
}
+12 -7
View File
@@ -1,7 +1,12 @@
import i18n from "../i18n";
/**
* Ports of web's src/lib/pet-display-name.ts and format-relative-time.ts.
* Hardcoded German strings rather than next-intl (mobile has no i18n system
* yet — the whole app is German-only for now, matching sign-in.tsx).
* Uses the raw i18next singleton (`i18n.t`) rather than the `useTranslation()`
* hook — this is a plain utility, not a component, and callers (PostCard,
* notifications, comments, ...) already re-render on language change via
* their own `useTranslation()` usage, so a fresh `i18n.t()` call here always
* reflects the current language without this function needing to be a hook.
*/
export function formatPetName(pet: { name: string; nickname?: string | null }): string {
return pet.nickname ? `${pet.name} (${pet.nickname})` : pet.name;
@@ -13,9 +18,9 @@ export function formatRelativeTime(date: Date | string): string {
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return "gerade eben";
if (diffMins < 60) return `vor ${diffMins} Min.`;
if (diffHours < 24) return `vor ${diffHours} Std.`;
if (diffDays < 7) return `vor ${diffDays} Tag${diffDays === 1 ? "" : "en"}`;
return new Date(date).toLocaleDateString("de-DE");
if (diffSecs < 60) return i18n.t("relativeTime.justNow");
if (diffMins < 60) return i18n.t("relativeTime.minutesAgo", { count: diffMins });
if (diffHours < 24) return i18n.t("relativeTime.hoursAgo", { count: diffHours });
if (diffDays < 7) return i18n.t("relativeTime.daysAgo", { count: diffDays });
return new Date(date).toLocaleDateString(i18n.language);
}
+19 -18
View File
@@ -19,28 +19,29 @@ export type MilestoneType =
| "FOLLOWERS_1M";
interface MilestoneMeta {
label: string;
/** Key into the "milestoneTypes" i18n namespace — resolve with t(meta.labelKey). */
labelKey: string;
icon: LucideIcon;
}
/** Port of web's src/lib/milestone-meta.ts, German labels (mobile has no i18n system yet). */
/** Port of web's src/lib/milestone-meta.ts — labels come from i18n's "milestoneTypes" namespace. */
export const MILESTONE_META: Record<MilestoneType, MilestoneMeta> = {
BIRTHDAY: { label: "Geburtstag", icon: Cake },
ADOPTION_DAY: { label: "Adoptionstag", icon: Heart },
FIRST_OUTING: { label: "Erster Ausflug", icon: Leaf },
VET_VISIT: { label: "Tierarztbesuch", icon: Stethoscope },
LITTER: { label: "Neuer Wurf", icon: Milk },
FOLLOWERS_500: { label: "500 Follower", icon: Trophy },
FOLLOWERS_1K: { label: "1.000 Follower", icon: Trophy },
FOLLOWERS_2_5K: { label: "2.500 Follower", icon: Trophy },
FOLLOWERS_5K: { label: "5.000 Follower", icon: Trophy },
FOLLOWERS_10K: { label: "10.000 Follower", icon: Star },
FOLLOWERS_25K: { label: "25.000 Follower", icon: Star },
FOLLOWERS_50K: { label: "50.000 Follower", icon: Star },
FOLLOWERS_100K: { label: "100.000 Follower", icon: Star },
FOLLOWERS_250K: { label: "250.000 Follower", icon: Award },
FOLLOWERS_500K: { label: "500.000 Follower", icon: Award },
FOLLOWERS_1M: { label: "1.000.000 Follower", icon: Award },
BIRTHDAY: { labelKey: "milestoneTypes.BIRTHDAY", icon: Cake },
ADOPTION_DAY: { labelKey: "milestoneTypes.ADOPTION_DAY", icon: Heart },
FIRST_OUTING: { labelKey: "milestoneTypes.FIRST_OUTING", icon: Leaf },
VET_VISIT: { labelKey: "milestoneTypes.VET_VISIT", icon: Stethoscope },
LITTER: { labelKey: "milestoneTypes.LITTER", icon: Milk },
FOLLOWERS_500: { labelKey: "milestoneTypes.FOLLOWERS_500", icon: Trophy },
FOLLOWERS_1K: { labelKey: "milestoneTypes.FOLLOWERS_1K", icon: Trophy },
FOLLOWERS_2_5K: { labelKey: "milestoneTypes.FOLLOWERS_2_5K", icon: Trophy },
FOLLOWERS_5K: { labelKey: "milestoneTypes.FOLLOWERS_5K", icon: Trophy },
FOLLOWERS_10K: { labelKey: "milestoneTypes.FOLLOWERS_10K", icon: Star },
FOLLOWERS_25K: { labelKey: "milestoneTypes.FOLLOWERS_25K", icon: Star },
FOLLOWERS_50K: { labelKey: "milestoneTypes.FOLLOWERS_50K", icon: Star },
FOLLOWERS_100K: { labelKey: "milestoneTypes.FOLLOWERS_100K", icon: Star },
FOLLOWERS_250K: { labelKey: "milestoneTypes.FOLLOWERS_250K", icon: Award },
FOLLOWERS_500K: { labelKey: "milestoneTypes.FOLLOWERS_500K", icon: Award },
FOLLOWERS_1M: { labelKey: "milestoneTypes.FOLLOWERS_1M", icon: Award },
};
/** The 5 user-initiated types shown in the creation picker — follower-threshold types are auto-triggered server-side. */
+5 -1
View File
@@ -21,12 +21,16 @@ function resolveClientIp(headerList: Awaited<ReturnType<typeof headers>>): strin
* procedures that return DB-stored, translatable text (Species/Breed names —
* see SpeciesTranslation/BreedTranslation) can resolve the right language
* without every caller having to pass a locale input param explicitly.
*
* The mobile app has no cookie jar to set NEXT_LOCALE in, so it sends its
* current language as an `x-locale` header instead — cookie wins when both
* are present (web is unaffected), header is the fallback (mobile-only).
*/
export const createTRPCContext = async () => {
const { userId } = await auth();
const cookieStore = await cookies();
const headerList = await headers();
const raw = cookieStore.get("NEXT_LOCALE")?.value ?? "";
const raw = cookieStore.get("NEXT_LOCALE")?.value || headerList.get("x-locale") || "";
const locale = (LOCALES as readonly string[]).includes(raw) ? raw : defaultLocale;
const ip = resolveClientIp(headerList);
return { userId, prisma, locale, ip };