feat: Gedenkmodus „In Erinnerung“ für verstorbene Tiere
- Pet.deceasedAt + NotificationType.PET_DECEASED (additiv) - pets.setDeceased: nur Besitzer, kein Datum in der Zukunft, rückgängig machbar; beim ersten Setzen einmalig stille Mitteilung (ohne Push) an Follower anderer Besitzer - Verstorbene Tiere bekommen keine Geburtstags-/Adoptionstag-Mitteilungen, Impf-Erinnerungen und Tier-Vorschläge mehr und werden nicht vorgeschlagen - Abzeichen „In Erinnerung“ (Ribbon) überall neben dem Namen, Gedenk-Banner im Profil, Bereich „Gedenkmodus“ in „Profil bearbeiten“ (Web + App) - Mitteilung führt zum Profil des Tiers (Liste + Push-Tap) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b244ef4c10
commit
9216f0c702
31 files changed
+537
-25
No files matched your search
+21
-1
@@ -487,7 +487,8 @@
|
||||
"typeStoryMention": "hat dich in einer Story markiert.",
|
||||
"typeStoryComment": "hat deine Story kommentiert.",
|
||||
"typeStoryReaction": "hat deiner Story eine Pfote gegeben.",
|
||||
"typeUnknown": "hat eine neue Mitteilung für dich."
|
||||
"typeUnknown": "hat eine neue Mitteilung für dich.",
|
||||
"typePetDeceased": "ist leider verstorben. In Erinnerung 🕯️"
|
||||
},
|
||||
"PhotoPostForm": {
|
||||
"captionPlaceholder": "Bildunterschrift schreiben…",
|
||||
@@ -859,5 +860,24 @@
|
||||
"FoundingMember": {
|
||||
"label": "Gründungsmitglied",
|
||||
"tooltip": "Gründungsmitglied – eines der ersten 250 Tiere auf PawFeed"
|
||||
},
|
||||
"InMemory": {
|
||||
"label": "In Erinnerung",
|
||||
"tooltip": "In Erinnerung – dieses Tier ist über die Regenbogenbrücke gegangen",
|
||||
"banner": "In Erinnerung an {name} · {date}",
|
||||
"settingsHeading": "Gedenkmodus",
|
||||
"settingsHint": "Wenn dein Tier verstorben ist, kannst du sein Profil als Erinnerung erhalten. Es bekommt das Abzeichen „In Erinnerung“, Beiträge bleiben sichtbar und du kannst weiter Erinnerungen teilen. Geburtstags-Mitteilungen, Impf-Erinnerungen und Vorschläge enden.",
|
||||
"activeHint": "{name} ist im Gedenkmodus. Du kannst das Datum korrigieren oder den Modus beenden.",
|
||||
"dateLabel": "Verstorben am",
|
||||
"activate": "Gedenkmodus aktivieren",
|
||||
"saveDate": "Datum speichern",
|
||||
"remove": "Gedenkmodus beenden",
|
||||
"confirmTitle": "{name} in Erinnerung halten?",
|
||||
"confirmDescription": "Das Profil bekommt das Abzeichen „In Erinnerung“. Wer deinem Tier folgt, erhält einmalig eine stille Mitteilung (ohne Push). Du kannst das jederzeit zurücknehmen.",
|
||||
"confirm": "Ja, aktivieren",
|
||||
"cancel": "Abbrechen",
|
||||
"saved": "Gedenkmodus gespeichert",
|
||||
"removed": "Gedenkmodus beendet",
|
||||
"saveFailed": "Konnte nicht gespeichert werden"
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -487,7 +487,8 @@
|
||||
"typeStoryMention": "tagged you in a story.",
|
||||
"typeStoryComment": "commented on your story.",
|
||||
"typeStoryReaction": "gave your story a paw.",
|
||||
"typeUnknown": "has a new notification for you."
|
||||
"typeUnknown": "has a new notification for you.",
|
||||
"typePetDeceased": "has sadly passed away. In memory 🕯️"
|
||||
},
|
||||
"PhotoPostForm": {
|
||||
"captionPlaceholder": "Write a caption…",
|
||||
@@ -859,5 +860,24 @@
|
||||
"FoundingMember": {
|
||||
"label": "Founding member",
|
||||
"tooltip": "Founding member – one of the first 250 pets on PawFeed"
|
||||
},
|
||||
"InMemory": {
|
||||
"label": "In memory",
|
||||
"tooltip": "In memory – this pet has crossed the rainbow bridge",
|
||||
"banner": "In memory of {name} · {date}",
|
||||
"settingsHeading": "Memorial mode",
|
||||
"settingsHint": "If your pet has passed away, you can keep their profile as a memorial. It gets the “In memory” badge, posts stay visible and you can keep sharing memories. Birthday notifications, vaccine reminders and suggestions stop.",
|
||||
"activeHint": "{name} is in memorial mode. You can correct the date or end the mode.",
|
||||
"dateLabel": "Passed away on",
|
||||
"activate": "Turn on memorial mode",
|
||||
"saveDate": "Save date",
|
||||
"remove": "End memorial mode",
|
||||
"confirmTitle": "Keep {name} in memory?",
|
||||
"confirmDescription": "The profile gets the “In memory” badge. Everyone following your pet receives a single quiet notification (no push). You can undo this at any time.",
|
||||
"confirm": "Yes, turn on",
|
||||
"cancel": "Cancel",
|
||||
"saved": "Memorial mode saved",
|
||||
"removed": "Memorial mode ended",
|
||||
"saveFailed": "Could not be saved"
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const ACTOR_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
|
||||
STORY_MENTION: "notifications.storyMention",
|
||||
STORY_COMMENT: "notifications.storyComment",
|
||||
STORY_REACTION: "notifications.storyReaction",
|
||||
PET_DECEASED: "notifications.petDeceased",
|
||||
};
|
||||
|
||||
// BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the
|
||||
|
||||
@@ -84,7 +84,7 @@ function PetRow({
|
||||
<View style={styles.rowText}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{formatPetName(pet)}</Text>
|
||||
<PetIdentityBadges isVerified={pet.isVerified} />
|
||||
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} />
|
||||
</View>
|
||||
<Text style={styles.breed}>{pet.breed?.name ?? pet.species.name}</Text>
|
||||
</View>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DateField } from "../../../../src/components/DateField";
|
||||
import { Button } from "../../../../src/components/Button";
|
||||
import { BreedPicker } from "../../../../src/components/pet/BreedPicker";
|
||||
import { PrivacySettings } from "../../../../src/components/pet/PrivacySettings";
|
||||
import { MemorialSettings } from "../../../../src/components/pet/MemorialSettings";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
|
||||
import { displayDateToIso, isoToDisplayDate } from "../../../../src/lib/date-format";
|
||||
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
|
||||
@@ -177,6 +178,8 @@ export default function EditPetScreen() {
|
||||
followerVisibility={pet.followerVisibility}
|
||||
/>
|
||||
|
||||
<MemorialSettings petId={pet.id} petName={pet.name} deceasedAt={pet.deceasedAt} />
|
||||
|
||||
<Pressable style={styles.deleteRow} onPress={confirmDelete} disabled={deleteMutation.isPending}>
|
||||
<Trash2 color={colors.alertRed} size={18} />
|
||||
<Text style={styles.deleteText}>{t("editPet.deletePet")}</Text>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { View, Text, FlatList, Pressable, ActivityIndicator, Alert, StyleSheet }
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { HeartPulse, Trophy, MessageCircle, MoreHorizontal } from "lucide-react-native";
|
||||
import { HeartPulse, Trophy, MessageCircle, MoreHorizontal, Ribbon } from "lucide-react-native";
|
||||
import { useTRPC } from "../../../../src/api/trpc";
|
||||
import { useActivePet } from "../../../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../../../src/context/ThemeContext";
|
||||
@@ -17,6 +17,7 @@ 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 { isoToDisplayDate } from "../../../../src/lib/date-format";
|
||||
import { getAvatarUrl } from "../../../../src/lib/media-url";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
|
||||
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
|
||||
@@ -145,8 +146,19 @@ export default function PetProfileScreen() {
|
||||
</ReferralAvatarRing>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{formatPetName(pet)}</Text>
|
||||
<PetIdentityBadges isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
|
||||
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
|
||||
</View>
|
||||
{pet.deceasedAt && (
|
||||
<View style={styles.memorialBanner}>
|
||||
<Ribbon size={13} color={colors.ink} fill={colors.ink} />
|
||||
<Text style={styles.memorialText}>
|
||||
{t("inMemory.banner", {
|
||||
name: pet.name,
|
||||
date: isoToDisplayDate(new Date(pet.deceasedAt).toISOString().slice(0, 10)),
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{pet.isFoundingMember && <Text style={styles.foundingHint}>{t("foundingMember.tooltip")}</Text>}
|
||||
<Text style={styles.species}>{pet.species.name}</Text>
|
||||
{pet.invitedByPet && (
|
||||
@@ -230,6 +242,18 @@ function makeStyles(colors: ColorTokens) {
|
||||
species: { ...typography.body, color: colors.quietGray },
|
||||
invitedBy: { ...typography.body, fontSize: 12, color: colors.quietGray },
|
||||
foundingHint: { ...typography.body, fontSize: 12, color: "#059669" },
|
||||
memorialBanner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
backgroundColor: colors.whisperGray,
|
||||
},
|
||||
memorialText: { ...typography.body, fontSize: 12, color: colors.quietGray },
|
||||
invitedByName: { color: colors.ink, fontWeight: "600" },
|
||||
statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 },
|
||||
stat: { alignItems: "center" },
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { View, StyleSheet } from "react-native";
|
||||
import { BadgeCheck, Sparkles, Sprout } from "lucide-react-native";
|
||||
import { BadgeCheck, Ribbon, Sparkles, Sprout } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isActiveSponsor } from "../lib/sponsor";
|
||||
import { colors } from "../theme/tokens";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { ReferralIcon } from "../lib/referral-icons";
|
||||
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useReferralRanks";
|
||||
|
||||
@@ -10,6 +11,8 @@ type Props = {
|
||||
isVerified?: boolean;
|
||||
/** Eines der ersten 250 Tiere (Pet.isFoundingMember). */
|
||||
isFoundingMember?: boolean;
|
||||
/** Gedenkmodus „In Erinnerung“ (Pet.deceasedAt gesetzt). */
|
||||
isDeceased?: boolean;
|
||||
sponsor?: { sponsorSince?: Date | string | null; sponsorPeriodEnd?: Date | string | null } | null;
|
||||
/** Owner-Felder referralRankId/referralPerks — Rang-Abzeichen des Empfehlungsprogramms. */
|
||||
referral?: ReferralOwnerFields | null;
|
||||
@@ -18,14 +21,16 @@ type Props = {
|
||||
/** Port of web's src/components/ui/pet-identity-badges.tsx. */
|
||||
const FOUNDING_COLOR = "#059669"; // emerald-600, wie im Web
|
||||
|
||||
export function PetIdentityBadges({ isVerified, isFoundingMember, sponsor, referral }: Props) {
|
||||
export function PetIdentityBadges({ isVerified, isFoundingMember, isDeceased, sponsor, referral }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors: theme } = useTheme();
|
||||
const showSponsor = sponsor ? isActiveSponsor(sponsor) : false;
|
||||
const rank = useOwnerRankWithPerk(referral, "NAME_BADGE");
|
||||
if (!isVerified && !isFoundingMember && !showSponsor && !rank) return null;
|
||||
if (!isDeceased && !isVerified && !isFoundingMember && !showSponsor && !rank) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{isDeceased && <Ribbon size={14} color={theme.ink} fill={theme.ink} accessibilityLabel={t("inMemory.label")} />}
|
||||
{isVerified && <BadgeCheck size={14} color={colors.marigoldOrange} />}
|
||||
{showSponsor && <Sparkles size={14} color={colors.marigoldOrange} />}
|
||||
{isFoundingMember && <Sprout size={14} color={FOUNDING_COLOR} accessibilityLabel={t("foundingMember.label")} />}
|
||||
|
||||
@@ -60,6 +60,7 @@ export type PostCardPost = {
|
||||
nickname?: string | null;
|
||||
avatarKey?: string | null;
|
||||
isVerified?: boolean;
|
||||
deceasedAt?: Date | string | null;
|
||||
isFoundingMember?: boolean;
|
||||
// Sponsor fields only come with petCardSelect (feed/explore); a plain pet
|
||||
// identity's owner (posts.byPetId, comments) carries just the referral rank.
|
||||
@@ -152,7 +153,7 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr
|
||||
<View style={styles.headerText}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{formatPetName(post.pet)}</Text>
|
||||
<PetIdentityBadges isVerified={post.pet.isVerified} isFoundingMember={post.pet.isFoundingMember} sponsor={post.pet.owner} referral={post.pet.owner} />
|
||||
<PetIdentityBadges isDeceased={!!post.pet.deceasedAt} isVerified={post.pet.isVerified} isFoundingMember={post.pet.isFoundingMember} sponsor={post.pet.owner} referral={post.pet.owner} />
|
||||
{isTrending && (
|
||||
<View style={styles.trendingBadge}>
|
||||
<Flame size={11} color={colors.marigoldDeep} fill={colors.marigoldDeep} />
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, Alert, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Ribbon } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { DateField } from "../DateField";
|
||||
import { Button } from "../Button";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { displayDateToIso, isoToDisplayDate, todayAsDisplayDate } from "../../lib/date-format";
|
||||
|
||||
type Props = {
|
||||
petId: string;
|
||||
petName: string;
|
||||
deceasedAt: Date | string | null;
|
||||
};
|
||||
|
||||
/** YYYY-MM-DD eines @db.Date-Werts (kommt als UTC-Mitternacht). */
|
||||
function toIso(value: Date | string | null): string | null {
|
||||
return value ? new Date(value).toISOString().slice(0, 10) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gedenkmodus „In Erinnerung“ — Port von web src/components/pet/MemorialSettings.tsx.
|
||||
* Datum setzen (mit Bestätigung, Follower werden einmalig still informiert),
|
||||
* korrigieren oder beenden. Server: pets.setDeceased.
|
||||
*/
|
||||
export function MemorialSettings({ petId, petName, deceasedAt }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const savedIso = toIso(deceasedAt);
|
||||
const isDeceased = savedIso != null;
|
||||
const [date, setDate] = useState(() => (savedIso ? isoToDisplayDate(savedIso) : todayAsDisplayDate()));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation(
|
||||
trpc.pets.setDeceased.mutationOptions({
|
||||
onSuccess: (_data, variables) => {
|
||||
void queryClient.invalidateQueries();
|
||||
Alert.alert(variables.deceasedAt ? t("inMemory.saved") : t("inMemory.removed"));
|
||||
},
|
||||
onError: () => Alert.alert(t("inMemory.saveFailed")),
|
||||
})
|
||||
);
|
||||
|
||||
/** Gültiges, nicht zukünftiges Datum als YYYY-MM-DD — sonst Fehlermeldung. */
|
||||
function validIso(): string | null {
|
||||
const iso = displayDateToIso(date);
|
||||
const todayIso = displayDateToIso(todayAsDisplayDate());
|
||||
if (!iso || (todayIso && iso > todayIso)) {
|
||||
setError(t("inMemory.invalidDate"));
|
||||
return null;
|
||||
}
|
||||
setError(null);
|
||||
return iso;
|
||||
}
|
||||
|
||||
function activate() {
|
||||
const iso = validIso();
|
||||
if (!iso) return;
|
||||
Alert.alert(t("inMemory.confirmTitle", { name: petName }), t("inMemory.confirmDescription"), [
|
||||
{ text: t("inMemory.cancel"), style: "cancel" },
|
||||
{ text: t("inMemory.confirm"), onPress: () => mutation.mutate({ petId, deceasedAt: iso }) },
|
||||
]);
|
||||
}
|
||||
|
||||
function saveDate() {
|
||||
const iso = validIso();
|
||||
if (iso && iso !== savedIso) mutation.mutate({ petId, deceasedAt: iso });
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.headingRow}>
|
||||
<Ribbon size={16} color={colors.ink} />
|
||||
<Text style={styles.heading}>{t("inMemory.settingsHeading")}</Text>
|
||||
</View>
|
||||
<Text style={styles.hint}>
|
||||
{isDeceased ? t("inMemory.activeHint", { name: petName }) : t("inMemory.settingsHint")}
|
||||
</Text>
|
||||
<Text style={styles.label}>{t("inMemory.dateLabel")}</Text>
|
||||
<DateField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
{isDeceased ? (
|
||||
<>
|
||||
<Button title={t("inMemory.saveDate")} variant="outline" disabled={mutation.isPending} onPress={saveDate} />
|
||||
<Button
|
||||
title={t("inMemory.remove")}
|
||||
variant="outline"
|
||||
disabled={mutation.isPending}
|
||||
onPress={() => mutation.mutate({ petId, deceasedAt: null })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button title={t("inMemory.activate")} variant="outline" disabled={mutation.isPending} onPress={activate} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
section: { marginTop: 24, gap: 8 },
|
||||
headingRow: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
heading: { ...typography.title, fontSize: 16 },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
|
||||
error: { ...typography.body, color: colors.alertRed },
|
||||
});
|
||||
}
|
||||
@@ -214,7 +214,8 @@
|
||||
"storyMention": "hat dich in einer Story markiert.",
|
||||
"storyComment": "hat deine Story kommentiert.",
|
||||
"storyReaction": "hat deiner Story eine Pfote gegeben.",
|
||||
"unknown": "Neue Mitteilung."
|
||||
"unknown": "Neue Mitteilung.",
|
||||
"petDeceased": "ist leider verstorben. In Erinnerung 🕯️"
|
||||
},
|
||||
"pets": {
|
||||
"empty": "Noch keine Tiere angelegt.",
|
||||
@@ -485,5 +486,25 @@
|
||||
"limitOverlays": "Höchstens {{max}} Elemente pro Story.",
|
||||
"limitMentions": "Höchstens {{max}} Tiere pro Story markieren.",
|
||||
"limitGifs": "Höchstens {{max}} GIFs pro Story."
|
||||
},
|
||||
"inMemory": {
|
||||
"label": "In Erinnerung",
|
||||
"tooltip": "In Erinnerung – dieses Tier ist über die Regenbogenbrücke gegangen",
|
||||
"banner": "In Erinnerung an {{name}} · {{date}}",
|
||||
"settingsHeading": "Gedenkmodus",
|
||||
"settingsHint": "Wenn dein Tier verstorben ist, kannst du sein Profil als Erinnerung erhalten. Es bekommt das Abzeichen „In Erinnerung“, Beiträge bleiben sichtbar und du kannst weiter Erinnerungen teilen. Geburtstags-Mitteilungen, Impf-Erinnerungen und Vorschläge enden.",
|
||||
"activeHint": "{{name}} ist im Gedenkmodus. Du kannst das Datum korrigieren oder den Modus beenden.",
|
||||
"dateLabel": "Verstorben am",
|
||||
"activate": "Gedenkmodus aktivieren",
|
||||
"saveDate": "Datum speichern",
|
||||
"remove": "Gedenkmodus beenden",
|
||||
"confirmTitle": "{{name}} in Erinnerung halten?",
|
||||
"confirmDescription": "Das Profil bekommt das Abzeichen „In Erinnerung“. Wer deinem Tier folgt, erhält einmalig eine stille Mitteilung (ohne Push). Du kannst das jederzeit zurücknehmen.",
|
||||
"confirm": "Ja, aktivieren",
|
||||
"cancel": "Abbrechen",
|
||||
"saved": "Gedenkmodus gespeichert",
|
||||
"removed": "Gedenkmodus beendet",
|
||||
"saveFailed": "Konnte nicht gespeichert werden",
|
||||
"invalidDate": "Bitte ein gültiges Datum eingeben, das nicht in der Zukunft liegt."
|
||||
}
|
||||
}
|
||||
@@ -214,7 +214,8 @@
|
||||
"storyMention": "tagged you in a story.",
|
||||
"storyComment": "commented on your story.",
|
||||
"storyReaction": "gave your story a paw.",
|
||||
"unknown": "New notification."
|
||||
"unknown": "New notification.",
|
||||
"petDeceased": "has sadly passed away. In memory 🕯️"
|
||||
},
|
||||
"pets": {
|
||||
"empty": "No pets added yet.",
|
||||
@@ -485,5 +486,25 @@
|
||||
"limitOverlays": "At most {{max}} items per story.",
|
||||
"limitMentions": "Tag at most {{max}} pets per story.",
|
||||
"limitGifs": "At most {{max}} GIFs per story."
|
||||
},
|
||||
"inMemory": {
|
||||
"label": "In memory",
|
||||
"tooltip": "In memory – this pet has crossed the rainbow bridge",
|
||||
"banner": "In memory of {{name}} · {{date}}",
|
||||
"settingsHeading": "Memorial mode",
|
||||
"settingsHint": "If your pet has passed away, you can keep their profile as a memorial. It gets the “In memory” badge, posts stay visible and you can keep sharing memories. Birthday notifications, vaccine reminders and suggestions stop.",
|
||||
"activeHint": "{{name}} is in memorial mode. You can correct the date or end the mode.",
|
||||
"dateLabel": "Passed away on",
|
||||
"activate": "Turn on memorial mode",
|
||||
"saveDate": "Save date",
|
||||
"remove": "End memorial mode",
|
||||
"confirmTitle": "Keep {{name}} in memory?",
|
||||
"confirmDescription": "The profile gets the “In memory” badge. Everyone following your pet receives a single quiet notification (no push). You can undo this at any time.",
|
||||
"confirm": "Yes, turn on",
|
||||
"cancel": "Cancel",
|
||||
"saved": "Memorial mode saved",
|
||||
"removed": "Memorial mode ended",
|
||||
"saveFailed": "Could not be saved",
|
||||
"invalidDate": "Please enter a valid date that is not in the future."
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@ describe("notificationRoute", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the actor's profile for follows, suggestions and referrals", () => {
|
||||
for (const type of ["FOLLOW", "PET_SUGGESTION", "REFERRAL_JOINED"]) {
|
||||
it("opens the actor's profile for follows, suggestions, referrals and memorials", () => {
|
||||
for (const type of ["FOLLOW", "PET_SUGGESTION", "REFERRAL_JOINED", "PET_DECEASED"]) {
|
||||
expect(notificationRoute({ type, actorPetId: "a1" }, NOW)).toBe("/pets/a1");
|
||||
}
|
||||
expect(notificationRoute({ type: "REFERRAL_RANK_UP" }, NOW)).toBe("/invite");
|
||||
|
||||
@@ -51,6 +51,7 @@ export function notificationRoute(target: NotificationTarget, now: number): stri
|
||||
case "FOLLOW":
|
||||
case "PET_SUGGESTION":
|
||||
case "REFERRAL_JOINED":
|
||||
case "PET_DECEASED":
|
||||
return actorPetId ? `/pets/${actorPetId}` : null;
|
||||
case "REFERRAL_RANK_UP":
|
||||
return "/invite";
|
||||
|
||||
@@ -270,6 +270,10 @@ model Pet {
|
||||
followerVisibility FollowerVisibility @default(EVERYONE) // who can view this pet's followers/following lists (Gitea #33)
|
||||
birthday DateTime? @db.Date // actual birthday for anniversary notifications
|
||||
adoptedAt DateTime? @db.Date // adoption date for anniversary notifications
|
||||
// Gedenkmodus „In Erinnerung“ (Roadmap 04.10.): vom Besitzer gesetzt, rückgängig
|
||||
// machbar. Verstorbene Tiere bekommen keine Geburtstags-/Impf-Mitteilungen und
|
||||
// werden nicht vorgeschlagen; Abzeichen + Banner in Web und App.
|
||||
deceasedAt DateTime? @db.Date
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -875,6 +879,7 @@ enum NotificationType {
|
||||
STORY_MENTION // Tier wurde in einer Story markiert (storyId gesetzt)
|
||||
STORY_COMMENT // Kommentar auf eine eigene Story (storyId gesetzt)
|
||||
STORY_REACTION // Pfote auf eine eigene Story (storyId gesetzt)
|
||||
PET_DECEASED // ein gefolgtes Tier ist verstorben (actorPetId = das Tier, kein Push)
|
||||
}
|
||||
|
||||
// ─── Phase 4 Models ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -261,6 +261,7 @@ interface MockNotificationClient {
|
||||
findUnique: MockFn;
|
||||
count: MockFn;
|
||||
updateMany: MockFn;
|
||||
createMany: MockFn;
|
||||
}
|
||||
|
||||
interface MockWeightLogClient {
|
||||
@@ -642,6 +643,7 @@ export function createMockPrisma(): MockPrismaClient {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
weightLog: {
|
||||
findMany: vi.fn(),
|
||||
|
||||
@@ -296,3 +296,60 @@ describe("Ownership assertion — non-owner cannot update", () => {
|
||||
expect(mockPrisma.pet.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setDeceased: Gedenkmodus „In Erinnerung“", () => {
|
||||
let mockPrisma: ReturnType<typeof createMockPrisma>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = createMockPrisma();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sets the date and quietly notifies follower pets of other owners once", async () => {
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ ...MOCK_PET, deceasedAt: null });
|
||||
mockPrisma.pet.update.mockResolvedValue({});
|
||||
mockPrisma.follow.findMany.mockResolvedValue([{ followerPetId: "f1" }, { followerPetId: "f2" }]);
|
||||
|
||||
const caller = createTestCaller(TEST_OWNER_ID, mockPrisma);
|
||||
await caller.setDeceased({ petId: PET_ID, deceasedAt: "2026-10-01" });
|
||||
|
||||
expect(mockPrisma.pet.update).toHaveBeenCalledWith({ where: { id: PET_ID }, data: { deceasedAt: new Date("2026-10-01") } });
|
||||
expect(mockPrisma.follow.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { followeePetId: PET_ID, follower: { ownerId: { not: TEST_OWNER_ID } } } })
|
||||
);
|
||||
expect(mockPrisma.notification.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{ recipientPetId: "f1", type: "PET_DECEASED", actorPetId: PET_ID },
|
||||
{ recipientPetId: "f2", type: "PET_DECEASED", actorPetId: PET_ID },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not notify again when only the date is corrected", async () => {
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ ...MOCK_PET, deceasedAt: new Date("2026-10-01") });
|
||||
mockPrisma.pet.update.mockResolvedValue({});
|
||||
|
||||
await createTestCaller(TEST_OWNER_ID, mockPrisma).setDeceased({ petId: PET_ID, deceasedAt: "2026-09-30" });
|
||||
|
||||
expect(mockPrisma.notification.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can be undone without notifications", async () => {
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ ...MOCK_PET, deceasedAt: new Date("2026-10-01") });
|
||||
mockPrisma.pet.update.mockResolvedValue({});
|
||||
|
||||
await createTestCaller(TEST_OWNER_ID, mockPrisma).setDeceased({ petId: PET_ID, deceasedAt: null });
|
||||
|
||||
expect(mockPrisma.pet.update).toHaveBeenCalledWith({ where: { id: PET_ID }, data: { deceasedAt: null } });
|
||||
expect(mockPrisma.notification.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-owners and future dates", async () => {
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ ...MOCK_PET, ownerId: OTHER_OWNER_ID, deceasedAt: null });
|
||||
await expect(createTestCaller(TEST_OWNER_ID, mockPrisma).setDeceased({ petId: PET_ID, deceasedAt: "2026-10-01" })).rejects.toThrow(TRPCError);
|
||||
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ ...MOCK_PET, deceasedAt: null });
|
||||
await expect(createTestCaller(TEST_OWNER_ID, mockPrisma).setDeceased({ petId: PET_ID, deceasedAt: "2999-01-01" })).rejects.toThrow(/Zukunft/);
|
||||
expect(mockPrisma.pet.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Bell, UserPlus, Heart, MessageCircle, Cake, Home, AtSign, Repeat2, Sparkles, Gift, Award, Syringe, PawPrint } from "lucide-react";
|
||||
import { Bell, UserPlus, Heart, MessageCircle, Cake, Home, AtSign, Repeat2, Sparkles, Gift, Award, Syringe, PawPrint, Ribbon } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
@@ -18,9 +18,9 @@ import { formatRelativeCompact } from "@/lib/format-relative-time";
|
||||
import { RankRing } from "@/components/referrals/RankRing";
|
||||
import { StoryViewer } from "@/components/stories/StoryViewer";
|
||||
|
||||
type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE" | "BIRTHDAY" | "ADOPTION_DAY" | "MENTION" | "PAW_BACK" | "PET_SUGGESTION" | "REFERRAL_JOINED" | "REFERRAL_RANK_UP" | "VACCINE_DUE" | "STORY_MENTION" | "STORY_COMMENT" | "STORY_REACTION";
|
||||
type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE" | "BIRTHDAY" | "ADOPTION_DAY" | "MENTION" | "PAW_BACK" | "PET_SUGGESTION" | "REFERRAL_JOINED" | "REFERRAL_RANK_UP" | "VACCINE_DUE" | "STORY_MENTION" | "STORY_COMMENT" | "STORY_REACTION" | "PET_DECEASED";
|
||||
|
||||
const KNOWN_TYPES = new Set<string>(["FOLLOW", "REACTION", "COMMENT", "MESSAGE", "BIRTHDAY", "ADOPTION_DAY", "MENTION", "PAW_BACK", "PET_SUGGESTION", "REFERRAL_JOINED", "REFERRAL_RANK_UP", "VACCINE_DUE", "STORY_MENTION", "STORY_COMMENT", "STORY_REACTION"]);
|
||||
const KNOWN_TYPES = new Set<string>(["FOLLOW", "REACTION", "COMMENT", "MESSAGE", "BIRTHDAY", "ADOPTION_DAY", "MENTION", "PAW_BACK", "PET_SUGGESTION", "REFERRAL_JOINED", "REFERRAL_RANK_UP", "VACCINE_DUE", "STORY_MENTION", "STORY_COMMENT", "STORY_REACTION", "PET_DECEASED"]);
|
||||
|
||||
function notificationIcon(type: NotificationType) {
|
||||
switch (type) {
|
||||
@@ -39,6 +39,7 @@ function notificationIcon(type: NotificationType) {
|
||||
case "STORY_MENTION": return <AtSign className="h-4 w-4 text-orange-500" />;
|
||||
case "STORY_COMMENT": return <MessageCircle className="h-4 w-4 text-orange-500" />;
|
||||
case "STORY_REACTION": return <PawPrint className="h-4 w-4 text-orange-500" />;
|
||||
case "PET_DECEASED": return <Ribbon className="h-4 w-4 fill-foreground text-foreground" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +107,7 @@ export default function NotificationsPage() {
|
||||
case "FOLLOW":
|
||||
case "PET_SUGGESTION":
|
||||
case "REFERRAL_JOINED":
|
||||
case "PET_DECEASED":
|
||||
return notif.actorPet ? `/pets/${notif.actorPet.id}` : null;
|
||||
case "REFERRAL_RANK_UP":
|
||||
return "/einladen";
|
||||
@@ -280,6 +282,7 @@ export default function NotificationsPage() {
|
||||
{notif.type === "STORY_MENTION" && t("typeStoryMention")}
|
||||
{notif.type === "STORY_COMMENT" && t("typeStoryComment")}
|
||||
{notif.type === "STORY_REACTION" && t("typeStoryReaction")}
|
||||
{notif.type === "PET_DECEASED" && t("typePetDeceased")}
|
||||
{notif.type === "VACCINE_DUE" && notif.vaccine?.nextDueDate &&
|
||||
t("typeVaccineDue", {
|
||||
name: notif.vaccine.name,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslations } from "next-intl";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
import { PetForm } from "@/components/pet/PetForm";
|
||||
import { MemorialSettings } from "@/components/pet/MemorialSettings";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -279,6 +280,10 @@ export default function EditPetPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gedenkmodus „In Erinnerung“ */}
|
||||
<Separator className="my-8" />
|
||||
<MemorialSettings petId={pet.id} petName={pet.name} deceasedAt={pet.deceasedAt} />
|
||||
|
||||
{/* Destructive zone */}
|
||||
<Separator className="my-8" />
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -2,7 +2,8 @@ import { notFound } from "next/navigation";
|
||||
import { formatPetName } from "@/lib/pet-display-name";
|
||||
import Link from "next/link";
|
||||
import { currentUser } from "@clerk/nextjs/server";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { Ribbon } from "lucide-react";
|
||||
import { createTRPCCaller } from "@/trpc/server";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
@@ -34,6 +35,8 @@ export default async function PetProfilePage({ params }: PageProps) {
|
||||
|
||||
const t = await getTranslations("PetProfile");
|
||||
const tFounding = await getTranslations("FoundingMember");
|
||||
const tMemory = await getTranslations("InMemory");
|
||||
const format = await getFormatter();
|
||||
const trpc = await createTRPCCaller();
|
||||
|
||||
let pet;
|
||||
@@ -77,6 +80,7 @@ export default async function PetProfilePage({ params }: PageProps) {
|
||||
<h1 className="flex items-center gap-1.5 text-xl font-semibold text-center">
|
||||
{formatPetName(pet)}
|
||||
<PetIdentityBadges
|
||||
isDeceased={!!pet.deceasedAt}
|
||||
isVerified={pet.isVerified}
|
||||
isFoundingMember={pet.isFoundingMember}
|
||||
sponsor={pet.owner}
|
||||
@@ -85,6 +89,15 @@ export default async function PetProfilePage({ params }: PageProps) {
|
||||
verifiedLabel={t("verified")}
|
||||
/>
|
||||
</h1>
|
||||
{pet.deceasedAt && (
|
||||
<p className="flex items-center gap-1.5 rounded-full border border-border bg-muted px-3 py-1 text-xs text-muted-foreground">
|
||||
<Ribbon className="h-3.5 w-3.5 fill-foreground text-foreground" aria-hidden />
|
||||
{tMemory("banner", {
|
||||
name: pet.name,
|
||||
date: format.dateTime(new Date(pet.deceasedAt), { dateStyle: "long", timeZone: "UTC" }),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{pet.isFoundingMember && (
|
||||
<p className="text-xs text-emerald-700 dark:text-emerald-400">{tFounding("tooltip")}</p>
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,7 @@ export async function GET(req: Request) {
|
||||
AND EXTRACT(MONTH FROM birthday) = ${month}
|
||||
AND EXTRACT(DAY FROM birthday) = ${day}
|
||||
AND birthday <= NOW() - INTERVAL '1 year'
|
||||
AND "deceasedAt" IS NULL
|
||||
`;
|
||||
|
||||
// Find all pets whose adoptedAt matches today and was at least 1 year ago
|
||||
@@ -35,6 +36,7 @@ export async function GET(req: Request) {
|
||||
AND EXTRACT(MONTH FROM "adoptedAt") = ${month}
|
||||
AND EXTRACT(DAY FROM "adoptedAt") = ${day}
|
||||
AND "adoptedAt" <= NOW() - INTERVAL '1 year'
|
||||
AND "deceasedAt" IS NULL
|
||||
`;
|
||||
|
||||
let created = 0;
|
||||
|
||||
@@ -48,7 +48,7 @@ export async function GET(req: Request) {
|
||||
const suggestionCooldownStart = new Date(Date.now() - SUGGESTION_COOLDOWN_MS);
|
||||
|
||||
const pets = await prisma.pet.findMany({
|
||||
where: { owner: { petSuggestionsEnabled: true } },
|
||||
where: { owner: { petSuggestionsEnabled: true }, deceasedAt: null },
|
||||
select: { id: true, speciesId: true, breedId: true, ownerId: true },
|
||||
});
|
||||
|
||||
@@ -89,6 +89,7 @@ export async function GET(req: Request) {
|
||||
id: { notIn: Array.from(excludedPetIds) },
|
||||
speciesId: targetSpeciesId,
|
||||
owner: { shadowban: null },
|
||||
deceasedAt: null, // verstorbene Tiere nicht vorschlagen
|
||||
...(spiderExclusionId ? { NOT: { speciesId: spiderExclusionId } } : {}),
|
||||
},
|
||||
select: { id: true, breedId: true, _count: { select: { followers: true } } },
|
||||
|
||||
@@ -81,6 +81,7 @@ type PostPet = {
|
||||
name: string;
|
||||
avatarKey: string | null;
|
||||
isVerified?: boolean;
|
||||
deceasedAt?: Date | string | null;
|
||||
isFoundingMember?: boolean;
|
||||
// Sponsor fields only come with petCardSelect (feed/explore); posts.byPetId
|
||||
// uses petIdentitySelect, whose owner carries just the referral rank.
|
||||
@@ -256,7 +257,7 @@ function PostCardImpl({ post, onDeleted, variant = "feed" }: PostCardProps) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="flex items-center gap-1 text-base font-semibold text-foreground leading-tight truncate">
|
||||
{formatPetName(pet)}
|
||||
<PetIdentityBadges isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
|
||||
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(createdAt)}
|
||||
|
||||
@@ -37,6 +37,7 @@ type Pet = {
|
||||
name: string;
|
||||
avatarKey: string | null;
|
||||
isVerified: boolean;
|
||||
deceasedAt?: Date | string | null;
|
||||
species: { name: string };
|
||||
};
|
||||
|
||||
@@ -110,7 +111,7 @@ export function MobileNav({ pets }: MobileNavProps) {
|
||||
/>
|
||||
<span className="font-medium text-foreground truncate max-w-[120px] flex items-center gap-1">
|
||||
{formatPetName(activePet)}
|
||||
<PetIdentityBadges isVerified={activePet.isVerified} size="sm" />
|
||||
<PetIdentityBadges isDeceased={!!activePet.deceasedAt} isVerified={activePet.isVerified} size="sm" />
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -128,7 +129,7 @@ export function MobileNav({ pets }: MobileNavProps) {
|
||||
/>
|
||||
<span className="flex-1 text-sm truncate flex items-center gap-1">
|
||||
{formatPetName(pet)}
|
||||
<PetIdentityBadges isVerified={pet.isVerified} size="sm" />
|
||||
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} size="sm" />
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{pet.species.name}
|
||||
|
||||
@@ -42,6 +42,7 @@ type Pet = {
|
||||
name: string;
|
||||
avatarKey: string | null;
|
||||
isVerified: boolean;
|
||||
deceasedAt?: Date | string | null;
|
||||
species: { name: string };
|
||||
};
|
||||
|
||||
@@ -152,7 +153,7 @@ export function Sidebar({ pets }: SidebarProps) {
|
||||
<>
|
||||
<span className="text-sm font-medium text-foreground flex-1 truncate flex items-center gap-1">
|
||||
{formatPetName(activePet)}
|
||||
<PetIdentityBadges isVerified={activePet.isVerified} />
|
||||
<PetIdentityBadges isDeceased={!!activePet.deceasedAt} isVerified={activePet.isVerified} />
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</>
|
||||
@@ -172,7 +173,7 @@ export function Sidebar({ pets }: SidebarProps) {
|
||||
/>
|
||||
<span className="flex-1 text-sm truncate flex items-center gap-1">
|
||||
{formatPetName(pet)}
|
||||
<PetIdentityBadges isVerified={pet.isVerified} size="sm" />
|
||||
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} size="sm" />
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{pet.species.name}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Ribbon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type Props = {
|
||||
petId: string;
|
||||
petName: string;
|
||||
deceasedAt: Date | string | null;
|
||||
};
|
||||
|
||||
/** YYYY-MM-DD eines @db.Date-Werts (kommt als UTC-Mitternacht). */
|
||||
function toDateInput(value: Date | string | null): string {
|
||||
return value ? new Date(value).toISOString().slice(0, 10) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gedenkmodus „In Erinnerung“ in „Profil bearbeiten“: Datum setzen (mit
|
||||
* Bestätigung, Follower werden einmalig still informiert), korrigieren oder
|
||||
* zurücknehmen. Server: pets.setDeceased.
|
||||
*/
|
||||
export function MemorialSettings({ petId, petName, deceasedAt }: Props) {
|
||||
const t = useTranslations("InMemory");
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const isDeceased = deceasedAt != null;
|
||||
const [today] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [date, setDate] = useState(() => toDateInput(deceasedAt) || today);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const mutation = useMutation({
|
||||
...trpc.pets.setDeceased.mutationOptions(),
|
||||
onSuccess: async (_data, variables) => {
|
||||
setConfirmOpen(false);
|
||||
toast.success(variables.deceasedAt ? t("saved") : t("removed"));
|
||||
await queryClient.invalidateQueries();
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => toast.error(t("saveFailed")),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Ribbon className="h-4 w-4" aria-hidden />
|
||||
{t("settingsHeading")}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isDeceased ? t("activeHint", { name: petName }) : t("settingsHint")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="deceased-at" className="text-xs">
|
||||
{t("dateLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="deceased-at"
|
||||
type="date"
|
||||
max={today}
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-44"
|
||||
/>
|
||||
</div>
|
||||
{isDeceased ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={mutation.isPending || !date || date === toDateInput(deceasedAt)}
|
||||
onClick={() => mutation.mutate({ petId, deceasedAt: date })}
|
||||
>
|
||||
{t("saveDate")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={mutation.isPending}
|
||||
onClick={() => mutation.mutate({ petId, deceasedAt: null })}
|
||||
>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled={!date} onClick={() => setConfirmOpen(true)}>
|
||||
{t("activate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold">{t("confirmTitle", { name: petName })}</DialogTitle>
|
||||
<DialogDescription>{t("confirmDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" onClick={() => setConfirmOpen(false)} />}>
|
||||
{t("cancel")}
|
||||
</DialogClose>
|
||||
<Button disabled={mutation.isPending} onClick={() => mutation.mutate({ petId, deceasedAt: date })}>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Ribbon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ICON_SIZE = { sm: "h-3 w-3", md: "h-3.5 w-3.5", lg: "h-4 w-4" } as const;
|
||||
|
||||
/** Gedenkmodus „In Erinnerung“ — das Tier ist verstorben (Pet.deceasedAt). */
|
||||
export function InMemoryBadge({ size = "md" }: { size?: keyof typeof ICON_SIZE }) {
|
||||
const t = useTranslations("InMemory");
|
||||
return (
|
||||
<span title={t("tooltip")} className="inline-flex shrink-0">
|
||||
<Ribbon className={cn(ICON_SIZE[size], "fill-foreground text-foreground")} aria-label={t("label")} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { isActiveSponsor } from "@/lib/sponsor";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReferralRankBadge, type ReferralOwnerFields } from "@/components/referrals/ReferralRankBadge";
|
||||
import { FoundingMemberBadge } from "@/components/ui/founding-member-badge";
|
||||
import { InMemoryBadge } from "@/components/ui/in-memory-badge";
|
||||
|
||||
type SponsorWindow = {
|
||||
// Optional: a pet identity's owner (petIdentitySelect) carries only the referral rank.
|
||||
@@ -14,6 +15,8 @@ type Props = {
|
||||
isVerified?: boolean;
|
||||
/** Eines der ersten 250 Tiere (Pet.isFoundingMember). */
|
||||
isFoundingMember?: boolean;
|
||||
/** Gedenkmodus „In Erinnerung“ (Pet.deceasedAt gesetzt). */
|
||||
isDeceased?: boolean;
|
||||
/** Omit entirely (rather than passing null) when the caller has no owner/sponsor data to show. */
|
||||
sponsor?: SponsorWindow | null;
|
||||
/** Owner-Felder referralRankId/referralPerks (petCardSelect.owner) — Rang-Abzeichen des Empfehlungsprogramms. */
|
||||
@@ -44,6 +47,7 @@ function isSponsorActive(sponsor: SponsorWindow): boolean {
|
||||
export function PetIdentityBadges({
|
||||
isVerified,
|
||||
isFoundingMember,
|
||||
isDeceased,
|
||||
sponsor,
|
||||
referral,
|
||||
size = "md",
|
||||
@@ -53,10 +57,11 @@ export function PetIdentityBadges({
|
||||
const iconClass = ICON_SIZE[size];
|
||||
const showSponsor = sponsor != null && isSponsorActive(sponsor);
|
||||
|
||||
if (!isVerified && !isFoundingMember && !showSponsor && !referral?.referralRankId) return null;
|
||||
if (!isDeceased && !isVerified && !isFoundingMember && !showSponsor && !referral?.referralRankId) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDeceased && <InMemoryBadge size={size} />}
|
||||
{isVerified && (
|
||||
<BadgeCheck
|
||||
className={cn(iconClass, "shrink-0 fill-blue-500 text-background")}
|
||||
|
||||
@@ -53,6 +53,7 @@ const PUSH_COPY: Record<
|
||||
VACCINE_DUE: { title: "Impfung bald fällig", genericBody: "Eine Impfung wird bald fällig.", actorBody: () => "Eine Impfung wird bald fällig." },
|
||||
STORY_MENTION: { title: "In einer Story markiert", genericBody: "Du wurdest in einer Story markiert.", actorBody: (a) => `${a} hat dich in einer Story markiert.` },
|
||||
STORY_COMMENT: { title: "Neuer Story-Kommentar", genericBody: "Jemand hat deine Story kommentiert.", actorBody: (a) => `${a} hat deine Story kommentiert.` },
|
||||
PET_DECEASED: { title: "In Erinnerung", genericBody: "Ein Tier, dem du folgst, ist verstorben.", actorBody: (a) => `${a} ist leider verstorben.` },
|
||||
STORY_REACTION: { title: "Neue Pfote", genericBody: "Jemand hat deiner Story eine Pfote gegeben.", actorBody: (a) => `${a} hat deiner Story eine Pfote gegeben.` },
|
||||
};
|
||||
|
||||
|
||||
@@ -42,7 +42,8 @@ export async function sendVaccineReminders(prisma: ReminderPrisma, now = new Dat
|
||||
const horizon = new Date(today.getTime() + VACCINE_REMINDER_LEAD_DAYS * DAY_MS);
|
||||
|
||||
const dueVaccines = await prisma.vaccine.findMany({
|
||||
where: { reminderSentAt: null, nextDueDate: { gte: today, lte: horizon } },
|
||||
// Verstorbene Tiere („In Erinnerung“) bekommen keine Impf-Erinnerungen mehr.
|
||||
where: { reminderSentAt: null, nextDueDate: { gte: today, lte: horizon }, pet: { deceasedAt: null } },
|
||||
select: { id: true, petId: true, name: true, nextDueDate: true, pet: { select: { name: true } } },
|
||||
orderBy: { nextDueDate: "asc" },
|
||||
take: MAX_REMINDERS_PER_RUN,
|
||||
|
||||
@@ -22,6 +22,8 @@ export const petIdentitySelect = {
|
||||
name: true,
|
||||
nickname: true,
|
||||
avatarKey: true,
|
||||
// „In Erinnerung“-Abzeichen überall neben dem Namen
|
||||
deceasedAt: true,
|
||||
owner: { select: referralOwnerSelect },
|
||||
} satisfies Prisma.PetSelect;
|
||||
|
||||
|
||||
@@ -153,6 +153,42 @@ export const petsRouter = router({
|
||||
* Asserts ownership before any DB write (T-3-01).
|
||||
* T-3-02: Zod enforces character limits server-side.
|
||||
*/
|
||||
/**
|
||||
* Gedenkmodus „In Erinnerung“: Besitzer markiert sein Tier als verstorben
|
||||
* (Datum) oder nimmt das zurück (null). Beim ersten Setzen bekommen die
|
||||
* Follower einmalig eine stille Mitteilung (PET_DECEASED, bewusst ohne Push),
|
||||
* eigene Tiere des Besitzers nicht.
|
||||
*/
|
||||
setDeceased: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
petId: z.string().min(1),
|
||||
deceasedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const pet = await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
|
||||
const deceasedAt = input.deceasedAt ? new Date(input.deceasedAt) : null;
|
||||
if (deceasedAt && deceasedAt.getTime() > Date.now()) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Das Datum liegt in der Zukunft." });
|
||||
}
|
||||
|
||||
await ctx.prisma.pet.update({ where: { id: input.petId }, data: { deceasedAt } });
|
||||
|
||||
if (deceasedAt && !pet.deceasedAt) {
|
||||
const followers = await ctx.prisma.follow.findMany({
|
||||
where: { followeePetId: input.petId, follower: { ownerId: { not: pet.ownerId } } },
|
||||
select: { followerPetId: true },
|
||||
});
|
||||
if (followers.length > 0) {
|
||||
await ctx.prisma.notification.createMany({
|
||||
data: followers.map((f) => ({ recipientPetId: f.followerPetId, type: "PET_DECEASED" as const, actorPetId: input.petId })),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { deceasedAt };
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
Reference in new issue
Block a user