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:
1 parent
0c6421c8de
commit
462f295caa
46 files changed
+1179
-285
No files matched your search
+66
-6
@@ -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
@@ -50,7 +50,8 @@
|
||||
"icon": "./assets/android-icon-monochrome.png",
|
||||
"color": "#f97316"
|
||||
}
|
||||
]
|
||||
],
|
||||
"expo-localization"
|
||||
],
|
||||
"extra": {
|
||||
"router": {},
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Generated
+86
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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}`)}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,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
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user