Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fc7503c08 | ||
|
|
9b52a20a15 | ||
|
|
1ea3a7172d | ||
|
|
9216f0c702 | ||
|
|
b244ef4c10 | ||
|
|
4cf28213ff | ||
|
|
9cf8369307 | ||
|
|
8aca1bc28e | ||
|
|
53d1382ac0 | ||
|
|
6d3136700b | ||
|
|
84a430a92a | ||
|
|
a907654de5 |
No files matched your search
+79
-1
@@ -486,7 +486,11 @@
|
||||
"typeVaccineDue": "💉 Die Impfung „{name}“ ist am {date} fällig – Zeit, einen Termin zu vereinbaren.",
|
||||
"typeStoryMention": "hat dich in einer Story markiert.",
|
||||
"typeStoryComment": "hat deine Story kommentiert.",
|
||||
"typeStoryReaction": "hat deiner Story eine Pfote gegeben."
|
||||
"typeStoryReaction": "hat deiner Story eine Pfote gegeben.",
|
||||
"typeUnknown": "hat eine neue Mitteilung für dich.",
|
||||
"typePetDeceased": "ist über die Regenbogenbrücke gegangen. In liebevoller Erinnerung 🤍",
|
||||
"typePetLost": "wird vermisst. Vielleicht kannst du helfen – tippe für alle Infos.",
|
||||
"typePetFound": "ist wieder sicher zu Hause. Danke fürs Mithelfen! 🎉"
|
||||
},
|
||||
"PhotoPostForm": {
|
||||
"captionPlaceholder": "Bildunterschrift schreiben…",
|
||||
@@ -858,5 +862,79 @@
|
||||
"FoundingMember": {
|
||||
"label": "Gründungsmitglied",
|
||||
"tooltip": "Gründungsmitglied – eines der ersten 250 Tiere auf PawFeed"
|
||||
},
|
||||
"InMemory": {
|
||||
"label": "In Erinnerung",
|
||||
"tooltip": "In liebevoller Erinnerung",
|
||||
"banner": "In liebevoller Erinnerung an {name} · {date}",
|
||||
"settingsHeading": "Gedenkmodus",
|
||||
"settingsHint": "Wenn dein Tier über die Regenbogenbrücke gegangen ist, kannst du sein Profil als liebevolle Erinnerung bewahren. Alle Beiträge bleiben erhalten, und du kannst weiterhin Erinnerungen teilen. Geburtstags- und Impf-Erinnerungen pausieren dann, damit dich nichts unvermittelt trifft.",
|
||||
"activeHint": "Das Profil von {name} wird als Erinnerung bewahrt. Du kannst das Datum jederzeit anpassen oder den Gedenkmodus wieder beenden.",
|
||||
"dateLabel": "Tag des Abschieds",
|
||||
"activate": "Profil als Erinnerung bewahren",
|
||||
"saveDate": "Datum speichern",
|
||||
"remove": "Gedenkmodus beenden",
|
||||
"confirmTitle": "Das Profil von {name} als Erinnerung bewahren?",
|
||||
"confirmDescription": "Es tut uns sehr leid. Das Profil von {name} bekommt eine Erinnerungsschleife, und alles, was ihr geteilt habt, bleibt erhalten. Wer {name} folgt, erfährt es einmal ganz in Ruhe über die Mitteilungen – ohne Push. Du kannst das jederzeit rückgängig machen.",
|
||||
"confirm": "Ja, bewahren",
|
||||
"cancel": "Noch nicht",
|
||||
"saved": "Das Profil wird nun als Erinnerung bewahrt.",
|
||||
"removed": "Gedenkmodus beendet.",
|
||||
"saveFailed": "Das hat leider nicht geklappt. Bitte versuch es noch einmal."
|
||||
},
|
||||
"LostPublic": {
|
||||
"badge": "Vermisst",
|
||||
"heading": "Hast du {name} gesehen?",
|
||||
"metaTitle": "Vermisst: {name} – hast du dieses Tier gesehen?",
|
||||
"metaDescription": "Zuletzt gesehen: {place}. Jeder Hinweis hilft – bitte teilen.",
|
||||
"lastSeenPlace": "Zuletzt gesehen",
|
||||
"lastSeenAt": "Wann",
|
||||
"details": "Erkennungsmerkmale & Hinweise",
|
||||
"contactHeading": "Bitte melde dich, wenn du {name} siehst",
|
||||
"messageInPawFeed": "Nachricht über PawFeed schreiben",
|
||||
"mailSubject": "Hinweis zu {name}",
|
||||
"approachHint": "Bitte das Tier nicht verfolgen oder einfangen wollen – verängstigte Tiere laufen sonst oft weiter weg. Ruhig beobachten, Ort und Zeit merken und Bescheid geben.",
|
||||
"photosHeading": "Weitere Fotos",
|
||||
"photoAlt": "Foto von {name}",
|
||||
"share": "Seite teilen – jede Weitergabe hilft",
|
||||
"copied": "Link kopiert",
|
||||
"foundTitle": "{name} ist wieder sicher zu Hause 🎉",
|
||||
"foundText": "Danke an alle, die geteilt und mitgesucht haben!",
|
||||
"openInPawFeed": "{name} auf PawFeed besuchen",
|
||||
"rateLimited": "Zu viele Anfragen – bitte versuch es in einer Minute erneut."
|
||||
},
|
||||
"LostReport": {
|
||||
"badge": "Wird vermisst",
|
||||
"teaser": "Ist {name} entlaufen? Hier kannst du sofort eine Vermisst-Meldung starten.",
|
||||
"open": "Vermisst melden",
|
||||
"formHeading": "{name} als vermisst melden",
|
||||
"formHint": "Wir drücken dir fest die Daumen. Alle, die {name} folgen oder denen {name} folgt, bekommen sofort eine Push-Mitteilung, und du erhältst eine Seite zum Teilen – auch für Menschen ohne PawFeed.",
|
||||
"activeHeading": "{name} wird vermisst",
|
||||
"activeHint": "Teile den Link, so oft du kannst – zum Beispiel in WhatsApp-Gruppen aus deiner Nachbarschaft. Du kannst die Angaben jederzeit ergänzen.",
|
||||
"lastSeenAt": "Zuletzt gesehen am",
|
||||
"lastSeenPlace": "Wo zuletzt gesehen?",
|
||||
"lastSeenPlacePlaceholder": "z. B. Stadtpark Nord, Nähe Spielplatz",
|
||||
"description": "Erkennungsmerkmale & Hinweise",
|
||||
"descriptionPlaceholder": "z. B. rotes Halsband, gechippt, eher scheu – bitte nicht rufen",
|
||||
"contactHint": "Kontaktdaten sind freiwillig und erscheinen nur auf der Teilen-Seite dieser Meldung. Eine Nachricht über PawFeed ist immer möglich.",
|
||||
"contactPhone": "Telefonnummer (optional)",
|
||||
"contactEmail": "E-Mail (optional)",
|
||||
"allowIndexing": "Seite darf über Suchmaschinen gefunden werden",
|
||||
"allowIndexingHint": "Mehr Reichweite, aber auch Fremde finden die Seite. Ohne Haken ist sie nur über den Link erreichbar.",
|
||||
"submit": "Vermisst-Meldung starten",
|
||||
"cancel": "Abbrechen",
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"notifyFound": "Allen Bescheid geben, dass {name} wieder da ist",
|
||||
"markFound": "{name} ist wieder da!",
|
||||
"share": "Link teilen",
|
||||
"shareTitle": "Vermisst: {name}",
|
||||
"copied": "Link kopiert",
|
||||
"created": "Die Meldung ist raus. Wir drücken die Daumen, dass {name} schnell nach Hause findet.",
|
||||
"updated": "Angaben gespeichert.",
|
||||
"foundToast": "Wie schön – willkommen zurück, {name}! 🎉",
|
||||
"saveFailed": "Das hat leider nicht geklappt. Bitte versuch es noch einmal.",
|
||||
"bannerTitle": "{name} wird vermisst",
|
||||
"bannerText": "Zuletzt gesehen: {place} · {date}",
|
||||
"bannerLink": "Alle Infos & Seite teilen"
|
||||
}
|
||||
}
|
||||
+79
-1
@@ -486,7 +486,11 @@
|
||||
"typeVaccineDue": "💉 The \"{name}\" vaccination is due on {date} – time to book an appointment.",
|
||||
"typeStoryMention": "tagged you in a story.",
|
||||
"typeStoryComment": "commented on your story.",
|
||||
"typeStoryReaction": "gave your story a paw."
|
||||
"typeStoryReaction": "gave your story a paw.",
|
||||
"typeUnknown": "has a new notification for you.",
|
||||
"typePetDeceased": "has crossed the rainbow bridge. In loving memory 🤍",
|
||||
"typePetLost": "is missing. Maybe you can help – tap for all details.",
|
||||
"typePetFound": "is safely back home. Thanks for helping! 🎉"
|
||||
},
|
||||
"PhotoPostForm": {
|
||||
"captionPlaceholder": "Write a caption…",
|
||||
@@ -858,5 +862,79 @@
|
||||
"FoundingMember": {
|
||||
"label": "Founding member",
|
||||
"tooltip": "Founding member – one of the first 250 pets on PawFeed"
|
||||
},
|
||||
"InMemory": {
|
||||
"label": "In memory",
|
||||
"tooltip": "In loving memory",
|
||||
"banner": "In loving memory of {name} · {date}",
|
||||
"settingsHeading": "Memorial mode",
|
||||
"settingsHint": "If your pet has crossed the rainbow bridge, you can keep their profile as a loving memorial. All posts stay, and you can keep sharing memories. Birthday and vaccine reminders pause so nothing catches you off guard.",
|
||||
"activeHint": "{name}'s profile is kept as a memorial. You can adjust the date or end memorial mode at any time.",
|
||||
"dateLabel": "Day of farewell",
|
||||
"activate": "Keep profile as a memorial",
|
||||
"saveDate": "Save date",
|
||||
"remove": "End memorial mode",
|
||||
"confirmTitle": "Keep {name}'s profile as a memorial?",
|
||||
"confirmDescription": "We're so sorry. {name}'s profile gets a memorial ribbon, and everything you've shared stays. Those who follow {name} will be told once, quietly in their notifications – no push. You can undo this at any time.",
|
||||
"confirm": "Yes, keep it",
|
||||
"cancel": "Not yet",
|
||||
"saved": "The profile is now kept as a memorial.",
|
||||
"removed": "Memorial mode ended.",
|
||||
"saveFailed": "That didn't work, sorry. Please try again."
|
||||
},
|
||||
"LostPublic": {
|
||||
"badge": "Missing",
|
||||
"heading": "Have you seen {name}?",
|
||||
"metaTitle": "Missing: {name} – have you seen this pet?",
|
||||
"metaDescription": "Last seen: {place}. Every tip helps – please share.",
|
||||
"lastSeenPlace": "Last seen",
|
||||
"lastSeenAt": "When",
|
||||
"details": "Distinguishing features & notes",
|
||||
"contactHeading": "Please get in touch if you see {name}",
|
||||
"messageInPawFeed": "Send a message on PawFeed",
|
||||
"mailSubject": "A tip about {name}",
|
||||
"approachHint": "Please don't chase or try to catch the pet – frightened animals often run further away. Watch calmly, note the place and time and let us know.",
|
||||
"photosHeading": "More photos",
|
||||
"photoAlt": "Photo of {name}",
|
||||
"share": "Share this page – every share helps",
|
||||
"copied": "Link copied",
|
||||
"foundTitle": "{name} is safely back home 🎉",
|
||||
"foundText": "Thank you to everyone who shared and helped search!",
|
||||
"openInPawFeed": "Visit {name} on PawFeed",
|
||||
"rateLimited": "Too many requests – please try again in a minute."
|
||||
},
|
||||
"LostReport": {
|
||||
"badge": "Missing",
|
||||
"teaser": "Has {name} run away? Start a missing report right here.",
|
||||
"open": "Report missing",
|
||||
"formHeading": "Report {name} as missing",
|
||||
"formHint": "We're keeping our fingers crossed. Everyone who follows {name} or whom {name} follows gets a push notification right away, and you get a page to share – even with people who aren't on PawFeed.",
|
||||
"activeHeading": "{name} is missing",
|
||||
"activeHint": "Share the link as often as you can – for example in neighbourhood WhatsApp groups. You can add details at any time.",
|
||||
"lastSeenAt": "Last seen on",
|
||||
"lastSeenPlace": "Where last seen?",
|
||||
"lastSeenPlacePlaceholder": "e.g. North City Park, near the playground",
|
||||
"description": "Distinguishing features & notes",
|
||||
"descriptionPlaceholder": "e.g. red collar, microchipped, rather shy – please don't call out",
|
||||
"contactHint": "Contact details are optional and only appear on this report's share page. A message via PawFeed is always possible.",
|
||||
"contactPhone": "Phone number (optional)",
|
||||
"contactEmail": "Email (optional)",
|
||||
"allowIndexing": "Allow search engines to find this page",
|
||||
"allowIndexingHint": "More reach, but strangers can find it too. Unchecked, the page is only reachable via the link.",
|
||||
"submit": "Start missing report",
|
||||
"cancel": "Cancel",
|
||||
"saveChanges": "Save changes",
|
||||
"notifyFound": "Let everyone know {name} is back",
|
||||
"markFound": "{name} is back!",
|
||||
"share": "Share link",
|
||||
"shareTitle": "Missing: {name}",
|
||||
"copied": "Link copied",
|
||||
"created": "Your report is out. We're keeping our fingers crossed that {name} finds the way home soon.",
|
||||
"updated": "Details saved.",
|
||||
"foundToast": "How wonderful – welcome back, {name}! 🎉",
|
||||
"saveFailed": "That didn't work, sorry. Please try again.",
|
||||
"bannerTitle": "{name} is missing",
|
||||
"bannerText": "Last seen: {place} · {date}",
|
||||
"bannerLink": "All details & share page"
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { useTheme } from "../../../src/context/ThemeContext";
|
||||
import { PetAvatar } from "../../../src/components/PetAvatar";
|
||||
import { formatPetName, formatRelativeTime } from "../../../src/lib/format";
|
||||
import { isoToDisplayDate } from "../../../src/lib/date-format";
|
||||
import { notificationRoute } from "../../../src/lib/notification-route";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
|
||||
import type { NotificationItem } from "../../../src/api/types";
|
||||
|
||||
@@ -25,6 +26,9 @@ 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",
|
||||
PET_LOST: "notifications.petLost",
|
||||
PET_FOUND: "notifications.petFound",
|
||||
};
|
||||
|
||||
// BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the
|
||||
@@ -50,7 +54,8 @@ function getNotificationSuffix(item: NotificationItem, t: TFunction): string {
|
||||
if (selfKey) return t(selfKey);
|
||||
const actorKey = ACTOR_MESSAGE_KEYS[item.type];
|
||||
if (actorKey && item.actorPet) return t(actorKey);
|
||||
return item.type;
|
||||
// Nie den Code-Namen zeigen (z. B. neuer Typ vom Server, den diese App-Version noch nicht kennt).
|
||||
return t("notifications.unknown");
|
||||
}
|
||||
|
||||
const SELF_ICONS: Partial<Record<NotificationItem["type"], string>> = {
|
||||
@@ -59,52 +64,32 @@ const SELF_ICONS: Partial<Record<NotificationItem["type"], string>> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Port of web's notifications/page.tsx getHref()/getPostId(): FOLLOW goes to
|
||||
* the actor's profile, BIRTHDAY/ADOPTION_DAY to this pet's own health page
|
||||
* (the anniversary is about *this* pet, there's no other actor to visit),
|
||||
* and REACTION/COMMENT/MENTION/PAW_BACK go to the post. Web opens the post
|
||||
* in an inline dialog; mobile has no such dialog, so this goes to the
|
||||
* comments screen instead — the closest existing route to "the post".
|
||||
* MESSAGE has no target here either, matching web (it doesn't handle it
|
||||
* there so this doesn't try to be smarter about it).
|
||||
* Ziel einer Mitteilung — gemeinsame Regel mit dem Antippen von Push-Mitteilungen
|
||||
* (src/lib/notification-route.ts): Nachricht → Gespräch, Kommentar/Pfote/Erwähnung
|
||||
* → Beitrag, Story-Mitteilungen → die Story, Follow/Vorschlag → Profil,
|
||||
* Geburtstag/Impfung → Gesundheit. Nur in Event-Handlern aufrufen (Date.now()).
|
||||
*/
|
||||
function getNotificationRoute(item: NotificationItem): string | null {
|
||||
// Story-Mitteilungen: Story öffnen, solange sie läuft (der Besitzer sieht
|
||||
// eigene Storys 7 Tage), sonst das Tierprofil.
|
||||
if (item.story && (item.type === "STORY_MENTION" || item.type === "STORY_COMMENT" || item.type === "STORY_REACTION")) {
|
||||
const isOwnStory = item.story.petId === item.recipientPetId;
|
||||
const isRunning = new Date(item.story.expiresAt).getTime() > Date.now();
|
||||
if (isRunning || isOwnStory) {
|
||||
const params = new URLSearchParams({
|
||||
petId: item.story.petId,
|
||||
petName: item.story.pet.name,
|
||||
petAvatarKey: item.story.pet.avatarKey ?? "",
|
||||
});
|
||||
return `/story-viewer?${params.toString()}`;
|
||||
}
|
||||
return `/pets/${item.story.petId}`;
|
||||
}
|
||||
switch (item.type) {
|
||||
case "FOLLOW":
|
||||
case "PET_SUGGESTION":
|
||||
case "REFERRAL_JOINED":
|
||||
return item.actorPet ? `/pets/${item.actorPet.id}` : null;
|
||||
case "REFERRAL_RANK_UP":
|
||||
return "/invite";
|
||||
case "BIRTHDAY":
|
||||
case "ADOPTION_DAY":
|
||||
case "VACCINE_DUE":
|
||||
return item.recipientPetId ? `/pets/${item.recipientPetId}/health` : null;
|
||||
case "REACTION":
|
||||
case "COMMENT":
|
||||
case "MENTION":
|
||||
case "PAW_BACK":
|
||||
return item.post?.id ? `/posts/${item.post.id}/comments` : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return notificationRoute(
|
||||
{
|
||||
type: item.type,
|
||||
recipientPetId: item.recipientPetId,
|
||||
actorPetId: item.actorPet?.id ?? null,
|
||||
postId: item.post?.id ?? null,
|
||||
conversationId: item.conversationId,
|
||||
storyId: item.story?.id ?? null,
|
||||
storyPetId: item.story?.petId ?? null,
|
||||
storyPetName: item.story?.pet.name ?? null,
|
||||
storyPetAvatarKey: item.story?.pet.avatarKey ?? null,
|
||||
storyExpiresAt: item.story?.expiresAt ?? null,
|
||||
},
|
||||
Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
/** Bei diesen Typen führt auch der Name direkt zum Ziel (nicht zum Profil-Umweg). */
|
||||
const NAME_OPENS_TARGET = new Set(["MESSAGE", "STORY_MENTION", "STORY_COMMENT", "STORY_REACTION"]);
|
||||
|
||||
/** Port of web's notifications list — tapping a row marks it read and navigates to its target (post/pet). */
|
||||
export default function NotificationsScreen() {
|
||||
const { t } = useTranslation();
|
||||
@@ -196,7 +181,19 @@ export default function NotificationsScreen() {
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.bodyText}>
|
||||
{item.actorPet && (
|
||||
<Text style={styles.actorName} onPress={() => router.push(`/pets/${item.actorPet!.id}`)}>
|
||||
<Text
|
||||
style={styles.actorName}
|
||||
onPress={() => {
|
||||
// Bei Nachrichten und Storys führt auch der Name direkt zum Ziel.
|
||||
const targetRoute = NAME_OPENS_TARGET.has(item.type) ? getNotificationRoute(item) : null;
|
||||
if (targetRoute) {
|
||||
if (!item.read) markRead.mutate({ notificationId: item.id });
|
||||
router.push(targetRoute);
|
||||
} else {
|
||||
router.push(`/pets/${item.actorPet!.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{formatPetName(item.actorPet)}{" "}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -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 isLost={!!pet.lostSince} isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} />
|
||||
</View>
|
||||
<Text style={styles.breed}>{pet.breed?.name ?? pet.species.name}</Text>
|
||||
</View>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { fontFamily } from "../../src/theme/tokens";
|
||||
import { useNotificationTapRouting } from "../../src/hooks/useNotificationTapRouting";
|
||||
|
||||
function CloseModalButton() {
|
||||
const router = useRouter();
|
||||
@@ -17,6 +18,8 @@ function CloseModalButton() {
|
||||
|
||||
export default function AppLayout() {
|
||||
const { t } = useTranslation();
|
||||
// Push-Mitteilung angetippt → direkt zum Ziel (Gespräch, Beitrag, Story …)
|
||||
useNotificationTapRouting();
|
||||
const { colors } = useTheme();
|
||||
// Plain prop objects, not StyleSheet.create — safe to recompute on every
|
||||
// render, unlike the module-level StyleSheet.create calls most other
|
||||
@@ -46,6 +49,7 @@ export default function AppLayout() {
|
||||
<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="lost/[petId]" options={{ ...headerOptions, title: t("screens.lost") }} />
|
||||
<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") }} />
|
||||
@@ -70,6 +74,11 @@ export default function AppLayout() {
|
||||
headerLeft: () => <CloseModalButton />,
|
||||
}}
|
||||
/>
|
||||
{/* Story-Editor: eigener Vollbild-Screen, ohne Wisch-zum-Schließen (Ziehgesten der Overlays). */}
|
||||
<Stack.Screen
|
||||
name="story-editor"
|
||||
options={{ headerShown: false, presentation: "fullScreenModal", animation: "fade", gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="milestone-new"
|
||||
options={{
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useMemo } from "react";
|
||||
import { View, Text, ScrollView, Pressable, Image, ActivityIndicator, Linking, Share, StyleSheet } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Clock, Info, Mail, MapPin, MessageCircle, Phone, Share2, Siren } from "lucide-react-native";
|
||||
import { useTRPC } from "../../../src/api/trpc";
|
||||
import { useActivePet } from "../../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../../src/context/ThemeContext";
|
||||
import { Button } from "../../../src/components/Button";
|
||||
import { env } from "../../../src/lib/env";
|
||||
import { getMediaUrl, getAvatarUrl } from "../../../src/lib/media-url";
|
||||
import { isoToDisplayDate } from "../../../src/lib/date-format";
|
||||
import { useBottomSafePadding } from "../../../src/hooks/useBottomSafePadding";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
|
||||
|
||||
const RED = "#dc2626";
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
/** „04.10.2026, 14:30“ in Ortszeit des Geräts. */
|
||||
function formatDateTime(value: Date | string): string {
|
||||
const d = new Date(value);
|
||||
const iso = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
return `${isoToDisplayDate(iso)}, ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vermisst-Ansicht in der App — dieselben Angaben wie die öffentliche Seite
|
||||
* /vermisst/<code>, ohne die App zu verlassen. Ziel des Profil-Banners und des
|
||||
* Antippens einer PET_LOST-Mitteilung (src/lib/notification-route.ts).
|
||||
*/
|
||||
export default function LostPetScreen() {
|
||||
const { petId } = useLocalSearchParams<{ petId: string }>();
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const router = useRouter();
|
||||
const { activePetId, pets } = useActivePet();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const bottomPadding = useBottomSafePadding();
|
||||
|
||||
const { data: report, isLoading } = useQuery(trpc.lostReports.byPet.queryOptions({ petId }));
|
||||
const openConversation = useMutation(
|
||||
trpc.conversations.getOrCreate.mutationOptions({
|
||||
onSuccess: (conversation) => router.push(`/messages/${conversation.id}`),
|
||||
})
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.marigoldOrange} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.emptyTitle}>{t("lostView.notActiveTitle")}</Text>
|
||||
<Text style={styles.muted}>{t("lostView.notActiveText")}</Text>
|
||||
<Button title={t("lostView.toProfile")} variant="outline" onPress={() => router.replace(`/pets/${petId}`)} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const { pet } = report;
|
||||
const isOwnPet = pets.some((p) => p.id === pet.id);
|
||||
const heroUrl = getAvatarUrl(pet.avatarKey) ?? (report.photos[0] ? getMediaUrl(report.photos[0].storageKey) : undefined);
|
||||
const photoUrls = report.photos.map((p) => getMediaUrl(p.storageKey));
|
||||
const publicUrl = `${env.apiUrl}/vermisst/${report.code}`;
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={[styles.container, bottomPadding]}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.badge}>
|
||||
<Siren size={13} color="#fff" />
|
||||
<Text style={styles.badgeText}>{t("lostView.badge")}</Text>
|
||||
</View>
|
||||
<Text style={styles.heading}>{t("lostView.heading", { name: pet.name })}</Text>
|
||||
<Text style={styles.muted}>
|
||||
{pet.speciesName}
|
||||
{pet.breedName ? ` · ${pet.breedName}` : ""}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{heroUrl ? <Image source={{ uri: heroUrl }} style={styles.hero} alt={pet.name} /> : null}
|
||||
|
||||
<View style={styles.card}>
|
||||
<Fact icon={<MapPin size={16} color={colors.quietGray} />} label={t("lostView.lastSeenPlace")} value={report.lastSeenPlace} styles={styles} />
|
||||
<Fact icon={<Clock size={16} color={colors.quietGray} />} label={t("lostView.lastSeenAt")} value={formatDateTime(report.lastSeenAt)} styles={styles} />
|
||||
{report.description ? (
|
||||
<Fact icon={<Info size={16} color={colors.quietGray} />} label={t("lostView.details")} value={report.description} styles={styles} />
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{!isOwnPet && (
|
||||
<View style={styles.contactCard}>
|
||||
<Text style={styles.contactHeading}>{t("lostView.contactHeading", { name: pet.name })}</Text>
|
||||
{report.contactPhone ? (
|
||||
<ContactButton
|
||||
icon={<Phone size={18} color={colors.ink} />}
|
||||
label={report.contactPhone}
|
||||
onPress={() => void Linking.openURL(`tel:${report.contactPhone!.replace(/[^+0-9]/g, "")}`)}
|
||||
styles={styles}
|
||||
/>
|
||||
) : null}
|
||||
{report.contactEmail ? (
|
||||
<ContactButton
|
||||
icon={<Mail size={18} color={colors.ink} />}
|
||||
label={report.contactEmail}
|
||||
onPress={() =>
|
||||
void Linking.openURL(
|
||||
`mailto:${report.contactEmail}?subject=${encodeURIComponent(t("lostView.mailSubject", { name: pet.name }))}`
|
||||
)
|
||||
}
|
||||
styles={styles}
|
||||
/>
|
||||
) : null}
|
||||
{activePetId ? (
|
||||
<ContactButton
|
||||
icon={<MessageCircle size={18} color={colors.ink} />}
|
||||
label={t("lostView.message")}
|
||||
onPress={() => openConversation.mutate({ senderPetId: activePetId, recipientPetId: pet.id })}
|
||||
styles={styles}
|
||||
/>
|
||||
) : null}
|
||||
<Text style={styles.hint}>{t("lostView.approachHint")}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{photoUrls.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{t("lostView.photosHeading")}</Text>
|
||||
<View style={styles.grid}>
|
||||
{photoUrls.map((url, index) => (
|
||||
<Pressable
|
||||
key={report.photos[index].postId}
|
||||
style={styles.gridItem}
|
||||
onPress={() =>
|
||||
router.push({ pathname: "/image-viewer", params: { images: JSON.stringify(photoUrls), index: String(index) } })
|
||||
}
|
||||
>
|
||||
<Image source={{ uri: url }} style={styles.gridImage} alt={t("lostView.photoAlt", { name: pet.name })} />
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Pressable
|
||||
style={styles.shareButton}
|
||||
accessibilityRole="button"
|
||||
onPress={() => void Share.share({ message: `${t("lostView.shareText", { name: pet.name })} ${publicUrl}` })}
|
||||
>
|
||||
<Share2 size={18} color={colors.ink} />
|
||||
<Text style={styles.shareText}>{t("lostView.share")}</Text>
|
||||
</Pressable>
|
||||
<Button title={t("lostView.toProfile")} variant="outline" onPress={() => router.push(`/pets/${pet.id}`)} />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
type Styles = ReturnType<typeof makeStyles>;
|
||||
|
||||
function Fact({ icon, label, value, styles }: { icon: React.ReactNode; label: string; value: string; styles: Styles }) {
|
||||
return (
|
||||
<View style={styles.fact}>
|
||||
<View style={styles.factIcon}>{icon}</View>
|
||||
<View style={styles.flex}>
|
||||
<Text style={styles.factLabel}>{label}</Text>
|
||||
<Text style={styles.factValue}>{value}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactButton({ icon, label, onPress, styles }: { icon: React.ReactNode; label: string; onPress: () => void; styles: Styles }) {
|
||||
return (
|
||||
<Pressable style={styles.contactButton} onPress={onPress} accessibilityRole="button">
|
||||
{icon}
|
||||
<Text style={styles.contactButtonText} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
container: { padding: 16, gap: 16, backgroundColor: colors.porchWhite, flexGrow: 1 },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12, padding: 24, backgroundColor: colors.porchWhite },
|
||||
emptyTitle: { ...typography.title, fontSize: 18, textAlign: "center" },
|
||||
header: { alignItems: "center", gap: 6 },
|
||||
badge: { flexDirection: "row", alignItems: "center", gap: 4, backgroundColor: RED, borderRadius: 999, paddingHorizontal: 10, paddingVertical: 4 },
|
||||
badgeText: { ...typography.label, color: "#fff", letterSpacing: 0.6 },
|
||||
heading: { ...typography.title, fontSize: 24, textAlign: "center" },
|
||||
muted: { ...typography.body, color: colors.quietGray, textAlign: "center" },
|
||||
hero: { width: "100%", aspectRatio: 1, borderRadius: radii.lg, backgroundColor: colors.whisperGray },
|
||||
card: { gap: 12, padding: 14, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.hairlineGray, backgroundColor: colors.pureWhite },
|
||||
fact: { flexDirection: "row", gap: 10 },
|
||||
factIcon: { paddingTop: 2 },
|
||||
flex: { flex: 1 },
|
||||
factLabel: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
factValue: { ...typography.body },
|
||||
contactCard: { gap: 8, padding: 14, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.alertRedTint, backgroundColor: colors.alertRedTint },
|
||||
contactHeading: { ...typography.title, fontSize: 16 },
|
||||
contactButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
height: 48,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: radii.lg,
|
||||
backgroundColor: colors.pureWhite,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
},
|
||||
contactButtonText: { ...typography.body, fontFamily: typography.title.fontFamily, flexShrink: 1 },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
|
||||
section: { gap: 8 },
|
||||
sectionTitle: { ...typography.title, fontSize: 15 },
|
||||
grid: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
|
||||
gridItem: { width: "32%", aspectRatio: 1 },
|
||||
gridImage: { width: "100%", height: "100%", borderRadius: 8, backgroundColor: colors.whisperGray },
|
||||
shareButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
height: 48,
|
||||
borderRadius: radii.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
},
|
||||
shareText: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,8 @@ 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 { LostReportSettings } from "../../../../src/components/lost/LostReportSettings";
|
||||
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";
|
||||
@@ -132,6 +134,9 @@ export default function EditPetScreen() {
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{/* Vermisst-Meldung ganz oben: im Ernstfall sofort auffindbar */}
|
||||
<LostReportSettings petId={pet.id} petName={pet.name} isDeceased={pet.deceasedAt != null} />
|
||||
|
||||
<Text style={styles.label}>{t("editPet.nameLabel")}</Text>
|
||||
<TextField value={name} onChangeText={setName} maxLength={30} />
|
||||
|
||||
@@ -177,6 +182,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, Siren } 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,38 @@ 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 isLost={!!pet.lostSince} isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
|
||||
</View>
|
||||
{pet.lostReport && (
|
||||
<Pressable
|
||||
style={styles.lostBanner}
|
||||
accessibilityRole="link"
|
||||
onPress={() => router.push(`/lost/${pet.id}`)}
|
||||
>
|
||||
<View style={styles.lostTitleRow}>
|
||||
<Siren size={15} color="#dc2626" />
|
||||
<Text style={styles.lostTitle}>{t("lostReport.bannerTitle", { name: pet.name })}</Text>
|
||||
</View>
|
||||
<Text style={styles.lostText}>
|
||||
{t("lostReport.bannerText", {
|
||||
place: pet.lostReport.lastSeenPlace,
|
||||
date: isoToDisplayDate(new Date(pet.lostReport.lastSeenAt).toISOString().slice(0, 10)),
|
||||
})}
|
||||
</Text>
|
||||
<Text style={styles.lostLink}>{t("lostReport.bannerLink")}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{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 +261,32 @@ 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 },
|
||||
lostBanner: {
|
||||
alignSelf: "stretch",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.alertRedTint,
|
||||
backgroundColor: colors.alertRedTint,
|
||||
},
|
||||
lostTitleRow: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
lostTitle: { ...typography.body, fontFamily: typography.title.fontFamily, color: "#b91c1c" },
|
||||
lostText: { ...typography.body, fontSize: 13, color: colors.ink, textAlign: "center" },
|
||||
lostLink: { ...typography.body, fontSize: 12, textDecorationLine: "underline", color: colors.ink },
|
||||
invitedByName: { color: colors.ink, fontWeight: "600" },
|
||||
statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 },
|
||||
stat: { alignItems: "center" },
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { View, Text, Image as RNImage, StyleSheet } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../../src/api/trpc";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { uploadImageToSupabase, uploadFileToUrl } from "../../src/lib/upload";
|
||||
import { resizeForUpload } from "../../src/lib/resize-image";
|
||||
import { StoryEditor, type EditorMedia, type EditorResult } from "../../src/components/story-editor/StoryEditor";
|
||||
import { fontFamily } from "../../src/theme/tokens";
|
||||
|
||||
type Params = { kind: "image" | "video" | "text"; uri?: string; width?: string; height?: string; mimeType?: string };
|
||||
|
||||
/** Echte Pixelmaße der (verkleinerten) Datei — der Picker meldet bei gedrehten Handyfotos teils vertauschte Werte. */
|
||||
function imageSize(uri: string): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve, reject) => RNImage.getSize(uri, (width, height) => resolve({ width, height }), reject));
|
||||
}
|
||||
|
||||
function mediaFromParams(params: Params): EditorMedia | null {
|
||||
if (params.kind === "text") return { kind: "text" };
|
||||
const width = Number(params.width);
|
||||
const height = Number(params.height);
|
||||
if (!params.uri || !width || !height) return null;
|
||||
return params.kind === "video"
|
||||
? { kind: "video", uri: params.uri, width, height, mimeType: params.mimeType || undefined }
|
||||
: { kind: "image", uri: params.uri, width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Story-Editor als eigener Vollbild-Screen (fullScreenModal, ohne Kopfzeile,
|
||||
* ohne Wisch-zum-Schließen). Vorher lief er innerhalb des Auswahl-Screens
|
||||
* story-new, der auf iOS ein Sheet ist — dort das Umschalten der Kopfzeile und
|
||||
* ein weiteres Modal führten dazu, dass alles verschwand und nur das X kurz
|
||||
* aufleuchtete (Tester-Feedback 04.10.). Das Medium kommt als Route-Parameter.
|
||||
*
|
||||
* Upload wie bisher: Foto verkleinert zu Supabase, Video direkt zu Mux
|
||||
* (createVideoUpload legt die PROCESSING-Story an), Text-Story ohne Datei.
|
||||
*/
|
||||
export default function StoryEditorScreen() {
|
||||
const params = useLocalSearchParams<Params>();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { activePetId } = useActivePet();
|
||||
const media = useMemo(() => mediaFromParams(params), [params]);
|
||||
|
||||
const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadPercent, setUploadPercent] = useState<number | null>(null);
|
||||
|
||||
const getPresignedUrlMutation = useMutation(trpc.stories.getPresignedUrl.mutationOptions());
|
||||
const createMutation = useMutation(trpc.stories.create.mutationOptions());
|
||||
const createTextMutation = useMutation(trpc.stories.createText.mutationOptions());
|
||||
const createVideoUploadMutation = useMutation(trpc.stories.createVideoUpload.mutationOptions());
|
||||
|
||||
const finish = useCallback(async () => {
|
||||
await queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||
await queryClient.invalidateQueries(trpc.stories.pathFilter());
|
||||
// Editor + Auswahl-Screen schließen → zurück dorthin, wo die Story gestartet wurde.
|
||||
router.dismissAll();
|
||||
}, [queryClient, trpc, router]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async ({ overlays, background }: EditorResult) => {
|
||||
if (!media || !activePetId) return;
|
||||
setError(null);
|
||||
const editorFields = overlays.length > 0 ? { overlays } : {};
|
||||
|
||||
try {
|
||||
if (media.kind === "text") {
|
||||
await createTextMutation.mutateAsync({ petId: activePetId, background: background ?? "sunset", overlays });
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (media.kind === "video") {
|
||||
const { uploadUrl } = await createVideoUploadMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
aiDisclosure: "NONE",
|
||||
...editorFields,
|
||||
mediaWidth: media.width,
|
||||
mediaHeight: media.height,
|
||||
});
|
||||
setIsUploading(true);
|
||||
setUploadPercent(0);
|
||||
await uploadFileToUrl(media.uri, uploadUrl ?? "", media.mimeType ?? "video/mp4", (fraction) => {
|
||||
const pct = Math.round(fraction * 100);
|
||||
setUploadPercent((prev) => (prev === pct ? prev : pct));
|
||||
});
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
const resized = await resizeForUpload(media.uri, media.width, media.height);
|
||||
const size = await imageSize(resized.uri).catch(() => ({ width: media.width, height: media.height }));
|
||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
contentType: resized.mimeType,
|
||||
fileSize: resized.fileSize,
|
||||
});
|
||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||
await createMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
storageKey: key,
|
||||
blurhash: null,
|
||||
...editorFields,
|
||||
mediaWidth: size.width,
|
||||
mediaHeight: size.height,
|
||||
});
|
||||
await finish();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error && err.message ? err.message : t("onboarding.uploadError"));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadPercent(null);
|
||||
}
|
||||
},
|
||||
[media, activePetId, createTextMutation, createVideoUploadMutation, getPresignedUrlMutation, createMutation, finish, t]
|
||||
);
|
||||
|
||||
const isSubmitting =
|
||||
isUploading ||
|
||||
getPresignedUrlMutation.isPending ||
|
||||
createMutation.isPending ||
|
||||
createTextMutation.isPending ||
|
||||
createVideoUploadMutation.isPending;
|
||||
|
||||
if (!media || !activePetId) {
|
||||
return (
|
||||
<View style={styles.missing}>
|
||||
<Text style={styles.missingText}>{t("onboarding.uploadError")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StoryEditor
|
||||
media={media}
|
||||
petId={activePetId}
|
||||
toolsEnabled={flags?.storyEditor ?? false}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={isSubmitting ? t("milestones.sharing") : t("story.shareButton")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
if (!isSubmitting) router.back();
|
||||
}}
|
||||
progress={
|
||||
<>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
{isSubmitting && media.kind === "video" ? <UploadProgress percent={uploadPercent} /> : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Video-Upload zu Mux: erst „wird vorbereitet“, dann echter Fortschritt (expo-file-system onProgress). */
|
||||
function UploadProgress({ percent }: { percent: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
const label = percent === null ? t("story.preparingUpload") : t("story.uploadingVideo", { percent });
|
||||
return (
|
||||
<View style={styles.progressWrap} accessibilityRole="progressbar" accessibilityValue={{ min: 0, max: 100, now: percent ?? 0 }} accessibilityLabel={label}>
|
||||
<View style={styles.progressTrack}>
|
||||
<View style={[styles.progressFill, { width: `${percent ?? 0}%` }]} />
|
||||
</View>
|
||||
<Text style={styles.progressLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
missing: { flex: 1, backgroundColor: "#111111", alignItems: "center", justifyContent: "center", padding: 24 },
|
||||
missingText: { color: "#ffffff", fontFamily: fontFamily.regular, fontSize: 15, textAlign: "center" },
|
||||
error: { color: "#f87171", fontFamily: fontFamily.regular, fontSize: 14, textAlign: "center" },
|
||||
progressWrap: { gap: 6 },
|
||||
progressTrack: { height: 6, borderRadius: 3, backgroundColor: "rgba(255,255,255,0.2)", overflow: "hidden" },
|
||||
progressFill: { height: "100%", borderRadius: 3, backgroundColor: "#f97316" },
|
||||
progressLabel: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 12, textAlign: "center" },
|
||||
});
|
||||
+37
-174
@@ -1,39 +1,27 @@
|
||||
import { useState, useCallback, useMemo, useLayoutEffect } from "react";
|
||||
import { View, Text, Image as RNImage, Pressable, StyleSheet, Alert } from "react-native";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { View, Text, Pressable, StyleSheet, Alert } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ImagePlus, Camera, Type } from "lucide-react-native";
|
||||
import { useTRPC } from "../../src/api/trpc";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { uploadImageToSupabase, uploadFileToUrl } from "../../src/lib/upload";
|
||||
import { resizeForUpload } from "../../src/lib/resize-image";
|
||||
import { MAX_STORY_VIDEO_DURATION_SECS } from "../../src/lib/story-video-limits";
|
||||
import { StoryEditor, type EditorMedia, type EditorResult } from "../../src/components/story-editor/StoryEditor";
|
||||
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
||||
|
||||
/** Echte Pixelmaße der (verkleinerten) Datei — der Picker meldet bei gedrehten Handyfotos teils vertauschte Werte. */
|
||||
function imageSize(uri: string): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve, reject) => RNImage.getSize(uri, (width, height) => resolve({ width, height }), reject));
|
||||
}
|
||||
|
||||
/**
|
||||
* Story erstellen: Foto, kurzes Video oder reine Text-Story (Farbverlauf),
|
||||
* danach der Story-Editor (Text, @-Markierungen, GIFs, Sticker — siehe
|
||||
* src/components/story-editor/). Overlays gehen als Daten mit, nicht ins Bild
|
||||
* gerechnet. Der Video-Pfad entspricht web's StoryForm.tsx: createVideoUpload
|
||||
* legt die (PROCESSING) Story an, die Bytes gehen direkt zu Mux. Ist der
|
||||
* Notschalter (Admin) aus, gibt es nur Vorschau + Teilen wie früher.
|
||||
* Story erstellen — Schritt 1: Foto, kurzes Video oder Text-Story wählen.
|
||||
* Bearbeiten und Teilen passieren im eigenen Vollbild-Screen story-editor.tsx
|
||||
* (dieser Screen ist auf iOS ein Sheet; den Editor darin einzublenden führte
|
||||
* dazu, dass alles verschwand — siehe story-editor.tsx). Ist der Notschalter
|
||||
* (Admin) aus, gibt es keine Text-Story und der Editor zeigt nur Vorschau + Teilen.
|
||||
*/
|
||||
export default function NewStoryScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { activePetId } = useActivePet();
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]);
|
||||
@@ -41,28 +29,21 @@ export default function NewStoryScreen() {
|
||||
const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
|
||||
const editorEnabled = flags?.storyEditor ?? false;
|
||||
|
||||
const [media, setMedia] = useState<EditorMedia | null>(null);
|
||||
const navigation = useNavigation();
|
||||
|
||||
// Im Editor gehört der ganze Bildschirm der Story — Kopfzeile ausblenden.
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({ headerShown: media === null });
|
||||
}, [navigation, media]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
// Video-Upload in Prozent (0–100), null solange kein Video hochgeladen wird.
|
||||
const [uploadPercent, setUploadPercent] = useState<number | null>(null);
|
||||
|
||||
const getPresignedUrlMutation = useMutation(trpc.stories.getPresignedUrl.mutationOptions());
|
||||
const createMutation = useMutation(trpc.stories.create.mutationOptions());
|
||||
const createTextMutation = useMutation(trpc.stories.createText.mutationOptions());
|
||||
const createVideoUploadMutation = useMutation(trpc.stories.createVideoUpload.mutationOptions());
|
||||
|
||||
const finish = useCallback(async () => {
|
||||
await queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||
await queryClient.invalidateQueries(trpc.stories.pathFilter());
|
||||
router.back();
|
||||
}, [queryClient, trpc, router]);
|
||||
const openEditor = useCallback(
|
||||
(params: { kind: "image" | "video" | "text"; uri?: string; width?: number; height?: number; mimeType?: string | null }) => {
|
||||
router.push({
|
||||
pathname: "/story-editor",
|
||||
params: {
|
||||
kind: params.kind,
|
||||
uri: params.uri ?? "",
|
||||
width: String(params.width ?? 0),
|
||||
height: String(params.height ?? 0),
|
||||
mimeType: params.mimeType ?? "",
|
||||
},
|
||||
});
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const pickFromGallery = useCallback(async () => {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
@@ -75,16 +56,18 @@ export default function NewStoryScreen() {
|
||||
|
||||
// expo-image-picker meldet `duration` (ms) nur bei Videos — verlässlicher als ein MIME-Typ.
|
||||
const picked = result.assets[0];
|
||||
if (picked.duration != null) {
|
||||
if (picked.duration / 1000 > MAX_STORY_VIDEO_DURATION_SECS) {
|
||||
Alert.alert(t("story.videoTooLong", { max: MAX_STORY_VIDEO_DURATION_SECS }));
|
||||
return;
|
||||
}
|
||||
setMedia({ kind: "video", uri: picked.uri, width: picked.width, height: picked.height, mimeType: picked.mimeType ?? undefined });
|
||||
if (picked.duration != null && picked.duration / 1000 > MAX_STORY_VIDEO_DURATION_SECS) {
|
||||
Alert.alert(t("story.videoTooLong", { max: MAX_STORY_VIDEO_DURATION_SECS }));
|
||||
return;
|
||||
}
|
||||
setMedia({ kind: "image", uri: picked.uri, width: picked.width, height: picked.height });
|
||||
}, [t]);
|
||||
openEditor({
|
||||
kind: picked.duration != null ? "video" : "image",
|
||||
uri: picked.uri,
|
||||
width: picked.width,
|
||||
height: picked.height,
|
||||
mimeType: picked.mimeType,
|
||||
});
|
||||
}, [t, openEditor]);
|
||||
|
||||
const takePhoto = useCallback(async () => {
|
||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||
@@ -95,100 +78,9 @@ export default function NewStoryScreen() {
|
||||
const result = await ImagePicker.launchCameraAsync({ quality: 0.8 });
|
||||
if (!result.canceled) {
|
||||
const photo = result.assets[0];
|
||||
setMedia({ kind: "image", uri: photo.uri, width: photo.width, height: photo.height });
|
||||
openEditor({ kind: "image", uri: photo.uri, width: photo.width, height: photo.height });
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async ({ overlays, background }: EditorResult) => {
|
||||
if (!media || !activePetId) return;
|
||||
setError(null);
|
||||
const editorFields = overlays.length > 0 ? { overlays } : {};
|
||||
|
||||
try {
|
||||
if (media.kind === "text") {
|
||||
await createTextMutation.mutateAsync({ petId: activePetId, background: background ?? "sunset", overlays });
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (media.kind === "video") {
|
||||
const { uploadUrl } = await createVideoUploadMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
aiDisclosure: "NONE",
|
||||
...editorFields,
|
||||
...(media.width > 0 && media.height > 0 ? { mediaWidth: media.width, mediaHeight: media.height } : {}),
|
||||
});
|
||||
setIsUploading(true);
|
||||
setUploadPercent(0);
|
||||
await uploadFileToUrl(media.uri, uploadUrl ?? "", media.mimeType ?? "video/mp4", (fraction) => {
|
||||
const pct = Math.round(fraction * 100);
|
||||
setUploadPercent((prev) => (prev === pct ? prev : pct));
|
||||
});
|
||||
// Story existiert schon (PROCESSING) — story-viewer.tsx fragt den Status ab.
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
const resized = await resizeForUpload(media.uri, media.width, media.height);
|
||||
const size = await imageSize(resized.uri).catch(() => ({ width: media.width, height: media.height }));
|
||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
contentType: resized.mimeType,
|
||||
fileSize: resized.fileSize,
|
||||
});
|
||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||
await createMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
storageKey: key,
|
||||
blurhash: null,
|
||||
...editorFields,
|
||||
mediaWidth: size.width,
|
||||
mediaHeight: size.height,
|
||||
});
|
||||
await finish();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error && err.message ? err.message : t("onboarding.uploadError"));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadPercent(null);
|
||||
}
|
||||
},
|
||||
[media, activePetId, createTextMutation, createVideoUploadMutation, getPresignedUrlMutation, createMutation, finish, t]
|
||||
);
|
||||
|
||||
const isSubmitting =
|
||||
isUploading ||
|
||||
getPresignedUrlMutation.isPending ||
|
||||
createMutation.isPending ||
|
||||
createTextMutation.isPending ||
|
||||
createVideoUploadMutation.isPending;
|
||||
|
||||
if (media && activePetId) {
|
||||
return (
|
||||
<StoryEditor
|
||||
media={media}
|
||||
petId={activePetId}
|
||||
toolsEnabled={editorEnabled}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={isSubmitting ? t("milestones.sharing") : t("story.shareButton")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
if (!isSubmitting) {
|
||||
setMedia(null);
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
progress={
|
||||
<>
|
||||
{error ? <Text style={styles.errorOnDark}>{error}</Text> : null}
|
||||
{isSubmitting && media.kind === "video" ? <UploadProgress percent={uploadPercent} styles={styles} /> : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [t, openEditor]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -203,7 +95,7 @@ export default function NewStoryScreen() {
|
||||
</Pressable>
|
||||
</View>
|
||||
{editorEnabled && (
|
||||
<Pressable style={styles.textStoryButton} onPress={() => setMedia({ kind: "text" })}>
|
||||
<Pressable style={styles.textStoryButton} onPress={() => openEditor({ kind: "text" })}>
|
||||
<Type color={colors.pureWhite} size={22} />
|
||||
<Text style={styles.textStoryText}>{t("storyEditor.textStory")}</Text>
|
||||
</Pressable>
|
||||
@@ -212,30 +104,6 @@ export default function NewStoryScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fortschritt beim Video-Upload zu Mux. `percent === null` = Upload wird noch
|
||||
* vorbereitet (createVideoUpload läuft), dann echter Fortschritt aus
|
||||
* expo-file-system `onProgress`.
|
||||
*/
|
||||
function UploadProgress({ percent, styles }: { percent: number | null; styles: ReturnType<typeof makeStyles> }) {
|
||||
const { t } = useTranslation();
|
||||
const label = percent === null ? t("story.preparingUpload") : t("story.uploadingVideo", { percent });
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.progressWrap}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityValue={{ min: 0, max: 100, now: percent ?? 0 }}
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
<View style={styles.progressTrack}>
|
||||
<View style={[styles.progressFill, { width: `${percent ?? 0}%` }]} />
|
||||
</View>
|
||||
<Text style={styles.progressLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens, bottomInset: number) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
@@ -269,10 +137,5 @@ function makeStyles(colors: ColorTokens, bottomInset: number) {
|
||||
backgroundColor: colors.marigoldOrange,
|
||||
},
|
||||
textStoryText: { ...typography.body, color: colors.pureWhite, fontFamily: typography.title.fontFamily },
|
||||
errorOnDark: { ...typography.body, color: "#f87171", textAlign: "center" },
|
||||
progressWrap: { gap: 6 },
|
||||
progressTrack: { height: 6, borderRadius: 3, backgroundColor: "rgba(255,255,255,0.2)", overflow: "hidden" },
|
||||
progressFill: { height: "100%", borderRadius: 3, backgroundColor: colors.marigoldOrange },
|
||||
progressLabel: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", color: "#ffffff" },
|
||||
});
|
||||
}
|
||||
@@ -34,10 +34,12 @@ type Sheet = "comments" | "viewers" | "confirm-delete" | "away" | null;
|
||||
* die Lösch-Rückfrage offen ist, hält der Timer an.
|
||||
*/
|
||||
export default function StoryViewerScreen() {
|
||||
const { petId, petName, petAvatarKey } = useLocalSearchParams<{
|
||||
const { petId, petName: petNameParam, petAvatarKey: petAvatarKeyParam, storyId: initialStoryId } = useLocalSearchParams<{
|
||||
petId: string;
|
||||
petName: string;
|
||||
petAvatarKey?: string;
|
||||
/** Aus einer Mitteilung: direkt diese Story zeigen statt der ersten. */
|
||||
storyId?: string;
|
||||
}>();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -46,10 +48,15 @@ export default function StoryViewerScreen() {
|
||||
const { activePetId } = useActivePet();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isOwnStory = activePetId === petId;
|
||||
// Über eine Push-Mitteilung geöffnet → nur petId bekannt, Name/Bild nachladen.
|
||||
const { data: storyPet } = useQuery({ ...trpc.pets.getProfile.queryOptions({ petId }), enabled: !petNameParam });
|
||||
const petName = petNameParam || storyPet?.name || "";
|
||||
const petAvatarKey = petAvatarKeyParam || storyPet?.avatarKey || undefined;
|
||||
|
||||
const listQuery = trpc.stories.listActive.queryOptions({ petId, viewerPetId: activePetId ?? undefined });
|
||||
const { data: stories = [], isSuccess: storiesLoaded } = useQuery(listQuery);
|
||||
const [index, setIndex] = useState(0);
|
||||
const jumpedToInitialRef = useRef(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [openSheet, setOpenSheet] = useState<Sheet>(null);
|
||||
const advancedRef = useRef(false);
|
||||
@@ -190,6 +197,15 @@ export default function StoryViewerScreen() {
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [currentStory, isVideo, isPaused, advance]);
|
||||
|
||||
// Aus einer Mitteilung geöffnet → einmalig zur genannten Story springen.
|
||||
useEffect(() => {
|
||||
if (jumpedToInitialRef.current || !initialStoryId || stories.length === 0) return;
|
||||
jumpedToInitialRef.current = true;
|
||||
const target = stories.findIndex((s) => s.id === initialStoryId);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmaliger Sprung, sobald die Liste da ist
|
||||
if (target > 0) setIndex(target);
|
||||
}, [initialStoryId, stories]);
|
||||
|
||||
// Keine (laufende) Story mehr, z. B. über eine alte Mitteilung geöffnet → zurück statt schwarzem Bildschirm.
|
||||
useEffect(() => {
|
||||
if (storiesLoaded && stories.length === 0) router.back();
|
||||
|
||||
+14
-2
@@ -179,13 +179,15 @@ Neu 7:
|
||||
→ **Behoben (04.10.):** Der Screen hatte keinen Tastatur-Ausgleich (nur `FlatList`, kein `KeyboardAvoidingView`). Neu: `automaticallyAdjustKeyboardInsets` (iOS: unterer Inset in Tastaturhöhe **und** automatisches Scrollen zum aktiven Feld, im RN-Quellcode `RCTScrollViewComponentView.mm` geprüft) plus `keyboardShouldPersistTaps="handled"`. Gleiche Lücke auch in Gesundheit (Gewicht/Impfung/Tierarzt), Neuer Beitrag, Neuer Meilenstein, Tier anlegen/Onboarding und Einladen gefunden und mitbehoben. Android unverändert (Prop gilt nur für iOS, dort nicht gemeldet). Typecheck sauber. Commit `333ba16`, OTA nur iOS Runtime 1.0.6 `718ede5a-91c4-4e4e-b444-174ab3af66a2`. Nicht auf einem Gerät getestet.
|
||||
- [x] Feedback iOS - Rassen suche ist als Dropdown, ohne Suchfunktion. Bei Hunden und Katzen ist die Liste lang, die Suche würde verkürzt werden, wenn man eintippen könnte um das Dropdown zu kürzen.
|
||||
→ **Umgesetzt (04.10.):** `OptionPicker` zeigt ab 10 Einträgen oben ein Suchfeld („Suchen …“), das beim Tippen filtert — ohne Groß-/Kleinschreibung und Akzente („frise“ findet „Bichon Frisé“, `src/lib/filter-options.ts` + Tests). Öffnet sich die Tastatur, rückt die Liste nach oben, damit sie nicht verdeckt wird. Wirkt in Tier anlegen, Profil bearbeiten und Explore-Filter; die kurze Tierarten-Liste bleibt ohne Suchfeld. Typecheck sauber. Commit `01459c7`, OTA Runtime 1.0.6: Android `02f677cc-406b-4475-9601-e61b8a4b963a`, iOS `708be8d6-9786-44ad-a3c9-c305a3f93dd2`. Nicht auf einem Gerät getestet — bitte besonders das Hochrücken über der Tastatur prüfen.
|
||||
- Feature: Storie-Edit Modus, mittels Overlays eigene Storys hübsch machen, durch Texteingaben in das Bild oder Effekte oder Gifs. (Analog zu Instagram) ->Branch
|
||||
- [x] Feature: Storie-Edit Modus, mittels Overlays eigene Storys hübsch machen, durch Texteingaben in das Bild oder Effekte oder Gifs. (Analog zu Instagram) ->Branch
|
||||
→ **Umgesetzt und live (04.10.)** als Story-Editor (Branch `feature/story-editor`, nach `main` gemergt): In der App nach Foto/Video oder als **Text-Story auf Farbverlauf**: Text (3 Stile, 8 Farben, 2 Schriften), **@-Markierungen** anderer Tiere, **GIFs** (GIPHY, nur jugendfrei), **8 Sticker** — verschieben, mit zwei Fingern zoomen/drehen, zum Löschen auf den Papierkorb ziehen. Markieren richtet sich nach „Wer darf die Follower sehen?“ (Alle / nur Follower / niemand), markierte Tiere und Story-Besitzer bekommen Mitteilungen (Markierung, Kommentar, Pfote). Web zeigt alles an, erstellt aber nicht. Overlays sind Daten über dem Bild (nicht eingerechnet), daher alles per OTA, kein Store-Build. **Notschalter** im Admin-Dashboard („Story-Editor“), blendet Editor und Overlays ohne Update aus. 20 neue Tests, alle 376 grün, Produktions-Build ok, gerenderte Vorschau geprüft. db push + RLS, Rolling-Deploy, OTA Runtime 1.0.6: Android `8a1a12cb-d61a-4d14-91ab-391396781d87`, iOS `fe114163-abea-4ccc-ad19-cd1f8ccc7532`. **Nicht am Gerät getestet** — besonders Zwei-Finger-Gesten und Tastatur im Texteditor prüfen.
|
||||
|
||||
- [x] Admin-Dashboard -> Zurückgewiesene Reports, als Meldung an den Melder mit einer Begründung warum abgewiesen wurde. Sowie Meldung bei bestätigter Report löschung, z.b mit Danksagung für die Meldung.
|
||||
→ **Umgesetzt und live (04.10.), als Team-Nachricht.** Admin → Reports: „Report ignorieren“ hat jetzt ein Feld „Begründung für den Melder“; der Melder bekommt vom PawFeed-Team „Danke für deine Meldung zum Beitrag/Profil von X … kein Verstoß … Begründung: …“ (Begründung steht zusätzlich im Moderations-Log). „Post löschen“ dankt jedem Melder des Beitrags einmal („… Inhalt entfernt. Danke, dass du hilfst …“); die interne Lösch-Begründung geht bewusst nicht an den Melder. Beides per Schalter im Dialog abschaltbar, Versand erst nach erfolgreicher Moderation und ohne sie bei einem Fehler zu kippen. **Nebenbei behoben:** Beim Löschen eines gemeldeten Beitrags blieben dessen Reports verwaist in der Liste (`targetPostId` ist keine Relation) — werden jetzt mitgelöscht. 7 neue Tests (`admin-report-feedback.test.ts`), alle 336 Tests grün. Commit `f51b158`, Rolling-Deploy auf dem NAS. Kein App-Update nötig. Im Live-Admin noch nicht mit einem echten Report ausprobiert.
|
||||
|
||||
|
||||
- Feedback - iOS & Andriod, Story-Bilder und Videos mit Overlays bearbeiten direkt in der App. Text hinzufügen, Gifs einbinden, Markierungen auf andere Pets setzten (Analog zu Instagram) bitte als neues Branch anlegen.
|
||||
- [x] Feedback - iOS & Andriod, Story-Bilder und Videos mit Overlays bearbeiten direkt in der App. Text hinzufügen, Gifs einbinden, Markierungen auf andere Pets setzten (Analog zu Instagram) bitte als neues Branch anlegen.
|
||||
→ **Umgesetzt und live (04.10.)** als Story-Editor (Branch `feature/story-editor`, nach `main` gemergt): In der App nach Foto/Video oder als **Text-Story auf Farbverlauf**: Text (3 Stile, 8 Farben, 2 Schriften), **@-Markierungen** anderer Tiere, **GIFs** (GIPHY, nur jugendfrei), **8 Sticker** — verschieben, mit zwei Fingern zoomen/drehen, zum Löschen auf den Papierkorb ziehen. Markieren richtet sich nach „Wer darf die Follower sehen?“ (Alle / nur Follower / niemand), markierte Tiere und Story-Besitzer bekommen Mitteilungen (Markierung, Kommentar, Pfote). Web zeigt alles an, erstellt aber nicht. Overlays sind Daten über dem Bild (nicht eingerechnet), daher alles per OTA, kein Store-Build. **Notschalter** im Admin-Dashboard („Story-Editor“), blendet Editor und Overlays ohne Update aus. 20 neue Tests, alle 376 grün, Produktions-Build ok, gerenderte Vorschau geprüft. db push + RLS, Rolling-Deploy, OTA Runtime 1.0.6: Android `8a1a12cb-d61a-4d14-91ab-391396781d87`, iOS `fe114163-abea-4ccc-ad19-cd1f8ccc7532`. **Nicht am Gerät getestet** — besonders Zwei-Finger-Gesten und Tastatur im Texteditor prüfen.
|
||||
|
||||
- [x] Admin-Dashboard - Tiere zeigt aktuell neben dem Namen nur einen Rang an, zur besseren übersicht sollten alle erlangten Ränge angezeigt werden in der Liste.
|
||||
→ **Umgesetzt und live (04.10.):** Die Liste zeigte nur den höchsten Rang (Cache `referralRankId`) und nur mit Vorteil NAME_BADGE. Jetzt lädt `petAdmin.list` alle Rang-Einträge des Besitzers (nach Schwelle sortiert) und zeigt jedes Rangsymbol (`AchievedRankBadges`), unabhängig von NAME_BADGE. Entzogene Ränge erscheinen ausgegraut, Tooltip mit „manuell vergeben“ / „entzogen“. Commit `dbc416c`, Rolling-Deploy.
|
||||
@@ -196,4 +198,14 @@ Neu 7:
|
||||
- [x] Testuser hat Goldene Pfote als Rang erhalten, im Rang habe ich Profil_RING mit eingesetzt damit sich der Profil-Ring mit färbt. aber das passiert nicht.
|
||||
→ **Ursache:** Kein Datenfehler — der Testnutzer („Krypton“) hatte PROFILE_RING korrekt im Cache. Der Ring war bewusst **nur im Tierprofil oben** zu sehen, nicht im Feed oder an Storys. **Auf Wunsch erweitert und live (04.10.):** (1) Ring in Rangfarbe jetzt **überall am Tierbild** — Feed, Kommentare, Suche, Explore, Nachrichten, Mitteilungen, Follower-Listen, Meilenstein-/Repost-Karten, Story-Kommentare/-Zuschauer (Web + App). Dafür liefert die gemeinsame Tier-Abfrage (`petIdentitySelect`) die Rangdaten jetzt mit. Der Ring liegt außen um das Bild, Listen verrutschen nicht. (2) **Story-Ring:** Bei ungesehener Story leuchtet die Pfote in der Rangfarbe statt Orange; ohne Story gibt es einen schlichten Ring in Rangfarbe; gesehene Storys bleiben grau. 6 neue Tests, alle 353 Tests grün. Commit `c7c1473`, Rolling-Deploy + OTA (wie oben). Nicht auf einem Gerät getestet.
|
||||
|
||||
- [x] Prüfung ob es möglich wäre, wenn ein Haushalt mit 2 Accounts, aber dem gleichen Tier. sich beide um das gleich tier kümmern können??? reiner research auf machbarkeit. pro / kontra liste erstellen
|
||||
→ **Research erledigt (04.10.)** — ausführlich in Obsidian: `PawFeed/Punkte/Gemeinsame Tierhalter – Machbarkeit`. Kurz: Heute hat ein Tier genau einen Account (`Pet.ownerId`). Möglich wären (A) gemeinsamer Login — geht schon, aber Push nur aufs zuletzt angemeldete Handy; (B) echte Mitbetreuer mit eigenen Accounts — passt gut zum tierzentrierten Konzept, ist aber ein großer Umbau (zentrale Rechteprüfung + ~22 Dateien, Einladungsablauf, Regeln für Empfehlungsrang, Gesundheitsdaten/DSGVO, Konto-Löschung), grob 4–6 Tage, ohne Store-Build; (C) Tier übertragen — klein, löst die Frage aber nicht. Empfehlung: erst „Push an mehrere Geräte“ bauen (½–1 Tag, hilft sofort), B nur bei häufigerem Wunsch und nach Klärung der offenen Regeln.
|
||||
|
||||
- [x] Tester-Feedback 04.10. zum Story-Editor: **Android** — Farbwahl im Texteditor liegt im Gesten-/Schutzbereich. **iOS** — „Text-Story“ lässt sich nicht öffnen (stattdessen „klickt es auf das X“), Foto aus der Galerie: alles verschwindet, das X leuchtet kurz auf.
|
||||
→ **Behoben (04.10.):** Ursache iOS: Der Editor lief im Auswahl-Screen „Neue Story“, der auf iOS ein Sheet ist — beim Öffnen wurde dessen Kopfzeile ausgeblendet (das aufleuchtende X) und zusätzlich ein natives Fenster (Texteditor) eingeblendet, was iOS während einer laufenden Präsentation verweigert. Jetzt eigener Vollbild-Screen `story-editor` (ohne Kopfzeile, ohne Wisch-zum-Schließen), Texteditor und Auswahlfenster sind Ebenen statt nativer Fenster (`EditorLayer`, Android-Zurück schließt sie). Android: echte Safe-Area-Abstände oben/unten. Neu: Tippen auf eine freie Stelle öffnet den Texteditor. **Am Android-Emulator durchgeklickt** (Text-Story, Texteditor, Verschieben, GIF-Suche live, Sticker, X zurück) — iOS hier nicht testbar, bitte am iPhone prüfen. Commit `6d31367`, OTA Runtime 1.0.6: Android `da1a187e-5032-4e4e-8401-43ba167e9fd5`, iOS `c90c270f-8c89-42fa-ae11-5202163f3f80`.
|
||||
|
||||
- [x] Tester-Feedback 04.10.: Mitteilung „STORY_MENTION“ — anklickbar, aber man landet auf dem Profil statt auf der Story; der Text ist nicht korrekt.
|
||||
→ **Behoben (04.10.):** Web öffnet die Story jetzt direkt als Overlay an der markierten Story (vorher bewusst Profil — unnötiger Umweg), die App öffnet sie über den ganzen Eintrag inkl. Namen und springt im Viewer direkt zur richtigen Story. Der rohe Text „STORY_MENTION“ kam von einem Gerät mit älterem App-Stand, der den neuen Typ noch nicht kannte — künftig zeigen App und Web für unbekannte Typen einen neutralen Text statt des Code-Namens. Zusätzlich: keine Markier-Mitteilung mehr an eigene Tiere. Commit `8aca1bc`, Rolling-Deploy, OTA Runtime 1.0.6: Android `da0b5a30-6a00-47f7-8c2f-91274f2629ec`, iOS `18681e15-dadf-46f4-b3cf-b44aab78fe42`. Bitte die App zweimal komplett neu starten.
|
||||
|
||||
- [x] Wunsch 04.10.: Eine Nachrichten-Mitteilung soll beim Antippen direkt zur Nachricht führen (wie bei Kommentaren) — für alle Funktionen: „Benachrichtigung bringt dich auf kürzestem Weg dorthin, wo es wichtig ist.“
|
||||
→ **Umgesetzt (04.10.):** Gemeinsame Zielregel für Mitteilungsliste und Push (`mobile/src/lib/notification-route.ts`, mit Tests): **Nachricht → Gespräch**, Kommentar/Pfote/Erwähnung/Paw-Back → Beitrag, Story-Mitteilungen → die Story, Follow/Vorschlag/Einladung → Profil, Rang → Einladen, Geburtstag/Adoptionstag/Impfung → Gesundheit. Neu: **Antippen einer Push-Mitteilung** führt direkt dorthin (vorher öffnete sich nur die App), auch beim Kaltstart; gilt sie für ein anderes eigenes Tier, wird vorher umgeschaltet. Bei Nachrichten und Storys führt auch der Name zum Ziel. Web: Nachrichten öffnen das Gespräch. Server liefert dafür das Gespräch zur Mitteilung und erweiterte Push-Daten. 386 Tests grün. Commit `4cf2821`, Rolling-Deploy, OTA Runtime 1.0.6: Android `7ea956bf-b519-4a57-a9f0-4008aa212899`, iOS `b8f7c983-fc18-43d7-aea3-10fa4315d4c6`. Nicht am Gerät getestet; Push-Ziele greifen für Mitteilungen ab jetzt (ältere Pushes haben die neuen Daten noch nicht).
|
||||
@@ -1,8 +1,9 @@
|
||||
import { View, StyleSheet } from "react-native";
|
||||
import { BadgeCheck, Sparkles, Sprout } from "lucide-react-native";
|
||||
import { BadgeCheck, Ribbon, Siren, 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,10 @@ type Props = {
|
||||
isVerified?: boolean;
|
||||
/** Eines der ersten 250 Tiere (Pet.isFoundingMember). */
|
||||
isFoundingMember?: boolean;
|
||||
/** Gedenkmodus „In Erinnerung“ (Pet.deceasedAt gesetzt). */
|
||||
isDeceased?: boolean;
|
||||
/** Aktive Vermisst-Meldung (Pet.lostSince gesetzt). */
|
||||
isLost?: boolean;
|
||||
sponsor?: { sponsorSince?: Date | string | null; sponsorPeriodEnd?: Date | string | null } | null;
|
||||
/** Owner-Felder referralRankId/referralPerks — Rang-Abzeichen des Empfehlungsprogramms. */
|
||||
referral?: ReferralOwnerFields | null;
|
||||
@@ -18,14 +23,17 @@ 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, isLost, 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 && !isLost && !isVerified && !isFoundingMember && !showSponsor && !rank) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{isLost && <Siren size={14} color="#dc2626" accessibilityLabel={t("lostReport.badge")} />}
|
||||
{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,8 @@ export type PostCardPost = {
|
||||
nickname?: string | null;
|
||||
avatarKey?: string | null;
|
||||
isVerified?: boolean;
|
||||
deceasedAt?: Date | string | null;
|
||||
lostSince?: 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 +154,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 isLost={!!post.pet.lostSince} 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,292 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, Pressable, Switch, Share, Linking, Alert, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Siren } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { TextField } from "../TextField";
|
||||
import { DateField } from "../DateField";
|
||||
import { Button } from "../Button";
|
||||
import { env } from "../../lib/env";
|
||||
import { displayDateToIso, isoToDisplayDate, todayAsDisplayDate } from "../../lib/date-format";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
|
||||
type Props = { petId: string; petName: string; isDeceased: boolean };
|
||||
|
||||
type Form = {
|
||||
date: string; // DD.MM.YYYY
|
||||
time: string; // HH:MM
|
||||
lastSeenPlace: string;
|
||||
description: string;
|
||||
contactPhone: string;
|
||||
contactEmail: string;
|
||||
allowIndexing: boolean;
|
||||
};
|
||||
|
||||
const TIME_PATTERN = /^([01]?\d|2[0-3]):[0-5]\d$/;
|
||||
const RED = "#dc2626";
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function formFromDate(date: Date): Pick<Form, "date" | "time"> {
|
||||
const iso = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
return { date: isoToDisplayDate(iso), time: `${pad(date.getHours())}:${pad(date.getMinutes())}` };
|
||||
}
|
||||
|
||||
/** Datum + Uhrzeit (Ortszeit) → ISO-Zeitpunkt, oder null bei ungültiger Eingabe. */
|
||||
function toIsoTimestamp(form: Form): string | null {
|
||||
const iso = displayDateToIso(form.date);
|
||||
if (!iso || !TIME_PATTERN.test(form.time)) return null;
|
||||
const [h, m] = form.time.split(":");
|
||||
const date = new Date(`${iso}T${pad(Number(h))}:${m}:00`);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vermisst-Meldung in „Profil bearbeiten“ — Port von web
|
||||
* src/components/lost/LostReportSettings.tsx. Melden (Push an Follower und
|
||||
* gefolgte Tiere), Angaben ergänzen, Link teilen, „Wieder da!“.
|
||||
*/
|
||||
export function LostReportSettings({ petId, petName, isDeceased }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const { data: report, isPending } = useQuery(trpc.lostReports.mine.queryOptions({ petId }));
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [notifyFound, setNotifyFound] = useState(true);
|
||||
const [form, setForm] = useState<Form | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const done = (message: string) => {
|
||||
setForm(null);
|
||||
setError(null);
|
||||
void queryClient.invalidateQueries();
|
||||
Alert.alert(message);
|
||||
};
|
||||
const onError = (err: { message: string }) => Alert.alert(err.message || t("lostReport.saveFailed"));
|
||||
const create = useMutation(
|
||||
trpc.lostReports.create.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setIsOpen(false);
|
||||
done(t("lostReport.created", { name: petName }));
|
||||
},
|
||||
onError,
|
||||
})
|
||||
);
|
||||
const update = useMutation(trpc.lostReports.update.mutationOptions({ onSuccess: () => done(t("lostReport.updated")), onError }));
|
||||
const markFound = useMutation(
|
||||
trpc.lostReports.markFound.mutationOptions({ onSuccess: () => done(t("lostReport.foundToast", { name: petName })), onError })
|
||||
);
|
||||
|
||||
if (isDeceased || isPending) return null;
|
||||
|
||||
const current: Form =
|
||||
form ??
|
||||
(report
|
||||
? {
|
||||
...formFromDate(new Date(report.lastSeenAt)),
|
||||
lastSeenPlace: report.lastSeenPlace,
|
||||
description: report.description ?? "",
|
||||
contactPhone: report.contactPhone ?? "",
|
||||
contactEmail: report.contactEmail ?? "",
|
||||
allowIndexing: report.allowIndexing,
|
||||
}
|
||||
: {
|
||||
date: todayAsDisplayDate(),
|
||||
time: "",
|
||||
lastSeenPlace: "",
|
||||
description: "",
|
||||
contactPhone: "",
|
||||
contactEmail: "",
|
||||
allowIndexing: false,
|
||||
});
|
||||
const set = (patch: Partial<Form>) => setForm({ ...current, ...patch });
|
||||
const publicUrl = report ? `${env.apiUrl}/vermisst/${report.code}` : "";
|
||||
|
||||
function payload() {
|
||||
const lastSeenAt = toIsoTimestamp(current);
|
||||
if (!lastSeenAt || !current.lastSeenPlace.trim()) {
|
||||
setError(t("lostReport.invalidForm"));
|
||||
return null;
|
||||
}
|
||||
setError(null);
|
||||
return {
|
||||
petId,
|
||||
lastSeenAt,
|
||||
lastSeenPlace: current.lastSeenPlace,
|
||||
description: current.description,
|
||||
contactPhone: current.contactPhone,
|
||||
contactEmail: current.contactEmail,
|
||||
allowIndexing: current.allowIndexing,
|
||||
};
|
||||
}
|
||||
|
||||
if (!report && !isOpen) {
|
||||
return (
|
||||
<View style={styles.teaser}>
|
||||
<Text style={styles.teaserText}>{t("lostReport.teaser", { name: petName })}</Text>
|
||||
<Pressable style={styles.teaserButton} onPress={() => setIsOpen(true)} accessibilityRole="button">
|
||||
<Siren size={16} color={RED} />
|
||||
<Text style={styles.teaserButtonText}>{t("lostReport.open")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.headingRow}>
|
||||
<Siren size={18} color={RED} />
|
||||
<Text style={styles.heading}>
|
||||
{report ? t("lostReport.activeHeading", { name: petName }) : t("lostReport.formHeading", { name: petName })}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.hint}>{report ? t("lostReport.activeHint") : t("lostReport.formHint", { name: petName })}</Text>
|
||||
|
||||
{report && (
|
||||
<>
|
||||
<Text style={styles.link} onPress={() => void Linking.openURL(publicUrl)}>
|
||||
{publicUrl}
|
||||
</Text>
|
||||
<Button
|
||||
title={t("lostReport.share")}
|
||||
variant="outline"
|
||||
onPress={() => void Share.share({ message: `${t("lostReport.shareTitle", { name: petName })} ${publicUrl}` })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={styles.label}>{t("lostReport.lastSeenAt")}</Text>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.flex}>
|
||||
<DateField placeholder={t("health.weightDatePlaceholder")} value={current.date} onChangeText={(date) => set({ date })} />
|
||||
</View>
|
||||
<TextField
|
||||
style={styles.time}
|
||||
placeholder={t("lostReport.timePlaceholder")}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
maxLength={5}
|
||||
value={current.time}
|
||||
onChangeText={(time) => set({ time })}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.label}>{t("lostReport.lastSeenPlace")}</Text>
|
||||
<TextField
|
||||
maxLength={120}
|
||||
placeholder={t("lostReport.lastSeenPlacePlaceholder")}
|
||||
value={current.lastSeenPlace}
|
||||
onChangeText={(lastSeenPlace) => set({ lastSeenPlace })}
|
||||
/>
|
||||
<Text style={styles.label}>{t("lostReport.description")}</Text>
|
||||
<TextField
|
||||
multiline
|
||||
maxLength={1000}
|
||||
style={styles.multiline}
|
||||
placeholder={t("lostReport.descriptionPlaceholder")}
|
||||
value={current.description}
|
||||
onChangeText={(description) => set({ description })}
|
||||
/>
|
||||
<Text style={styles.hint}>{t("lostReport.contactHint")}</Text>
|
||||
<Text style={styles.label}>{t("lostReport.contactPhone")}</Text>
|
||||
<TextField keyboardType="phone-pad" maxLength={40} value={current.contactPhone} onChangeText={(contactPhone) => set({ contactPhone })} />
|
||||
<Text style={styles.label}>{t("lostReport.contactEmail")}</Text>
|
||||
<TextField
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
maxLength={120}
|
||||
value={current.contactEmail}
|
||||
onChangeText={(contactEmail) => set({ contactEmail })}
|
||||
/>
|
||||
<View style={styles.switchRow}>
|
||||
<View style={styles.flex}>
|
||||
<Text style={styles.switchLabel}>{t("lostReport.allowIndexing")}</Text>
|
||||
<Text style={styles.hint}>{t("lostReport.allowIndexingHint")}</Text>
|
||||
</View>
|
||||
<Switch value={current.allowIndexing} onValueChange={(allowIndexing) => set({ allowIndexing })} />
|
||||
</View>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
{report ? (
|
||||
<>
|
||||
<Button
|
||||
title={t("lostReport.saveChanges")}
|
||||
variant="outline"
|
||||
disabled={update.isPending || !form}
|
||||
onPress={() => {
|
||||
const p = payload();
|
||||
if (p) update.mutate(p);
|
||||
}}
|
||||
/>
|
||||
<View style={styles.divider} />
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={[styles.switchLabel, styles.flex]}>{t("lostReport.notifyFound", { name: petName })}</Text>
|
||||
<Switch value={notifyFound} onValueChange={setNotifyFound} />
|
||||
</View>
|
||||
<Button
|
||||
title={t("lostReport.markFound", { name: petName })}
|
||||
disabled={markFound.isPending}
|
||||
onPress={() => markFound.mutate({ petId, notifyAudience: notifyFound })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
title={t("lostReport.submit")}
|
||||
disabled={create.isPending}
|
||||
onPress={() => {
|
||||
const p = payload();
|
||||
if (p) create.mutate(p);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
title={t("lostReport.cancel")}
|
||||
variant="outline"
|
||||
onPress={() => {
|
||||
setIsOpen(false);
|
||||
setForm(null);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
teaser: { gap: 8, padding: 12, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.hairlineGray, marginBottom: 8 },
|
||||
teaserText: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
teaserButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
height: 44,
|
||||
borderRadius: radii.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.alertRedTint,
|
||||
},
|
||||
teaserButtonText: { ...typography.body, color: RED, fontFamily: typography.title.fontFamily },
|
||||
card: { gap: 8, padding: 12, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.alertRedTint, marginBottom: 8 },
|
||||
headingRow: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
heading: { ...typography.title, fontSize: 16, flexShrink: 1 },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
|
||||
link: { ...typography.body, textDecorationLine: "underline" },
|
||||
row: { flexDirection: "row", gap: 8 },
|
||||
flex: { flex: 1 },
|
||||
time: { width: 88 },
|
||||
multiline: { height: 88, textAlignVertical: "top", paddingTop: 12 },
|
||||
switchRow: { flexDirection: "row", alignItems: "center", gap: 12, marginTop: 4 },
|
||||
switchLabel: { ...typography.body },
|
||||
divider: { height: 1, backgroundColor: colors.hairlineGray, marginVertical: 8 },
|
||||
error: { ...typography.body, color: colors.alertRed },
|
||||
});
|
||||
}
|
||||
@@ -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", { name: petName }), [
|
||||
{ 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 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from "react";
|
||||
import { BackHandler, KeyboardAvoidingView, Platform, StyleSheet, type StyleProp, type ViewStyle } from "react-native";
|
||||
|
||||
/**
|
||||
* Ebene innerhalb des Story-Editors (statt eines nativen <Modal>): Der Editor
|
||||
* liegt selbst in einem modal präsentierten Screen. iOS verweigert ein weiteres
|
||||
* Modal, solange noch eine Präsentation läuft (z. B. direkt beim Öffnen einer
|
||||
* Text-Story) — der Texteditor erschien dann nicht und man traf nur das X.
|
||||
* Als normale Ebene gibt es diesen Konflikt nicht; Safe-Area-Abstände setzt der
|
||||
* Inhalt selbst. Android-Zurück-Taste schließt die Ebene.
|
||||
*/
|
||||
export function EditorLayer({
|
||||
visible,
|
||||
onRequestClose,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onRequestClose: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||
onRequestClose();
|
||||
return true;
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [visible, onRequestClose]);
|
||||
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={[StyleSheet.absoluteFill, styles.layer, style]}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
>
|
||||
{children}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
layer: { zIndex: 20, elevation: 20 },
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Modal, View, Text, Pressable, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
|
||||
import { View, Text, Pressable, StyleSheet } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { X } from "lucide-react-native";
|
||||
import { fontFamily } from "../../theme/tokens";
|
||||
import { EditorLayer } from "./EditorLayer";
|
||||
|
||||
/** Dunkles Bottom-Sheet für die Auswahlfenster des Story-Editors (Markieren, GIF, Sticker). */
|
||||
/** Dunkles Bottom-Sheet für die Auswahlfenster des Story-Editors (Markieren, GIF, Sticker) — als Ebene, kein natives Modal (siehe EditorLayer). */
|
||||
export function PickerSheet({ visible, title, onClose, children }: { visible: boolean; title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<KeyboardAvoidingView style={styles.backdrop} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||
<EditorLayer visible={visible} onRequestClose={onClose} style={styles.backdrop}>
|
||||
<Pressable style={styles.dismiss} onPress={onClose} accessibilityLabel={title} />
|
||||
<View style={[styles.sheet, { paddingBottom: insets.bottom + 12 }]}>
|
||||
<View style={styles.header}>
|
||||
@@ -19,13 +19,12 @@ export function PickerSheet({ visible, title, onClose, children }: { visible: bo
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
</EditorLayer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: { flex: 1, justifyContent: "flex-end" },
|
||||
backdrop: { justifyContent: "flex-end", backgroundColor: "rgba(0,0,0,0.35)" },
|
||||
dismiss: { flex: 1 },
|
||||
sheet: { backgroundColor: "#1c1c1c", borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingTop: 12, paddingHorizontal: 16, maxHeight: "75%", gap: 12 },
|
||||
header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||
|
||||
@@ -143,6 +143,16 @@ export function StoryEditor({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Tippen auf eine freie Stelle der Story öffnet den Texteditor (wie Instagram). */}
|
||||
{toolsEnabled && rect.width > 0 && (
|
||||
<Pressable
|
||||
style={{ position: "absolute", left: rect.x, top: rect.y, width: rect.width, height: rect.height }}
|
||||
onPress={() => setPicker("text")}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("storyEditor.addText")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{overlays.map((overlay, index) => (
|
||||
<EditableOverlay
|
||||
key={index}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal, View, Text, TextInput, Pressable, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
|
||||
import { View, Text, TextInput, Pressable, StyleSheet } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fontFamily } from "../../theme/tokens";
|
||||
import { MAX_TEXT_LENGTH, TEXT_COLORS, TEXT_STYLES, type TextColor, type TextFont, type TextStyle } from "../../lib/story-overlays";
|
||||
import { textAppearance } from "./OverlayContent";
|
||||
import { EditorLayer } from "./EditorLayer";
|
||||
|
||||
export type TextDraft = { text: string; color: TextColor; style: TextStyle; font: TextFont };
|
||||
|
||||
@@ -26,6 +28,7 @@ export function TextEditorModal({
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [draft, setDraft] = useState<TextDraft>(initial ?? DEFAULT_DRAFT);
|
||||
|
||||
// Beim Öffnen mit dem bearbeiteten Overlay bzw. leer starten.
|
||||
@@ -38,8 +41,7 @@ export function TextEditorModal({
|
||||
const nextStyle = TEXT_STYLES[(TEXT_STYLES.indexOf(draft.style) + 1) % TEXT_STYLES.length];
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel} statusBarTranslucent>
|
||||
<KeyboardAvoidingView style={styles.backdrop} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||
<EditorLayer visible={visible} onRequestClose={onCancel} style={[styles.backdrop, { paddingTop: insets.top + 8, paddingBottom: insets.bottom + 12 }]}>
|
||||
<View style={styles.topBar}>
|
||||
<Pressable onPress={onCancel} hitSlop={12} accessibilityRole="button">
|
||||
<Text style={styles.topButton}>{t("common.cancel")}</Text>
|
||||
@@ -98,13 +100,12 @@ export function TextEditorModal({
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
</EditorLayer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.7)", paddingTop: 48, paddingBottom: 16 },
|
||||
backdrop: { backgroundColor: "rgba(0,0,0,0.75)" },
|
||||
topBar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16 },
|
||||
topButton: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 16 },
|
||||
done: { color: "#f97316" },
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useActivePet } from "../context/ActivePetContext";
|
||||
import { notificationRoute, targetFromPushData } from "../lib/notification-route";
|
||||
|
||||
/**
|
||||
* Antippen einer Push-Mitteilung führt direkt zum Ziel (Gespräch, Beitrag,
|
||||
* Story, Profil, Gesundheit) — vorher öffnete ein Tipp nur die App.
|
||||
* useLastNotificationResponse deckt laufende App und Kaltstart ab.
|
||||
*
|
||||
* Gilt die Mitteilung für ein anderes eigenes Tier, wird vorher dorthin
|
||||
* gewechselt (Nachrichten und Gesundheit hängen am aktiven Tier).
|
||||
* Läuft im (app)-Layout, also erst nach Login und mit geladenen Tieren.
|
||||
*/
|
||||
export function useNotificationTapRouting(): void {
|
||||
const response = Notifications.useLastNotificationResponse();
|
||||
const router = useRouter();
|
||||
const { activePetId, setActivePetId, pets, isPending } = useActivePet();
|
||||
const handledId = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!response || isPending) return;
|
||||
if (response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return;
|
||||
const id = response.notification.request.identifier;
|
||||
if (handledId.current === id) return;
|
||||
handledId.current = id;
|
||||
|
||||
const target = targetFromPushData(response.notification.request.content.data as Record<string, unknown> | undefined);
|
||||
Notifications.clearLastNotificationResponse();
|
||||
if (!target) return;
|
||||
|
||||
if (target.recipientPetId && target.recipientPetId !== activePetId && pets.some((p) => p.id === target.recipientPetId)) {
|
||||
setActivePetId(target.recipientPetId);
|
||||
}
|
||||
const route = notificationRoute(target, Date.now());
|
||||
if (route) router.push(route);
|
||||
}, [response, isPending, activePetId, pets, setActivePetId, router]);
|
||||
}
|
||||
@@ -213,7 +213,11 @@
|
||||
"referralRankUp": "Du hast einen neuen Empfehlungsrang erreicht!",
|
||||
"storyMention": "hat dich in einer Story markiert.",
|
||||
"storyComment": "hat deine Story kommentiert.",
|
||||
"storyReaction": "hat deiner Story eine Pfote gegeben."
|
||||
"storyReaction": "hat deiner Story eine Pfote gegeben.",
|
||||
"unknown": "Neue Mitteilung.",
|
||||
"petDeceased": "ist über die Regenbogenbrücke gegangen. In liebevoller Erinnerung 🤍",
|
||||
"petLost": "wird vermisst. Vielleicht kannst du helfen – tippe für alle Infos.",
|
||||
"petFound": "ist wieder sicher zu Hause. Danke fürs Mithelfen! 🎉"
|
||||
},
|
||||
"pets": {
|
||||
"empty": "Noch keine Tiere angelegt.",
|
||||
@@ -333,7 +337,8 @@
|
||||
"newStory": "Neue Story",
|
||||
"newMilestone": "Neuer Meilenstein",
|
||||
"deleteAccount": "Konto löschen",
|
||||
"invite": "Freunde einladen"
|
||||
"invite": "Freunde einladen",
|
||||
"lost": "Vermisst"
|
||||
},
|
||||
"block": {
|
||||
"menuItem": "{{petName}} blockieren",
|
||||
@@ -484,5 +489,79 @@
|
||||
"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 liebevoller Erinnerung",
|
||||
"banner": "In liebevoller Erinnerung an {{name}} · {{date}}",
|
||||
"settingsHeading": "Gedenkmodus",
|
||||
"settingsHint": "Wenn dein Tier über die Regenbogenbrücke gegangen ist, kannst du sein Profil als liebevolle Erinnerung bewahren. Alle Beiträge bleiben erhalten, und du kannst weiterhin Erinnerungen teilen. Geburtstags- und Impf-Erinnerungen pausieren dann, damit dich nichts unvermittelt trifft.",
|
||||
"activeHint": "Das Profil von {{name}} wird als Erinnerung bewahrt. Du kannst das Datum jederzeit anpassen oder den Gedenkmodus wieder beenden.",
|
||||
"dateLabel": "Tag des Abschieds",
|
||||
"activate": "Profil als Erinnerung bewahren",
|
||||
"saveDate": "Datum speichern",
|
||||
"remove": "Gedenkmodus beenden",
|
||||
"confirmTitle": "Das Profil von {{name}} als Erinnerung bewahren?",
|
||||
"confirmDescription": "Es tut uns sehr leid. Das Profil von {{name}} bekommt eine Erinnerungsschleife, und alles, was ihr geteilt habt, bleibt erhalten. Wer {{name}} folgt, erfährt es einmal ganz in Ruhe über die Mitteilungen – ohne Push. Du kannst das jederzeit rückgängig machen.",
|
||||
"confirm": "Ja, bewahren",
|
||||
"cancel": "Noch nicht",
|
||||
"saved": "Das Profil wird nun als Erinnerung bewahrt.",
|
||||
"removed": "Gedenkmodus beendet.",
|
||||
"saveFailed": "Das hat leider nicht geklappt. Bitte versuch es noch einmal.",
|
||||
"invalidDate": "Bitte gib ein gültiges Datum ein, das nicht in der Zukunft liegt."
|
||||
},
|
||||
"lostReport": {
|
||||
"badge": "Wird vermisst",
|
||||
"teaser": "Ist {{name}} entlaufen? Hier kannst du sofort eine Vermisst-Meldung starten.",
|
||||
"open": "Vermisst melden",
|
||||
"formHeading": "{{name}} als vermisst melden",
|
||||
"formHint": "Wir drücken dir fest die Daumen. Alle, die {{name}} folgen oder denen {{name}} folgt, bekommen sofort eine Push-Mitteilung, und du erhältst eine Seite zum Teilen – auch für Menschen ohne PawFeed.",
|
||||
"activeHeading": "{{name}} wird vermisst",
|
||||
"activeHint": "Teile den Link, so oft du kannst – zum Beispiel in WhatsApp-Gruppen aus deiner Nachbarschaft. Du kannst die Angaben jederzeit ergänzen.",
|
||||
"lastSeenAt": "Zuletzt gesehen am",
|
||||
"lastSeenPlace": "Wo zuletzt gesehen?",
|
||||
"lastSeenPlacePlaceholder": "z. B. Stadtpark Nord, Nähe Spielplatz",
|
||||
"description": "Erkennungsmerkmale & Hinweise",
|
||||
"descriptionPlaceholder": "z. B. rotes Halsband, gechippt, eher scheu – bitte nicht rufen",
|
||||
"contactHint": "Kontaktdaten sind freiwillig und erscheinen nur auf der Teilen-Seite dieser Meldung. Eine Nachricht über PawFeed ist immer möglich.",
|
||||
"contactPhone": "Telefonnummer (optional)",
|
||||
"contactEmail": "E-Mail (optional)",
|
||||
"allowIndexing": "Seite darf über Suchmaschinen gefunden werden",
|
||||
"allowIndexingHint": "Mehr Reichweite, aber auch Fremde finden die Seite. Ohne Haken ist sie nur über den Link erreichbar.",
|
||||
"submit": "Vermisst-Meldung starten",
|
||||
"cancel": "Abbrechen",
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"notifyFound": "Allen Bescheid geben, dass {{name}} wieder da ist",
|
||||
"markFound": "{{name}} ist wieder da!",
|
||||
"share": "Link teilen",
|
||||
"shareTitle": "Vermisst: {{name}}",
|
||||
"copied": "Link kopiert",
|
||||
"created": "Die Meldung ist raus. Wir drücken die Daumen, dass {{name}} schnell nach Hause findet.",
|
||||
"updated": "Angaben gespeichert.",
|
||||
"foundToast": "Wie schön – willkommen zurück, {{name}}! 🎉",
|
||||
"saveFailed": "Das hat leider nicht geklappt. Bitte versuch es noch einmal.",
|
||||
"bannerTitle": "{{name}} wird vermisst",
|
||||
"bannerText": "Zuletzt gesehen: {{place}} · {{date}}",
|
||||
"bannerLink": "Alle Infos & Seite teilen",
|
||||
"timePlaceholder": "HH:MM",
|
||||
"invalidForm": "Bitte gib Datum, Uhrzeit (z. B. 14:30) und den Ort an, wo du dein Tier zuletzt gesehen hast."
|
||||
},
|
||||
"lostView": {
|
||||
"badge": "VERMISST",
|
||||
"heading": "Hast du {{name}} gesehen?",
|
||||
"lastSeenPlace": "Zuletzt gesehen",
|
||||
"lastSeenAt": "Wann",
|
||||
"details": "Erkennungsmerkmale & Hinweise",
|
||||
"contactHeading": "Bitte melde dich, wenn du {{name}} siehst",
|
||||
"message": "Nachricht schreiben",
|
||||
"mailSubject": "Hinweis zu {{name}}",
|
||||
"approachHint": "Bitte das Tier nicht verfolgen oder einfangen wollen – verängstigte Tiere laufen sonst oft weiter weg. Ruhig beobachten, Ort und Zeit merken und Bescheid geben.",
|
||||
"photosHeading": "Weitere Fotos",
|
||||
"photoAlt": "Foto von {{name}}",
|
||||
"share": "Meldung teilen – jede Weitergabe hilft",
|
||||
"shareText": "Vermisst: {{name}} – hast du dieses Tier gesehen?",
|
||||
"toProfile": "Zum Profil",
|
||||
"notActiveTitle": "Keine aktive Vermisst-Meldung",
|
||||
"notActiveText": "Vielleicht ist das Tier schon wieder sicher zu Hause. 🎉"
|
||||
}
|
||||
}
|
||||
@@ -213,7 +213,11 @@
|
||||
"referralRankUp": "You reached a new referral rank!",
|
||||
"storyMention": "tagged you in a story.",
|
||||
"storyComment": "commented on your story.",
|
||||
"storyReaction": "gave your story a paw."
|
||||
"storyReaction": "gave your story a paw.",
|
||||
"unknown": "New notification.",
|
||||
"petDeceased": "has crossed the rainbow bridge. In loving memory 🤍",
|
||||
"petLost": "is missing. Maybe you can help – tap for all details.",
|
||||
"petFound": "is safely back home. Thanks for helping! 🎉"
|
||||
},
|
||||
"pets": {
|
||||
"empty": "No pets added yet.",
|
||||
@@ -333,7 +337,8 @@
|
||||
"newStory": "New story",
|
||||
"newMilestone": "New milestone",
|
||||
"deleteAccount": "Delete account",
|
||||
"invite": "Invite friends"
|
||||
"invite": "Invite friends",
|
||||
"lost": "Missing"
|
||||
},
|
||||
"block": {
|
||||
"menuItem": "Block {{petName}}",
|
||||
@@ -484,5 +489,79 @@
|
||||
"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 loving memory",
|
||||
"banner": "In loving memory of {{name}} · {{date}}",
|
||||
"settingsHeading": "Memorial mode",
|
||||
"settingsHint": "If your pet has crossed the rainbow bridge, you can keep their profile as a loving memorial. All posts stay, and you can keep sharing memories. Birthday and vaccine reminders pause so nothing catches you off guard.",
|
||||
"activeHint": "{{name}}'s profile is kept as a memorial. You can adjust the date or end memorial mode at any time.",
|
||||
"dateLabel": "Day of farewell",
|
||||
"activate": "Keep profile as a memorial",
|
||||
"saveDate": "Save date",
|
||||
"remove": "End memorial mode",
|
||||
"confirmTitle": "Keep {{name}}'s profile as a memorial?",
|
||||
"confirmDescription": "We're so sorry. {{name}}'s profile gets a memorial ribbon, and everything you've shared stays. Those who follow {{name}} will be told once, quietly in their notifications – no push. You can undo this at any time.",
|
||||
"confirm": "Yes, keep it",
|
||||
"cancel": "Not yet",
|
||||
"saved": "The profile is now kept as a memorial.",
|
||||
"removed": "Memorial mode ended.",
|
||||
"saveFailed": "That didn't work, sorry. Please try again.",
|
||||
"invalidDate": "Please enter a valid date that is not in the future."
|
||||
},
|
||||
"lostReport": {
|
||||
"badge": "Missing",
|
||||
"teaser": "Has {{name}} run away? Start a missing report right here.",
|
||||
"open": "Report missing",
|
||||
"formHeading": "Report {{name}} as missing",
|
||||
"formHint": "We're keeping our fingers crossed. Everyone who follows {{name}} or whom {{name}} follows gets a push notification right away, and you get a page to share – even with people who aren't on PawFeed.",
|
||||
"activeHeading": "{{name}} is missing",
|
||||
"activeHint": "Share the link as often as you can – for example in neighbourhood WhatsApp groups. You can add details at any time.",
|
||||
"lastSeenAt": "Last seen on",
|
||||
"lastSeenPlace": "Where last seen?",
|
||||
"lastSeenPlacePlaceholder": "e.g. North City Park, near the playground",
|
||||
"description": "Distinguishing features & notes",
|
||||
"descriptionPlaceholder": "e.g. red collar, microchipped, rather shy – please don't call out",
|
||||
"contactHint": "Contact details are optional and only appear on this report's share page. A message via PawFeed is always possible.",
|
||||
"contactPhone": "Phone number (optional)",
|
||||
"contactEmail": "Email (optional)",
|
||||
"allowIndexing": "Allow search engines to find this page",
|
||||
"allowIndexingHint": "More reach, but strangers can find it too. Unchecked, the page is only reachable via the link.",
|
||||
"submit": "Start missing report",
|
||||
"cancel": "Cancel",
|
||||
"saveChanges": "Save changes",
|
||||
"notifyFound": "Let everyone know {{name}} is back",
|
||||
"markFound": "{{name}} is back!",
|
||||
"share": "Share link",
|
||||
"shareTitle": "Missing: {{name}}",
|
||||
"copied": "Link copied",
|
||||
"created": "Your report is out. We're keeping our fingers crossed that {{name}} finds the way home soon.",
|
||||
"updated": "Details saved.",
|
||||
"foundToast": "How wonderful – welcome back, {{name}}! 🎉",
|
||||
"saveFailed": "That didn't work, sorry. Please try again.",
|
||||
"bannerTitle": "{{name}} is missing",
|
||||
"bannerText": "Last seen: {{place}} · {{date}}",
|
||||
"bannerLink": "All details & share page",
|
||||
"timePlaceholder": "HH:MM",
|
||||
"invalidForm": "Please enter the date, time (e.g. 14:30) and the place where you last saw your pet."
|
||||
},
|
||||
"lostView": {
|
||||
"badge": "MISSING",
|
||||
"heading": "Have you seen {{name}}?",
|
||||
"lastSeenPlace": "Last seen",
|
||||
"lastSeenAt": "When",
|
||||
"details": "Distinguishing features & notes",
|
||||
"contactHeading": "Please get in touch if you see {{name}}",
|
||||
"message": "Send a message",
|
||||
"mailSubject": "A tip about {{name}}",
|
||||
"approachHint": "Please don't chase or try to catch the pet – frightened animals often run further away. Watch calmly, note the place and time and let us know.",
|
||||
"photosHeading": "More photos",
|
||||
"photoAlt": "Photo of {{name}}",
|
||||
"share": "Share report – every share helps",
|
||||
"shareText": "Missing: {{name}} – have you seen this pet?",
|
||||
"toProfile": "Go to profile",
|
||||
"notActiveTitle": "No active missing report",
|
||||
"notActiveText": "Maybe the pet is already safely back home. 🎉"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { notificationRoute, targetFromPushData } from "./notification-route";
|
||||
|
||||
const NOW = new Date("2026-10-04T12:00:00Z").getTime();
|
||||
|
||||
describe("notificationRoute", () => {
|
||||
it("opens the conversation for messages", () => {
|
||||
expect(notificationRoute({ type: "MESSAGE", conversationId: "c1" }, NOW)).toBe("/messages/c1");
|
||||
expect(notificationRoute({ type: "MESSAGE" }, NOW)).toBe("/messages");
|
||||
});
|
||||
|
||||
it("opens the post for reactions, comments, mentions and paw-backs", () => {
|
||||
for (const type of ["REACTION", "COMMENT", "MENTION", "PAW_BACK"]) {
|
||||
expect(notificationRoute({ type, postId: "p1" }, NOW)).toBe("/posts/p1/comments");
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the actor's profile for follows, suggestions, referrals and memorials", () => {
|
||||
for (const type of ["FOLLOW", "PET_SUGGESTION", "REFERRAL_JOINED", "PET_DECEASED", "PET_FOUND"]) {
|
||||
expect(notificationRoute({ type, actorPetId: "a1" }, NOW)).toBe("/pets/a1");
|
||||
}
|
||||
expect(notificationRoute({ type: "REFERRAL_RANK_UP" }, NOW)).toBe("/invite");
|
||||
});
|
||||
|
||||
it("opens the in-app missing view for a missing pet", () => {
|
||||
expect(notificationRoute({ type: "PET_LOST", actorPetId: "lost1" }, NOW)).toBe("/lost/lost1");
|
||||
expect(notificationRoute({ type: "PET_LOST" }, NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the own pet's health page for birthdays, adoption days and vaccines", () => {
|
||||
for (const type of ["BIRTHDAY", "ADOPTION_DAY", "VACCINE_DUE"]) {
|
||||
expect(notificationRoute({ type, recipientPetId: "me" }, NOW)).toBe("/pets/me/health");
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the tagged story directly, at the right story", () => {
|
||||
const route = notificationRoute({ type: "STORY_MENTION", recipientPetId: "me", actorPetId: "tagger", storyId: "s1" }, NOW)!;
|
||||
expect(route.startsWith("/story-viewer?")).toBe(true);
|
||||
const params = new URLSearchParams(route.split("?")[1]);
|
||||
expect(params.get("petId")).toBe("tagger");
|
||||
expect(params.get("storyId")).toBe("s1");
|
||||
});
|
||||
|
||||
it("comments and paws on the own story open the own story", () => {
|
||||
const route = notificationRoute({ type: "STORY_COMMENT", recipientPetId: "me", actorPetId: "fan", storyId: "s2" }, NOW)!;
|
||||
expect(new URLSearchParams(route.split("?")[1]).get("petId")).toBe("me");
|
||||
});
|
||||
|
||||
it("falls back to the profile when someone else's story has expired", () => {
|
||||
expect(
|
||||
notificationRoute(
|
||||
{ type: "STORY_MENTION", recipientPetId: "me", storyId: "s1", storyPetId: "tagger", storyExpiresAt: "2026-10-03T00:00:00Z" },
|
||||
NOW
|
||||
)
|
||||
).toBe("/pets/tagger");
|
||||
});
|
||||
|
||||
it("returns null for unknown types", () => {
|
||||
expect(notificationRoute({ type: "SOMETHING_NEW" }, NOW)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("targetFromPushData", () => {
|
||||
it("reads the server's push payload and treats empty strings as missing", () => {
|
||||
expect(targetFromPushData({ type: "MESSAGE", petId: "me", actorPetId: "a", postId: "", storyId: "", conversationId: "c1" })).toEqual({
|
||||
type: "MESSAGE",
|
||||
recipientPetId: "me",
|
||||
actorPetId: "a",
|
||||
postId: null,
|
||||
storyId: null,
|
||||
conversationId: "c1",
|
||||
});
|
||||
expect(targetFromPushData(undefined)).toBeNull();
|
||||
expect(targetFromPushData({ foo: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Wohin führt eine Mitteilung? Eine Regel für die Mitteilungsliste und für das
|
||||
* Antippen einer Push-Mitteilung (Tester-Wunsch 04.10.: „Benachrichtigung bringt
|
||||
* dich auf kürzestem Weg dorthin, wo es wichtig ist“).
|
||||
*
|
||||
* Push-Daten kommen von src/lib/notify.ts (Server): type, petId (Empfänger),
|
||||
* actorPetId, postId, storyId, conversationId — fehlende Felder als "".
|
||||
*/
|
||||
export type NotificationTarget = {
|
||||
type: string;
|
||||
/** Empfänger-Tier (eigenes Tier) */
|
||||
recipientPetId?: string | null;
|
||||
actorPetId?: string | null;
|
||||
postId?: string | null;
|
||||
conversationId?: string | null;
|
||||
storyId?: string | null;
|
||||
/** Tier, dem die Story gehört — aus der Liste bekannt, beim Push hergeleitet */
|
||||
storyPetId?: string | null;
|
||||
storyPetName?: string | null;
|
||||
storyPetAvatarKey?: string | null;
|
||||
/** Ablauf der Story, falls bekannt (Liste); beim Push unbekannt → Viewer prüft selbst */
|
||||
storyExpiresAt?: string | Date | null;
|
||||
};
|
||||
|
||||
const STORY_TYPES = new Set(["STORY_MENTION", "STORY_COMMENT", "STORY_REACTION"]);
|
||||
|
||||
export function notificationRoute(target: NotificationTarget, now: number): string | null {
|
||||
const { type, recipientPetId, actorPetId, postId, conversationId } = target;
|
||||
|
||||
if (STORY_TYPES.has(type) && target.storyId) {
|
||||
// Markierung: Story gehört dem markierenden Tier; Kommentar/Pfote: der eigenen.
|
||||
const storyPetId = target.storyPetId || (type === "STORY_MENTION" ? actorPetId : recipientPetId);
|
||||
if (!storyPetId) return null;
|
||||
const isOwnStory = storyPetId === recipientPetId;
|
||||
const expired = target.storyExpiresAt ? new Date(target.storyExpiresAt).getTime() <= now : false;
|
||||
if (expired && !isOwnStory) return `/pets/${storyPetId}`;
|
||||
const params = new URLSearchParams({ petId: storyPetId, storyId: target.storyId });
|
||||
if (target.storyPetName) params.set("petName", target.storyPetName);
|
||||
if (target.storyPetAvatarKey) params.set("petAvatarKey", target.storyPetAvatarKey);
|
||||
return `/story-viewer?${params.toString()}`;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "MESSAGE":
|
||||
return conversationId ? `/messages/${conversationId}` : "/messages";
|
||||
case "REACTION":
|
||||
case "COMMENT":
|
||||
case "MENTION":
|
||||
case "PAW_BACK":
|
||||
return postId ? `/posts/${postId}/comments` : null;
|
||||
case "FOLLOW":
|
||||
case "PET_SUGGESTION":
|
||||
case "REFERRAL_JOINED":
|
||||
case "PET_DECEASED":
|
||||
case "PET_FOUND":
|
||||
return actorPetId ? `/pets/${actorPetId}` : null;
|
||||
case "PET_LOST":
|
||||
return actorPetId ? `/lost/${actorPetId}` : null;
|
||||
case "REFERRAL_RANK_UP":
|
||||
return "/invite";
|
||||
case "BIRTHDAY":
|
||||
case "ADOPTION_DAY":
|
||||
case "VACCINE_DUE":
|
||||
return recipientPetId ? `/pets/${recipientPetId}/health` : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Push-Daten (alles Strings, leer = fehlt) → Ziel. */
|
||||
export function targetFromPushData(data: Record<string, unknown> | null | undefined): NotificationTarget | null {
|
||||
if (!data || typeof data.type !== "string") return null;
|
||||
const str = (key: string) => (typeof data[key] === "string" && data[key] ? (data[key] as string) : null);
|
||||
return {
|
||||
type: data.type,
|
||||
recipientPetId: str("petId"),
|
||||
actorPetId: str("actorPetId"),
|
||||
postId: str("postId"),
|
||||
storyId: str("storyId"),
|
||||
conversationId: str("conversationId"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Vermisst-Meldung (feature/vermisst): neue Tabelle für den PostgREST-Zugriff
|
||||
-- sperren, wie alle anderen Tabellen (siehe enable_rls.sql). Die App greift
|
||||
-- ausschließlich über Prisma (Service-Rolle) zu.
|
||||
-- Nach dem prisma db push einmal ausführen.
|
||||
ALTER TABLE public."LostReport" ENABLE ROW LEVEL SECURITY;
|
||||
+35
-1
@@ -270,6 +270,14 @@ 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
|
||||
// Vermisst-Meldung (Roadmap 04.10.): Zeitpunkt der aktiven Meldung, sonst null.
|
||||
// Cache von LostReport (foundAt = null) für das Abzeichen in Feed/Profil —
|
||||
// nur src/trpc/routers/lost-reports.ts schreibt es.
|
||||
lostSince DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -326,6 +334,9 @@ model Pet {
|
||||
// Story-Editor: Storys, in denen dieses Tier markiert wurde
|
||||
storyMentions StoryMention[] @relation("StoryMentions")
|
||||
|
||||
// Vermisst-Meldungen (aktive: foundAt = null, höchstens eine)
|
||||
lostReports LostReport[]
|
||||
|
||||
// Gitea #18 — legal AI-content disclosure. Set once at avatar upload,
|
||||
// owner-immutable afterward; only a moderator can correct it (see
|
||||
// admin.setAiDisclosure). Overlay-badge only, never baked into pixels.
|
||||
@@ -818,6 +829,26 @@ model EmergencyVet {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Vermisst-Meldung (Roadmap 04.10.). Öffentliche Teilen-Seite /vermisst/<code>
|
||||
// ohne Login; Kontaktdaten und Auffindbarkeit wählt der Besitzer je Meldung.
|
||||
// Nach dem Finden bleibt die Zeile (Seite zeigt „Wieder zu Hause“, ohne Kontakt).
|
||||
model LostReport {
|
||||
id String @id @default(cuid())
|
||||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||||
petId String
|
||||
code String @unique
|
||||
lastSeenAt DateTime
|
||||
lastSeenPlace String @db.VarChar(120)
|
||||
description String? @db.VarChar(1000)
|
||||
contactPhone String? @db.VarChar(40)
|
||||
contactEmail String? @db.VarChar(120)
|
||||
allowIndexing Boolean @default(false) // Suchmaschinen dürfen die Seite finden
|
||||
createdAt DateTime @default(now())
|
||||
foundAt DateTime?
|
||||
|
||||
@@index([petId, createdAt])
|
||||
}
|
||||
|
||||
model HealthCard {
|
||||
id String @id @default(cuid())
|
||||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||||
@@ -875,6 +906,9 @@ 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)
|
||||
PET_LOST // ein Tier aus dem Umfeld wird vermisst (actorPetId = das Tier, mit Push)
|
||||
PET_FOUND // Entwarnung: das vermisste Tier ist wieder da (actorPetId = das Tier)
|
||||
}
|
||||
|
||||
// ─── Phase 4 Models ──────────────────────────────────────────────────────────
|
||||
@@ -1039,7 +1073,7 @@ model AdRepost {
|
||||
|
||||
model ModerationLog {
|
||||
id String @id @default(cuid())
|
||||
action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT | APPROVE_VERIFICATION_REQUEST | REJECT_VERIFICATION_REQUEST | HIDE_POST | UNHIDE_POST | SHADOWBAN_USER | UNSHADOWBAN_USER | SET_AI_DISCLOSURE | ENABLE_MAINTENANCE_MODE | DISABLE_MAINTENANCE_MODE | PIN_POST | UNPIN_POST | SEND_BROADCAST | DELETE_BROADCAST | CREATE_MUSIC_TRACK | UPDATE_MUSIC_TRACK | DELETE_MUSIC_TRACK | CREATE_REFERRAL_RANK | UPDATE_REFERRAL_RANK | DEACTIVATE_REFERRAL_RANK | UPDATE_REFERRAL_SETTINGS | APPROVE_REFERRAL | REVOKE_REFERRAL | REVOKE_REFERRAL_RANK | UPDATE_CURATOR_SETTINGS | GRANT_REFERRAL_RANK | RESTORE_REFERRAL_RANK | DELETE_PET | SET_FOUNDING_MEMBER | CLEAR_PET_CONTENT | ENABLE_STORY_EDITOR | DISABLE_STORY_EDITOR
|
||||
action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT | APPROVE_VERIFICATION_REQUEST | REJECT_VERIFICATION_REQUEST | HIDE_POST | UNHIDE_POST | SHADOWBAN_USER | UNSHADOWBAN_USER | SET_AI_DISCLOSURE | ENABLE_MAINTENANCE_MODE | DISABLE_MAINTENANCE_MODE | PIN_POST | UNPIN_POST | SEND_BROADCAST | DELETE_BROADCAST | CREATE_MUSIC_TRACK | UPDATE_MUSIC_TRACK | DELETE_MUSIC_TRACK | CREATE_REFERRAL_RANK | UPDATE_REFERRAL_RANK | DEACTIVATE_REFERRAL_RANK | UPDATE_REFERRAL_SETTINGS | APPROVE_REFERRAL | REVOKE_REFERRAL | REVOKE_REFERRAL_RANK | UPDATE_CURATOR_SETTINGS | GRANT_REFERRAL_RANK | RESTORE_REFERRAL_RANK | DELETE_PET | SET_FOUNDING_MEMBER | CLEAR_PET_CONTENT | ENABLE_STORY_EDITOR | DISABLE_STORY_EDITOR | CLOSE_LOST_REPORT | DELETE_LOST_REPORT
|
||||
targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE | AD | REPORT | PET | SYSTEM | MUSIC_TRACK
|
||||
targetId String
|
||||
reason String? @db.VarChar(500)
|
||||
|
||||
@@ -261,6 +261,8 @@ interface MockNotificationClient {
|
||||
findUnique: MockFn;
|
||||
count: MockFn;
|
||||
updateMany: MockFn;
|
||||
createMany: MockFn;
|
||||
groupBy: MockFn;
|
||||
}
|
||||
|
||||
interface MockWeightLogClient {
|
||||
@@ -373,6 +375,7 @@ interface MockPrismaClient {
|
||||
milestone: MockMilestoneClient;
|
||||
story: MockStoryClient;
|
||||
storyMention: { createMany: MockFn; findMany: MockFn; deleteMany: MockFn };
|
||||
lostReport: { findFirst: MockFn; findUnique: MockFn; findMany: MockFn; count: MockFn; create: MockFn; update: MockFn; delete: MockFn };
|
||||
storyView: MockStoryViewClient;
|
||||
follow: MockFollowClient;
|
||||
block: MockBlockClient;
|
||||
@@ -505,6 +508,15 @@ export function createMockPrisma(): MockPrismaClient {
|
||||
deleteMany: vi.fn(),
|
||||
aggregate: vi.fn(),
|
||||
},
|
||||
lostReport: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
storyMention: {
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
@@ -642,6 +654,8 @@ export function createMockPrisma(): MockPrismaClient {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
groupBy: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
weightLog: {
|
||||
findMany: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Admin-Seite „Vermisst“ (lost-report-admin.ts) und die Vermisst-Ansicht der
|
||||
* App (lostReports.byPet → loadLostReportView).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
vi.mock("@clerk/nextjs/server", () => ({ auth: vi.fn().mockResolvedValue({ userId: "admin-1" }) }));
|
||||
vi.mock("@/lib/notify", () => ({ sendPushForNotification: vi.fn() }));
|
||||
const mockAssertAdmin = vi.fn();
|
||||
vi.mock("@/trpc/routers/admin", () => ({ assertAdmin: (...a: unknown[]) => mockAssertAdmin(...a) }));
|
||||
|
||||
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
|
||||
import { lostReportAdminRouter } from "@/trpc/routers/lost-report-admin";
|
||||
import { lostReportsRouter } from "@/trpc/routers/lost-reports";
|
||||
|
||||
type MockPrisma = ReturnType<typeof createMockPrisma>;
|
||||
const ctx = (prisma: MockPrisma) => ({ userId: "admin-1", prisma: prisma as never, locale: "de", ip: "127.0.0.1" });
|
||||
|
||||
describe("lostReportAdmin", () => {
|
||||
let prisma: MockPrisma;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAssertAdmin.mockResolvedValue("MODERATOR");
|
||||
prisma = createMockPrisma();
|
||||
prisma.$transaction.mockImplementation((ops: unknown[]) => Promise.all(ops));
|
||||
prisma.notification.groupBy.mockResolvedValue([{ actorPetId: "pet-1", _count: { _all: 4 } }]);
|
||||
});
|
||||
|
||||
it("lists reports with their reach and filters active ones", async () => {
|
||||
prisma.lostReport.findMany.mockResolvedValue([{ id: "r1", pet: { id: "pet-1" } }, { id: "r2", pet: { id: "pet-2" } }]);
|
||||
prisma.lostReport.count.mockResolvedValue(1);
|
||||
|
||||
const result = await lostReportAdminRouter.createCaller(ctx(prisma)).list({ status: "ACTIVE" });
|
||||
|
||||
expect(prisma.lostReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { foundAt: null } }));
|
||||
expect(result.activeCount).toBe(1);
|
||||
expect(result.reports.map((r) => r.notifiedCount)).toEqual([4, 0]);
|
||||
});
|
||||
|
||||
it("is moderator-only", async () => {
|
||||
mockAssertAdmin.mockRejectedValue(new TRPCError({ code: "FORBIDDEN" }));
|
||||
await expect(lostReportAdminRouter.createCaller(ctx(prisma)).list({ status: "ALL" })).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("closes an active report without an all-clear, clears the badge and logs it", async () => {
|
||||
prisma.lostReport.findUnique.mockResolvedValue({ id: "r1", petId: "pet-1", foundAt: null });
|
||||
|
||||
await lostReportAdminRouter.createCaller(ctx(prisma)).moderate({ reportId: "r1", mode: "CLOSE", reason: "vergessen" });
|
||||
|
||||
expect(prisma.lostReport.update).toHaveBeenCalledWith({ where: { id: "r1" }, data: { foundAt: expect.any(Date) } });
|
||||
expect(prisma.pet.update).toHaveBeenCalledWith({ where: { id: "pet-1" }, data: { lostSince: null } });
|
||||
expect(prisma.moderationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: "CLOSE_LOST_REPORT", targetId: "pet-1", reason: "vergessen", moderatorRole: "MODERATOR" }),
|
||||
});
|
||||
expect(prisma.notification.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes an abusive report; an already closed one leaves the pet untouched", async () => {
|
||||
prisma.lostReport.findUnique.mockResolvedValue({ id: "r1", petId: "pet-1", foundAt: new Date() });
|
||||
|
||||
await lostReportAdminRouter.createCaller(ctx(prisma)).moderate({ reportId: "r1", mode: "DELETE" });
|
||||
|
||||
expect(prisma.lostReport.delete).toHaveBeenCalledWith({ where: { id: "r1" } });
|
||||
expect(prisma.pet.update).not.toHaveBeenCalled();
|
||||
expect(prisma.moderationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "DELETE_LOST_REPORT" }) });
|
||||
});
|
||||
});
|
||||
|
||||
describe("lostReports.byPet", () => {
|
||||
it("returns the active report for the in-app view and hides nothing the owner published", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.lostReport.findFirst.mockResolvedValue({
|
||||
code: "abcdefgh",
|
||||
lastSeenAt: new Date(),
|
||||
lastSeenPlace: "Park",
|
||||
description: null,
|
||||
contactPhone: "0171 123",
|
||||
contactEmail: null,
|
||||
allowIndexing: false,
|
||||
foundAt: null,
|
||||
pet: {
|
||||
id: "pet-1",
|
||||
name: "Bello",
|
||||
avatarKey: null,
|
||||
species: { name: "Hund", translations: [] },
|
||||
breed: null,
|
||||
posts: [{ id: "p1", images: [{ storageKey: "posts/a.jpg" }] }, { id: "p2", images: [] }],
|
||||
},
|
||||
});
|
||||
|
||||
const view = await lostReportsRouter.createCaller(ctx(prisma)).byPet({ petId: "pet-1" });
|
||||
|
||||
expect(prisma.lostReport.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { petId: "pet-1", foundAt: null } }));
|
||||
expect(view?.contactPhone).toBe("0171 123");
|
||||
expect(view?.pet).toMatchObject({ name: "Bello", speciesName: "Hund", breedName: null });
|
||||
expect(view?.photos).toEqual([{ postId: "p1", storageKey: "posts/a.jpg" }]);
|
||||
});
|
||||
|
||||
it("is null when the pet is not missing", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
expect(await lostReportsRouter.createCaller(ctx(prisma)).byPet({ petId: "pet-1" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { lostReportsRouter } from "@/trpc/routers/lost-reports";
|
||||
import { pickRecipientsPerOwner, notifyLostReportAudience } from "@/lib/lost-reports";
|
||||
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@clerk/nextjs/server", () => ({ auth: vi.fn().mockResolvedValue({ userId: "owner-1" }) }));
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
const mockPush = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/notify", () => ({ sendPushForNotification: (...args: unknown[]) => mockPush(...args) }));
|
||||
|
||||
const OWNER = "owner-1";
|
||||
const PET = { id: "pet-1", ownerId: OWNER, name: "Bello", deceasedAt: null };
|
||||
const DETAILS = {
|
||||
lastSeenAt: "2026-10-04T10:00:00Z",
|
||||
lastSeenPlace: "Stadtpark Nord",
|
||||
description: "rotes Halsband",
|
||||
contactPhone: "",
|
||||
contactEmail: "",
|
||||
allowIndexing: false,
|
||||
};
|
||||
|
||||
type Mock = ReturnType<typeof createMockPrisma>;
|
||||
|
||||
function caller(userId: string, prisma: Mock) {
|
||||
return lostReportsRouter.createCaller({
|
||||
userId,
|
||||
prisma: prisma as unknown as import("@prisma/client").PrismaClient,
|
||||
locale: "de",
|
||||
ip: "test",
|
||||
});
|
||||
}
|
||||
|
||||
describe("pickRecipientsPerOwner", () => {
|
||||
it("takes followers and followed pets, one pet per owner, never the own owner", () => {
|
||||
const followers = [
|
||||
{ id: "f1", ownerId: "o2" },
|
||||
{ id: "f2", ownerId: "o2" },
|
||||
{ id: "own", ownerId: OWNER },
|
||||
];
|
||||
const following = [
|
||||
{ id: "g1", ownerId: "o2" },
|
||||
{ id: "g2", ownerId: "o3" },
|
||||
];
|
||||
expect(pickRecipientsPerOwner(OWNER, followers, following)).toEqual(["f1", "g2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifyLostReportAudience", () => {
|
||||
it("creates notifications and pushes for every recipient", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.follow.findMany
|
||||
.mockResolvedValueOnce([{ follower: { id: "f1", ownerId: "o2" } }])
|
||||
.mockResolvedValueOnce([{ followee: { id: "g1", ownerId: "o3" } }]);
|
||||
|
||||
const count = await notifyLostReportAudience(prisma as never, PET, "PET_LOST");
|
||||
|
||||
expect(count).toBe(2);
|
||||
expect(prisma.notification.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{ recipientPetId: "f1", type: "PET_LOST", actorPetId: "pet-1" },
|
||||
{ recipientPetId: "g1", type: "PET_LOST", actorPetId: "pet-1" },
|
||||
],
|
||||
});
|
||||
expect(mockPush).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does nothing without an audience", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.follow.findMany.mockResolvedValue([]);
|
||||
expect(await notifyLostReportAudience(prisma as never, PET, "PET_FOUND")).toBe(0);
|
||||
expect(prisma.notification.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("lostReports router", () => {
|
||||
let prisma: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
prisma = createMockPrisma();
|
||||
prisma.pet.findUnique.mockResolvedValue(PET);
|
||||
prisma.follow.findMany.mockResolvedValue([]);
|
||||
prisma.$transaction.mockImplementation((ops: unknown[]) => Promise.all(ops));
|
||||
prisma.lostReport.create.mockResolvedValue({ id: "r1", code: "abc" });
|
||||
prisma.pet.update.mockResolvedValue({});
|
||||
prisma.lostReport.update.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("creates a report, stores empty contacts as null and marks the pet as lost", async () => {
|
||||
const report = await caller(OWNER, prisma).create({ petId: "pet-1", ...DETAILS });
|
||||
|
||||
expect(report).toEqual({ id: "r1", code: "abc" });
|
||||
const data = prisma.lostReport.create.mock.calls[0][0].data;
|
||||
expect(data).toMatchObject({ petId: "pet-1", lastSeenPlace: "Stadtpark Nord", contactPhone: null, contactEmail: null });
|
||||
expect(data.code).toMatch(/^[A-Za-z0-9_-]{8,}$/);
|
||||
expect(prisma.pet.update).toHaveBeenCalledWith({ where: { id: "pet-1" }, data: { lostSince: expect.any(Date) } });
|
||||
});
|
||||
|
||||
it("refuses a second active report and a new one within 24 hours", async () => {
|
||||
prisma.lostReport.findFirst.mockResolvedValueOnce({ createdAt: new Date(Date.now() - 1000), foundAt: null });
|
||||
await expect(caller(OWNER, prisma).create({ petId: "pet-1", ...DETAILS })).rejects.toThrow(/aktive/);
|
||||
|
||||
prisma.lostReport.findFirst.mockResolvedValueOnce({ createdAt: new Date(Date.now() - 1000), foundAt: new Date() });
|
||||
await expect(caller(OWNER, prisma).create({ petId: "pet-1", ...DETAILS })).rejects.toThrow(/pro Tag/);
|
||||
expect(prisma.lostReport.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses non-owners, pets in memorial mode, future times and invalid contacts", async () => {
|
||||
prisma.pet.findUnique.mockResolvedValueOnce({ ...PET, ownerId: "someone-else" });
|
||||
await expect(caller(OWNER, prisma).create({ petId: "pet-1", ...DETAILS })).rejects.toThrow(TRPCError);
|
||||
|
||||
prisma.pet.findUnique.mockResolvedValueOnce({ ...PET, deceasedAt: new Date() });
|
||||
await expect(caller(OWNER, prisma).create({ petId: "pet-1", ...DETAILS })).rejects.toThrow(/Gedenkmodus/);
|
||||
|
||||
await expect(
|
||||
caller(OWNER, prisma).create({ petId: "pet-1", ...DETAILS, lastSeenAt: "2999-01-01T00:00:00Z" })
|
||||
).rejects.toThrow(/Zukunft/);
|
||||
await expect(caller(OWNER, prisma).create({ petId: "pet-1", ...DETAILS, contactEmail: "kein-mail" })).rejects.toThrow();
|
||||
await expect(caller(OWNER, prisma).create({ petId: "pet-1", ...DETAILS, contactPhone: "<script>" })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("marks the pet as found and sends the all-clear only when asked", async () => {
|
||||
prisma.lostReport.findFirst.mockResolvedValue({ id: "r1" });
|
||||
|
||||
await caller(OWNER, prisma).markFound({ petId: "pet-1", notifyAudience: false });
|
||||
expect(prisma.lostReport.update).toHaveBeenCalledWith({ where: { id: "r1" }, data: { foundAt: expect.any(Date) } });
|
||||
expect(prisma.pet.update).toHaveBeenCalledWith({ where: { id: "pet-1" }, data: { lostSince: null } });
|
||||
expect(prisma.follow.findMany).not.toHaveBeenCalled();
|
||||
|
||||
await caller(OWNER, prisma).markFound({ petId: "pet-1", notifyAudience: true });
|
||||
expect(prisma.follow.findMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("markFound without an active report is NOT_FOUND", async () => {
|
||||
await expect(caller(OWNER, prisma).markFound({ petId: "pet-1", notifyAudience: true })).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,25 @@ describe("notifications.list", () => {
|
||||
|
||||
expect(result).toEqual({ notifications: [], nextCursor: null });
|
||||
});
|
||||
|
||||
it("adds the conversation to message notifications so a tap opens the chat", async () => {
|
||||
const { caller, mockPrisma } = await createNotificationsCaller();
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "my-pet", ownerId: "owner-1" });
|
||||
mockPrisma.notification.findMany.mockResolvedValue([
|
||||
{ id: "n1", type: "MESSAGE", recipientPetId: "my-pet", actorPetId: "a-friend", createdAt: new Date() },
|
||||
{ id: "n2", type: "FOLLOW", recipientPetId: "my-pet", actorPetId: "a-friend", createdAt: new Date() },
|
||||
]);
|
||||
// Gespräch ist sortiert gespeichert (petAId < petBId)
|
||||
mockPrisma.conversation.findMany.mockResolvedValue([{ id: "conv-1", petAId: "a-friend", petBId: "my-pet" }]);
|
||||
|
||||
const result = await caller.notifications.list({ petId: "my-pet" });
|
||||
|
||||
expect(result.notifications.map((n: { id: string; conversationId: string | null }) => [n.id, n.conversationId])).toEqual([
|
||||
["n1", "conv-1"],
|
||||
["n2", null],
|
||||
]);
|
||||
expect(mockPrisma.conversation.findMany.mock.calls[0][0].where.OR).toEqual([{ petAId: "a-friend", petBId: "my-pet" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifications.unreadCount", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
storyMediaSize,
|
||||
storyOverlaysSchema,
|
||||
} from "@/lib/story-overlays";
|
||||
import { filterMentionablePetIds } from "@/lib/story-mentions";
|
||||
import { filterMentionablePetIds, petsOfOtherOwners } from "@/lib/story-mentions";
|
||||
import { isStoryEditorEnabled, setStoryEditorEnabled } from "@/lib/feature-flags";
|
||||
|
||||
function createCaller() {
|
||||
@@ -152,6 +152,19 @@ describe("filterMentionablePetIds — „Wer darf die Follower sehen?“ regelt
|
||||
});
|
||||
});
|
||||
|
||||
describe("petsOfOtherOwners", () => {
|
||||
it("skips the creator's own pets when notifying", async () => {
|
||||
const p = createMockPrisma();
|
||||
p.pet.findMany.mockResolvedValue([
|
||||
{ id: "me", ownerId: "owner-1" },
|
||||
{ id: "my-other-pet", ownerId: "owner-1" },
|
||||
{ id: "friend", ownerId: "owner-2" },
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(await petsOfOtherOwners(p as any, "me", ["my-other-pet", "friend"])).toEqual(["friend"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stories.create with overlays", () => {
|
||||
it("stores overlays + media size, drops disallowed mentions and notifies only allowed pets", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
|
||||
@@ -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, Siren, PartyPopper } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
@@ -16,8 +16,11 @@ import type { PostCardPost } from "@/components/feed/PostCard";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
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" | "PET_LOST" | "PET_FOUND";
|
||||
|
||||
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", "PET_LOST", "PET_FOUND"]);
|
||||
|
||||
function notificationIcon(type: NotificationType) {
|
||||
switch (type) {
|
||||
@@ -36,6 +39,9 @@ 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" />;
|
||||
case "PET_LOST": return <Siren className="h-4 w-4 text-red-600" />;
|
||||
case "PET_FOUND": return <PartyPopper className="h-4 w-4 text-emerald-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +55,7 @@ export default function NotificationsPage() {
|
||||
const locale = useLocale();
|
||||
const formatRelativeTime = (date: Date | string) => formatRelativeCompact(date, trt);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, dataUpdatedAt } = useQuery({
|
||||
...trpc.notifications.list.queryOptions({ petId: activePetId ?? "", limit: 30 }),
|
||||
enabled: !!activePetId,
|
||||
});
|
||||
@@ -59,6 +65,9 @@ export default function NotificationsPage() {
|
||||
// swapped out the notifications page in the background, so closing the
|
||||
// dialog stranded the user on that profile instead of back on this list.
|
||||
const [openPostId, setOpenPostId] = useState<string | null>(null);
|
||||
// Story-Mitteilungen öffnen die Story direkt hier als Overlay (an der richtigen Story),
|
||||
// statt über das Tierprofil — Tester-Feedback 04.10.
|
||||
const [openStory, setOpenStory] = useState<{ petId: string; petName: string; petAvatarKey: string | null; storyId: string } | null>(null);
|
||||
const [selectedPost, setSelectedPost] = useState<PostCardPost | null>(null);
|
||||
|
||||
const { data: fetchedPost } = useQuery({
|
||||
@@ -100,14 +109,21 @@ export default function NotificationsPage() {
|
||||
case "FOLLOW":
|
||||
case "PET_SUGGESTION":
|
||||
case "REFERRAL_JOINED":
|
||||
case "PET_DECEASED":
|
||||
case "PET_LOST":
|
||||
case "PET_FOUND":
|
||||
return notif.actorPet ? `/pets/${notif.actorPet.id}` : null;
|
||||
case "REFERRAL_RANK_UP":
|
||||
return "/einladen";
|
||||
// Story-Mitteilungen → Tierprofil (dort läuft die Story über den Ring)
|
||||
// Nachricht → direkt ins Gespräch (conversationId liefert notifications.list)
|
||||
case "MESSAGE":
|
||||
return notif.conversationId ? `/messages/${notif.conversationId}` : "/messages";
|
||||
// Story-Mitteilungen: läuft die Story noch (oder ist es die eigene, 7 Tage sichtbar),
|
||||
// öffnet getStoryTarget() sie direkt; sonst bleibt nur das Tierprofil.
|
||||
case "STORY_MENTION":
|
||||
case "STORY_COMMENT":
|
||||
case "STORY_REACTION":
|
||||
return notif.story ? `/pets/${notif.story.petId}` : null;
|
||||
return notif.story && !getStoryTarget(notif) ? `/pets/${notif.story.petId}` : null;
|
||||
case "BIRTHDAY":
|
||||
case "ADOPTION_DAY":
|
||||
case "VACCINE_DUE":
|
||||
@@ -117,6 +133,21 @@ export default function NotificationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function getStoryTarget(notif: (typeof notifications)[number]) {
|
||||
if (!notif.story) return null;
|
||||
if (notif.type !== "STORY_MENTION" && notif.type !== "STORY_COMMENT" && notif.type !== "STORY_REACTION") return null;
|
||||
const isOwnStory = notif.story.petId === notif.recipientPetId;
|
||||
// Bezugszeit = Ladezeitpunkt der Liste (kein Date.now() im Rendern)
|
||||
const isRunning = new Date(notif.story.expiresAt).getTime() > dataUpdatedAt;
|
||||
if (!isRunning && !isOwnStory) return null;
|
||||
return {
|
||||
petId: notif.story.petId,
|
||||
petName: notif.story.pet.name,
|
||||
petAvatarKey: notif.story.pet.avatarKey,
|
||||
storyId: notif.story.id,
|
||||
};
|
||||
}
|
||||
|
||||
// Post-related notifications — opened in-place via PostDetailDialog instead.
|
||||
function getPostId(notif: (typeof notifications)[number]): string | null {
|
||||
switch (notif.type) {
|
||||
@@ -171,17 +202,20 @@ export default function NotificationsPage() {
|
||||
: null;
|
||||
const href = getHref(notif);
|
||||
const postId = getPostId(notif);
|
||||
const isClickable = !!href || !!postId;
|
||||
const storyTarget = getStoryTarget(notif);
|
||||
const isClickable = !!href || !!postId || !!storyTarget;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={notif.id}
|
||||
onClick={
|
||||
href
|
||||
? () => router.push(href)
|
||||
: postId
|
||||
? () => setOpenPostId(postId)
|
||||
: undefined
|
||||
storyTarget
|
||||
? () => setOpenStory(storyTarget)
|
||||
: href
|
||||
? () => router.push(href)
|
||||
: postId
|
||||
? () => setOpenPostId(postId)
|
||||
: undefined
|
||||
}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg transition-colors ${
|
||||
!notif.read ? "bg-orange-500/5" : "hover:bg-muted/50"
|
||||
@@ -248,9 +282,13 @@ export default function NotificationsPage() {
|
||||
{notif.type === "PET_SUGGESTION" && t("typePetSuggestion")}
|
||||
{notif.type === "REFERRAL_JOINED" && t("typeReferralJoined")}
|
||||
{notif.type === "REFERRAL_RANK_UP" && t("typeReferralRankUp")}
|
||||
{!KNOWN_TYPES.has(notif.type) && t("typeUnknown")}
|
||||
{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 === "PET_LOST" && t("typePetLost")}
|
||||
{notif.type === "PET_FOUND" && t("typePetFound")}
|
||||
{notif.type === "VACCINE_DUE" && notif.vaccine?.nextDueDate &&
|
||||
t("typeVaccineDue", {
|
||||
name: notif.vaccine.name,
|
||||
@@ -293,6 +331,18 @@ export default function NotificationsPage() {
|
||||
onClose={closePostDialog}
|
||||
onDeleted={closePostDialog}
|
||||
/>
|
||||
{openStory && (
|
||||
<StoryViewer
|
||||
petId={openStory.petId}
|
||||
petName={openStory.petName}
|
||||
petAvatarKey={openStory.petAvatarKey}
|
||||
initialStoryId={openStory.storyId}
|
||||
open
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) setOpenStory(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ 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 { LostReportSettings } from "@/components/lost/LostReportSettings";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -144,6 +146,11 @@ export default function EditPetPage() {
|
||||
<div className="container max-w-[512px] mx-auto px-6 py-8">
|
||||
<h1 className="text-xl font-semibold mb-6">{t("title", { name: pet.name })}</h1>
|
||||
|
||||
{/* Vermisst-Meldung ganz oben: im Ernstfall sofort auffindbar */}
|
||||
<div className="mb-6">
|
||||
<LostReportSettings petId={pet.id} petName={pet.name} isDeceased={pet.deceasedAt != null} />
|
||||
</div>
|
||||
|
||||
<PetForm
|
||||
mode="edit"
|
||||
petId={pet.id}
|
||||
@@ -279,6 +286,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, Siren } from "lucide-react";
|
||||
import { createTRPCCaller } from "@/trpc/server";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
@@ -34,6 +35,9 @@ export default async function PetProfilePage({ params }: PageProps) {
|
||||
|
||||
const t = await getTranslations("PetProfile");
|
||||
const tFounding = await getTranslations("FoundingMember");
|
||||
const tMemory = await getTranslations("InMemory");
|
||||
const tLost = await getTranslations("LostReport");
|
||||
const format = await getFormatter();
|
||||
const trpc = await createTRPCCaller();
|
||||
|
||||
let pet;
|
||||
@@ -77,6 +81,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
|
||||
isLost={!!pet.lostSince} isDeceased={!!pet.deceasedAt}
|
||||
isVerified={pet.isVerified}
|
||||
isFoundingMember={pet.isFoundingMember}
|
||||
sponsor={pet.owner}
|
||||
@@ -85,6 +90,33 @@ export default async function PetProfilePage({ params }: PageProps) {
|
||||
verifiedLabel={t("verified")}
|
||||
/>
|
||||
</h1>
|
||||
{pet.lostReport && (
|
||||
<Link
|
||||
href={`/vermisst/${pet.lostReport.code}`}
|
||||
className="flex w-full flex-col items-center gap-1 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-center text-sm hover:bg-red-100 dark:border-red-900 dark:bg-red-950/40 dark:hover:bg-red-950/60"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 font-semibold text-red-700 dark:text-red-400">
|
||||
<Siren className="h-4 w-4" aria-hidden />
|
||||
{tLost("bannerTitle", { name: pet.name })}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{tLost("bannerText", {
|
||||
place: pet.lostReport.lastSeenPlace,
|
||||
date: format.dateTime(new Date(pet.lostReport.lastSeenAt), { dateStyle: "medium", timeStyle: "short", timeZone: "Europe/Berlin" }),
|
||||
})}
|
||||
</span>
|
||||
<span className="text-xs font-medium underline">{tLost("bannerLink")}</span>
|
||||
</Link>
|
||||
)}
|
||||
{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 } } },
|
||||
|
||||
@@ -5,7 +5,7 @@ import { redis } from "@/lib/redis";
|
||||
import { fanOutPost } from "@/lib/feed-helpers";
|
||||
import { MAX_STORY_VIDEO_DURATION_SECS } from "@/lib/story-video-limits";
|
||||
import { maybeQualifyReferral } from "@/lib/referrals";
|
||||
import { notifyStoryMentions } from "@/lib/story-mentions";
|
||||
import { notifyStoryMentions, petsOfOtherOwners } from "@/lib/story-mentions";
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/mux
|
||||
@@ -130,7 +130,8 @@ export async function POST(req: NextRequest) {
|
||||
// jetzt ist die Story sichtbar → jetzt benachrichtigen.
|
||||
if (!overStoryLimit) {
|
||||
const mentions = await prisma.storyMention.findMany({ where: { storyId: story.id }, select: { petId: true } });
|
||||
notifyStoryMentions(prisma, story.id, story.petId, mentions.map((m) => m.petId));
|
||||
const recipients = await petsOfOtherOwners(prisma, story.petId, mentions.map((m) => m.petId));
|
||||
notifyStoryMentions(prisma, story.id, story.petId, recipients);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Gift,
|
||||
Sprout,
|
||||
PawPrint,
|
||||
Siren,
|
||||
} from "lucide-react";
|
||||
|
||||
interface AdminLayoutProps {
|
||||
@@ -75,6 +76,7 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps
|
||||
{ href: `${base}/broadcasts`, label: "Mitteilungen", Icon: Radio },
|
||||
{ href: `${base}/log`, label: "Audit Log", Icon: ScrollText },
|
||||
{ href: `${base}/verification`, label: "Verification", Icon: BadgeCheck },
|
||||
{ href: `${base}/lost`, label: "Vermisst", Icon: Siren },
|
||||
{ href: `${base}/referrals`, label: "Empfehlungen", Icon: Gift },
|
||||
{ href: `${base}/curator`, label: "Begrüßung", Icon: Sprout },
|
||||
{ href: `${base}/blacklist`, label: "Blacklist", Icon: Ban },
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ExternalLink, Loader2, Mail, MapPin, Phone, Search, Siren, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
|
||||
type Status = "ACTIVE" | "FOUND" | "ALL";
|
||||
type Mode = "CLOSE" | "DELETE";
|
||||
|
||||
const STATUS_LABEL: Record<Status, string> = { ACTIVE: "Aktiv", FOUND: "Wieder da", ALL: "Alle" };
|
||||
|
||||
const DATE_FORMAT = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short", timeZone: "Europe/Berlin" });
|
||||
|
||||
/**
|
||||
* Admin „Vermisst“ — alle Vermisst-Meldungen. Moderatoren schließen vergessene
|
||||
* Meldungen (ohne Entwarnung) oder löschen Missbrauch; beides im Audit-Log.
|
||||
*/
|
||||
export default function AdminLostReportsPage() {
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const [status, setStatus] = useState<Status>("ACTIVE");
|
||||
const [target, setTarget] = useState<{ id: string; name: string; mode: Mode } | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const { data, isLoading } = useQuery(trpc.lostReportAdmin.list.queryOptions({ status }));
|
||||
const moderate = useMutation(
|
||||
trpc.lostReportAdmin.moderate.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success(target?.mode === "DELETE" ? "Meldung gelöscht" : "Meldung geschlossen");
|
||||
setTarget(null);
|
||||
setReason("");
|
||||
void qc.invalidateQueries({ queryKey: trpc.lostReportAdmin.list.queryKey() });
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-xl font-semibold">
|
||||
<Siren className="h-5 w-5 text-red-600" />
|
||||
Vermisst
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data ? `${data.activeCount} aktive Meldung${data.activeCount === 1 ? "" : "en"}` : "Vermisst-Meldungen der Nutzer"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 rounded-lg border p-1">
|
||||
{(Object.keys(STATUS_LABEL) as Status[]).map((s) => (
|
||||
<Button key={s} size="sm" variant={status === s ? "default" : "ghost"} onClick={() => setStatus(s)}>
|
||||
{STATUS_LABEL[s]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Loader2 className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
|
||||
) : !data?.reports.length ? (
|
||||
<div className="rounded-xl border border-dashed p-10 text-center text-sm text-muted-foreground">
|
||||
<Search className="mx-auto mb-2 h-5 w-5" />
|
||||
Keine Meldungen in dieser Ansicht.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid gap-3 md:grid-cols-2">
|
||||
{data.reports.map((r) => {
|
||||
const isActive = r.foundAt == null;
|
||||
return (
|
||||
<li
|
||||
key={r.id}
|
||||
className={`space-y-3 rounded-xl border p-4 ${isActive ? "border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={getAvatarUrl(r.pet.avatarKey) ?? undefined} />
|
||||
<AvatarFallback>{r.pet.name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link href={`/pets/${r.pet.id}`} className="font-medium hover:underline">
|
||||
{r.pet.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">gemeldet {DATE_FORMAT.format(new Date(r.createdAt))}</p>
|
||||
</div>
|
||||
{isActive ? (
|
||||
<Badge className="bg-red-600 text-white">Aktiv</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Wieder da · {DATE_FORMAT.format(new Date(r.foundAt!))}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="flex items-start gap-1.5">
|
||||
<MapPin className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{r.lastSeenPlace} · {DATE_FORMAT.format(new Date(r.lastSeenAt))}
|
||||
</p>
|
||||
{r.description && <p className="whitespace-pre-line text-muted-foreground">{r.description}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 text-xs">
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{r.notifiedCount} benachrichtigt
|
||||
</Badge>
|
||||
{r.contactPhone && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Phone className="h-3 w-3" />
|
||||
Telefon
|
||||
</Badge>
|
||||
)}
|
||||
{r.contactEmail && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Mail className="h-3 w-3" />
|
||||
E-Mail
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline">{r.allowIndexing ? "Suchmaschinen erlaubt" : "Nur per Link"}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-t pt-3">
|
||||
<a
|
||||
href={`/vermisst/${r.code}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium hover:underline"
|
||||
>
|
||||
Öffentliche Seite <ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<div className="ml-auto flex gap-2">
|
||||
{isActive && (
|
||||
<Button size="sm" variant="outline" onClick={() => setTarget({ id: r.id, name: r.pet.name, mode: "CLOSE" })}>
|
||||
Schließen
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setTarget({ id: r.id, name: r.pet.name, mode: "DELETE" })}
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Dialog open={target != null} onOpenChange={(open) => !open && setTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{target?.mode === "DELETE" ? `Meldung zu ${target?.name} löschen?` : `Meldung zu ${target?.name} schließen?`}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{target?.mode === "DELETE"
|
||||
? "Für Missbrauch oder Fake-Meldungen. Die öffentliche Seite ist danach nicht mehr erreichbar. Es geht keine Mitteilung raus."
|
||||
: "Für vergessene Meldungen. Das Abzeichen verschwindet, die öffentliche Seite zeigt „wieder zu Hause“. Es geht keine Entwarnung raus."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Textarea placeholder="Grund (optional, fürs Audit-Log)" maxLength={500} value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setTarget(null)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant={target?.mode === "DELETE" ? "destructive" : "default"}
|
||||
disabled={moderate.isPending}
|
||||
onClick={() => target && moderate.mutate({ reportId: target.id, mode: target.mode, reason: reason || undefined })}
|
||||
>
|
||||
{target?.mode === "DELETE" ? "Löschen" : "Schließen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cache } from "react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import { getFormatter, getLocale, getTranslations } from "next-intl/server";
|
||||
import { Clock, Mail, MapPin, MessageCircle, Phone, Siren, Info } from "lucide-react";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
import { getMediaUrl } from "@/lib/media-url";
|
||||
import { loadLostReportView } from "@/lib/lost-reports-view";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { LandingHeader } from "@/components/landing/LandingHeader";
|
||||
import { LandingFooter } from "@/components/landing/LandingFooter";
|
||||
import { SharePageButton } from "@/components/lost/SharePageButton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LOOKUPS_PER_MINUTE = 30;
|
||||
const CODE_PATTERN = /^[A-Za-z0-9_-]{8,24}$/;
|
||||
|
||||
type PageProps = { params: Promise<{ code: string }> };
|
||||
|
||||
/** Eine Abfrage für Metadaten und Seite (React cache dedupliziert pro Request). */
|
||||
const loadReport = cache(async (code: string, locale: string) =>
|
||||
CODE_PATTERN.test(code) ? loadLostReportView(prisma, { code }, locale) : null
|
||||
);
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { code } = await params;
|
||||
const locale = await getLocale();
|
||||
const t = await getTranslations("LostPublic");
|
||||
const report = await loadReport(code, locale);
|
||||
if (!report) return { title: "PawFeed", robots: { index: false, follow: false } };
|
||||
|
||||
const isActive = report.foundAt == null;
|
||||
const title = isActive ? t("metaTitle", { name: report.pet.name }) : t("foundTitle", { name: report.pet.name });
|
||||
const description = isActive ? t("metaDescription", { place: report.lastSeenPlace }) : t("foundText");
|
||||
const image = getAvatarUrl(report.pet.avatarKey) ?? getMediaUrl(report.photos[0]?.storageKey);
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
// Suchmaschinen nur, wenn der Besitzer es erlaubt hat und das Tier noch vermisst wird.
|
||||
robots: isActive && report.allowIndexing ? { index: true, follow: false } : { index: false, follow: false },
|
||||
openGraph: { title, description, type: "article", images: image ? [{ url: image }] : undefined },
|
||||
twitter: { card: image ? "summary_large_image" : "summary", title, description },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentliche Vermisst-Seite (/vermisst/<code>) — ohne Login, zum Teilen per
|
||||
* WhatsApp/Facebook. Kontaktdaten zeigt sie nur, wenn der Besitzer sie für
|
||||
* diese Meldung eingetragen hat; nach „Wieder da“ verschwinden sie.
|
||||
*/
|
||||
export default async function LostPetPage({ params }: PageProps) {
|
||||
const { code } = await params;
|
||||
const locale = await getLocale();
|
||||
const t = await getTranslations("LostPublic");
|
||||
const format = await getFormatter();
|
||||
|
||||
const headerList = await headers();
|
||||
const ip = headerList.get("cf-connecting-ip") ?? headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
const { success } = await checkRateLimit({ key: `lost-page:${ip}`, limit: LOOKUPS_PER_MINUTE, windowSeconds: 60 });
|
||||
if (!success) {
|
||||
return (
|
||||
<PageShell>
|
||||
<p className="text-center text-sm text-muted-foreground">{t("rateLimited")}</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const report = await loadReport(code, locale);
|
||||
if (!report) notFound();
|
||||
|
||||
const { pet } = report;
|
||||
const avatarUrl = getAvatarUrl(pet.avatarKey);
|
||||
const photos = report.photos
|
||||
.map((p) => ({ id: p.postId, url: getMediaUrl(p.storageKey) }))
|
||||
.filter((p): p is { id: string; url: string } => p.url != null);
|
||||
const { speciesName, breedName } = pet;
|
||||
|
||||
if (report.foundAt) {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="space-y-4 text-center">
|
||||
<HeroImage url={avatarUrl ?? photos[0]?.url} name={pet.name} />
|
||||
<h1 className="text-2xl font-semibold">{t("foundTitle", { name: pet.name })}</h1>
|
||||
<p className="text-muted-foreground">{t("foundText")}</p>
|
||||
<Link href={`/pets/${pet.id}`} className={buttonVariants({ variant: "outline" })}>
|
||||
{t("openInPawFeed", { name: pet.name })}
|
||||
</Link>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const lastSeen = format.dateTime(report.lastSeenAt, { dateStyle: "long", timeStyle: "short", timeZone: "Europe/Berlin" });
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3 text-center">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-red-600 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white">
|
||||
<Siren className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("badge")}
|
||||
</span>
|
||||
<h1 className="text-3xl font-semibold leading-tight">{t("heading", { name: pet.name })}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{speciesName}
|
||||
{breedName ? ` · ${breedName}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HeroImage url={avatarUrl ?? photos[0]?.url} name={pet.name} />
|
||||
|
||||
<dl className="space-y-3 rounded-xl border bg-card p-4">
|
||||
<Fact icon={<MapPin className="h-4 w-4" />} label={t("lastSeenPlace")} value={report.lastSeenPlace} />
|
||||
<Fact icon={<Clock className="h-4 w-4" />} label={t("lastSeenAt")} value={lastSeen} />
|
||||
{report.description && <Fact icon={<Info className="h-4 w-4" />} label={t("details")} value={report.description} />}
|
||||
</dl>
|
||||
|
||||
<section className="space-y-3 rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
|
||||
<h2 className="font-semibold">{t("contactHeading", { name: pet.name })}</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{report.contactPhone && (
|
||||
<a href={`tel:${report.contactPhone.replace(/[^+0-9]/g, "")}`} className={cn(buttonVariants(), "justify-start gap-2")}>
|
||||
<Phone className="h-4 w-4" />
|
||||
{report.contactPhone}
|
||||
</a>
|
||||
)}
|
||||
{report.contactEmail && (
|
||||
<a
|
||||
href={`mailto:${report.contactEmail}?subject=${encodeURIComponent(t("mailSubject", { name: pet.name }))}`}
|
||||
className={cn(buttonVariants({ variant: "outline" }), "justify-start gap-2")}
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
{report.contactEmail}
|
||||
</a>
|
||||
)}
|
||||
<Link href={`/pets/${pet.id}`} className={cn(buttonVariants({ variant: "outline" }), "justify-start gap-2")}>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
{t("messageInPawFeed")}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("approachHint")}</p>
|
||||
</section>
|
||||
|
||||
{photos.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-medium">{t("photosHeading")}</h2>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{photos.map((photo) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- öffentliche Supabase-URLs, wie im Feed
|
||||
<img
|
||||
key={photo.id}
|
||||
src={photo.url}
|
||||
alt={t("photoAlt", { name: pet.name })}
|
||||
loading="lazy"
|
||||
className="aspect-square w-full rounded-md object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<SharePageButton title={t("metaTitle", { name: pet.name })} label={t("share")} copiedLabel={t("copied")} />
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function PageShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<LandingHeader />
|
||||
<main className="mx-auto w-full max-w-md flex-1 px-4 py-8">{children}</main>
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroImage({ url, name }: { url: string | null | undefined; name: string }) {
|
||||
if (!url) return null;
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- öffentliche Supabase-URL, Vorschaubild der Seite
|
||||
<img src={url} alt={name} className="mx-auto aspect-square w-full max-w-sm rounded-2xl object-cover shadow-md" />
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<span className="mt-0.5 text-muted-foreground">{icon}</span>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||
<dd className="whitespace-pre-line text-sm">{value}</dd>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -81,6 +81,8 @@ type PostPet = {
|
||||
name: string;
|
||||
avatarKey: string | null;
|
||||
isVerified?: boolean;
|
||||
deceasedAt?: Date | string | null;
|
||||
lostSince?: 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 +258,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 isLost={!!pet.lostSince} 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,8 @@ type Pet = {
|
||||
name: string;
|
||||
avatarKey: string | null;
|
||||
isVerified: boolean;
|
||||
deceasedAt?: Date | string | null;
|
||||
lostSince?: Date | string | null;
|
||||
species: { name: string };
|
||||
};
|
||||
|
||||
@@ -110,7 +112,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 isLost={!!activePet.lostSince} isDeceased={!!activePet.deceasedAt} isVerified={activePet.isVerified} size="sm" />
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -128,7 +130,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 isLost={!!pet.lostSince} isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} size="sm" />
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{pet.species.name}
|
||||
|
||||
@@ -42,6 +42,8 @@ type Pet = {
|
||||
name: string;
|
||||
avatarKey: string | null;
|
||||
isVerified: boolean;
|
||||
deceasedAt?: Date | string | null;
|
||||
lostSince?: Date | string | null;
|
||||
species: { name: string };
|
||||
};
|
||||
|
||||
@@ -152,7 +154,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 isLost={!!activePet.lostSince} isDeceased={!!activePet.deceasedAt} isVerified={activePet.isVerified} />
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</>
|
||||
@@ -172,7 +174,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 isLost={!!pet.lostSince} isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} size="sm" />
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{pet.species.name}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Siren } 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 { Textarea } from "@/components/ui/textarea";
|
||||
import { SharePageButton } from "@/components/lost/SharePageButton";
|
||||
|
||||
type Props = { petId: string; petName: string; isDeceased: boolean };
|
||||
|
||||
type Form = {
|
||||
lastSeenAt: string;
|
||||
lastSeenPlace: string;
|
||||
description: string;
|
||||
contactPhone: string;
|
||||
contactEmail: string;
|
||||
allowIndexing: boolean;
|
||||
};
|
||||
|
||||
/** Date → Wert für <input type="datetime-local"> in Ortszeit. */
|
||||
function toLocalInput(date: Date): string {
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vermisst-Meldung in „Profil bearbeiten“: melden (Follower und gefolgte Tiere
|
||||
* bekommen eine Push-Mitteilung), Details ändern, Link teilen, „Wieder da!“.
|
||||
* Server: lostReports.*; öffentliche Seite: /vermisst/<code>.
|
||||
*/
|
||||
export function LostReportSettings({ petId, petName, isDeceased }: Props) {
|
||||
const t = useTranslations("LostReport");
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const { data: report, isPending } = useQuery(trpc.lostReports.mine.queryOptions({ petId }));
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [notifyFound, setNotifyFound] = useState(true);
|
||||
const [form, setForm] = useState<Form | null>(null);
|
||||
|
||||
const refresh = async () => {
|
||||
await queryClient.invalidateQueries();
|
||||
router.refresh();
|
||||
};
|
||||
const onError = (err: { message: string }) => toast.error(err.message || t("saveFailed"));
|
||||
const create = useMutation({
|
||||
...trpc.lostReports.create.mutationOptions(),
|
||||
onSuccess: async () => {
|
||||
toast.success(t("created", { name: petName }));
|
||||
setForm(null);
|
||||
setIsOpen(false);
|
||||
await refresh();
|
||||
},
|
||||
onError,
|
||||
});
|
||||
const update = useMutation({
|
||||
...trpc.lostReports.update.mutationOptions(),
|
||||
onSuccess: async () => {
|
||||
toast.success(t("updated"));
|
||||
setForm(null);
|
||||
await refresh();
|
||||
},
|
||||
onError,
|
||||
});
|
||||
const markFound = useMutation({
|
||||
...trpc.lostReports.markFound.mutationOptions(),
|
||||
onSuccess: async () => {
|
||||
toast.success(t("foundToast", { name: petName }));
|
||||
await refresh();
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
if (isDeceased || isPending) return null;
|
||||
|
||||
const current: Form =
|
||||
form ??
|
||||
(report
|
||||
? {
|
||||
lastSeenAt: toLocalInput(new Date(report.lastSeenAt)),
|
||||
lastSeenPlace: report.lastSeenPlace,
|
||||
description: report.description ?? "",
|
||||
contactPhone: report.contactPhone ?? "",
|
||||
contactEmail: report.contactEmail ?? "",
|
||||
allowIndexing: report.allowIndexing,
|
||||
}
|
||||
: { lastSeenAt: toLocalInput(new Date()), lastSeenPlace: "", description: "", contactPhone: "", contactEmail: "", allowIndexing: false });
|
||||
const set = (patch: Partial<Form>) => setForm({ ...current, ...patch });
|
||||
const payload = { petId, ...current, lastSeenAt: new Date(current.lastSeenAt).toISOString() };
|
||||
const publicUrl = report ? `${typeof window === "undefined" ? "" : window.location.origin}/vermisst/${report.code}` : "";
|
||||
|
||||
if (!report && !isOpen) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border p-3">
|
||||
<p className="text-sm text-muted-foreground">{t("teaser", { name: petName })}</p>
|
||||
<Button variant="outline" size="sm" className="shrink-0 gap-1.5" onClick={() => setIsOpen(true)}>
|
||||
<Siren className="h-4 w-4 text-red-600" />
|
||||
{t("open")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-red-200 bg-red-50/60 p-4 dark:border-red-900 dark:bg-red-950/30">
|
||||
<h2 className="flex items-center gap-1.5 text-sm font-semibold">
|
||||
<Siren className="h-4 w-4 text-red-600" aria-hidden />
|
||||
{report ? t("activeHeading", { name: petName }) : t("formHeading", { name: petName })}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{report ? t("activeHint") : t("formHint", { name: petName })}</p>
|
||||
|
||||
{report && (
|
||||
<div className="space-y-2">
|
||||
<a href={`/vermisst/${report.code}`} target="_blank" rel="noopener noreferrer" className="block break-all text-sm font-medium underline">
|
||||
{publicUrl}
|
||||
</a>
|
||||
<SharePageButton title={t("shareTitle", { name: petName })} label={t("share")} copiedLabel={t("copied")} url={publicUrl} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3">
|
||||
<Field id="lost-at" label={t("lastSeenAt")}>
|
||||
<Input id="lost-at" type="datetime-local" value={current.lastSeenAt} onChange={(e) => set({ lastSeenAt: e.target.value })} />
|
||||
</Field>
|
||||
<Field id="lost-place" label={t("lastSeenPlace")}>
|
||||
<Input id="lost-place" maxLength={120} placeholder={t("lastSeenPlacePlaceholder")} value={current.lastSeenPlace} onChange={(e) => set({ lastSeenPlace: e.target.value })} />
|
||||
</Field>
|
||||
<Field id="lost-desc" label={t("description")}>
|
||||
<Textarea id="lost-desc" maxLength={1000} placeholder={t("descriptionPlaceholder")} value={current.description} onChange={(e) => set({ description: e.target.value })} />
|
||||
</Field>
|
||||
<p className="text-xs text-muted-foreground">{t("contactHint")}</p>
|
||||
<Field id="lost-phone" label={t("contactPhone")}>
|
||||
<Input id="lost-phone" type="tel" maxLength={40} value={current.contactPhone} onChange={(e) => set({ contactPhone: e.target.value })} />
|
||||
</Field>
|
||||
<Field id="lost-mail" label={t("contactEmail")}>
|
||||
<Input id="lost-mail" type="email" maxLength={120} value={current.contactEmail} onChange={(e) => set({ contactEmail: e.target.value })} />
|
||||
</Field>
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input type="checkbox" className="mt-1" checked={current.allowIndexing} onChange={(e) => set({ allowIndexing: e.target.checked })} />
|
||||
<span>
|
||||
{t("allowIndexing")}
|
||||
<span className="block text-xs text-muted-foreground">{t("allowIndexingHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{report ? (
|
||||
<div className="space-y-3">
|
||||
<Button variant="outline" size="sm" disabled={update.isPending || !form || !current.lastSeenPlace.trim()} onClick={() => update.mutate(payload)}>
|
||||
{t("saveChanges")}
|
||||
</Button>
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={notifyFound} onChange={(e) => setNotifyFound(e.target.checked)} />
|
||||
{t("notifyFound", { name: petName })}
|
||||
</label>
|
||||
<Button disabled={markFound.isPending} onClick={() => markFound.mutate({ petId, notifyAudience: notifyFound })}>
|
||||
{t("markFound", { name: petName })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button disabled={create.isPending || !current.lastSeenPlace.trim()} onClick={() => create.mutate(payload)}>
|
||||
{t("submit")}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => { setIsOpen(false); setForm(null); }}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ id, label, children }: { id: string; label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={id} className="text-xs">
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Share2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Props = { title: string; label: string; copiedLabel: string; url?: string };
|
||||
|
||||
/** Teilen per System-Dialog (Handy) oder Link kopieren (Desktop). Ohne url: die aktuelle Seite. */
|
||||
export function SharePageButton({ title, label, copiedLabel, url }: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function share() {
|
||||
const link = url ?? window.location.href;
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title, url: link });
|
||||
return;
|
||||
} catch (err) {
|
||||
// abgebrochen: nichts tun; nicht erlaubt: auf Kopieren ausweichen
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
}
|
||||
}
|
||||
await navigator.clipboard.writeText(link);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="outline" className="w-full gap-2" onClick={share}>
|
||||
<Share2 className="h-4 w-4" />
|
||||
{copied ? copiedLabel : label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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", { name: petName })}</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Siren } 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;
|
||||
|
||||
/** „Wird vermisst“ — aktive Vermisst-Meldung (Pet.lostSince). */
|
||||
export function LostBadge({ size = "md" }: { size?: keyof typeof ICON_SIZE }) {
|
||||
const t = useTranslations("LostReport");
|
||||
return (
|
||||
<span title={t("badge")} className="inline-flex shrink-0">
|
||||
<Siren className={cn(ICON_SIZE[size], "text-red-600 dark:text-red-500")} aria-label={t("badge")} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ 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";
|
||||
import { LostBadge } from "@/components/ui/lost-badge";
|
||||
|
||||
type SponsorWindow = {
|
||||
// Optional: a pet identity's owner (petIdentitySelect) carries only the referral rank.
|
||||
@@ -14,6 +16,10 @@ type Props = {
|
||||
isVerified?: boolean;
|
||||
/** Eines der ersten 250 Tiere (Pet.isFoundingMember). */
|
||||
isFoundingMember?: boolean;
|
||||
/** Gedenkmodus „In Erinnerung“ (Pet.deceasedAt gesetzt). */
|
||||
isDeceased?: boolean;
|
||||
/** Aktive Vermisst-Meldung (Pet.lostSince gesetzt). */
|
||||
isLost?: 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 +50,8 @@ function isSponsorActive(sponsor: SponsorWindow): boolean {
|
||||
export function PetIdentityBadges({
|
||||
isVerified,
|
||||
isFoundingMember,
|
||||
isDeceased,
|
||||
isLost,
|
||||
sponsor,
|
||||
referral,
|
||||
size = "md",
|
||||
@@ -53,10 +61,12 @@ export function PetIdentityBadges({
|
||||
const iconClass = ICON_SIZE[size];
|
||||
const showSponsor = sponsor != null && isSponsorActive(sponsor);
|
||||
|
||||
if (!isVerified && !isFoundingMember && !showSponsor && !referral?.referralRankId) return null;
|
||||
if (!isDeceased && !isLost && !isVerified && !isFoundingMember && !showSponsor && !referral?.referralRankId) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLost && <LostBadge size={size} />}
|
||||
{isDeceased && <InMemoryBadge size={size} />}
|
||||
{isVerified && (
|
||||
<BadgeCheck
|
||||
className={cn(iconClass, "shrink-0 fill-blue-500 text-background")}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import "server-only";
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { pickLocalizedName } from "@/lib/localized-name";
|
||||
|
||||
/** Wie viele Fotos aus dem Feed die Vermisst-Ansicht zeigt. */
|
||||
export const LOST_VIEW_PHOTO_COUNT = 6;
|
||||
|
||||
/**
|
||||
* Was Finder sehen dürfen — öffentliche Seite /vermisst/<code> und der
|
||||
* Vermisst-Bildschirm der App teilen sich diese Abfrage. Kontaktdaten sind die,
|
||||
* die der Besitzer für genau diese Meldung freigegeben hat.
|
||||
*/
|
||||
export async function loadLostReportView(
|
||||
prisma: Pick<PrismaClient, "lostReport">,
|
||||
where: Prisma.LostReportWhereInput,
|
||||
locale: string
|
||||
) {
|
||||
const report = await prisma.lostReport.findFirst({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
code: true,
|
||||
lastSeenAt: true,
|
||||
lastSeenPlace: true,
|
||||
description: true,
|
||||
contactPhone: true,
|
||||
contactEmail: true,
|
||||
allowIndexing: true,
|
||||
foundAt: true,
|
||||
pet: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
avatarKey: true,
|
||||
species: { include: { translations: { where: { locale } } } },
|
||||
breed: { include: { translations: { where: { locale } } } },
|
||||
posts: {
|
||||
where: { hiddenAt: null, images: { some: {} } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: LOST_VIEW_PHOTO_COUNT,
|
||||
select: { id: true, images: { orderBy: { position: "asc" }, take: 1, select: { storageKey: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!report) return null;
|
||||
|
||||
const { pet, ...rest } = report;
|
||||
const isFound = rest.foundAt != null;
|
||||
return {
|
||||
...rest,
|
||||
// Nach dem Finden keine Kontaktdaten mehr zeigen.
|
||||
contactPhone: isFound ? null : rest.contactPhone,
|
||||
contactEmail: isFound ? null : rest.contactEmail,
|
||||
pet: {
|
||||
id: pet.id,
|
||||
name: pet.name,
|
||||
avatarKey: pet.avatarKey,
|
||||
speciesName: pickLocalizedName(pet.species.name, pet.species.translations, locale),
|
||||
breedName: pet.breed ? pickLocalizedName(pet.breed.name, pet.breed.translations, locale) : null,
|
||||
},
|
||||
photos: pet.posts
|
||||
.map((p) => ({ postId: p.id, storageKey: p.images[0]?.storageKey }))
|
||||
.filter((p): p is { postId: string; storageKey: string } => Boolean(p.storageKey)),
|
||||
};
|
||||
}
|
||||
|
||||
export type LostReportView = NonNullable<Awaited<ReturnType<typeof loadLostReportView>>>;
|
||||
@@ -0,0 +1,68 @@
|
||||
import "server-only";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { sendPushForNotification } from "@/lib/notify";
|
||||
|
||||
/** Wie viele Pushes gleichzeitig rausgehen (Expo-Requests nacheinander in kleinen Stapeln). */
|
||||
const PUSH_BATCH_SIZE = 10;
|
||||
|
||||
/** Kurzer, nicht erratbarer Code für den öffentlichen Link /vermisst/<code>. */
|
||||
export function generateLostReportCode(): string {
|
||||
return randomBytes(8).toString("base64url");
|
||||
}
|
||||
|
||||
type PetRef = { id: string; ownerId: string };
|
||||
|
||||
/**
|
||||
* Wer erfährt von der Vermisst-Meldung? Follower UND die Tiere, denen das
|
||||
* vermisste Tier folgt (Entscheidung 04.10.) — je Besitzer nur ein Tier, eigene
|
||||
* Tiere des Besitzers nie. Follower haben Vorrang (sie kennen das Tier sicher).
|
||||
*/
|
||||
export function pickRecipientsPerOwner(ownerId: string, followers: PetRef[], following: PetRef[]): string[] {
|
||||
const byOwner = new Map<string, string>();
|
||||
for (const pet of [...followers, ...following]) {
|
||||
if (pet.ownerId === ownerId || byOwner.has(pet.ownerId)) continue;
|
||||
byOwner.set(pet.ownerId, pet.id);
|
||||
}
|
||||
return [...byOwner.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mitteilung + Push an das Umfeld des Tiers (PET_LOST bei der Meldung,
|
||||
* PET_FOUND als Entwarnung). Läuft im Hintergrund — darf die Meldung nie
|
||||
* blockieren oder scheitern lassen. Gibt die Zahl der Empfänger zurück.
|
||||
*/
|
||||
export async function notifyLostReportAudience(
|
||||
prisma: Pick<PrismaClient, "follow" | "notification" | "pet" | "owner">,
|
||||
pet: PetRef,
|
||||
type: "PET_LOST" | "PET_FOUND"
|
||||
): Promise<number> {
|
||||
const [followers, following] = await Promise.all([
|
||||
prisma.follow.findMany({
|
||||
where: { followeePetId: pet.id },
|
||||
select: { follower: { select: { id: true, ownerId: true } } },
|
||||
}),
|
||||
prisma.follow.findMany({
|
||||
where: { followerPetId: pet.id },
|
||||
select: { followee: { select: { id: true, ownerId: true } } },
|
||||
}),
|
||||
]);
|
||||
const recipients = pickRecipientsPerOwner(
|
||||
pet.ownerId,
|
||||
followers.map((f) => f.follower),
|
||||
following.map((f) => f.followee)
|
||||
);
|
||||
if (recipients.length === 0) return 0;
|
||||
|
||||
await prisma.notification.createMany({
|
||||
data: recipients.map((recipientPetId) => ({ recipientPetId, type, actorPetId: pet.id })),
|
||||
});
|
||||
for (let i = 0; i < recipients.length; i += PUSH_BATCH_SIZE) {
|
||||
await Promise.allSettled(
|
||||
recipients
|
||||
.slice(i, i + PUSH_BATCH_SIZE)
|
||||
.map((recipientPetId) => sendPushForNotification(prisma, { recipientPetId, type, actorPetId: pet.id }))
|
||||
);
|
||||
}
|
||||
return recipients.length;
|
||||
}
|
||||
+32
-4
@@ -12,6 +12,12 @@ interface NotifyParams {
|
||||
storyId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur für die Push-Daten (nicht in der Notification-Tabelle): Ziele, die die
|
||||
* App beim Antippen der Push-Mitteilung braucht, z. B. das Gespräch einer Nachricht.
|
||||
*/
|
||||
export type PushExtras = { conversationId?: string };
|
||||
|
||||
/**
|
||||
* Fire-and-forget notification create — never blocks or fails the caller.
|
||||
* Use for user-triggered notifications (follow, reaction, comment, repost,
|
||||
@@ -22,10 +28,11 @@ interface NotifyParams {
|
||||
*/
|
||||
export function notify(
|
||||
prisma: Pick<PrismaClient, "notification" | "pet" | "owner">,
|
||||
params: NotifyParams
|
||||
params: NotifyParams,
|
||||
extras?: PushExtras
|
||||
): void {
|
||||
prisma.notification.create({ data: params }).catch((err) => console.error("[notify] failed", err));
|
||||
sendPushForNotification(prisma, params).catch((err) => console.error("[notify] push failed", err));
|
||||
sendPushForNotification(prisma, params, undefined, extras).catch((err) => console.error("[notify] push failed", err));
|
||||
}
|
||||
|
||||
const PUSH_COPY: Record<
|
||||
@@ -46,6 +53,17 @@ 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 liebevoller Erinnerung", genericBody: "Ein Tier, dem du folgst, ist über die Regenbogenbrücke gegangen.", actorBody: (a) => `${a} ist über die Regenbogenbrücke gegangen.` },
|
||||
PET_LOST: {
|
||||
title: "Bitte halte die Augen offen 🐾",
|
||||
genericBody: "Ein Tier, das du kennst, wird vermisst. Vielleicht kannst du helfen.",
|
||||
actorBody: (a) => `${a} wird vermisst. Vielleicht kannst du helfen – tippe für alle Infos.`,
|
||||
},
|
||||
PET_FOUND: {
|
||||
title: "Gute Nachrichten 🎉",
|
||||
genericBody: "Ein vermisstes Tier ist wieder sicher zu Hause.",
|
||||
actorBody: (a) => `${a} ist wieder sicher zu Hause. Danke, dass du mitgeholfen hast!`,
|
||||
},
|
||||
STORY_REACTION: { title: "Neue Pfote", genericBody: "Jemand hat deiner Story eine Pfote gegeben.", actorBody: (a) => `${a} hat deiner Story eine Pfote gegeben.` },
|
||||
};
|
||||
|
||||
@@ -64,7 +82,8 @@ const PUSH_COPY: Record<
|
||||
export async function sendPushForNotification(
|
||||
prisma: Pick<PrismaClient, "pet" | "owner">,
|
||||
params: NotifyParams,
|
||||
bodyOverride?: string
|
||||
bodyOverride?: string,
|
||||
extras?: PushExtras
|
||||
): Promise<void> {
|
||||
const recipientPet = await prisma.pet.findUnique({
|
||||
where: { id: params.recipientPetId },
|
||||
@@ -86,6 +105,15 @@ export async function sendPushForNotification(
|
||||
await sendExpoPushNotification(owner.expoPushToken, {
|
||||
title: copy.title,
|
||||
body: bodyOverride ?? (actorPet ? copy.actorBody(actorPet.name) : copy.genericBody),
|
||||
data: { type: params.type, postId: params.postId ?? "", commentId: params.commentId ?? "" },
|
||||
// Ziele für das Antippen der Push-Mitteilung (App: src/lib/notification-route.ts).
|
||||
data: {
|
||||
type: params.type,
|
||||
postId: params.postId ?? "",
|
||||
commentId: params.commentId ?? "",
|
||||
petId: params.recipientPetId,
|
||||
actorPetId: params.actorPetId ?? "",
|
||||
storyId: params.storyId ?? "",
|
||||
conversationId: extras?.conversationId ?? "",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -72,7 +72,25 @@ export async function saveStoryMentions(
|
||||
data: petIds.map((petId) => ({ storyId, petId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
if (sendNotifications) notifyStoryMentions(prisma, storyId, actorPetId, petIds);
|
||||
if (sendNotifications) notifyStoryMentions(prisma, storyId, actorPetId, await petsOfOtherOwners(prisma, actorPetId, petIds));
|
||||
}
|
||||
|
||||
/** Eigene Tiere des Erstellers markieren ist erlaubt, aber eine Mitteilung an sich selbst ist sinnlos. */
|
||||
export async function petsOfOtherOwners(
|
||||
prisma: Pick<PrismaClient, "pet">,
|
||||
actorPetId: string,
|
||||
petIds: readonly string[]
|
||||
): Promise<string[]> {
|
||||
if (petIds.length === 0) return [];
|
||||
const pets = await prisma.pet.findMany({
|
||||
where: { id: { in: [actorPetId, ...petIds] } },
|
||||
select: { id: true, ownerId: true },
|
||||
});
|
||||
const actorOwner = pets.find((p) => p.id === actorPetId)?.ownerId;
|
||||
return petIds.filter((id) => {
|
||||
const owner = pets.find((p) => p.id === id)?.ownerId;
|
||||
return owner !== undefined && owner !== actorOwner;
|
||||
});
|
||||
}
|
||||
|
||||
/** Fire-and-forget wie notify() selbst — darf das Erstellen der Story nie scheitern lassen. */
|
||||
|
||||
+1
-1
@@ -83,5 +83,5 @@ export async function sendTeamMessage(
|
||||
}),
|
||||
]);
|
||||
|
||||
notify(prisma, { recipientPetId: targetPetId, type: "MESSAGE", actorPetId: teamPet.id });
|
||||
notify(prisma, { recipientPetId: targetPetId, type: "MESSAGE", actorPetId: teamPet.id }, { conversationId: conversation.id });
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -29,6 +29,7 @@ const isPublicRoute = createRouteMatcher([
|
||||
"/kinderschutz",
|
||||
"/join(.*)",
|
||||
"/r/(.*)", // öffentliche Einladungsseite des Empfehlungsprogramms
|
||||
"/vermisst/(.*)", // öffentliche Vermisst-Seite zum Teilen (src/app/vermisst/[code])
|
||||
"/app", // Download-Weiterleitung für Flyer/QR-Codes (src/app/app/route.ts)
|
||||
"/api/webhooks/(.*)",
|
||||
"/api/auth/(.*)",
|
||||
|
||||
@@ -22,6 +22,10 @@ export const petIdentitySelect = {
|
||||
name: true,
|
||||
nickname: true,
|
||||
avatarKey: true,
|
||||
// „In Erinnerung“-Abzeichen überall neben dem Namen
|
||||
deceasedAt: true,
|
||||
// „Vermisst“-Abzeichen (aktive Vermisst-Meldung)
|
||||
lostSince: true,
|
||||
owner: { select: referralOwnerSelect },
|
||||
} satisfies Prisma.PetSelect;
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ import { insightsRouter } from "@/trpc/routers/insights";
|
||||
import { referralsRouter } from "@/trpc/routers/referrals";
|
||||
import { referralAdminRouter } from "@/trpc/routers/referral-admin";
|
||||
import { curatorAdminRouter } from "@/trpc/routers/curator-admin";
|
||||
import { lostReportsRouter } from "@/trpc/routers/lost-reports";
|
||||
import { lostReportAdminRouter } from "@/trpc/routers/lost-report-admin";
|
||||
import { petAdminRouter } from "@/trpc/routers/pet-admin";
|
||||
|
||||
export const appRouter = router({
|
||||
@@ -61,6 +63,8 @@ export const appRouter = router({
|
||||
referrals: referralsRouter,
|
||||
referralAdmin: referralAdminRouter,
|
||||
curatorAdmin: curatorAdminRouter,
|
||||
lostReports: lostReportsRouter,
|
||||
lostReportAdmin: lostReportAdminRouter,
|
||||
petAdmin: petAdminRouter,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import "server-only";
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { AdminRoleType, type Prisma } from "@prisma/client";
|
||||
import { router, protectedProcedure } from "@/trpc/init";
|
||||
import { assertAdmin } from "@/trpc/routers/admin";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Admin-Seite „Vermisst“ (/p/[secret]/lost): alle Vermisst-Meldungen mit Status,
|
||||
* Reichweite und Moderation. Schließen (ohne Entwarnung) oder bei Missbrauch
|
||||
* löschen — beides ab MODERATOR, beides im Audit-Log.
|
||||
*/
|
||||
export const lostReportAdminRouter = router({
|
||||
list: protectedProcedure
|
||||
.input(z.object({ status: z.enum(["ACTIVE", "FOUND", "ALL"]) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
|
||||
const where: Prisma.LostReportWhereInput =
|
||||
input.status === "ACTIVE" ? { foundAt: null } : input.status === "FOUND" ? { foundAt: { not: null } } : {};
|
||||
|
||||
const [reports, activeCount] = await Promise.all([
|
||||
ctx.prisma.lostReport.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: PAGE_SIZE,
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
lastSeenAt: true,
|
||||
lastSeenPlace: true,
|
||||
description: true,
|
||||
contactPhone: true,
|
||||
contactEmail: true,
|
||||
allowIndexing: true,
|
||||
createdAt: true,
|
||||
foundAt: true,
|
||||
pet: { select: { id: true, name: true, avatarKey: true, ownerId: true } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.lostReport.count({ where: { foundAt: null } }),
|
||||
]);
|
||||
|
||||
// Reichweite: wie viele Tiere eine PET_LOST-Mitteilung zu diesem Tier bekommen haben.
|
||||
const petIds = [...new Set(reports.map((r) => r.pet.id))];
|
||||
const audience = petIds.length
|
||||
? await ctx.prisma.notification.groupBy({
|
||||
by: ["actorPetId"],
|
||||
where: { type: "PET_LOST", actorPetId: { in: petIds } },
|
||||
_count: { _all: true },
|
||||
})
|
||||
: [];
|
||||
const audienceByPet = new Map(audience.map((a) => [a.actorPetId, a._count._all]));
|
||||
|
||||
return {
|
||||
activeCount,
|
||||
reports: reports.map((r) => ({ ...r, notifiedCount: audienceByPet.get(r.pet.id) ?? 0 })),
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* CLOSE: als erledigt schließen (z. B. Besitzer hat vergessen, „Wieder da“ zu
|
||||
* drücken) — keine Entwarnung. DELETE: Missbrauch/Fake — Meldung entfernen,
|
||||
* die öffentliche Seite ist danach nicht mehr erreichbar.
|
||||
*/
|
||||
moderate: protectedProcedure
|
||||
.input(z.object({ reportId: z.string().min(1), mode: z.enum(["CLOSE", "DELETE"]), reason: z.string().trim().max(500).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
|
||||
const report = await ctx.prisma.lostReport.findUnique({
|
||||
where: { id: input.reportId },
|
||||
select: { id: true, petId: true, foundAt: true },
|
||||
});
|
||||
if (!report) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
|
||||
await ctx.prisma.$transaction([
|
||||
input.mode === "DELETE"
|
||||
? ctx.prisma.lostReport.delete({ where: { id: report.id } })
|
||||
: ctx.prisma.lostReport.update({ where: { id: report.id }, data: { foundAt: report.foundAt ?? new Date() } }),
|
||||
// Abzeichen nur entfernen, wenn es diese Meldung war (aktive Meldung).
|
||||
...(report.foundAt ? [] : [ctx.prisma.pet.update({ where: { id: report.petId }, data: { lostSince: null } })]),
|
||||
ctx.prisma.moderationLog.create({
|
||||
data: {
|
||||
action: input.mode === "DELETE" ? "DELETE_LOST_REPORT" : "CLOSE_LOST_REPORT",
|
||||
targetType: "PET",
|
||||
targetId: report.petId,
|
||||
reason: input.reason || null,
|
||||
moderatorId: ctx.userId,
|
||||
moderatorRole: callerRole,
|
||||
ipAddress: ctx.ip,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { router, protectedProcedure } from "@/trpc/init";
|
||||
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
|
||||
import { generateLostReportCode, notifyLostReportAudience } from "@/lib/lost-reports";
|
||||
import { loadLostReportView } from "@/lib/lost-reports-view";
|
||||
|
||||
/** Höchstens eine neue Meldung pro Tier in diesem Zeitraum (gegen Fehlbedienung/Spam). */
|
||||
const REPORT_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
||||
/** Kleine Toleranz für abweichende Geräte-Uhren bei „zuletzt gesehen“. */
|
||||
const CLOCK_SKEW_MS = 10 * 60 * 1000;
|
||||
|
||||
const detailsInput = z.object({
|
||||
lastSeenAt: z.coerce.date(),
|
||||
lastSeenPlace: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().max(1000).optional().nullable(),
|
||||
contactPhone: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(40)
|
||||
.regex(/^[+0-9 ()/-]*$/)
|
||||
.optional()
|
||||
.nullable(),
|
||||
contactEmail: z.string().trim().max(120).email().optional().nullable().or(z.literal("")),
|
||||
allowIndexing: z.boolean(),
|
||||
});
|
||||
|
||||
/** Leere Eingaben als „nicht angegeben“ speichern. */
|
||||
function normalizeDetails(input: z.infer<typeof detailsInput>) {
|
||||
if (input.lastSeenAt.getTime() > Date.now() + CLOCK_SKEW_MS) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Der Zeitpunkt liegt in der Zukunft." });
|
||||
}
|
||||
return {
|
||||
lastSeenAt: input.lastSeenAt,
|
||||
lastSeenPlace: input.lastSeenPlace,
|
||||
description: input.description || null,
|
||||
contactPhone: input.contactPhone || null,
|
||||
contactEmail: input.contactEmail || null,
|
||||
allowIndexing: input.allowIndexing,
|
||||
};
|
||||
}
|
||||
|
||||
const ownerReportSelect = {
|
||||
id: true,
|
||||
code: true,
|
||||
lastSeenAt: true,
|
||||
lastSeenPlace: true,
|
||||
description: true,
|
||||
contactPhone: true,
|
||||
contactEmail: true,
|
||||
allowIndexing: true,
|
||||
createdAt: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Vermisst-Meldung (Roadmap 04.10.). Nur der Besitzer meldet, bearbeitet und
|
||||
* gibt Entwarnung; Pet.lostSince ist der Cache für das Abzeichen. Die öffentliche
|
||||
* Seite /vermisst/<code> liest direkt über Prisma (src/app/vermisst/[code]).
|
||||
*/
|
||||
export const lostReportsRouter = router({
|
||||
/**
|
||||
* Vermisst-Ansicht in der App (Banner, Push-Tipp): die aktive Meldung eines
|
||||
* Tiers mit denselben Angaben wie die öffentliche Seite — null, wenn keine.
|
||||
*/
|
||||
byPet: protectedProcedure.input(z.object({ petId: z.string().min(1) })).query(({ ctx, input }) =>
|
||||
loadLostReportView(ctx.prisma, { petId: input.petId, foundAt: null }, ctx.locale)
|
||||
),
|
||||
|
||||
/** Aktive Meldung mit allen Feldern — nur für den Besitzer (Verwaltung). */
|
||||
mine: protectedProcedure.input(z.object({ petId: z.string().min(1) })).query(async ({ ctx, input }) => {
|
||||
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
|
||||
return ctx.prisma.lostReport.findFirst({
|
||||
where: { petId: input.petId, foundAt: null },
|
||||
select: ownerReportSelect,
|
||||
});
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(detailsInput.extend({ petId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const pet = await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
|
||||
if (pet.deceasedAt) throw new TRPCError({ code: "BAD_REQUEST", message: "Das Tier ist im Gedenkmodus." });
|
||||
|
||||
const latest = await ctx.prisma.lostReport.findFirst({
|
||||
where: { petId: input.petId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true, foundAt: true },
|
||||
});
|
||||
if (latest && !latest.foundAt) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "Es gibt schon eine aktive Vermisst-Meldung." });
|
||||
}
|
||||
if (latest && Date.now() - latest.createdAt.getTime() < REPORT_COOLDOWN_MS) {
|
||||
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Höchstens eine Vermisst-Meldung pro Tag." });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [report] = await ctx.prisma.$transaction([
|
||||
ctx.prisma.lostReport.create({
|
||||
data: { petId: input.petId, code: generateLostReportCode(), ...normalizeDetails(input) },
|
||||
select: ownerReportSelect,
|
||||
}),
|
||||
ctx.prisma.pet.update({ where: { id: input.petId }, data: { lostSince: now } }),
|
||||
]);
|
||||
|
||||
notifyLostReportAudience(ctx.prisma, pet, "PET_LOST").catch((err) =>
|
||||
console.error("[lostReports.create] notify failed", err)
|
||||
);
|
||||
return report;
|
||||
}),
|
||||
|
||||
/** Details der aktiven Meldung ändern (z. B. Telefonnummer nachtragen) — ohne neue Mitteilung. */
|
||||
update: protectedProcedure
|
||||
.input(detailsInput.extend({ petId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
|
||||
const active = await ctx.prisma.lostReport.findFirst({
|
||||
where: { petId: input.petId, foundAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!active) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
return ctx.prisma.lostReport.update({
|
||||
where: { id: active.id },
|
||||
data: normalizeDetails(input),
|
||||
select: ownerReportSelect,
|
||||
});
|
||||
}),
|
||||
|
||||
/** „Wieder da!“ — Meldung schließen, optional Entwarnung an dieselben Empfänger. */
|
||||
markFound: protectedProcedure
|
||||
.input(z.object({ petId: z.string().min(1), notifyAudience: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const pet = await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
|
||||
const active = await ctx.prisma.lostReport.findFirst({
|
||||
where: { petId: input.petId, foundAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!active) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
|
||||
await ctx.prisma.$transaction([
|
||||
ctx.prisma.lostReport.update({ where: { id: active.id }, data: { foundAt: new Date() } }),
|
||||
ctx.prisma.pet.update({ where: { id: input.petId }, data: { lostSince: null } }),
|
||||
]);
|
||||
|
||||
if (input.notifyAudience) {
|
||||
notifyLostReportAudience(ctx.prisma, pet, "PET_FOUND").catch((err) =>
|
||||
console.error("[lostReports.markFound] notify failed", err)
|
||||
);
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
@@ -248,11 +248,11 @@ export const messagesRouter = router({
|
||||
]);
|
||||
|
||||
// MESSAGE notification — fire-and-forget
|
||||
notify(ctx.prisma, {
|
||||
recipientPetId,
|
||||
type: "MESSAGE",
|
||||
actorPetId: input.senderPetId,
|
||||
});
|
||||
notify(
|
||||
ctx.prisma,
|
||||
{ recipientPetId, type: "MESSAGE", actorPetId: input.senderPetId },
|
||||
{ conversationId: input.conversationId }
|
||||
);
|
||||
|
||||
return message;
|
||||
}),
|
||||
|
||||
@@ -36,8 +36,29 @@ export const notificationsRouter = router({
|
||||
});
|
||||
|
||||
const hasNextPage = notifications.length > input.limit;
|
||||
const items = hasNextPage ? notifications.slice(0, input.limit) : notifications;
|
||||
const nextCursor = hasNextPage ? items[items.length - 1].createdAt.toISOString() : null;
|
||||
const page = hasNextPage ? notifications.slice(0, input.limit) : notifications;
|
||||
const nextCursor = hasNextPage ? page[page.length - 1].createdAt.toISOString() : null;
|
||||
|
||||
// Nachrichten-Mitteilungen führen direkt ins Gespräch: Notification hat
|
||||
// keine conversationId, das Gespräch ist aber über das Tier-Paar eindeutig
|
||||
// (Conversation.petAId < petBId, sortiert wie in messages.getOrCreate).
|
||||
const pairs = page
|
||||
.filter((n) => n.type === "MESSAGE" && n.actorPetId)
|
||||
.map((n) => [n.recipientPetId, n.actorPetId as string].sort());
|
||||
const conversations = pairs.length
|
||||
? await ctx.prisma.conversation.findMany({
|
||||
where: { OR: pairs.map(([petAId, petBId]) => ({ petAId, petBId })) },
|
||||
select: { id: true, petAId: true, petBId: true },
|
||||
})
|
||||
: [];
|
||||
const conversationByPair = new Map(conversations.map((c) => [`${c.petAId}|${c.petBId}`, c.id]));
|
||||
const items = page.map((n) => ({
|
||||
...n,
|
||||
conversationId:
|
||||
n.type === "MESSAGE" && n.actorPetId
|
||||
? (conversationByPair.get([n.recipientPetId, n.actorPetId].sort().join("|")) ?? null)
|
||||
: null,
|
||||
}));
|
||||
|
||||
return { notifications: items, nextCursor };
|
||||
}),
|
||||
|
||||
@@ -123,12 +123,19 @@ export const petsRouter = router({
|
||||
species: { include: { translations: { where: { locale: ctx.locale } } } },
|
||||
breed: { include: { translations: { where: { locale: ctx.locale } } } },
|
||||
owner: { select: { ...petCardSelect.owner.select, invitedById: true } },
|
||||
// Vermisst-Banner: nur öffentliche Felder (Kontaktdaten stehen auf /vermisst/<code>)
|
||||
lostReports: {
|
||||
where: { foundAt: null },
|
||||
take: 1,
|
||||
select: { code: true, lastSeenAt: true, lastSeenPlace: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!pet) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
|
||||
// Empfehlungsprogramm: "Eingeladen von <Tier>" — nur das Tier verlassen den Server, nie die Clerk-ID.
|
||||
const { invitedById, ...owner } = pet.owner;
|
||||
const { lostReports, ...petFields } = pet;
|
||||
const invitedByPet = invitedById
|
||||
? await ctx.prisma.pet.findFirst({
|
||||
where: { ownerId: invitedById },
|
||||
@@ -138,9 +145,10 @@ export const petsRouter = router({
|
||||
: null;
|
||||
|
||||
return {
|
||||
...pet,
|
||||
...petFields,
|
||||
owner,
|
||||
invitedByPet,
|
||||
lostReport: lostReports[0] ?? null,
|
||||
species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) },
|
||||
breed: pet.breed
|
||||
? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) }
|
||||
@@ -148,6 +156,42 @@ export const petsRouter = router({
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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 a pet's details.
|
||||
* Asserts ownership before any DB write (T-3-01).
|
||||
|
||||
Reference in new issue
Block a user