feat(mobile): block pets and delete account in-app (App Store requirements)

- Block (Guideline 1.2, UGC): useBlockPet hook (native confirm Alert,
  blocks.create). PostCard "..." on other pets' posts now offers
  Report / Block; pet profile gets a header "..." with Report pet / Block
  (hidden for any of the owner's own pets), navigating back after a block.
  Feed, comments and DMs already enforce blocks server-side; explore and
  search don't yet (server follow-up).
- Delete account (Guideline 5.1.1(v)): new delete-account screen reached
  from a quiet link below Log out in Settings. Mirrors web's
  DeleteAccountDialog (warning list + type a pet name) and calls the same
  POST /api/account/delete with the Clerk bearer token, then clears the
  query cache and signs out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-26 11:19:17 +02:00
1 parent 0c3ab5c2a2
commit 4213d7029e
9 files changed
+442 -93

No files matched your search

+1
View File
@@ -50,6 +50,7 @@ export default function AppLayout() {
<Stack.Screen name="messages/[conversationId]" options={{ ...headerOptions, title: t("screens.conversation") }} />
<Stack.Screen name="search" options={{ ...headerOptions, title: t("screens.search") }} />
<Stack.Screen name="settings" options={{ ...headerOptions, title: t("screens.settings") }} />
<Stack.Screen name="delete-account" options={{ ...headerOptions, title: t("screens.deleteAccount") }} />
<Stack.Screen
name="post-new"
options={{
+140
View File
@@ -0,0 +1,140 @@
import { useMemo, useState } from "react";
import { View, Text, ScrollView, Alert, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useAuth } from "@clerk/expo";
import { useQueryClient } from "@tanstack/react-query";
import { TriangleAlert } from "lucide-react-native";
import * as Sentry from "@sentry/react-native";
import { useActivePet } from "../../src/context/ActivePetContext";
import { useTheme } from "../../src/context/ThemeContext";
import { Button } from "../../src/components/Button";
import { TextField } from "../../src/components/TextField";
import { env } from "../../src/lib/env";
import { getTypography, radii, type ColorTokens, type Typography } from "../../src/theme/tokens";
const DELETED_ITEMS = ["itemPets", "itemPosts", "itemMedia", "itemMessages", "itemHealth", "itemAccount"] as const;
/**
* In-app account deletion — required by App Store Guideline 5.1.1(v) for any
* app that lets users create an account. Port of web's
* src/components/account/DeleteAccountDialog.tsx: same warning list, same
* "type one of your pets' names" confirmation, and the same backend route
* (POST /api/account/delete → deleteOwnerAccount(), which also deletes the
* Clerk user). That route reads Clerk's auth() like tRPC does, so the mobile
* session token in the Authorization header authenticates it.
*/
export default function DeleteAccountScreen() {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const { getToken, signOut } = useAuth();
const { pets, clearActivePetId } = useActivePet();
const queryClient = useQueryClient();
const [petName, setPetName] = useState("");
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const isValidName = pets.some((pet) => pet.name.toLowerCase() === petName.trim().toLowerCase());
const deleteAccount = async () => {
setIsDeleting(true);
setError(null);
try {
const token = await getToken();
const res = await fetch(`${env.apiUrl}/api/account/delete`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ petNameConfirmation: petName.trim() }),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? t("deleteAccount.genericError"));
}
} catch (err) {
Sentry.captureException(err);
setError(err instanceof Error ? err.message : t("deleteAccount.genericError"));
setIsDeleting(false);
return;
}
// The Clerk user no longer exists server-side, so signOut() may reject —
// either way the local session is cleared and useProtectedRoute() sends
// the user back to sign-in.
queryClient.clear();
clearActivePetId();
Alert.alert(t("deleteAccount.doneTitle"));
await signOut().catch(() => undefined);
};
const confirmDelete = () => {
Alert.alert(t("deleteAccount.finalConfirmTitle"), t("deleteAccount.finalConfirmMessage"), [
{ text: t("common.cancel"), style: "cancel" },
{ text: t("deleteAccount.confirmDelete"), style: "destructive", onPress: () => void deleteAccount() },
]);
};
return (
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
<View style={styles.titleRow}>
<TriangleAlert color={colors.alertRed} size={22} />
<Text style={styles.title}>{t("deleteAccount.warningTitle")}</Text>
</View>
<Text style={styles.intro}>{t("deleteAccount.warningIntro")}</Text>
<View style={styles.card}>
{DELETED_ITEMS.map((item) => (
<Text key={item} style={styles.item}>
<Text style={styles.itemMark}>✕ </Text>
{t(`deleteAccount.${item}`)}
</Text>
))}
</View>
<Text style={styles.intro}>{t("deleteAccount.confirmIntro")}</Text>
<TextField
placeholder={t("deleteAccount.inputPlaceholder")}
value={petName}
onChangeText={setPetName}
autoCapitalize="none"
autoCorrect={false}
editable={!isDeleting}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title={isDeleting ? t("deleteAccount.deleting") : t("deleteAccount.confirmDelete")}
disabled={!isValidName || isDeleting}
onPress={confirmDelete}
style={styles.deleteButton}
/>
</ScrollView>
</KeyboardAvoidingView>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
flex: { flex: 1, backgroundColor: colors.porchWhite },
content: { padding: 20, gap: 12, flexGrow: 1, backgroundColor: colors.porchWhite },
titleRow: { flexDirection: "row", alignItems: "center", gap: 8 },
title: { ...typography.title, fontSize: 18, color: colors.alertRed },
intro: { ...typography.body, color: colors.quietGray },
card: {
gap: 8,
padding: 14,
borderRadius: radii.xl,
borderWidth: 1,
borderColor: colors.hairlineGray,
backgroundColor: colors.pureWhite,
},
item: { ...typography.body },
itemMark: { color: colors.alertRed, fontWeight: "700" },
error: { ...typography.body, color: colors.alertRed },
deleteButton: { marginTop: 8, backgroundColor: colors.alertRed },
});
}
+132 -88
View File
@@ -1,9 +1,9 @@
import { useMemo } from "react";
import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useMemo, useState } from "react";
import { View, Text, FlatList, Pressable, ActivityIndicator, Alert, StyleSheet } from "react-native";
import { Stack, 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 { HeartPulse, Trophy, MessageCircle, MoreHorizontal } from "lucide-react-native";
import { useTRPC } from "../../../../src/api/trpc";
import { useActivePet } from "../../../../src/context/ActivePetContext";
import { useTheme } from "../../../../src/context/ThemeContext";
@@ -12,6 +12,8 @@ import { PetIdentityBadges } from "../../../../src/components/PetIdentityBadges"
import { PostCard } from "../../../../src/components/PostCard";
import { Button } from "../../../../src/components/Button";
import { FollowButton } from "../../../../src/components/FollowButton";
import { ReportSheet } from "../../../../src/components/ReportSheet";
import { useBlockPet } from "../../../../src/hooks/useBlockPet";
import { useVisiblePostIds } from "../../../../src/hooks/useVisiblePostIds";
import { formatPetName } from "../../../../src/lib/format";
import { getAvatarUrl } from "../../../../src/lib/media-url";
@@ -29,7 +31,7 @@ export default function PetProfileScreen() {
const { petId } = useLocalSearchParams<{ petId: string }>();
const router = useRouter();
const trpc = useTRPC();
const { activePetId } = useActivePet();
const { activePetId, pets: ownPets } = useActivePet();
const { colors, typography } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const { visibleIds, viewabilityConfigCallbackPairs } = useVisiblePostIds();
@@ -40,6 +42,11 @@ export default function PetProfileScreen() {
const { data: postsPage } = useQuery(trpc.posts.byPetId.queryOptions({ petId }));
const isOwnPet = activePetId === petId;
// Any of the owner's pets, not just the active one — blocking/reporting
// your own other pet makes no sense.
const isOwnedByMe = ownPets.some((ownPet) => ownPet.id === petId);
const [reportOpen, setReportOpen] = useState(false);
const { confirmBlock } = useBlockPet(() => router.back());
const getOrCreateConversation = useMutation(
trpc.conversations.getOrCreate.mutationOptions({
@@ -64,99 +71,136 @@ export default function PetProfileScreen() {
}
const posts = postsPage?.posts ?? [];
const canModerate = !isOwnedByMe && !!activePetId;
// Report + block for other pets (App Store Guideline 1.2), matching web's
// ProfileActions "..." menu.
const openMoreMenu = () => {
if (!activePetId) return;
Alert.alert(formatPetName(pet), undefined, [
{ text: t("petProfile.reportPet"), onPress: () => setReportOpen(true) },
{
text: t("block.menuItem", { petName: pet.name }),
style: "destructive",
onPress: () => confirmBlock(activePetId, { id: pet.id, name: pet.name }),
},
{ text: t("common.cancel"), style: "cancel" },
]);
};
return (
<FlatList
data={posts}
keyExtractor={(post) => post.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<PostCard post={item} actingPetId={activePetId ?? petId} isVisible={visibleIds.has(item.id)} />
<>
{canModerate && (
<>
<Stack.Screen
options={{
headerRight: () => (
<Pressable onPress={openMoreMenu} hitSlop={12} accessibilityLabel={t("petProfile.moreActions")}>
<MoreHorizontal color={colors.ink} size={22} />
</Pressable>
),
}}
/>
<ReportSheet
visible={reportOpen}
onClose={() => setReportOpen(false)}
reporterPetId={activePetId}
targetPetId={pet.id}
/>
</>
)}
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
ItemSeparatorComponent={() => (
<View style={styles.postSeparatorWrap}>
<View style={styles.postSeparatorLine} />
</View>
)}
ListHeaderComponent={
<View style={styles.header}>
{pet.avatarKey ? (
<Pressable
onPress={() =>
router.push({
pathname: "/image-viewer",
params: { images: JSON.stringify([getAvatarUrl(pet.avatarKey)]), index: "0" },
})
}
>
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
</Pressable>
) : (
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
)}
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(pet)}</Text>
<PetIdentityBadges isVerified={pet.isVerified} sponsor={pet.owner} />
<FlatList
data={posts}
keyExtractor={(post) => post.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<PostCard post={item} actingPetId={activePetId ?? petId} isVisible={visibleIds.has(item.id)} />
)}
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
ItemSeparatorComponent={() => (
<View style={styles.postSeparatorWrap}>
<View style={styles.postSeparatorLine} />
</View>
<Text style={styles.species}>{pet.species.name}</Text>
<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}>{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}>{t("petProfile.followingStat")}</Text>
</Pressable>
</View>
{isOwnPet ? (
<Button title={t("common.edit")} variant="outline" onPress={() => router.push(`/pets/${petId}/edit`)} />
) : (
activePetId && <FollowButton followerPetId={activePetId} followeePetId={petId} />
)}
<View style={styles.iconRow}>
{isOwnPet && (
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/health`)}>
<HeartPulse color={colors.ink} size={18} />
<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}>{t("petProfile.milestonesButton")}</Text>
</Pressable>
{!isOwnPet && activePetId && (
)}
ListHeaderComponent={
<View style={styles.header}>
{pet.avatarKey ? (
<Pressable
style={styles.iconButton}
onPress={() => getOrCreateConversation.mutate({ senderPetId: activePetId, recipientPetId: petId })}
onPress={() =>
router.push({
pathname: "/image-viewer",
params: { images: JSON.stringify([getAvatarUrl(pet.avatarKey)]), index: "0" },
})
}
>
<MessageCircle color={colors.ink} size={18} />
<Text style={styles.iconButtonText}>{t("petProfile.messageButton")}</Text>
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
</Pressable>
) : (
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
)}
</View>
{pet.bio ? <Text style={styles.section}>{pet.bio}</Text> : null}
{pet.adoptionStory ? (
<View>
<Text style={styles.sectionTitle}>{t("petProfile.adoptionStoryTitle")}</Text>
<Text style={styles.section}>{pet.adoptionStory}</Text>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(pet)}</Text>
<PetIdentityBadges isVerified={pet.isVerified} sponsor={pet.owner} />
</View>
) : null}
<Text style={styles.species}>{pet.species.name}</Text>
<View style={styles.divider} />
</View>
}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>{t("petProfile.noPosts")}</Text>
</View>
}
/>
<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}>{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}>{t("petProfile.followingStat")}</Text>
</Pressable>
</View>
{isOwnPet ? (
<Button title={t("common.edit")} variant="outline" onPress={() => router.push(`/pets/${petId}/edit`)} />
) : (
activePetId && <FollowButton followerPetId={activePetId} followeePetId={petId} />
)}
<View style={styles.iconRow}>
{isOwnPet && (
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/health`)}>
<HeartPulse color={colors.ink} size={18} />
<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}>{t("petProfile.milestonesButton")}</Text>
</Pressable>
{!isOwnPet && activePetId && (
<Pressable
style={styles.iconButton}
onPress={() => getOrCreateConversation.mutate({ senderPetId: activePetId, recipientPetId: petId })}
>
<MessageCircle color={colors.ink} size={18} />
<Text style={styles.iconButtonText}>{t("petProfile.messageButton")}</Text>
</Pressable>
)}
</View>
{pet.bio ? <Text style={styles.section}>{pet.bio}</Text> : null}
{pet.adoptionStory ? (
<View>
<Text style={styles.sectionTitle}>{t("petProfile.adoptionStoryTitle")}</Text>
<Text style={styles.section}>{pet.adoptionStory}</Text>
</View>
) : null}
<View style={styles.divider} />
</View>
}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>{t("petProfile.noPosts")}</Text>
</View>
}
/>
</>
);
}
+2
View File
@@ -6,6 +6,7 @@ import { LanguageSection } from "../../src/components/settings/LanguageSection";
import { PetSuggestionsSection } from "../../src/components/settings/PetSuggestionsSection";
import { LegalSection } from "../../src/components/settings/LegalSection";
import { LogoutSection } from "../../src/components/settings/LogoutSection";
import { DeleteAccountSection } from "../../src/components/settings/DeleteAccountSection";
import { FooterErrorBoundary } from "../../src/components/settings/FooterErrorBoundary";
import type { ColorTokens } from "../../src/theme/tokens";
@@ -43,6 +44,7 @@ export default function SettingsScreen() {
<PetSuggestionsSection />
<LegalSection />
<LogoutSection />
<DeleteAccountSection />
</ScrollView>
</FooterErrorBoundary>
);
+13 -1
View File
@@ -13,6 +13,7 @@ import { PawBackButton } from "./PawBackButton";
import { RepostCard } from "./RepostCard";
import { VideoPlayer } from "./VideoPlayer";
import { ReportSheet } from "./ReportSheet";
import { useBlockPet } from "../hooks/useBlockPet";
import { getMediaUrl } from "../lib/media-url";
import { formatPetName, formatRelativeTime } from "../lib/format";
import { useTheme } from "../context/ThemeContext";
@@ -90,6 +91,7 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr
const cardWidth = width - 32; // matches the 16px screen padding used by the feed list
const [reportOpen, setReportOpen] = useState(false);
const isOwnPost = post.pet.id === actingPetId;
const { confirmBlock } = useBlockPet();
const deleteMutation = useMutation(
trpc.posts.delete.mutationOptions({
@@ -119,7 +121,17 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr
{ text: t("common.delete"), style: "destructive", onPress: () => deleteMutation.mutate({ postId: post.id }) },
]);
} else {
setReportOpen(true);
// Report + block (App Store Guideline 1.2), matching web PostCard's
// "..." menu for other pets' posts.
Alert.alert(formatPetName(post.pet), undefined, [
{ text: t("report.title"), onPress: () => setReportOpen(true) },
{
text: t("block.menuItem", { petName: post.pet.name }),
style: "destructive",
onPress: () => confirmBlock(actingPetId, { id: post.pet.id, name: post.pet.name }),
},
{ text: t("common.cancel"), style: "cancel" },
]);
}
};
@@ -0,0 +1,33 @@
import { useMemo } from "react";
import { Pressable, Text, StyleSheet } from "react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useTheme } from "../../context/ThemeContext";
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
/**
* Entry point to app/(app)/delete-account.tsx. Deliberately a quiet text
* link below Log out (like web's DeleteAccountDialog trigger) rather than a
* prominent button — it has to be findable (App Store Guideline 5.1.1(v)),
* not inviting.
*/
export function DeleteAccountSection() {
const { t } = useTranslation();
const router = useRouter();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
return (
<Pressable onPress={() => router.push("/delete-account")} hitSlop={8} style={styles.row}>
<Text style={styles.text}>{t("deleteAccount.trigger")}</Text>
</Pressable>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
row: { alignItems: "center", paddingVertical: 4 },
text: { ...typography.body, fontSize: 13, color: colors.alertRed },
});
}
+55
View File
@@ -0,0 +1,55 @@
import { Alert } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "../api/trpc";
type BlockTarget = { id: string; name: string };
/**
* Port of web's src/components/safety/BlockDialog.tsx (SAFE-01) as a native
* confirm Alert instead of a dialog component. Required for App Store
* Guideline 1.2 (UGC apps must let users block abusive accounts).
*
* The block itself is enforced server-side: feed.getFeed filters blocked
* pets, comments/DMs reject blocked pairs. Explore/search don't filter blocks
* yet (server change) — invalidating them here is harmless groundwork.
*
* `onBlocked` lets a caller leave a screen that shows the blocked pet (the
* pet profile navigates back).
*/
export function useBlockPet(onBlocked?: () => void) {
const { t } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
const blockMutation = useMutation(
trpc.blocks.create.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries(trpc.feed.pathFilter());
queryClient.invalidateQueries(trpc.explore.pathFilter());
queryClient.invalidateQueries(trpc.search.pathFilter());
queryClient.invalidateQueries(trpc.posts.pathFilter());
queryClient.invalidateQueries(trpc.follows.pathFilter());
onBlocked?.();
},
onError: () => Alert.alert(t("block.errorToast")),
})
);
const confirmBlock = (blockerPetId: string, target: BlockTarget) => {
Alert.alert(t("block.title", { petName: target.name }), t("block.description", { petName: target.name }), [
{ text: t("block.cancel"), style: "cancel" },
{
text: t("block.confirm"),
style: "destructive",
onPress: () =>
blockMutation.mutate(
{ blockerPetId, blockedPetId: target.id },
{ onSuccess: () => Alert.alert(t("block.successToast", { petName: target.name })) }
),
},
]);
};
return { confirmBlock, isBlocking: blockMutation.isPending };
}
+33 -2
View File
@@ -179,7 +179,9 @@
"messageButton": "Nachricht",
"adoptionStoryTitle": "Adoptionsgeschichte",
"noFollowers": "Noch keine Follower.",
"noFollowing": "Folgt noch niemandem."
"noFollowing": "Folgt noch niemandem.",
"reportPet": "Tier melden",
"moreActions": "Weitere Aktionen"
},
"notifications": {
"follow": "folgt dir jetzt.",
@@ -309,6 +311,35 @@
"settings": "Einstellungen",
"newPost": "Neuer Beitrag",
"newStory": "Neue Story",
"newMilestone": "Neuer Meilenstein"
"newMilestone": "Neuer Meilenstein",
"deleteAccount": "Konto löschen"
},
"block": {
"menuItem": "{{petName}} blockieren",
"title": "{{petName}} blockieren?",
"description": "Posts von {{petName}} werden in deinem Feed ausgeblendet, und ihr könnt euch nicht mehr kommentieren oder schreiben. Niemand erfährt, dass du blockiert hast.",
"cancel": "Nicht blockieren",
"confirm": "Blockieren",
"successToast": "Du hast {{petName}} blockiert.",
"errorToast": "Tier konnte nicht blockiert werden. Bitte versuch es erneut."
},
"deleteAccount": {
"trigger": "Konto löschen",
"warningTitle": "Konto wirklich löschen?",
"warningIntro": "Diese Aktion ist unwiderruflich. Folgendes wird dauerhaft gelöscht:",
"itemPets": "Alle Tierprofile",
"itemPosts": "Alle Posts, Kommentare und Reaktionen",
"itemMedia": "Alle Bilder und Videos",
"itemMessages": "Alle Chats und Nachrichten",
"itemHealth": "Alle Gesundheitsdaten",
"itemAccount": "Dein PawFeed-Konto",
"confirmIntro": "Gib den Namen eines deiner Haustiere ein, um die Löschung zu bestätigen:",
"inputPlaceholder": "Tiername eingeben…",
"confirmDelete": "Konto endgültig löschen",
"finalConfirmTitle": "Letzte Bestätigung",
"finalConfirmMessage": "Dein Konto und alle Daten werden sofort und endgültig gelöscht. Das kann nicht rückgängig gemacht werden.",
"deleting": "Konto wird gelöscht…",
"doneTitle": "Dein Konto wurde gelöscht.",
"genericError": "Etwas ist schiefgelaufen. Bitte versuch es erneut."
}
}
+33 -2
View File
@@ -179,7 +179,9 @@
"messageButton": "Message",
"adoptionStoryTitle": "Adoption story",
"noFollowers": "No followers yet.",
"noFollowing": "Not following anyone yet."
"noFollowing": "Not following anyone yet.",
"reportPet": "Report pet",
"moreActions": "More actions"
},
"notifications": {
"follow": "is now following you.",
@@ -309,6 +311,35 @@
"settings": "Settings",
"newPost": "New post",
"newStory": "New story",
"newMilestone": "New milestone"
"newMilestone": "New milestone",
"deleteAccount": "Delete account"
},
"block": {
"menuItem": "Block {{petName}}",
"title": "Block {{petName}}?",
"description": "{{petName}}'s posts will be hidden from your feed, and you won't be able to comment on or message each other. They won't know you blocked them.",
"cancel": "Don't block",
"confirm": "Block",
"successToast": "You've blocked {{petName}}.",
"errorToast": "Couldn't block this pet. Please try again."
},
"deleteAccount": {
"trigger": "Delete account",
"warningTitle": "Really delete your account?",
"warningIntro": "This can't be undone. The following will be permanently deleted:",
"itemPets": "All pet profiles",
"itemPosts": "All posts, comments and reactions",
"itemMedia": "All photos and videos",
"itemMessages": "All chats and messages",
"itemHealth": "All health records",
"itemAccount": "Your PawFeed account",
"confirmIntro": "Type the name of one of your pets to confirm the deletion:",
"inputPlaceholder": "Enter pet name…",
"confirmDelete": "Permanently delete account",
"finalConfirmTitle": "Final confirmation",
"finalConfirmMessage": "Your account and all data will be deleted immediately and permanently. This can't be undone.",
"deleting": "Deleting account…",
"doneTitle": "Your account has been deleted.",
"genericError": "Something went wrong. Please try again."
}
}