Author SHA1 Message Date
adminandClaude Opus 5.5 9216f0c702 feat: Gedenkmodus „In Erinnerung“ für verstorbene Tiere
- Pet.deceasedAt + NotificationType.PET_DECEASED (additiv)
- pets.setDeceased: nur Besitzer, kein Datum in der Zukunft, rückgängig
  machbar; beim ersten Setzen einmalig stille Mitteilung (ohne Push) an
  Follower anderer Besitzer
- Verstorbene Tiere bekommen keine Geburtstags-/Adoptionstag-Mitteilungen,
  Impf-Erinnerungen und Tier-Vorschläge mehr und werden nicht vorgeschlagen
- Abzeichen „In Erinnerung“ (Ribbon) überall neben dem Namen, Gedenk-Banner
  im Profil, Bereich „Gedenkmodus“ in „Profil bearbeiten“ (Web + App)
- Mitteilung führt zum Profil des Tiers (Liste + Push-Tap)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:24:08 +02:00
adminandClaude Opus 5.5 b244ef4c10 docs(mobile): found.md — Mitteilungen fuehren direkt zum Ziel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:56:34 +02:00
adminandClaude Opus 5.5 4cf28213ff feat: Jede Mitteilung fuehrt direkt zum Ziel (Liste + Push)
Tester-Wunsch 04.10.: „Benachrichtigung bringt dich auf kuerzestem Weg
dorthin, wo es wichtig ist“.
- Nachrichten: notifications.list liefert die conversationId (ueber das
  sortierte Tier-Paar), App und Web oeffnen direkt das Gespraech.
- Push-Mitteilungen: Antippen fuehrte bisher nur in die App. Jetzt
  useNotificationTapRouting (useLastNotificationResponse, auch Kaltstart),
  wechselt bei Bedarf aufs betroffene eigene Tier. Push-Daten tragen dafuer
  petId, actorPetId, storyId, conversationId.
- Eine gemeinsame Zielregel fuer Liste und Push (mobile/src/lib/
  notification-route.ts, getestet): Nachricht → Gespraech, Kommentar/Pfote/
  Erwaehnung/Paw-Back → Beitrag, Story → Story, Follow/Vorschlag → Profil,
  Geburtstag/Adoption/Impfung → Gesundheit, Rang → Einladen.
- Bei Nachrichten und Storys fuehrt auch der Name zum Ziel; Story-Viewer
  laedt Name/Bild nach, wenn er per Push geoeffnet wird.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:52:23 +02:00
adminandClaude Opus 5.5 9cf8369307 docs(mobile): found.md — Story-Mitteilung oeffnet Story
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:32:31 +02:00
adminandClaude Opus 5.5 8aca1bc28e fix: Story-Mitteilung oeffnet direkt die Story, nie mehr Code-Namen als Text
Tester-Feedback 04.10. (STORY_MENTION):
- Web: Klick oeffnet die Story als Overlay an der richtigen Stelle
  (initialStoryId) statt ueber das Tierprofil; abgelaufen → Profil.
- App: ganzer Eintrag inkl. Name oeffnet die Story, Viewer springt per
  storyId direkt zur markierten Story.
- Unbekannte Mitteilungstypen zeigen einen neutralen Text statt des
  Code-Namens (der rohe Text kam von einem Geraet mit aelterem App-Stand).
- Keine Markier-Mitteilung an eigene Tiere des Erstellers (petsOfOtherOwners).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:28:15 +02:00
adminandClaude Opus 5.5 53d1382ac0 docs(mobile): found.md — Story-Editor iOS/Android-Fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:23:34 +02:00
adminandClaude Opus 5.5 6d3136700b fix(mobile): Story-Editor als eigener Vollbild-Screen (iOS) + Safe-Area (Android)
Tester-Feedback 04.10.:
- iOS: Text-Story liess sich nicht oeffnen, beim Foto aus der Galerie
  verschwand alles und nur das X leuchtete auf. Ursache: der Editor lief im
  Auswahl-Screen, der auf iOS ein Sheet ist; Kopfzeile umschalten und ein
  weiteres natives Modal waehrend der Praesentation scheiterten. Jetzt eigener
  Screen story-editor (fullScreenModal, ohne Kopfzeile, ohne Wisch-zum-
  Schliessen); Texteditor und Auswahlfenster sind Ebenen (EditorLayer) statt
  nativer Modals, Android-Zurueck schliesst sie.
- Android: Farbreihe im Texteditor lag hinter der Gestenleiste → echte
  Safe-Area-Abstaende oben/unten.
- Tippen auf eine freie Stelle der Story oeffnet den Texteditor.

Am Android-Emulator durchgeklickt (Text-Story, Texteditor, Verschieben,
GIF-Suche live, Sticker, X zurueck). iOS nicht testbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:20:23 +02:00
adminandClaude Opus 5.5 84a430a92a docs(mobile): found.md — Story-Editor live
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:57:46 +02:00
adminandClaude Opus 5.5 a907654de5 docs(mobile): found.md — Research gemeinsame Tierhalter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:48:36 +02:00
adminandClaude Opus 5.5 5676065400 feat(web): Story-Overlays und Text-Storys im Web-Viewer anzeigen
Web zeigt Story-Editor-Inhalte nur an (Entscheidung: kein Web-Editor):
StoryOverlayLayer mit gleicher Geometrie/Groessen wie die App, Text-Storys
mit Farbverlauf, Markierungen fuehren ins Tierprofil, Notschalter beachtet.
Mitteilungen STORY_MENTION/COMMENT/REACTION, Hinweistexte: Follower-
Sichtbarkeit regelt jetzt auch das Markieren.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:47:25 +02:00
adminandClaude Opus 5.5 5ffa472da0 fix(mobile): Story-Editor lint-sauber (Gesten-Fabrik ausserhalb der Komponente)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:43:36 +02:00
adminandClaude Opus 5.5 0c448de4c1 wip(mobile): Story-Editor-Oberflaeche (Editor, Pickers, Viewer, Mitteilungen)
Typecheck gruen. Offen: Lint (React-Compiler-Regeln in EditableOverlay,
setState-in-effect im Viewer), Web-Anzeige, Geraetetest, Deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:38:24 +02:00
adminandClaude Opus 5.5 795a39355e feat(stories): Overlay-Spiegel fuer die App + Paritaetstest
- mobile/src/lib/story-overlays.ts + story-stickers.ts (identisch zu src/lib,
  plus Gesten-Geometrie fuer den Editor); Paritaetstest liegt in mobile/
  (Docker-Build kennt mobile/ nicht)
- Rueckgaben von create/createText/getVideoStatus ohne overlays-JSON und
  flacher Eingabetyp: Prismas rekursiver JsonValue- bzw. der tiefe Zod-Typ
  sprengten sonst den App-Compiler (TS2589)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:31:14 +02:00
adminandClaude Opus 5.5 8c640e2837 feat(stories): Server fuer Story-Editor (Overlays, Markierungen, Text-Storys, GIFs)
- Schema: Story.overlays/background/mediaWidth/mediaHeight, StoryMediaType.TEXT,
  StoryMention, Notification.storyId, STORY_MENTION/COMMENT/REACTION
- Markieren nach Pet.followerVisibility (Entscheidung 04.10.), Block-Pruefung,
  eigene Tiere immer erlaubt; unzulaessige Markierungen werden verworfen
- stories.createText, searchMentionable, searchGifs (GIPHY-Proxy, rating g,
  Redis-Cache), featureFlags; Kommentar/Pfote benachrichtigen den Besitzer
- Video-Storys benachrichtigen Markierte erst bei READY (Mux-Webhook)
- Notschalter Story-Editor im Admin-Dashboard (Redis, ModerationLog)
- RLS-Skript fuer StoryMention

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:27:34 +02:00
adminandClaude Opus 5.5 66230a8695 feat(mobile): Privatsphaere-Einstellungen im Profil bearbeiten
Wer darf Nachrichten schreiben (dmPolicy) und wer darf die Follower sehen
(followerVisibility) waren nur im Web einstellbar. Neue PrivacySettings-
Komponente mit denselben Optionen/Texten wie im Web, speichert jede Auswahl
sofort ueber pets.update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:08:17 +02:00
adminandClaude Opus 5.5 0a0ff90745 docs(mobile): found.md — Raenge im Admin, Nachrichten-Badge, Profil-Ring
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:17:01 +02:00
adminandClaude Opus 5.5 c7c1473e2a feat: Profil-Ring (PROFILE_RING) ueberall am Tierbild + Story-Ring in Rangfarbe
found.md: Testnutzer mit "Goldene Pfote" (PROFILE_RING) sah keinen Ring —
der Ring war bewusst nur im Profilkopf. Auf Wunsch jetzt ueberall:
- petIdentitySelect liefert owner.referralRankId/referralPerks mit
  (referralOwnerSelect), ebenso search, explore.listPets und pets.list.
- Web: RankRing (box-shadow, keine Layoutverschiebung) um Avatare in Feed,
  Kommentaren, Suche, Explore, Nachrichten, Mitteilungen, Follower-Listen,
  Meilenstein-/Repost-Karten, Story-Zuschauern/-Kommentaren; PetAvatar
  nimmt owner.
- App: PetAvatar mit owner zeichnet den Ring als Overlay; an allen
  Listen/Karten uebergeben.
- PawRing (Web + App): ungesehene Story-Pfote in Rangfarbe statt Orange,
  ohne Story schlichter Ring in Rangfarbe; gesehene Storys bleiben grau.
Sponsor-Felder im Kartentyp optional (Identity-owner traegt nur den Rang).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:12:07 +02:00
adminandClaude Opus 5.5 0668459c1e fix(test): Import-Pfad im verschobenen Datums-Test mitcommitten
Beim git mv in 7ab74f8 blieb die sed-Korrektur des Imports unstaged; im Repo
zeigte der Test noch auf ../../mobile/src/lib/date-format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:12:07 +02:00
adminandClaude Opus 5.5 dbc416c43c feat(admin): Tierliste zeigt alle erreichten Raenge
found.md: Admin -> Tiere zeigte nur den hoechsten Rang (Cache referralRankId).
Die Liste laedt jetzt alle ReferralRankAchievements des Owners (nach Schwelle)
und zeigt sie als Symbole, unabhaengig vom Vorteil NAME_BADGE; entzogene
Raenge ausgegraut, Tooltip mit 'manuell vergeben' / 'entzogen'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:01:51 +02:00
adminandClaude Opus 5.5 2bd4b2d973 feat(mobile): Badge fuer ungelesene Nachrichten an der Sprechblase
found.md (iOS-Feedback): Push kam an, aber die Sprechblase zeigte nichts.
MessagesHeaderButton zeigt jetzt conversations.unreadCount im selben Badge
wie die Glocke (gemeinsame UnreadBadge-Komponente), aktualisiert alle 30 s;
Lesen eines Chats invalidiert den Zaehler bereits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:01:51 +02:00
adminandClaude Opus 5.5 361aba0d1d docs(mobile): found.md — Gewichtsdiagramm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:55:19 +02:00
adminandClaude Opus 5.5 e60abbabc6 feat(mobile): Gewichtsverlauf als Diagramm
found.md: Gewichtsdaten wurden nur als Liste angezeigt. Neues WeightChart
(react-native-svg, Port des Web-Diagramms) ab 2 Eintraegen ueber der Liste:
x-Achse zeitproportional (unregelmaessige Wiegetermine), runde y-Ticks
(1/2/5 x 10^n), Datumslabels nach Pixelabstand, Antippen waehlt die
naechste Messung (Wert + Datum im Kopf), Veraenderung seit erster Messung.
Linienfarbe als neues Token chartLine (#ea580c), mit dem Dataviz-Validator
gegen helle und dunkle Flaechen geprueft. Geometrie rein in
src/lib/weight-chart.ts mit Tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:52:38 +02:00
adminandClaude Opus 5.5 81e617ece5 fix(admin): Melder-Nachricht grammatisch korrekt (zum Beitrag/Profil)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:16:48 +02:00
adminandClaude Opus 5.5 c678c4cf27 docs(mobile): found.md — Rassensuche + Report-Rückmeldung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:16:12 +02:00
adminandClaude Opus 5.5 f51b158989 feat(admin): Team-Nachricht an Melder bei abgewiesenem/bestaetigtem Report
found.md: Abweisen schickt dem Melder eine Team-Nachricht mit optionaler
Begruendung (auch im ModerationLog), Loeschen eines gemeldeten Beitrags
dankt jedem Melder einmal. Beides per Schalter im Admin-Dialog abschaltbar,
best-effort nach der Moderation. Die interne Loesch-Begruendung geht nicht
an den Melder.

Fix nebenbei: deleteReportedPost loescht jetzt die Reports des Beitrags
(targetPostId ist keine Relation, sie blieben bisher verwaist in der Liste).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:09:39 +02:00
adminandClaude Opus 5.5 01459c7e0e feat(mobile): Suchfeld im Rassen-Dropdown
found.md (iOS-Feedback): Hunde-/Katzen-Rassenlisten sind lang. OptionPicker
zeigt ab 10 Eintraegen oben ein Suchfeld, das ohne Gross-/Kleinschreibung
und Akzente filtert (filterOptionsByName). Bei offener Tastatur rueckt die
Liste nach oben, damit sie nicht verdeckt wird. Wirkt in Tier anlegen,
Profil bearbeiten und Explore-Filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:09:38 +02:00
adminandClaude Opus 5.5 a2af4eda6a docs(mobile): found.md — iOS-Tastatur verdeckt Datumsfeld
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:56:09 +02:00
adminandClaude Opus 5.5 333ba16573 fix(mobile): iOS-Tastatur verdeckt Eingabefelder nicht mehr
Profil bearbeiten (Geburtstag/Adoptionstag), Gesundheit, neuer Beitrag,
neuer Meilenstein, Tier anlegen/Onboarding und Einladen hatten keinen
Tastatur-Ausgleich: das fokussierte Feld lag unter der Tastatur.
automaticallyAdjustKeyboardInsets setzt den unteren Inset und scrollt
das aktive Feld in den sichtbaren Bereich (RN Fabric ScrollView),
keyboardShouldPersistTaps="handled" laesst Feldwechsel ohne Zuklappen zu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:54:35 +02:00
adminandClaude Opus 5.5 9136981af7 docs(mobile): found.md — Impf-Erinnerung live
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:52:20 +02:00
adminandClaude Opus 5.5 7ab74f8077 fix: Datums-Test nach mobile/ verschieben (Docker-Build ohne mobile/)
Der Test in src/__tests__ importierte aus mobile/, das .dockerignore und
die Web-tsconfig ausschliessen -> next build brach beim Typecheck ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:50:35 +02:00
adminandClaude Opus 5.5 4214a26bd7 docs: Cron vaccine-reminders im NAS-Zeitplan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:48:38 +02:00
adminandClaude Opus 5.5 6a88214186 docs(mobile): found.md — Datum TT.MM.JJJJ + Impf-Erinnerung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:46:30 +02:00
adminandClaude Opus 5.5 c74bbffccb feat: Datumseingabe TT.MM.JJJJ (App) + Erinnerung an Folgeimpfung
Datum (found.md, iOS-Feedback): neue DateField-Komponente mit Ziffern-
tastatur und automatisch eingefügten Punkten (TT.MM.JJJJ) für Geburtstag,
Adoptionstag, Gewicht, Tierarzt und Impfungen. Parser akzeptiert weiter
"-" und "/" sowie einstellige Tage/Monate. Wheel-Picker bleibt in
PENDING-NATIVE-BUILD.md (natives Modul).

Impf-Erinnerung (found.md, iOS-Feedback): täglicher Cron
/api/cron/vaccine-reminders erinnert einmalig 30 Tage vor
Vaccine.nextDueDate per Mitteilung (VACCINE_DUE) + Push mit Impfname und
Datum. Schema: NotificationType.VACCINE_DUE, Notification.vaccineId
(Cascade), Vaccine.reminderSentAt (Claim gegen Doppelversand, wird bei
Fehler wieder freigegeben). Web + App zeigen die Mitteilung an, Tippen
öffnet die Gesundheitsseite. App: Schnellauswahl +1/+3 Jahre für den
Folgetermin plus Hinweis auf die Erinnerung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:42:20 +02:00
adminandClaude Opus 5.5 7169840000 fix(mobile): Apple-Login hängt nicht mehr still bei fehlenden Pflichtfeldern
Fehlen nach dem Apple-Login noch Pflichtangaben (z. B. Vor-/Nachname,
die Apple nur bei der allerersten Autorisierung schickt), zeigt der
Button jetzt eine Fehlermeldung und meldet die Felder an Sentry, statt
kommentarlos nichts zu tun.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:01:19 +02:00
adminandClaude Opus 5.5 f112d36f4a feat: neues Tier folgt dem Kurator-Tier zurück
Bisher folgte nur "PawFeed Team" dem neuen Tier, der Feed des neuen
Nutzers startete leer. welcomeNewPet legt jetzt auch den Follow in
Gegenrichtung an und befüllt den Redis-Feed per backfillOnFollow mit den
letzten Team-Posts. Gleicher Admin-Schalter (autoFollowEnabled),
Beschriftung im Kurator-Panel angepasst.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:49:46 +02:00
adminandClaude Opus 5.5 80e27a7f0b docs(mobile): found.md — Upload-Fortschritt Video-Storys
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:47:03 +02:00
adminandClaude Opus 5.5 5606a81dd2 feat(mobile): Fortschrittsbalken beim Video-Story-Upload
Statt nur Spinner: „Upload wird vorbereitet …“, dann Balken + Prozent aus
expo-file-system File.upload onProgress (nativ seit 57.0.7 im 1.0.6-Build,
daher OTA). Vorschau ist während des Uploads nicht mehr antippbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:44:35 +02:00
adminandClaude Opus 5.5 6979447b6d docs(mobile): found.md — Video-Storys per OTA live
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:29:48 +02:00
adminandClaude Opus 5.5 69e2c1364e feat(mobile): Video-Storys in der App (Upload + Wiedergabe)
Merged feature/mobile-video-stories (Gitea #37) auf den seit 02.10.
umgebauten Story-Viewer: Galerie-Picker nimmt Videos bis 30 s, Upload
direkt zu Mux, Viewer spielt READY-Videos ab (Fortschritt aus der echten
Wiedergabe, Weiter bei playToEnd) und zeigt PROCESSING/ERROR. Offene
Kommentar-/Zuschauer-Sheets pausieren das Video wie den Foto-Timer.
Nur JS — expo-video ist seit dem 1.0.6-Build nativ enthalten, also OTA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:25:24 +02:00
adminandClaude Opus 5.5 a9b6a57585 docs(mobile): found.md — HEIF-Fix vom Nutzer bestätigt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:37:50 +02:00
adminandClaude Opus 5.5 00fb0db168 docs: CLAUDE.md — Dashboard-Kachel Tierarten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:34:19 +02:00
adminandClaude Opus 5.5 f1ccc78005 feat: Dashboard-Kachel Tierarten klappbar (standardmäßig zu, Zustand gemerkt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:31:09 +02:00
adminandClaude Opus 5.5 9adba560a7 feat: Admin-Dashboard — Verteilung der Tiere nach Tierart und Rasse
- petAdmin.speciesDistribution: Anzahl pro Tierart (auch Arten ohne Tiere)
  und pro Rasse, deutsche Namen, ohne das Kurator-Tier „PawFeed Team“
- Karte "Tierarten" auf dem Dashboard: sortiertes Balkendiagramm (eine
  Reihe, Markenorange), Anzahl + Anteil als Text, Tooltip bei Hover/Fokus,
  Klick klappt die Rassen der Art auf

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:17:21 +02:00
adminandClaude Opus 5.5 efc82d636d docs: CLAUDE.md — Tierverwaltung, deletePetCompletely, Rang-Sperre
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:05:05 +02:00
adminandClaude Opus 5.5 566a2eedd0 feat: Admin-Tierverwaltung + vollständiges Löschen von Tieren
Admin /p/[secret]/pets:
- Liste mit Suche (Name, Spitzname, Empfehlungscode, ID), Filtern und
  Sortierung; Detail-Panel mit Kennzahlen, Owner, Profiltexten, Beiträgen
- Verifiziert / Gründungsmitglied pro Tier umschalten
- Empfehlungsränge des Owners: dauerhaft entziehen (bis zur Freigabe),
  freigeben oder manuell vergeben (besondere Leistung)
- Inhalte leeren (Profilbild, Bio, Adoptionsgeschichte, Spitzname) und Tier
  löschen — jeweils mit Begründung, Owner per Team-DM informiert bzw. per
  E-Mail, wenn es sein einziges Tier war

Ränge: ReferralRankAchievement bekommt revokedAt/revokeReason/
grantedManually; entzogene Ränge werden weder neu vergeben noch im Cache
berücksichtigt (awardRanks, recomputeReferralRanks, referrals.me, Export).

Fix: deletePetCompletely (src/lib/delete-pet.ts) für Owner-Löschen,
Kontolöschung und Admin — vorher konnte das Löschen an StoryView/Report/
Repost-Fremdschlüsseln ohne Cascade scheitern, und die Zähler an fremden
Beiträgen/Storys wurden nicht verringert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:03:05 +02:00
adminandClaude Opus 5.5 7a064d8a36 docs: CLAUDE.md — Download-Weiterleitung /app
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:41:49 +02:00
adminandClaude Opus 5.5 94c143e17d feat: Download-Weiterleitung /app für Flyer und QR-Codes (D3)
- Android → Google Play, UTM-Werte als Play-referrer (Play Console)
- iPhone → App Store, sobald APP_STORE_URL gesetzt ist (D5); bis dahin Web
- sonst → Web-Startseite; ?ref=<Code> führt auf die Einladungsseite /r/<Code>
- nur sichere UTM-Werte werden übernommen, nie gecacht (302, no-store)
- Zählung pro Tag/Quelle/Gerät in Redis, Anzeige im Admin unter
  Empfehlungen → Übersicht (30 Tage)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:39:40 +02:00
adminandClaude Opus 5.5 8143e7b667 docs: CLAUDE.md — Gründungsmitglieder und Kurator-Begrüßung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:33:30 +02:00
adminandClaude Opus 5.5 307b76e8fd feat: Gründungsmitglieder (erste 250 Tiere) und Kurator-Begrüßung
- Pet.isFoundingMember: in pets.create per bewachter SQL-Anweisung für die
  ersten 250 Tiere gesetzt, nie entzogen; Abzeichen (Spross) in Feed und
  Profil, Web + App, unabhängig vom Empfehlungsprogramm
- Kurator "PawFeed Team": folgt jedem neuen Tier und schickt eine
  Begrüßungs-Direktnachricht (DE/EN nach Sprache beim Anlegen), im
  Hintergrund — blockiert das Onboarding nicht
- CuratorSettings + Admin-Seite /p/[secret]/curator: Schalter, Texte mit
  Pflicht-Platzhalter {name} und Vorschau, Zähler der Gründungsmitglieder
- prisma/backfill_founding_members.sql, prisma/enable_rls_curator.sql

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:26:43 +02:00
adminandClaude Opus 5.5 9f7acf1d98 docs(mobile): found.md — 7-Tage-Story-Ansicht deployt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:35:46 +02:00
adminandClaude Opus 5.5 2c0680cef9 feat: 7-Tage-Ansicht der eigenen Storys für den Ersteller (Web + App)
- stories.listActive: der Owner bekommt seine Storys der letzten 7 Tage mit
  isExpired, alle anderen weiterhin nur die aktiven 24 Stunden
- stories.hasActiveStory: optional includeOwnRecent → grauer Ring
  (expiredOnly) nur in der Story-Leiste, nicht an Profilbildern im Feed
- Viewer: Hinweis "Abgelaufen – nur für dich sichtbar", Kommentare bei
  abgelaufenen Storys nur lesbar, Pfote nur als Zähler; Löschen und
  Zuschauerliste bleiben

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:30:22 +02:00
adminandClaude Opus 5.5 a01c58e5f0 feat: Aufräum-Job für Storys älter als 7 Tage
- /api/cron/cleanup-stories (CRON_SECRET): löscht Storys älter als 7 Tage
  samt Aufrufen, Pfoten, Kommentaren, Supabase-Bild und Mux-Asset; max. 200
  pro Lauf, Fehler einzelner Storys brechen den Lauf nicht ab
- gemeinsame Funktion deleteStoryWithMedia (src/lib/story-cleanup.ts), auch
  von stories.delete genutzt
- Datenschutzerklärung Abschnitt 6: Storys spätestens nach 7 Tagen gelöscht
- Datenexport: Story-Block heißt "stories" statt "activeStories"
- CLAUDE.md: Cron-Jobs laufen über die NAS-Crontab + docker/run-cron.sh
  (der alte Hinweis "nichts ruft trim-feeds auf" war veraltet)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:43:07 +02:00
adminandClaude Opus 5.5 ac664d488d docs(mobile): found.md — Story löschen deployt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:21:12 +02:00
adminandClaude Opus 5.5 81d9c6da30 feat: eigene Story löschen (Web + App)
- stories.delete: nur Owner; Aufrufe, Story (Pfoten/Kommentare per Cascade),
  danach Bild in Supabase Storage bzw. Mux-Asset best-effort
- Web: Papierkorb neben der Zuschauerzahl + Rückfrage, Timer pausiert,
  gelöscht wird die beim Antippen gemerkte Story
- App: gleicher Papierkorb mit nativer Rückfrage, Timer pausiert
- found.md: Nutzer-Feedback "Story löschbar" erledigt, 7-Tage-Wunsch mit
  Befund als offener Punkt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:17:16 +02:00
adminandClaude Opus 5.5 4ec5d8c5f4 docs(mobile): found.md — Story-Fixes deployt (Web + OTA)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:03:06 +02:00
adminandClaude Opus 5.5 ccebfa46a6 feat: Story-Kommentare, Pfoten und Zuschauerliste in der App
- App-Viewer: Pfote, Kommentar-Sheet (schreiben, eigene löschen) und für die
  eigene Story die Zuschauerliste; Timer pausiert, solange ein Sheet offen ist
- stories.listActive: optionales viewerPetId → reactedByViewer, damit die
  Pfote gefüllt startet (abwärtskompatibel für alte App-Builds)
- Web-StoryPawButton nutzt reactedByViewer ebenfalls und wird pro Story
  neu initialisiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:59:02 +02:00
adminandClaude Opus 5.5 204d5f6831 fix: eigene Story als Ring "Deine Story" in der Story-Leiste (Web + App)
Die Leiste zeigte nur Storys gefolgter Tiere — das eigene aktive Tier war
nie enthalten, man konnte die eigene Story nach dem Posten nicht ansehen.
Neuer Ring direkt nach der "+"-Kachel, solange stories.hasActiveStory für
das aktive Tier true ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:46:29 +02:00
adminandClaude Opus 5.5 1a24ebae89 docs(mobile): found.md — HEIC-Fix deployt (Web + OTA)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:32:11 +02:00
adminandClaude Opus 5.5 1bf0f9282a fix: HEIF/HEIC-Fotos (Apple) für Beiträge und Storys annehmen
- App: post-new.tsx sortierte HEIC/HEIF still aus, obwohl resizeForUpload()
  ohnehin in JPEG umwandelt — jetzt erlaubt, plus Hinweis bei übersprungenen
  Fotos
- Web: neues src/lib/heic-to-jpeg.ts wandelt HEIC vor dem Upload im Browser
  in JPEG um (Safari kann HEIC nativ dekodieren); eingebaut in Beiträge,
  Storys, Meilensteine und Tierbild, accept um HEIC ergänzt. Browser ohne
  HEIC-Dekodierung bekommen eine klare Meldung statt "falsches Format".
- Server-Whitelist (JPEG/PNG/WebP) bleibt unverändert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:26:24 +02:00
adminandClaude Opus 5.5 8f8fc8d75e fix: Tierbild auf der Einladungsseite /r/[code] größer darstellen
Die Avatar-Variante "lg" setzt per data-Attribut fest size-10 (40 px) und
überstimmte die eigene Größe — jetzt Standardvariante mit size-28 / sm:size-36
und dezentem Ring in Markenfarbe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:23:08 +02:00
adminandClaude Opus 5.5 18f25ce316 fix: DSGVO-Lücken des Empfehlungsprogramms schließen (Auskunft + Löschung)
- Datenexport (Art. 15): neuer Block referralProgram mit eigenem Code,
  Herkunftsangabe, "eingeladen von", gemachten Einladungen, erreichten Rängen,
  aktuellen Vorteilen und den früheren Einmal-Codes. Andere Personen nur über
  den Tiernamen — invitedById (Clerk-ID eines Dritten) fliegt aus dem Export.
- Kontolöschung: invitedById bei Eingeladenen und usedById bei Beta-Codes
  werden geleert, damit keine Clerk-ID der gelöschten Person zurückbleibt.
  Gezählte Einladungen bleiben gezählt.
- Einlösen sperrt jetzt auch über invitedAt, damit nach dem Leeren von
  invitedById kein zweiter Code eingelöst werden kann.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:57:59 +02:00
adminandClaude Sonnet 5 2b285de09e feat(mobile): add video story upload and playback (Gitea #37)
Extends the gallery picker to accept videos, uploads them via Mux direct
upload (same flow web already uses), and adds PROCESSING/READY/ERROR
handling to the story viewer with real playback-driven progress instead
of a fixed timer.

Fixes a video-viewer bug found during device testing: play() was called
before the player reached readyToPlay, leaving duration at 0 and firing
playToEnd immediately — the viewer closed within ~1s instead of playing.
Now waits for statusChange to readyToPlay before calling play().

Also fixes the story-creation "Story teilen" button sitting flush against
gesture navigation on devices without a bottom safe-area inset applied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 14:23:03 +02:00
176 changed files with 10331 additions and 550 deletions

No files matched your search

+13 -3
View File
@@ -84,13 +84,23 @@ All uploads are presigned/direct-to-storage — media bytes never pass through t
### Admin / moderation panel
Gated by path secret (see `proxy.ts` above) *and* by role at the tRPC layer: `src/trpc/routers/admin.ts`'s `assertAdmin()` allows either `ADMIN_OWNER_ID` (env-configured super-admin bootstrap) or an `AdminRole` row (`SUPER_ADMIN` / `MODERATOR`). Actions are written to `ModerationLog`. Pages live under `src/app/p/[secret]/{users,moderators,posts,reports,ads,referrals,…}`.
Gated by path secret (see `proxy.ts` above) *and* by role at the tRPC layer: `src/trpc/routers/admin.ts`'s `assertAdmin()` allows either `ADMIN_OWNER_ID` (env-configured super-admin bootstrap) or an `AdminRole` row (`SUPER_ADMIN` / `MODERATOR`). Actions are written to `ModerationLog`. Pages live under `src/app/p/[secret]/{users,pets,moderators,posts,reports,ads,referrals,curator,…}`. The dashboard has a collapsible "Tierarten" card (`SpeciesDistributionCard`, `petAdmin.speciesDistribution`: pets per species incl. empty species and per breed, curator pet excluded). The "Tiere" page (`pets`, router `pet-admin.ts`) manages single pets: verified/founding toggles, clearing profile content, deleting a pet (reason is sent to the owner as a team DM on another of their pets, or by e-mail if it was their only pet) and the owner's referral ranks.
**Never delete a pet with a bare `prisma.pet.delete`** — use `deletePetCompletely()` (`src/lib/delete-pet.ts`). `StoryView` (D-08), `Report.reporter` and `Repost.originalPost` have no cascade and would block the delete, and the denormalized counters on *other* pets' posts/stories must drop by this pet's paws/comments/paw-backs. `pets.delete`, `deleteOwnerAccount` and the admin delete all go through it.
### Download redirect `/app`
`src/app/app/route.ts` (public in `proxy.ts`) is the target for flyers/QR codes: Android → Play Store with sanitized `utm_*` passed as Play `referrer`, iOS → `APP_STORE_URL` in `src/lib/app-links.ts` once set (still `null` until the iOS release; until then the web start page), everything else → web start page; `?ref=<code>` → `/r/<code>`. Logic is pure in `src/lib/app-redirect.ts`; clicks are counted per day/source/platform in Redis (`appredirect:<date>` hashes, `src/lib/app-redirect-stats.ts`) and shown in the admin referral overview. Redis on the NAS is the `pawfeed-redis` container (not `glitchtip-redis`), password in `docker/.env` `REDIS_PASSWORD`.
### New-pet welcome & founding members
`pets.create` fires `welcomeNewPet()` (`src/lib/new-pet-welcome.ts`) in the background — it must never block pet creation. It (1) sets `Pet.isFoundingMember` via one guarded SQL update for the first 250 pets by `createdAt` (never revoked; badge = Sprout icon next to the name, web + mobile), (2) lets the curator pet "PawFeed Team" (`ensureTeamPet` in `src/lib/team-pet.ts`) follow the new pet, and (3) sends a welcome DM via `sendTeamMessage`. (2) and (3) are switchable and the DE/EN text (mandatory `{name}` placeholder) is editable in the admin panel under "Begrüßung" (`/p/[secret]/curator`, `CuratorSettings` id = 1, `curator-admin.ts`). Replies to the welcome DM land in the admin "Messages" team inbox.
### Referral program (Empfehlungsprogramm)
Registration is open; invites are a **Clerk-independent** referral layer on top. Every owner has one permanent, unlimited `Owner.referralCode` (e.g. `BUBI-7K3M`, prefix from the first pet's name — `src/lib/referral-code.ts`). A code is never part of the Clerk sign-up: it is redeemed **after** sign-up, in the onboarding step "Wie hast du von PawFeed erfahren?" (`AcquisitionStep`, web + mobile) or later on `/einladen` (mobile: `/invite`) within `ReferralSettings.redeemWindowDays`. `/r/[code]` is the public landing page (sets a `pf_ref` cookie only to prefill the web onboarding); old `/join?code=` links redirect there. Logic lives in `src/lib/referrals.ts`: redeem sets `Owner.invitedById` and makes both first pets follow each other; an invite **counts** (`referralQualifiedAt`, inviter's denormalized `qualifiedReferralCount` +1) only once the invitee has a pet **and** a first real post — `maybeQualifyReferral()` is called from `posts.create` and the Mux webhook on `READY`; a new post-creating mutation must call it too.
Ranks (`ReferralRank`) are data, fully editable in the admin panel (`/p/[secret]/referrals`, `referral-admin.ts`); only the perk catalog (`ReferralPerk`, `IMPLEMENTED_REFERRAL_PERKS` in `src/lib/referral-ranks.ts`) needs code. Perks are cumulative. Reached ranks are **grandfathered** via `ReferralRankAchievement` — raising a threshold never removes a rank. `Owner.referralRankId`/`referralPerks` are a cache maintained only by `src/lib/referrals.ts` and the set-based `recomputeReferralRanks()` (`src/lib/referral-recompute.ts`, run after every admin rank change); feed cards read them via `petCardSelect`. The legacy `InviteCode` table is history only — nothing writes to it anymore. One-time setup after the schema push: `npx tsx prisma/scripts/referral-setup.ts` (+ `prisma/enable_rls_referrals.sql`).
Ranks (`ReferralRank`) are data, fully editable in the admin panel (`/p/[secret]/referrals`, `referral-admin.ts`); only the perk catalog (`ReferralPerk`, `IMPLEMENTED_REFERRAL_PERKS` in `src/lib/referral-ranks.ts`) needs code. Perks are cumulative. Reached ranks are **grandfathered** via `ReferralRankAchievement` — raising a threshold never removes a rank. An admin can revoke a rank permanently (`revokedAt` on the achievement row — the row stays so award/recompute never re-grant it; it is ignored for badges/perks until "Freigeben") or grant one manually (`grantedManually`). `Owner.referralRankId`/`referralPerks` are a cache maintained only by `src/lib/referrals.ts` and the set-based `recomputeReferralRanks()` (`src/lib/referral-recompute.ts`, run after every admin rank change); feed cards read them via `petCardSelect`. The legacy `InviteCode` table is history only — nothing writes to it anymore. One-time setup after the schema push: `npx tsx prisma/scripts/referral-setup.ts` (+ `prisma/enable_rls_referrals.sql`).
### Testing
@@ -104,7 +114,7 @@ Despite `next.config.ts`'s `output: "standalone"` and the original stack researc
**Runs as 3 load-balanced replicas, not a single container (since 2026-08-16, Gitea #26).** `docker/docker-compose.yml` defines `pawfeed-1`/`pawfeed-2`/`pawfeed-3` (via the `x-pawfeed-common` YAML anchor) instead of one `pawfeed` service — Next.js standalone output is single-process (`node server.js`), so one container only ever used one CPU core regardless of how many the NAS has. A load test on 2026-08-15 hit a hard ceiling at ~60–65 req/sec with one core pegged at 133% while 3 cores sat idle; splitting into 3 replicas fixed it (confirmed via a follow-up load test showing ~100% CPU spread evenly across all three). Nginx Proxy Manager (container `nginx-app-1` on this NAS, admin UI at `192.168.1.222:81`) load-balances across them via a `least_conn` upstream block — that block lives in `/Docker-Data/nginxproxymanager/data/nginx/custom/http_top.conf` on the NAS host (an nginx `upstream {}` is only valid in the `http{}` context, which NPM's per-host "Custom Nginx Configuration" field can't reach — that field only writes into the `server{}`/`location{}` context, and holds the `proxy_pass http://pawfeed_upstream;` override for the `pawfeed.org` Proxy Host instead). Full runbook, decisions, and risk notes: `docker/SCALING-ROADMAP.md`. **Deploying a config or code change means rebuilding all 3 replicas** — there is no separate single-container path anymore, and NPM's proxy target is `pawfeed_upstream`, not a bare `pawfeed:3000` hostname. Use `./docker/rolling-deploy.sh` for deploys against the live site — it rebuilds the shared `pawfeed:latest` image once, then restarts `pawfeed-1`/`2`/`3` one at a time, gated on each becoming `healthy` per the Docker `healthcheck` (`GET /api/health`, a dependency-free liveness check — see `src/app/api/health/route.ts`) before moving to the next, so at least 2 replicas always serve traffic. Verified zero-downtime on 2026-08-16 (continuous curl monitoring showed unbroken HTTP 200 through a full rolling deploy). `docker compose up -d --build` (`start.sh`) still works but restarts all 3 at once — only use it for first-time setup or when a brief outage is acceptable. `src/lib/prisma.ts`'s Prisma pg pool size is capped per-replica via `DB_POOL_MAX` (compose sets `10`) so 3 replicas stay well under the Supabase pooler's client-connection limit.
`src/app/api/cron/trim-feeds/route.ts` trims each pet's Redis feed sorted set to the 90-day retention window and expects a `CRON_SECRET` bearer token. `vercel.json`'s `crons` array that points at it is **inert** on this self-hosted deployment — nothing calls this route yet. It needs an external trigger (e.g. a NAS-side cron job hitting the route with the secret header) before it actually runs.
**Cron jobs run from the NAS crontab** (user `daniel`, `crontab -l`), not from `vercel.json` (whose `crons` array is inert here). Each entry calls `docker/run-cron.sh /api/cron/<name>`, which reads `CRON_SECRET` from `docker/.env`, hits the route with the bearer token via the DDNS host (`--resolve` to localhost — plain `pawfeed.org` fails TLS from the NAS) and logs every run with status + body to `docker/cron.log`. Current schedule: `trim-feeds` 04:00 (Redis feeds → 90 days), `cleanup-stories` 04:30 (deletes stories older than 7 days incl. views, paws, comments, Supabase image and Mux asset — `src/lib/story-cleanup.ts`, max 200 per run), `anniversaries` 08:00, `vaccine-reminders` 08:15 (one-time reminder + push 30 days before `Vaccine.nextDueDate`, `src/lib/vaccine-reminders.ts`, guarded by `Vaccine.reminderSentAt`), `pet-suggestions` Mondays 09:00, plus `docker/backup-db.sh` 03:00. A new cron route needs a new crontab line on the NAS — it does not run otherwise.
### Directories that are not app code
+53 -10
View File
@@ -116,7 +116,8 @@
"zoom": "Zoom",
"cropCancel": "Abbrechen",
"cropSave": "Übernehmen",
"cropSaving": "Wird verarbeitet…"
"cropSaving": "Wird verarbeitet…",
"heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG."
},
"Onboarding": {
"addFirstPet": "Füge dein erstes Tier hinzu",
@@ -259,7 +260,8 @@
"postError": "Kommentar konnte nicht gepostet werden. Bitte versuche es erneut.",
"deleteError": "Kommentar konnte nicht gelöscht werden. Bitte versuche es erneut.",
"countSingular": "1 Kommentar",
"countPlural": "{count} Kommentare"
"countPlural": "{count} Kommentare",
"readOnlyExpired": "Die Story ist abgelaufen. Kommentare kannst du noch lesen, aber nicht mehr schreiben."
},
"RelativeTime": {
"justNow": "gerade eben",
@@ -480,7 +482,13 @@
"typeAdoptionDay": "🏠 Heute ist der Adoptionstag! Feiere den Jahrestag.",
"typePetSuggestion": "könnte dir gefallen",
"typeReferralJoined": "ist über deine Einladung zu PawFeed gekommen",
"typeReferralRankUp": "Du hast einen neuen Empfehlungsrang erreicht. Schau ihn dir an!"
"typeReferralRankUp": "Du hast einen neuen Empfehlungsrang erreicht. Schau ihn dir an!",
"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.",
"typeUnknown": "hat eine neue Mitteilung für dich.",
"typePetDeceased": "ist leider verstorben. In Erinnerung 🕯️"
},
"PhotoPostForm": {
"captionPlaceholder": "Bildunterschrift schreiben…",
@@ -510,7 +518,8 @@
"videoTooLong": "Story-Videos dürfen höchstens {max} Sekunden lang sein.",
"videoUnreadable": "Videolänge konnte nicht gelesen werden — bitte versuch eine andere Datei.",
"musicLabel": "Musik hinzufügen (optional)",
"muteOriginalLabel": "Original-Ton stummschalten, stattdessen Musik abspielen"
"muteOriginalLabel": "Original-Ton stummschalten, stattdessen Musik abspielen",
"heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG."
},
"MilestoneForm": {
"questionLabel": "Was ist der Meilenstein?",
@@ -530,7 +539,8 @@
"errorToast": "Meilenstein konnte nicht geteilt werden. Bitte versuch es erneut.",
"fileTooLarge": "Foto muss unter 10 MB sein.",
"invalidType": "Lade ein JPG-, PNG- oder WebP-Bild hoch.",
"uploadFailed": "Upload fehlgeschlagen — bitte versuch es erneut."
"uploadFailed": "Upload fehlgeschlagen — bitte versuch es erneut.",
"heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG."
},
"MilestoneTypes": {
"BIRTHDAY": "Geburtstag",
@@ -578,12 +588,22 @@
"mute": "Stummschalten",
"unmute": "Ton einschalten",
"videoError": "Video konnte nicht verarbeitet werden.",
"openComments": "Kommentare öffnen"
"openComments": "Kommentare öffnen",
"deleteStory": "Story löschen",
"deleteStoryTitle": "Story löschen?",
"deleteStoryDescription": "Die Story wird mit allen Aufrufen, Pfoten und Kommentaren endgültig gelöscht.",
"deleteStoryCancel": "Abbrechen",
"deleteStoryConfirm": "Löschen",
"storyDeleted": "Story gelöscht",
"storyDeleteError": "Die Story konnte nicht gelöscht werden. Bitte versuch es noch einmal.",
"expiredOwnerOnly": "Abgelaufen – nur für dich sichtbar",
"pawCount": "{count, plural, one {# Pfote} other {# Pfoten}}"
},
"StoryTray": {
"storiesAriaLabel": "Storys",
"addStory": "Story hinzufügen",
"newStoryTitle": "Neue Story"
"newStoryTitle": "Neue Story",
"yourStory": "Deine Story"
},
"RepostCard": {
"pawedBackBy": "Paw-Back von"
@@ -654,9 +674,9 @@
"onlyMe": "Nur ich",
"followerVisibilitySaved": "Follower-Sichtbarkeit gespeichert",
"followerVisibilitySaveFailed": "Follower-Sichtbarkeit konnte nicht gespeichert werden",
"followerEveryoneHint": "Jeder kann die Follower- und Folgt-Listen von {name} sehen.",
"followerFollowersOnlyHint": "Nur Tiere, die {name} folgen, können die Follower- und Folgt-Listen sehen.",
"followerNobodyHint": "Nur du kannst die Follower- und Folgt-Listen von {name} sehen.",
"followerEveryoneHint": "Jeder kann die Follower- und Folgt-Listen von {name} sehen und {name} in Storys markieren.",
"followerFollowersOnlyHint": "Nur Tiere, die {name} folgen, können die Follower- und Folgt-Listen sehen und {name} in Storys markieren.",
"followerNobodyHint": "Nur du kannst die Follower- und Folgt-Listen von {name} sehen — markieren in Storys kann {name} niemand.",
"verificationHeading": "Verifizierung",
"verifiedStatus": "Dieses Tier ist verifiziert.",
"pendingStatus": "Dein Verifizierungsantrag wird noch geprüft.",
@@ -836,5 +856,28 @@
"codeLabel": "Einladungscode",
"codeHint": "Gib den Code nach der Registrierung im Onboarding ein, dann seid ihr direkt verbunden.",
"webSignUp": "Im Browser registrieren"
},
"FoundingMember": {
"label": "Gründungsmitglied",
"tooltip": "Gründungsmitglied – eines der ersten 250 Tiere auf PawFeed"
},
"InMemory": {
"label": "In Erinnerung",
"tooltip": "In Erinnerung – dieses Tier ist über die Regenbogenbrücke gegangen",
"banner": "In Erinnerung an {name} · {date}",
"settingsHeading": "Gedenkmodus",
"settingsHint": "Wenn dein Tier verstorben ist, kannst du sein Profil als Erinnerung erhalten. Es bekommt das Abzeichen „In Erinnerung“, Beiträge bleiben sichtbar und du kannst weiter Erinnerungen teilen. Geburtstags-Mitteilungen, Impf-Erinnerungen und Vorschläge enden.",
"activeHint": "{name} ist im Gedenkmodus. Du kannst das Datum korrigieren oder den Modus beenden.",
"dateLabel": "Verstorben am",
"activate": "Gedenkmodus aktivieren",
"saveDate": "Datum speichern",
"remove": "Gedenkmodus beenden",
"confirmTitle": "{name} in Erinnerung halten?",
"confirmDescription": "Das Profil bekommt das Abzeichen „In Erinnerung“. Wer deinem Tier folgt, erhält einmalig eine stille Mitteilung (ohne Push). Du kannst das jederzeit zurücknehmen.",
"confirm": "Ja, aktivieren",
"cancel": "Abbrechen",
"saved": "Gedenkmodus gespeichert",
"removed": "Gedenkmodus beendet",
"saveFailed": "Konnte nicht gespeichert werden"
}
}
+53 -10
View File
@@ -116,7 +116,8 @@
"zoom": "Zoom",
"cropCancel": "Cancel",
"cropSave": "Apply",
"cropSaving": "Processing…"
"cropSaving": "Processing…",
"heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG."
},
"Onboarding": {
"addFirstPet": "Add your first pet",
@@ -259,7 +260,8 @@
"postError": "Couldn't post comment. Please try again.",
"deleteError": "Couldn't delete comment. Please try again.",
"countSingular": "1 comment",
"countPlural": "{count} comments"
"countPlural": "{count} comments",
"readOnlyExpired": "This story has expired. You can still read comments but not add new ones."
},
"RelativeTime": {
"justNow": "now",
@@ -480,7 +482,13 @@
"typeAdoptionDay": "🏠 Today is adoption day! Celebrate the anniversary.",
"typePetSuggestion": "might be a good match",
"typeReferralJoined": "joined PawFeed through your invite",
"typeReferralRankUp": "You reached a new referral rank. Take a look!"
"typeReferralRankUp": "You reached a new referral rank. Take a look!",
"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.",
"typeUnknown": "has a new notification for you.",
"typePetDeceased": "has sadly passed away. In memory 🕯️"
},
"PhotoPostForm": {
"captionPlaceholder": "Write a caption…",
@@ -510,7 +518,8 @@
"videoTooLong": "Story videos can be at most {max} seconds long.",
"videoUnreadable": "Couldn't read the video length — please try a different file.",
"musicLabel": "Add music (optional)",
"muteOriginalLabel": "Mute original audio, play this track instead"
"muteOriginalLabel": "Mute original audio, play this track instead",
"heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG."
},
"MilestoneForm": {
"questionLabel": "What's the milestone?",
@@ -530,7 +539,8 @@
"errorToast": "Couldn't share this milestone. Please try again.",
"fileTooLarge": "Photo must be under 10 MB.",
"invalidType": "Upload a JPG, PNG, or WebP image.",
"uploadFailed": "Upload failed — please try again."
"uploadFailed": "Upload failed — please try again.",
"heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG."
},
"MilestoneTypes": {
"BIRTHDAY": "Birthday",
@@ -578,12 +588,22 @@
"mute": "Mute",
"unmute": "Unmute",
"videoError": "Video could not be processed.",
"openComments": "Open comments"
"openComments": "Open comments",
"deleteStory": "Delete story",
"deleteStoryTitle": "Delete story?",
"deleteStoryDescription": "The story and all its views, paws and comments will be deleted permanently.",
"deleteStoryCancel": "Cancel",
"deleteStoryConfirm": "Delete",
"storyDeleted": "Story deleted",
"storyDeleteError": "The story could not be deleted. Please try again.",
"expiredOwnerOnly": "Expired – only visible to you",
"pawCount": "{count, plural, one {# paw} other {# paws}}"
},
"StoryTray": {
"storiesAriaLabel": "Stories",
"addStory": "Add story",
"newStoryTitle": "New Story"
"newStoryTitle": "New Story",
"yourStory": "Your story"
},
"RepostCard": {
"pawedBackBy": "Pawed-back by"
@@ -654,9 +674,9 @@
"onlyMe": "Only me",
"followerVisibilitySaved": "Follower privacy setting saved",
"followerVisibilitySaveFailed": "Failed to save follower privacy setting",
"followerEveryoneHint": "Anyone can see {name}'s followers and following lists.",
"followerFollowersOnlyHint": "Only pets that follow {name} can see the followers and following lists.",
"followerNobodyHint": "Only you can see {name}'s followers and following lists.",
"followerEveryoneHint": "Anyone can see {name}'s followers and following lists and tag {name} in stories.",
"followerFollowersOnlyHint": "Only pets that follow {name} can see the followers and following lists and tag {name} in stories.",
"followerNobodyHint": "Only you can see {name}'s followers and following lists — nobody can tag {name} in stories.",
"verificationHeading": "Verification",
"verifiedStatus": "This pet is verified.",
"pendingStatus": "Your verification request is pending review.",
@@ -836,5 +856,28 @@
"codeLabel": "Invite code",
"codeHint": "Enter the code during onboarding after signing up and you are connected right away.",
"webSignUp": "Sign up in the browser"
},
"FoundingMember": {
"label": "Founding member",
"tooltip": "Founding member – one of the first 250 pets on PawFeed"
},
"InMemory": {
"label": "In memory",
"tooltip": "In memory – this pet has crossed the rainbow bridge",
"banner": "In memory of {name} · {date}",
"settingsHeading": "Memorial mode",
"settingsHint": "If your pet has passed away, you can keep their profile as a memorial. It gets the “In memory” badge, posts stay visible and you can keep sharing memories. Birthday notifications, vaccine reminders and suggestions stop.",
"activeHint": "{name} is in memorial mode. You can correct the date or end the mode.",
"dateLabel": "Passed away on",
"activate": "Turn on memorial mode",
"saveDate": "Save date",
"remove": "End memorial mode",
"confirmTitle": "Keep {name} in memory?",
"confirmDescription": "The profile gets the “In memory” badge. Everyone following your pet receives a single quiet notification (no push). You can undo this at any time.",
"confirm": "Yes, turn on",
"cancel": "Cancel",
"saved": "Memorial mode saved",
"removed": "Memorial mode ended",
"saveFailed": "Could not be saved"
}
}
+26 -7
View File
@@ -152,13 +152,28 @@ function SettingsHeaderButton() {
);
}
// found.md: a push for a new message arrived but the speech bubble showed
// nothing. Polling keeps the badge current while the app stays open; reading
// a conversation invalidates conversations.* (see messages/[conversationId]).
const UNREAD_MESSAGES_POLL_MS = 30 * 1000;
function MessagesHeaderButton() {
const router = useRouter();
const trpc = useTRPC();
const { activePetId } = useActivePet();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const { data } = useQuery({
...trpc.conversations.unreadCount.queryOptions({ petId: activePetId ?? "" }),
enabled: !!activePetId,
refetchInterval: UNREAD_MESSAGES_POLL_MS,
});
return (
<Pressable onPress={() => router.push("/messages")} hitSlop={8} style={styles.headerButton}>
<MessageCircle color={colors.ink} size={22} />
<View>
<MessageCircle color={colors.ink} size={22} />
<UnreadBadge count={data?.count ?? 0} styles={styles} />
</View>
</Pressable>
);
}
@@ -172,16 +187,20 @@ function NotificationsTabIcon({ color, size }: { color: ColorValue; size: number
...trpc.notifications.unreadCount.queryOptions({ petId: activePetId ?? "" }),
enabled: !!activePetId,
});
const count = data?.count ?? 0;
return (
<View>
<Bell color={color} size={size} />
{count > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{count > 9 ? "9+" : count}</Text>
</View>
)}
<UnreadBadge count={data?.count ?? 0} styles={styles} />
</View>
);
}
function UnreadBadge({ count, styles }: { count: number; styles: ReturnType<typeof makeStyles> }) {
if (count <= 0) return null;
return (
<View style={styles.badge}>
<Text style={styles.badgeText}>{count > 9 ? "9+" : count}</Text>
</View>
);
}
+1 -1
View File
@@ -164,7 +164,7 @@ function ExplorePetCard({ pet }: { pet: ExplorePet }) {
return (
<Pressable style={styles.cardPress} onPress={() => router.push(`/pets/${pet.id}`)}>
<Card style={styles.card}>
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" owner={pet.owner} />
<Text style={styles.name} numberOfLines={1}>
{formatPetName(pet)}
</Text>
+55 -30
View File
@@ -9,6 +9,8 @@ import { useActivePet } from "../../../src/context/ActivePetContext";
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";
@@ -21,6 +23,10 @@ const ACTOR_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
PAW_BACK: "notifications.pawBack",
PET_SUGGESTION: "notifications.petSuggestion",
REFERRAL_JOINED: "notifications.referralJoined",
STORY_MENTION: "notifications.storyMention",
STORY_COMMENT: "notifications.storyComment",
STORY_REACTION: "notifications.storyReaction",
PET_DECEASED: "notifications.petDeceased",
};
// BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the
@@ -37,44 +43,51 @@ const SELF_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
// independently of the row's own tap target, ported from web's
// notifications/page.tsx (name as a <Link>, rest as plain text).
function getNotificationSuffix(item: NotificationItem, t: TFunction): string {
if (item.type === "VACCINE_DUE") {
if (!item.vaccine?.nextDueDate) return t("notifications.vaccineDueGeneric");
const iso = new Date(item.vaccine.nextDueDate).toISOString().slice(0, 10);
return t("notifications.vaccineDue", { name: item.vaccine.name, date: isoToDisplayDate(iso) });
}
const selfKey = SELF_MESSAGE_KEYS[item.type];
if (selfKey) return t(selfKey);
const actorKey = ACTOR_MESSAGE_KEYS[item.type];
if (actorKey && item.actorPet) return t(actorKey);
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>> = {
BIRTHDAY: "🎂",
VACCINE_DUE: "💉",
};
/**
* 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 {
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":
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();
@@ -157,16 +170,28 @@ export default function NotificationsScreen() {
}}
>
{item.actorPet ? (
<PetAvatar name={item.actorPet.name} avatarKey={item.actorPet.avatarKey} />
<PetAvatar name={item.actorPet.name} avatarKey={item.actorPet.avatarKey} owner={item.actorPet.owner} />
) : (
<View style={styles.selfIcon}>
<Text style={styles.selfIconText}>{item.type === "BIRTHDAY" ? "🎂" : "🏠"}</Text>
<Text style={styles.selfIconText}>{SELF_ICONS[item.type] ?? "🏠"}</Text>
</View>
)}
<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>
)}
+2 -2
View File
@@ -80,11 +80,11 @@ function PetRow({
return (
<Pressable onPress={onPress}>
<Card style={styles.row}>
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" owner={pet.owner} />
<View style={styles.rowText}>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(pet)}</Text>
<PetIdentityBadges isVerified={pet.isVerified} />
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} />
</View>
<Text style={styles.breed}>{pet.breed?.name ?? pet.species.name}</Text>
</View>
+8
View File
@@ -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
@@ -70,6 +73,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={{
+5 -1
View File
@@ -52,7 +52,11 @@ export default function InviteScreen() {
}
return (
<ScrollView contentContainerStyle={[styles.content, bottomPadding]}>
<ScrollView
contentContainerStyle={[styles.content, bottomPadding]}
automaticallyAdjustKeyboardInsets
keyboardShouldPersistTaps="handled"
>
<Text style={styles.subtitle}>{t("referrals.subtitle")}</Text>
{!data.programActive && <Text style={styles.notice}>{t("referrals.programPaused")}</Text>}
+1 -1
View File
@@ -66,7 +66,7 @@ function ConversationRow({ conversation, onPress }: { conversation: Conversation
const styles = useMemo(() => makeStyles(colors), [colors]);
return (
<Pressable style={styles.row} onPress={onPress}>
<PetAvatar name={conversation.otherPet.name} avatarKey={conversation.otherPet.avatarKey} />
<PetAvatar name={conversation.otherPet.name} avatarKey={conversation.otherPet.avatarKey} owner={conversation.otherPet.owner} />
<View style={styles.rowText}>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(conversation.otherPet)}</Text>
+5 -1
View File
@@ -88,7 +88,11 @@ export default function NewMilestoneScreen() {
const isSubmitting = getPresignedUrlMutation.isPending || createMutation.isPending;
return (
<ScrollView contentContainerStyle={styles.container}>
<ScrollView
contentContainerStyle={styles.container}
automaticallyAdjustKeyboardInsets
keyboardShouldPersistTaps="handled"
>
<Text style={styles.label}>{t("milestones.typeLabel")}</Text>
<View style={styles.chipRow}>
{USER_INITIATED_MILESTONE_TYPES.map((type) => {
+18 -3
View File
@@ -8,8 +8,11 @@ import { useTRPC } from "../../../../src/api/trpc";
import { useActivePet } from "../../../../src/context/ActivePetContext";
import { useTheme } from "../../../../src/context/ThemeContext";
import { TextField } from "../../../../src/components/TextField";
import { DateField } from "../../../../src/components/DateField";
import { Button } from "../../../../src/components/Button";
import { BreedPicker } from "../../../../src/components/pet/BreedPicker";
import { PrivacySettings } from "../../../../src/components/pet/PrivacySettings";
import { MemorialSettings } from "../../../../src/components/pet/MemorialSettings";
import { radii, getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
import { displayDateToIso, isoToDisplayDate } from "../../../../src/lib/date-format";
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
@@ -17,7 +20,8 @@ import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding
/**
* Edit form (name, nickname, breed, bio, adoption story, birthday, adoption
* date) — a subset of web's src/app/(app)/pets/[petId]/edit/page.tsx.
* DM-policy/follower-visibility/avatar upload aren't editable here yet.
* Privacy (DM policy, follower visibility) lives in PrivacySettings and saves
* on its own; avatar upload isn't editable here yet.
* Species itself stays fixed (only its breed is re-selectable) — found.md
* asked specifically for breed correction, not a full species change.
*/
@@ -125,6 +129,8 @@ export default function EditPetScreen() {
data={EMPTY_DATA}
renderItem={null}
contentContainerStyle={[styles.container, bottomPadding]}
automaticallyAdjustKeyboardInsets
keyboardShouldPersistTaps="handled"
ListHeaderComponent={
<>
<Text style={styles.label}>{t("editPet.nameLabel")}</Text>
@@ -156,15 +162,24 @@ export default function EditPetScreen() {
/>
<Text style={styles.label}>{t("editPet.birthdayLabel")}</Text>
<TextField placeholder={t("health.weightDatePlaceholder")} value={birthday} onChangeText={setBirthday} />
<DateField placeholder={t("health.weightDatePlaceholder")} value={birthday} onChangeText={setBirthday} />
<Text style={styles.label}>{t("editPet.adoptedAtLabel")}</Text>
<TextField placeholder={t("health.weightDatePlaceholder")} value={adoptedAt} onChangeText={setAdoptedAt} />
<DateField placeholder={t("health.weightDatePlaceholder")} value={adoptedAt} onChangeText={setAdoptedAt} />
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button title={t("common.save")} disabled={updateMutation.isPending} onPress={handleSave} />
<PrivacySettings
petId={pet.id}
petName={pet.name}
dmPolicy={pet.dmPolicy}
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>
+1 -1
View File
@@ -37,7 +37,7 @@ export default function FollowersScreen() {
ItemSeparatorComponent={() => <View style={styles.separator} />}
renderItem={({ item }) => (
<Pressable style={styles.row} onPress={() => router.push(`/pets/${item.follower.id}`)}>
<PetAvatar name={item.follower.name} avatarKey={item.follower.avatarKey} />
<PetAvatar name={item.follower.name} avatarKey={item.follower.avatarKey} owner={item.follower.owner} />
<Text style={typography.body}>{formatPetName(item.follower)}</Text>
</Pressable>
)}
+1 -1
View File
@@ -37,7 +37,7 @@ export default function FollowingScreen() {
ItemSeparatorComponent={() => <View style={styles.separator} />}
renderItem={({ item }) => (
<Pressable style={styles.row} onPress={() => router.push(`/pets/${item.followee.id}`)}>
<PetAvatar name={item.followee.name} avatarKey={item.followee.avatarKey} />
<PetAvatar name={item.followee.name} avatarKey={item.followee.avatarKey} owner={item.followee.owner} />
<Text style={typography.body}>{formatPetName(item.followee)}</Text>
</Pressable>
)}
@@ -22,7 +22,11 @@ export default function PetHealthScreen() {
const bottomPadding = useBottomSafePadding();
return (
<ScrollView contentContainerStyle={[styles.container, bottomPadding]}>
<ScrollView
contentContainerStyle={[styles.container, bottomPadding]}
automaticallyAdjustKeyboardInsets
keyboardShouldPersistTaps="handled"
>
<HealthConsentGate petId={petId}>
<WeightLogSection petId={petId} />
<VetVisitSection petId={petId} />
+28 -2
View File
@@ -3,7 +3,7 @@ import { View, Text, FlatList, Pressable, ActivityIndicator, Alert, StyleSheet }
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation } from "@tanstack/react-query";
import { HeartPulse, Trophy, MessageCircle, MoreHorizontal } from "lucide-react-native";
import { HeartPulse, Trophy, MessageCircle, MoreHorizontal, Ribbon } from "lucide-react-native";
import { useTRPC } from "../../../../src/api/trpc";
import { useActivePet } from "../../../../src/context/ActivePetContext";
import { useTheme } from "../../../../src/context/ThemeContext";
@@ -17,6 +17,7 @@ import { ReportSheet } from "../../../../src/components/ReportSheet";
import { useBlockPet } from "../../../../src/hooks/useBlockPet";
import { useVisiblePostIds } from "../../../../src/hooks/useVisiblePostIds";
import { formatPetName } from "../../../../src/lib/format";
import { isoToDisplayDate } from "../../../../src/lib/date-format";
import { getAvatarUrl } from "../../../../src/lib/media-url";
import { getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
@@ -145,8 +146,20 @@ export default function PetProfileScreen() {
</ReferralAvatarRing>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(pet)}</Text>
<PetIdentityBadges isVerified={pet.isVerified} sponsor={pet.owner} referral={pet.owner} />
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
</View>
{pet.deceasedAt && (
<View style={styles.memorialBanner}>
<Ribbon size={13} color={colors.ink} fill={colors.ink} />
<Text style={styles.memorialText}>
{t("inMemory.banner", {
name: pet.name,
date: isoToDisplayDate(new Date(pet.deceasedAt).toISOString().slice(0, 10)),
})}
</Text>
</View>
)}
{pet.isFoundingMember && <Text style={styles.foundingHint}>{t("foundingMember.tooltip")}</Text>}
<Text style={styles.species}>{pet.species.name}</Text>
{pet.invitedByPet && (
<Text style={styles.invitedBy}>
@@ -228,6 +241,19 @@ function makeStyles(colors: ColorTokens) {
name: { ...typography.title },
species: { ...typography.body, color: colors.quietGray },
invitedBy: { ...typography.body, fontSize: 12, color: colors.quietGray },
foundingHint: { ...typography.body, fontSize: 12, color: "#059669" },
memorialBanner: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 999,
borderWidth: 1,
borderColor: colors.hairlineGray,
backgroundColor: colors.whisperGray,
},
memorialText: { ...typography.body, fontSize: 12, color: colors.quietGray },
invitedByName: { color: colors.ink, fontWeight: "600" },
statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 },
stat: { alignItems: "center" },
+16 -5
View File
@@ -14,7 +14,10 @@ import { TextField } from "../../src/components/TextField";
import { Button } from "../../src/components/Button";
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
// HEIC/HEIF (iPhone, manche Android-Kameras) ist hier ausdrücklich erlaubt:
// resizeForUpload() wandelt jedes Bild vor dem Upload in JPEG um, der Server
// sieht also nie HEIC. Vorher wurden solche Fotos hier still aussortiert.
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"];
const MAX_IMAGES = 10;
type PickedImage = {
@@ -92,9 +95,13 @@ export default function NewPostScreen() {
return;
}
const accepted = assets
.filter((asset) => ALLOWED_MIME_TYPES.includes(asset.mimeType ?? "image/jpeg"))
.slice(0, available);
const supported = assets.filter((asset) =>
ALLOWED_MIME_TYPES.includes((asset.mimeType ?? "image/jpeg").toLowerCase())
);
if (supported.length < assets.length) {
Alert.alert(t("post.unsupportedFormat"));
}
const accepted = supported.slice(0, available);
if (accepted.length === 0) return;
@@ -155,7 +162,11 @@ export default function NewPostScreen() {
};
return (
<ScrollView contentContainerStyle={styles.container}>
<ScrollView
contentContainerStyle={styles.container}
automaticallyAdjustKeyboardInsets
keyboardShouldPersistTaps="handled"
>
{images.length > 0 && (
<View style={styles.grid}>
{images.map((img, i) => (
+1 -1
View File
@@ -154,7 +154,7 @@ function CommentRow({ comment, isOwn, onDelete, onPressPet }: CommentRowProps) {
return (
<View style={styles.row}>
<Pressable onPress={onPressPet}>
<PetAvatar name={comment.pet.name} avatarKey={comment.pet.avatarKey} size="sm" />
<PetAvatar name={comment.pet.name} avatarKey={comment.pet.avatarKey} size="sm" owner={comment.pet.owner} />
</Pressable>
<View style={styles.rowBody}>
<View style={styles.rowHeader}>
+3 -1
View File
@@ -15,6 +15,7 @@ import { FollowButton } from "../../src/components/FollowButton";
import { getMediaUrl } from "../../src/lib/media-url";
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
import { useBottomSafePadding } from "../../src/hooks/useBottomSafePadding";
import type { ReferralOwnerFields } from "../../src/hooks/useReferralRanks";
type Tab = "pets" | "people" | "tags";
type Styles = ReturnType<typeof makeStyles>;
@@ -25,6 +26,7 @@ type SearchPet = {
avatarKey: string | null;
species: { name: string };
breed: { name: string } | null;
owner?: ReferralOwnerFields | null;
};
/**
@@ -207,7 +209,7 @@ function PetRow({
return (
<View style={[styles.petRow, indented && styles.petRowIndented]}>
<Pressable style={styles.petRowMain} onPress={onPress}>
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="sm" />
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="sm" owner={pet.owner} />
<View style={styles.petRowText}>
<Text style={styles.petName}>{pet.name}</Text>
<Text style={styles.petSpecies}>{pet.breed?.name ?? pet.species.name}</Text>
+183
View File
@@ -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" },
});
+82 -82
View File
@@ -1,47 +1,48 @@
import { useState, useCallback, useMemo } from "react";
import { View, Text, Image as RNImage, Pressable, ActivityIndicator, StyleSheet, Alert } from "react-native";
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, useQueryClient } from "@tanstack/react-query";
import { ImagePlus, Camera } from "lucide-react-native";
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 } from "../../src/lib/upload";
import { resizeForUpload } from "../../src/lib/resize-image";
import { Button } from "../../src/components/Button";
import { MAX_STORY_VIDEO_DURATION_SECS } from "../../src/lib/story-video-limits";
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
/**
* Photo-only story creation (matches web's own "Phase 2" note in
* stories.ts: "Photo-only in Phase 2" — video stories are a Mux upload
* flow web itself only added later, not carried over here). Single image,
* unlike post-new.tsx's multi-image picker — a story is one photo.
* 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 styles = useMemo(() => makeStyles(colors), [colors]);
const insets = useSafeAreaInsets();
const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]);
const [asset, setAsset] = useState<ImagePicker.ImagePickerAsset | null>(null);
const [error, setError] = useState<string | null>(null);
const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
const editorEnabled = flags?.storyEditor ?? false;
const getPresignedUrlMutation = useMutation(trpc.stories.getPresignedUrl.mutationOptions());
const createMutation = useMutation(
trpc.stories.create.mutationOptions({
onSuccess: async () => {
await queryClient.invalidateQueries(trpc.follows.pathFilter());
await queryClient.invalidateQueries(trpc.stories.pathFilter());
router.back();
},
onError: (err) => setError(err.message),
})
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 () => {
@@ -50,9 +51,23 @@ export default function NewStoryScreen() {
Alert.alert(t("onboarding.galleryPermissionDenied"));
return;
}
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.8 });
if (!result.canceled) setAsset(result.assets[0]);
}, []);
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images", "videos"], quality: 0.8 });
if (result.canceled) return;
// expo-image-picker meldet `duration` (ms) nur bei Videos — verlässlicher als ein MIME-Typ.
const picked = result.assets[0];
if (picked.duration != null && picked.duration / 1000 > MAX_STORY_VIDEO_DURATION_SECS) {
Alert.alert(t("story.videoTooLong", { max: MAX_STORY_VIDEO_DURATION_SECS }));
return;
}
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();
@@ -61,65 +76,44 @@ export default function NewStoryScreen() {
return;
}
const result = await ImagePicker.launchCameraAsync({ quality: 0.8 });
if (!result.canceled) setAsset(result.assets[0]);
}, []);
const handleSubmit = useCallback(async () => {
if (!asset || !activePetId) return;
setError(null);
try {
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
petId: activePetId,
contentType: resized.mimeType,
fileSize: resized.fileSize,
});
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
createMutation.mutate({ petId: activePetId, storageKey: key, blurhash: null });
} catch {
setError(t("onboarding.uploadError"));
if (!result.canceled) {
const photo = result.assets[0];
openEditor({ kind: "image", uri: photo.uri, width: photo.width, height: photo.height });
}
}, [asset, activePetId, getPresignedUrlMutation, createMutation]);
const isSubmitting = getPresignedUrlMutation.isPending || createMutation.isPending;
}, [t, openEditor]);
return (
<View style={styles.container}>
{asset ? (
<Pressable style={styles.preview} onPress={() => setAsset(null)}>
<RNImage source={{ uri: asset.uri }} style={styles.previewImage} />
<Text style={styles.previewHint}>{t("story.changePhotoHint")}</Text>
<View style={styles.pickerRow}>
<Pressable style={styles.pickerButton} onPress={pickFromGallery}>
<ImagePlus color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
</Pressable>
<Pressable style={styles.pickerButton} onPress={takePhoto}>
<Camera color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
</Pressable>
</View>
{editorEnabled && (
<Pressable style={styles.textStoryButton} onPress={() => openEditor({ kind: "text" })}>
<Type color={colors.pureWhite} size={22} />
<Text style={styles.textStoryText}>{t("storyEditor.textStory")}</Text>
</Pressable>
) : (
<View style={styles.pickerRow}>
<Pressable style={styles.pickerButton} onPress={pickFromGallery}>
<ImagePlus color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
</Pressable>
<Pressable style={styles.pickerButton} onPress={takePhoto}>
<Camera color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
</Pressable>
</View>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title={isSubmitting ? t("milestones.sharing") : t("story.shareButton")}
onPress={handleSubmit}
disabled={!asset || isSubmitting}
/>
{isSubmitting && <ActivityIndicator color={colors.marigoldOrange} />}
</View>
);
}
function makeStyles(colors: ColorTokens) {
function makeStyles(colors: ColorTokens, bottomInset: number) {
const typography = getTypography(colors);
return StyleSheet.create({
container: { flex: 1, padding: 16, gap: 12, backgroundColor: colors.porchWhite },
container: {
flex: 1,
padding: 16,
paddingBottom: Math.max(16, bottomInset + 12),
gap: 12,
backgroundColor: colors.porchWhite,
},
pickerRow: { flexDirection: "row", gap: 12 },
pickerButton: {
flex: 1,
@@ -133,9 +127,15 @@ function makeStyles(colors: ColorTokens) {
borderColor: colors.hairlineGray,
},
pickerText: { ...typography.body, color: colors.ink },
preview: { flex: 1, borderRadius: radii.md, overflow: "hidden", backgroundColor: colors.hairlineGray, gap: 8 },
previewImage: { width: "100%", flex: 1 },
previewHint: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", paddingVertical: 6 },
error: { ...typography.body, color: colors.alertRed },
textStoryButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
height: 48,
borderRadius: radii.lg,
backgroundColor: colors.marigoldOrange,
},
textStoryText: { ...typography.body, color: colors.pureWhite, fontFamily: typography.title.fontFamily },
});
}
+359 -20
View File
@@ -1,52 +1,151 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { View, Text, Pressable, StyleSheet, Alert, ActivityIndicator, type LayoutChangeEvent } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
import { X } from "lucide-react-native";
import { useVideoPlayer, VideoView } from "expo-video";
import { useEventListener } from "expo";
import { AlertCircle, Clock, Eye, MessageCircle, PawPrint, Trash2, X } from "lucide-react-native";
import { useTRPC } from "../../src/api/trpc";
import { useActivePet } from "../../src/context/ActivePetContext";
import { PetAvatar } from "../../src/components/PetAvatar";
import { StoryPawButton } from "../../src/components/stories/StoryPawButton";
import { StoryCommentsSheet } from "../../src/components/stories/StoryCommentsSheet";
import { StoryViewersSheet } from "../../src/components/stories/StoryViewersSheet";
import { getMediaUrl } from "../../src/lib/media-url";
import { StoryOverlayLayer } from "../../src/components/story-editor/StoryOverlayLayer";
import { StoryBackground } from "../../src/components/story-editor/StoryBackground";
import { containRect, storyMediaSize } from "../../src/lib/story-overlays";
import { colors } from "../../src/theme/tokens";
const STORY_DURATION_MS = 5000;
const IMAGE_STORY_DURATION_MS = 5000;
// "away" = über eine Markierung ins Tierprofil gewechselt — Story pausiert bis zur Rückkehr.
type Sheet = "comments" | "viewers" | "confirm-delete" | "away" | null;
/**
* Port of web's src/components/stories/StoryViewer.tsx, scoped to photo
* stories only (no Mux video story playback — see story-new.tsx). Also
* drops the owner-only viewer-count/list Sheet and the paw-reaction/comment
* overlay (Gitea #29 additions on web) — fast-follows, not core to "view a
* story". Tap the left third to go back a story, the rest to skip ahead;
* a fixed timer still auto-advances either way.
* Port of web's src/components/stories/StoryViewer.tsx. Tap the left third
* to go back a story, the rest to skip ahead. Foto-Storys laufen über einen
* festen 5-s-Timer; Video-Storys folgen der echten Wiedergabe (expo-video
* `timeUpdate`) und springen bei `playToEnd` weiter, wie im Web. Pfote, Kommentare (Bottom-Sheet) und — nur für die eigene
* Story — die Zuschauerliste und Löschen wie im Web; solange ein Sheet oder
* 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();
const trpc = useTRPC();
const queryClient = useQueryClient();
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 { data: stories = [] } = useQuery(trpc.stories.listActive.queryOptions({ petId }));
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);
const elapsedRef = useRef(0); // bereits abgelaufene Zeit der aktuellen Story (für Pause/Weiter)
const currentStory = stories[index];
const isPaused = openSheet !== null;
const isVideo = currentStory?.mediaType === "VIDEO";
const isText = currentStory?.mediaType === "TEXT";
const [screen, setScreen] = useState({ width: 0, height: 0 });
const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
// Notschalter: Overlays nur zeigen, wenn der Story-Editor im Admin an ist.
const showOverlays = flags?.storyEditor ?? false;
// Zurück aus dem Tierprofil (über eine Markierung) → Story läuft weiter.
useFocusEffect(
useCallback(() => {
setOpenSheet((sheet) => (sheet === "away" ? null : sheet));
}, [])
);
const recordViewMutation = useMutation(trpc.stories.recordView.mutationOptions());
const deleteMutation = useMutation(
trpc.stories.delete.mutationOptions({
onSuccess: () => {
const remaining = stories.length - 1;
setOpenSheet(null);
void queryClient.invalidateQueries(trpc.stories.pathFilter());
void queryClient.invalidateQueries(trpc.follows.pathFilter());
if (remaining <= 0) router.back();
else setIndex((i) => Math.min(i, remaining - 1));
},
onError: () => {
setOpenSheet(null);
Alert.alert(t("storyViewer.deleteError"));
},
})
);
// Story-ID beim Antippen festhalten: gelöscht wird genau die angezeigte Story.
const confirmDelete = (storyId: string) => {
setOpenSheet("confirm-delete");
Alert.alert(
t("storyViewer.deleteTitle"),
t("storyViewer.deleteDescription"),
[
{ text: t("common.cancel"), style: "cancel", onPress: () => setOpenSheet(null) },
{ text: t("storyViewer.deleteConfirm"), style: "destructive", onPress: () => deleteMutation.mutate({ storyId }) },
],
{ cancelable: true, onDismiss: () => setOpenSheet(null) }
);
};
// Solange ein Video noch verarbeitet wird, Status abfragen (Self-Heal wie VideoPlayer.tsx).
const { data: videoStatusData } = useQuery({
...trpc.stories.getVideoStatus.queryOptions({ storyId: currentStory?.id ?? "" }),
enabled: !!currentStory && isVideo && currentStory.videoStatus === "PROCESSING",
refetchInterval: (query) => (query.state.data?.videoStatus === "PROCESSING" ? 3000 : false),
});
const videoStatus = videoStatusData?.videoStatus ?? currentStory?.videoStatus;
const videoPlaybackId = videoStatusData?.muxPlaybackId ?? currentStory?.muxPlaybackId;
const { data: viewers = [] } = useQuery({
...trpc.stories.getViewers.queryOptions({ storyId: currentStory?.id ?? "" }),
enabled: isOwnStory && !!currentStory,
});
const close = useCallback(() => {
queryClient.invalidateQueries(trpc.stories.hasActiveStory.queryOptions({ petId }));
queryClient.invalidateQueries(trpc.follows.pathFilter());
router.back();
}, [queryClient, trpc, petId, router]);
const openPet = useCallback(
(targetPetId: string) => {
setOpenSheet(null);
router.push(`/pets/${targetPetId}`);
},
[router]
);
const openMentionedPet = useCallback(
(targetPetId: string) => {
setOpenSheet("away");
router.push(`/pets/${targetPetId}`);
},
[router]
);
const advance = useCallback(() => {
if (advancedRef.current) return;
advancedRef.current = true;
@@ -70,16 +169,25 @@ export default function StoryViewerScreen() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStory?.id, activePetId]);
// Fixed-duration progress bar, auto-advances/closes when it fills.
// Neue Story → Fortschritt zurücksetzen.
useEffect(() => {
if (!currentStory) return;
setProgress(0);
elapsedRef.current = 0;
advancedRef.current = false;
const startTime = Date.now();
setProgress(0);
}, [currentStory?.id]);
// Fester Timer nur für Foto-Storys; pausiert, solange ein Sheet offen ist.
// Video-Storys steuern Fortschritt und Weiterspringen selbst (ReadyVideoStory).
useEffect(() => {
if (!currentStory || isVideo || isPaused) return;
const resumedAt = Date.now();
const elapsedBefore = elapsedRef.current;
let rafId: number;
function tick() {
const pct = Math.min(((Date.now() - startTime) / STORY_DURATION_MS) * 100, 100);
const elapsed = elapsedBefore + (Date.now() - resumedAt);
elapsedRef.current = elapsed;
const pct = Math.min((elapsed / IMAGE_STORY_DURATION_MS) * 100, 100);
setProgress(pct);
if (pct < 100) rafId = requestAnimationFrame(tick);
else advance();
@@ -87,14 +195,36 @@ export default function StoryViewerScreen() {
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [currentStory, advance]);
}, [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();
}, [storiesLoaded, stories.length, router]);
if (!currentStory) return null;
const mediaUrl = currentStory.storageKey ? getMediaUrl(currentStory.storageKey) : null;
const refreshStories = () => void queryClient.invalidateQueries({ queryKey: listQuery.queryKey });
// Medienfläche wie bei contentFit="contain" — darauf beziehen sich die Overlays.
const mediaSize = storyMediaSize(currentStory);
const rect = mediaSize ? containRect(screen.width, screen.height, mediaSize.width, mediaSize.height) : null;
const onLayout = (e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setScreen((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));
};
return (
<View style={styles.backdrop}>
<View style={styles.backdrop} onLayout={onLayout}>
<View style={[styles.progressRow, { top: insets.top + 12 }]}>
{stories.map((s, i) => (
<View key={s.id} style={styles.progressTrack}>
@@ -108,28 +238,210 @@ export default function StoryViewerScreen() {
))}
</View>
{mediaUrl && <Image source={{ uri: mediaUrl }} style={styles.media} contentFit="contain" />}
{isVideo ? (
<VideoStory
key={currentStory.id}
status={videoStatus}
playbackId={videoPlaybackId}
paused={isPaused}
onProgress={setProgress}
onEnded={advance}
/>
) : isText ? (
rect && (
<View style={{ position: "absolute", left: rect.x, top: rect.y }}>
<StoryBackground backgroundKey={currentStory.background ?? "sunset"} width={rect.width} height={rect.height} />
</View>
)
) : (
mediaUrl && <Image source={{ uri: mediaUrl }} style={styles.media} contentFit="contain" />
)}
<View style={styles.tapZones}>
<Pressable style={styles.tapZoneLeft} onPress={goBack} />
<Pressable style={styles.tapZoneRight} onPress={advance} />
</View>
{showOverlays && rect && (
<StoryOverlayLayer
overlays={currentStory.overlays}
rect={rect}
mentionedPets={currentStory.mentionedPets}
onPressMention={openMentionedPet}
/>
)}
<View style={[styles.petInfo, { bottom: insets.bottom + 24 }]}>
<PetAvatar name={petName} avatarKey={petAvatarKey || null} size="sm" />
<Text style={styles.petName}>{petName}</Text>
</View>
{currentStory.isExpired && (
<View style={[styles.expiredRow, { top: insets.top + 56 }]} pointerEvents="none">
<View style={styles.expiredBadge}>
<Clock size={14} color={colors.pureWhite} />
<Text style={styles.expiredText}>{t("storyViewer.expiredOwnerOnly")}</Text>
</View>
</View>
)}
{isOwnStory && (
<View style={[styles.ownerActions, { top: insets.top + 8 }]}>
<Pressable
style={styles.viewerCount}
onPress={() => setOpenSheet("viewers")}
accessibilityRole="button"
accessibilityLabel={t("storyViewer.viewersTitle", { count: viewers.length })}
>
<Eye size={16} color={colors.pureWhite} />
<Text style={styles.viewerCountText}>{viewers.length}</Text>
</Pressable>
<Pressable
style={styles.deleteButton}
onPress={() => confirmDelete(currentStory.id)}
disabled={deleteMutation.isPending}
accessibilityRole="button"
accessibilityLabel={t("storyViewer.deleteStory")}
hitSlop={8}
>
{deleteMutation.isPending ? <ActivityIndicator color={colors.pureWhite} size="small" /> : <Trash2 size={16} color={colors.pureWhite} />}
</Pressable>
</View>
)}
{activePetId && (
<View style={[styles.actions, { bottom: insets.bottom + 24 }]}>
{currentStory.isExpired ? (
<View style={styles.actionWrap} accessibilityLabel={t("storyViewer.pawCount", { count: currentStory.reactionCount })}>
<View style={styles.actionCircle}>
<PawPrint size={20} color={colors.pureWhite} />
</View>
{currentStory.reactionCount > 0 && <Text style={styles.actionCount}>{currentStory.reactionCount}</Text>}
</View>
) : (
<StoryPawButton
key={currentStory.id}
storyId={currentStory.id}
petId={activePetId}
initialCount={currentStory.reactionCount}
initialReacted={currentStory.reactedByViewer}
/>
)}
<Pressable
onPress={() => setOpenSheet("comments")}
accessibilityRole="button"
accessibilityLabel={t("storyViewer.openComments")}
hitSlop={8}
style={styles.actionWrap}
>
<View style={styles.actionCircle}>
<MessageCircle size={20} color={colors.pureWhite} />
</View>
{currentStory.commentCount > 0 && <Text style={styles.actionCount}>{currentStory.commentCount}</Text>}
</Pressable>
</View>
)}
<Pressable style={[styles.closeButton, { top: insets.top + 8 }]} onPress={close} hitSlop={12}>
<X color={colors.pureWhite} size={22} />
</Pressable>
{activePetId && (
<StoryCommentsSheet
storyId={currentStory.id}
activePetId={activePetId}
visible={openSheet === "comments"}
onClose={() => setOpenSheet(null)}
onChanged={refreshStories}
onPressPet={openPet}
readOnly={currentStory.isExpired}
/>
)}
{isOwnStory && (
<StoryViewersSheet
storyId={currentStory.id}
visible={openSheet === "viewers"}
onClose={() => setOpenSheet(null)}
onPressPet={openPet}
/>
)}
</View>
);
}
type VideoStatusValue = "PROCESSING" | "READY" | "ERROR";
interface VideoStoryProps {
status: VideoStatusValue | null | undefined;
playbackId: string | null | undefined;
paused: boolean;
onProgress: (pct: number) => void;
onEnded: () => void;
}
/** Zeigt den Verarbeitungs-/Fehlerzustand; der Player wird erst bei READY gemountet (wie VideoPlayer.tsx). */
function VideoStory({ status, playbackId, ...playerProps }: VideoStoryProps) {
const { t } = useTranslation();
if (status === "READY" && playbackId) {
return <ReadyVideoStory playbackId={playbackId} {...playerProps} />;
}
return (
<View style={styles.statusBox}>
{status === "ERROR" ? (
<>
<AlertCircle color={colors.alertRed} size={28} />
<Text style={styles.statusLabel}>{t("video.error")}</Text>
</>
) : (
<>
<ActivityIndicator color={colors.marigoldOrange} />
<Text style={styles.statusLabel}>{t("video.processing")}</Text>
</>
)}
</View>
);
}
/**
* Spielt einmal ab (kein Loop) und springt bei `playToEnd` weiter.
* `play()` erst bei `readyToPlay` — vorher ist `duration` 0 und `playToEnd`
* feuerte sofort (Viewer schloss sich nach < 1 s, auf Emulator und Gerät
* beobachtet). Offenes Sheet pausiert das Video wie den Foto-Timer.
*/
function ReadyVideoStory({ playbackId, paused, onProgress, onEnded }: Omit<VideoStoryProps, "status" | "playbackId"> & { playbackId: string }) {
const player = useVideoPlayer(`https://stream.mux.com/${playbackId}.m3u8`, (p) => {
p.loop = false;
p.timeUpdateEventInterval = 0.25;
});
const pausedRef = useRef(paused);
useEffect(() => {
pausedRef.current = paused;
if (paused) player.pause();
else if (player.status === "readyToPlay") player.play();
}, [player, paused]);
useEventListener(player, "statusChange", ({ status }) => {
if (status === "readyToPlay" && !pausedRef.current) player.play();
});
useEventListener(player, "timeUpdate", ({ currentTime }) => {
if (player.duration > 0) onProgress((currentTime / player.duration) * 100);
});
useEventListener(player, "playToEnd", () => {
onProgress(100);
onEnded();
});
return <VideoView player={player} style={styles.media} contentFit="contain" nativeControls={false} />;
}
const styles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: colors.ink },
media: { flex: 1, width: "100%" },
statusBox: { flex: 1, width: "100%", alignItems: "center", justifyContent: "center", gap: 8 },
statusLabel: { color: colors.pureWhite, fontSize: 14 },
tapZones: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, flexDirection: "row" },
tapZoneLeft: { flex: 1 },
tapZoneRight: { flex: 2 },
@@ -145,6 +457,33 @@ const styles = StyleSheet.create({
zIndex: 2,
},
petName: { color: colors.pureWhite, fontWeight: "600", fontSize: 14 },
actions: { position: "absolute", right: 16, alignItems: "center", gap: 16, zIndex: 2 },
actionWrap: { alignItems: "center", gap: 2 },
actionCircle: { width: 44, height: 44, borderRadius: 22, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center" },
actionCount: { color: colors.pureWhite, fontSize: 12, fontWeight: "600", textShadowColor: "rgba(0,0,0,0.6)", textShadowRadius: 3 },
expiredRow: { position: "absolute", left: 0, right: 0, alignItems: "center", zIndex: 2 },
expiredBadge: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: "rgba(0,0,0,0.6)",
},
expiredText: { color: colors.pureWhite, fontSize: 12, fontWeight: "600" },
ownerActions: { position: "absolute", left: 16, flexDirection: "row", alignItems: "center", gap: 8, zIndex: 2 },
deleteButton: { width: 36, height: 36, borderRadius: 18, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center" },
viewerCount: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: 12,
height: 36,
borderRadius: 18,
backgroundColor: "rgba(0,0,0,0.4)",
},
viewerCountText: { color: colors.pureWhite, fontSize: 13, fontWeight: "600" },
closeButton: {
position: "absolute",
right: 16,
+2
View File
@@ -79,6 +79,8 @@ export default function OnboardingPetScreen() {
data={EMPTY_DATA}
renderItem={null}
contentContainerStyle={{ flexGrow: 1, backgroundColor: colors.porchWhite }}
automaticallyAdjustKeyboardInsets
keyboardShouldPersistTaps="handled"
ListHeaderComponent={<>{content}</>}
/>
);
+64 -1
View File
@@ -22,8 +22,9 @@ TODO Neu2:
→ Zurück-Pfeil war nur unsichtbar (schwarz auf schwarzem Header im Dark Mode), jetzt auf allen Unterseiten sichtbar. Speicherproblem lag am Datumsformat (nächster Punkt) plus fehlender Fehlermeldung bei ungültiger Eingabe — beides behoben, End-to-End getestet.
- [x] Datum wird Korrekt dargestellt, die eingabe ist aber, aktuelle ansicht JJJJ-MM-DD // korrekt wäre DD-MM-JJJJ
→ Eingabefelder akzeptieren jetzt TT-MM-JJJJ, werden intern korrekt umgewandelt; ungültige Eingaben zeigen eine Fehlermeldung.
- [ ] Gewichtsdaten werden nicht als Diagramm angezeigt (feature)
- [x] Gewichtsdaten werden nicht als Diagramm angezeigt (feature)
→ Echtes Feature, kein Bug — noch offen, Rückmeldung ob/wann gewünscht.
→ **Umgesetzt (04.10.):** Neues `WeightChart` (`src/components/health/WeightChart.tsx`, Port des Web-Diagramms mit `react-native-svg`) erscheint ab 2 Einträgen über der Gewichtsliste. Die x-Achse ist zeitproportional (unregelmäßige Wiegetermine werden nicht verzerrt), die y-Achse hat runde Werte, Datumslabels überlappen nicht (bei über einem Jahr „Feb. 25“). Antippen wählt die nächste Messung, Wert + Datum stehen im Kopf; rechts die Veränderung seit der ersten Messung. Linienfarbe als neues Token `chartLine` (#ea580c), mit dem Dataviz-Validator gegen helle und dunkle Flächen geprüft (Kontrast ≥ 3:1). Geometrie in `src/lib/weight-chart.ts` mit 11 Tests; die Darstellung habe ich als Vorschau mit der echten Geometrie in Hell + Dunkel gerendert und geprüft. Commit `e60abba`, OTA Runtime 1.0.6: Android `16d95168-caf2-4c0d-a0c7-730acd1bfb96`, iOS `d8e82f19-2a66-4f21-9558-b16296a7ba3f`. Nicht auf einem Gerät getestet.
- [x] Technische Frage, was wird beim Feed abgerufen? Zum vergleich in der Web umgebung wird mein feed(followerbezogen) und der trend (könnte dir auch gefallen) seperat eingeblendet, wird in der App eine kombination daraus gemacht ? falls ja super.
→ Nein: Mobile Feed zeigt ausschließlich den chronologischen Follower-Feed (feed.getFeed, mode "chrono"). Web's separater "Trending"-Tab (explore.getTrending, "könnte dir auch gefallen") ist in der App noch gar nicht gebaut — bekannte Lücke, bereits in der README dokumentiert.
- [x] unter meine Tiere, bitte Impressum datenschutz und agbs als link einbinden. sowie eine Versionsnummer der App
@@ -113,6 +114,7 @@ ErrorRecovery: exception encountered: Error: Missing required env var EXPO_PUBLI
Neu 7:
- [ ] Datums eingabe unüblich mit - getrennt statt mit . ansicht am Handy wird meist durch die tastatur überblendet. können wir für die mobile eine data wheel picker nutzen ?
→ Analyse erledigt, betrifft 4 Dateien (edit.tsx Geburtstag/Adoptionsdatum + 3 Health-Sektionen). Wheel-Picker braucht ein natives Modul (`@react-native-community/datetimepicker`) → kein einzelner Build nur dafür, stattdessen auf `PENDING-NATIVE-BUILD.md` verschoben und wird mit dem nächsten ohnehin anstehenden nativen Build mitgenommen. Der reine Trennzeichen-Fix ("-" → ".") ist davon unabhängig und OTA-fähig, sobald gewünscht.
→ **Trennzeichen erledigt (04.10.)**, siehe iOS-Abschnitt unten (`DateField`, TT.MM.JJJJ). Wheel-Picker weiter offen (nativer Build).
- [x] Ein User bemängelt, das die Regisrierung gut ist, aber beim eingeben des Passworts diese hinter der "Bildschrimtastatur" verschwindet, ausserdem gibt es keine Möglichkeit für den User die korrekte eingabe nochmal zu prüfen.
→ Beides im Code bestätigt (`app/(auth)/sign-up.tsx`, gleiches Muster auch in `sign-in.tsx` gefunden und mitbehoben): (1) Der Screen nutzte nur ein einfaches `View` als äußeren Container, kein `ScrollView`/`KeyboardAvoidingView` — auf kleinen Screens schob sich die Tastatur einfach über die unteren Felder. Fix: äußerer Container jetzt `KeyboardAvoidingView` (iOS: `padding`) + `ScrollView` (`keyboardShouldPersistTaps="handled"`, `contentContainerStyle` mit `flexGrow` statt `flex`, damit weiterhin zentriert bleibt aber scrollbar wird). (2) Neue geteilte `PasswordField`-Komponente (`src/components/PasswordField.tsx`) mit Auge/Auge-durchgestrichen-Toggle (lucide `Eye`/`EyeOff`) ersetzt das nackte `secureTextEntry`-`TextField` in beiden Screens. Kein natives Modul, OTA-fähig. Typecheck sauber (Mobile).
- [x] Ein anderer User, meinte wäre es nicht schöner und aufgeräumter wenn die App Settings nicht unter MyPets stehen würden sondern einen eigenen Tab erhalten.
@@ -139,10 +141,71 @@ Neu 7:
**Stolperstein beim Publish:** Das lokale (gitignorete) `mobile/android/`-Verzeichnis vom Device-Test der letzten Session ließ `eas update` das Projekt fälschlich als "Bare Workflow" erkennen (`CommandError: runtime version policies are not supported` statt der konfigurierten `"policy": "appVersion"`) — Fix war, `android/` kurz umzubenennen/wegzuschieben, Update zu publishen, danach zurückzubenennen. Für künftige OTA-Publishes nach einem lokalen `expo run:android`-Test: dasselbe Problem erwarten.
- [x] Im Pet-Profil ist der Erste Post nicht Klickbar da er in die Gesten-Steuerung rein ragt.
→ Ursache: Android läuft edge-to-edge, und die Stack-Screens (ohne Tab-Leiste darunter) hatten unten keinen Safe-Area-Abstand — der letzte Inhalt lag unter der Gesten-Leiste. Fix: neuer Hook `src/hooks/useBottomSafePadding.ts` (paddingBottom = 16 + insets.bottom), eingebaut in Tierprofil, Follower, Folgt, Gesundheit, Meilensteine, Profil bearbeiten, Einstellungen, Suche, Nachrichten-Liste und Konto löschen. Commit `de237f1`, per OTA für Android veröffentlicht (Update-Group `4dc6f797`), noch nicht am Gerät bestätigt. Gilt automatisch auch für iOS (Home-Indicator).
- [x] Nutzer-Feedback: Nach dem Posten einer Story erscheint man selbst nicht als Ring — man kann die eigene Story nicht ansehen.
→ Ursache (Web **und** App gleich): Die Story-Leiste zeigt nur die „+“-Kachel und Storys gefolgter Tiere (`follows.getPetsWithActiveStories`); das eigene aktive Tier ist darin nie enthalten. Fix: zusätzlicher Ring „Deine Story“ direkt nach der „+“-Kachel, solange das aktive Tier eine aktive Story hat (`stories.hasActiveStory`, wird nach dem Posten schon invalidiert). Web: `src/components/stories/StoryTray.tsx` (öffnet den StoryViewer — dort sieht der Owner auch die Zuschauerliste); App: `src/components/StoryTray.tsx`. Commit `204d5f6`, live (Web + OTA Android `58e99a29`, iOS `2aaa3975`). Typecheck/Tests/Build grün; die 2 Lint-Fehler „Cannot access refs during render“ in der Web-StoryTray bestanden schon vorher (horizontales Scrollen).
**Story-Kommentare:** Backend (`stories.addComment/listComments/deleteComment`, `toggleReaction`, `getViewers`) und Web-UI (`StoryCommentsOverlay`) existieren bereits — nur der App-Viewer (`app/(app)/story-viewer.tsx`) hat Kommentare, Reaktionen und Zuschauerliste beim Portieren bewusst ausgelassen. Offen als eigener Punkt.
- [x] Story-Kommentare, Pfoten-Reaktionen und Zuschauerliste in der App (Folge-Punkt zum Story-Feedback).
→ Neuer App-Viewer (`app/(app)/story-viewer.tsx`) mit Pfote (`src/components/stories/StoryPawButton.tsx`), Kommentar-Bottom-Sheet (`StoryCommentsSheet.tsx`, Schreiben + eigene löschen) und — nur bei der eigenen Story — Zuschauerliste (`StoryViewersSheet.tsx`, Augen-Symbol oben links). Solange ein Sheet offen ist, pausiert der Story-Timer (anders als im Web, damit die Story beim Tippen nicht weiterspringt). Nutzt die vorhandenen Server-Prozeduren; einzige Server-Änderung: `stories.listActive` nimmt optional `viewerPetId` und liefert `reactedByViewer`, damit die Pfote gefüllt startet, wenn man schon reagiert hat — abwärtskompatibel, und der Web-Viewer nutzt es jetzt auch (bekannte Einschränkung "Pfote startet immer leer" behoben). Tests in `src/__tests__/stories.test.ts`, alle 268 Tests + Build + App-Typecheck grün. Nicht auf einem Gerät getestet; Tastaturverhalten im Kommentar-Sheet (Android `KeyboardAvoidingView` behavior "height" im Modal) bitte bei Testern beobachten. Weiterhin offen: keine Benachrichtigung bei Story-Kommentaren/-Pfoten (Notification hat keine `storyId`, bräuchte Schemaänderung); Video-Storys spielt der App-Viewer weiterhin nicht ab. Commit `ccebfa4`, live (Web + OTA Runtime 1.0.6: Android `a3941052-a3ec-4c3c-a875-24ad3dcd1c98`, iOS `5e855c5b-f6db-44d0-8350-6b0b91308804`).
-
!!! iOS - Founds beginnen hier - nicht mit Android vertauschen !!!
- [x] Nutzer-Feedback: Storys und Beiträge mit Bildern im HEIF/HEIC-Format (Apple) lassen sich nicht erstellen.
→ **Kein reines Serverproblem.** Der Server nimmt bewusst nur JPEG/PNG/WebP an (`ALLOWED_CONTENT_TYPES` in `src/lib/supabase-storage.ts`), und die Bytes gehen direkt vom Client zu Supabase — umgewandelt wurde HEIC nirgends. Zwei Fundstellen:
1. **App, Beiträge:** `app/(app)/post-new.tsx` filterte gewählte Fotos nach ihrem *Original*-MIME-Typ (`image/jpeg|png|webp`) und warf `image/heic`/`image/heif` still raus — obwohl `resizeForUpload()` danach sowieso jedes Bild in JPEG umwandelt. Storys/Meilensteine/Avatar in der App filtern nicht und waren nicht betroffen.
2. **Web, alle Bild-Uploads** (Beiträge, Storys, Meilensteine, Tierbild): nur JPEG/PNG/WebP erlaubt; ein `.heic` vom Mac oder eine rohe HEIC-Datei aus iOS-Safari (Storys nutzen `accept="image/*,video/*"`, das Safaris automatische HEIC→JPEG-Umwandlung aushebelt) wurde abgelehnt.
Fix: App — HEIC/HEIF in `post-new.tsx` zugelassen, plus Hinweis-Alert, falls doch einmal Bilder übersprungen werden (`post.unsupportedFormat`). Web — neues `src/lib/heic-to-jpeg.ts` wandelt HEIC vor dem Upload per Browser-Dekodierung (Canvas → JPEG 0,9) um, eingebaut in `MultiImageUpload`, `StoryForm`, `MilestoneForm`, `AvatarUpload`; `accept` um HEIC ergänzt, damit der Mac-Dateidialog sie anbietet. Bewusst **ohne** Zusatzbibliothek (heic-to: LGPL-3.0, ~26 MB; heic2any: seit 2023 ungepflegt): Safari (iOS 17+/macOS 14+) dekodiert HEIC selbst; Browser ohne HEIC-Support (z. B. Chrome/Windows) bekommen eine klare Meldung „Öffne PawFeed in Safari oder speichere das Foto als JPG“ (`heicUnsupported`). Tests in `src/__tests__/heic-to-jpeg.test.ts`, alle 266 Tests + Build grün. Nicht auf einem echten iPhone getestet. Commit `1bf0f92`, Web per Rolling-Deploy live, OTA Runtime 1.0.6: Android `1b4baa80-a35a-49b7-ac6e-f44ebe93b4db`, iOS `c31382ee-83b7-4a81-a06d-1a2e53180d7d`. **Vom Nutzer bestätigt (02.10.2026): funktioniert.**
- [x] Nutzerfeedback - Story löschbar ? wie kann man die story löschen, falls man mal was falsches in die story geladen hat ?
→ Gab es bisher nirgends. Neu: Server `stories.delete` (nur Owner; löscht Aufrufe — `StoryView` hat bewusst kein Cascade —, dann die Story, Pfoten/Kommentare per Cascade; danach Bild in Supabase Storage bzw. Mux-Asset best-effort). Web: Papierkorb neben der Zuschauerzahl in der eigenen Story + Rückfrage (`AlertDialog`), Bild-Timer pausiert während der Rückfrage, gelöscht wird die beim Antippen gemerkte Story-ID. App: gleicher Papierkorb + native Rückfrage, Timer pausiert. Nach dem Löschen springt der Viewer zur nächsten Story bzw. schließt, wenn keine mehr da ist. 5 neue Tests in `src/__tests__/stories.test.ts`. Nicht auf einem Gerät getestet. Commit `81d9c6d`, live (Web + OTA Runtime 1.0.6: Android `49757586-c443-4952-a679-c66a4225ef82`, iOS `48d22b11-abc9-4c75-be52-a122b0130c5e`).
- [x] Storys nur für Ersteller länger einsehbar wegen den Kommentaren (7Tage)
→ **Umgesetzt (02.10.):** Der Ersteller sieht seine Storys 7 Tage lang (danach löscht der Aufräum-Job sie), alle anderen weiterhin nur 24 Stunden. Server: `stories.listActive` liefert dem Owner die Storys der letzten 7 Tage mit `isExpired`; `stories.hasActiveStory` meldet mit `includeOwnRecent` (nur die Story-Leiste setzt das) einen grauen Ring `expiredOnly`, damit Profilbilder im Feed nicht ebenfalls grau umrandet werden. Web + App: grauer Ring „Deine Story“, Hinweis „Abgelaufen – nur für dich sichtbar“ im Viewer, Kommentare bei abgelaufenen Storys nur lesbar, Pfote nur als Zähler; Zuschauerliste und Löschen bleiben. Keine Schemaänderung. 4 neue Tests. Nicht auf einem Gerät getestet. Commit `2c0680c`, live (Web + OTA Runtime 1.0.6: Android `5b324ee8-4821-4ca5-b6e7-6a02635a41f7`, iOS `a430ccd0-f2bb-452f-b3cd-45e94910901b`).
→ Noch offen. Befund: abgelaufene Storys werden nie gelöscht (Einträge, Bilder, Mux-Videos bleiben liegen) — Vorschlag: grauer Ring "Deine Story" 7 Tage für den Ersteller, danach Aufräum-Cron (braucht einen Auslöser auf dem NAS, die bestehenden Cron-Routen haben keinen) + Satz unter "Speicherdauer" in der Datenschutzerklärung. **Teil erledigt (02.10.):** Aufräum-Job gebaut — `/api/cron/cleanup-stories` löscht Storys älter als 7 Tage komplett (Aufrufe, Pfoten, Kommentare, Bild, Mux-Video; gemeinsame Funktion `src/lib/story-cleanup.ts`, auch von „Story löschen“ genutzt), täglich 04:30 über die bestehende NAS-Crontab (`docker/run-cron.sh`, Log in `docker/cron.log`); Datenschutzerklärung Abschnitt 6 ergänzt. Offen bleibt nur die 7-Tage-Ansicht für den Ersteller.
- [x] Video-Storys in der App (Upload + Wiedergabe)
→ **Umgesetzt (03.10.):** Branch `feature/mobile-video-stories` (Gitea #37, 20.09. auf Emulator + Gerät getestet) auf den seit 02.10. umgebauten Story-Viewer portiert. Galerie-Picker nimmt Fotos und Videos (max. 30 s, Prüfung vor dem Upload), Upload direkt zu Mux über `stories.createVideoUpload`; Viewer spielt READY-Videos über `expo-video` ab (Fortschritt aus `timeUpdate`, weiter bei `playToEnd`, `play()` erst bei `readyToPlay`), zeigt „wird verarbeitet“/Fehler und pausiert das Video, solange Kommentare/Zuschauer/Löschen offen sind. Nur JS (`expo-video` ist seit dem 1.0.6-Build nativ drin). App-Typecheck grün; der Port selbst (Pause bei offenem Sheet) **nicht auf einem Gerät getestet**. Kamera nimmt weiterhin nur Fotos auf (Video-Aufnahme bräuchte Mikrofon-Berechtigung = neuer Build). Commit `69e2c13`, OTA Runtime 1.0.6: Android `665f2bf0-7e19-4b38-a07f-5da03459a55c`, iOS `963e721c-3c7c-4d45-b08a-b3c49969e5a2`.
- [x] Nutzer-Feedback: Beim Video-Story-Upload sieht man nur einen Spinner — Fortschritt nicht erkennbar.
→ **Umgesetzt (03.10.):** Beim Video zuerst „Upload wird vorbereitet …“, dann Fortschrittsbalken mit Prozent („Video wird hochgeladen … 45 %“) aus `File.upload` `onProgress` (expo-file-system 57.0.7, nativ im 1.0.6-Build — Android meldet alle 100 ms). Vorschau während des Uploads gesperrt. Fotos behalten den Spinner (Upload dauert dort nur Sekundenbruchteile). App-Typecheck grün, nicht auf einem Gerät getestet. Commit `5606a81`, OTA Runtime 1.0.6: Android `000908ec-4aac-4bc3-80c7-a86ea438d1c2`, iOS `cd0481b9-84d3-4734-987b-c1b418d51c61`.
- [x] Feedback kam über iOS, betrifft aber das System, bei Impfungen die man anlegt, sollte ein Wiederholungstermin nach dem Abklingen der Impfung eingebunden werden, mit einem Reminder -1Monat vor ablauf damit man einen neuen Termin vereinbaren kann zum Impfen.
→ **Umgesetzt und live (04.10.).** Den Folgetermin gab es schon (`Vaccine.nextDueDate`, Web + App), es fehlte nur die Erinnerung. Neu: täglicher Cron `/api/cron/vaccine-reminders` (`src/lib/vaccine-reminders.ts`) erinnert **einmalig 30 Tage vor** dem Folgetermin per Mitteilung `VACCINE_DUE` + Push („Die Impfung „Tollwut“ für Bubi ist am 01.11.2026 fällig …“). Bereits überfällige Termine werden nicht nachträglich erinnert (die zeigt die Gesundheitsseite als „überfällig“). Schema: `NotificationType.VACCINE_DUE`, `Notification.vaccineId` (Cascade), `Vaccine.reminderSentAt` (Schutz gegen doppelten Versand, wird bei Fehler wieder freigegeben). Web + App zeigen die Mitteilung mit 💉, Tippen öffnet die Gesundheitsseite. App: Schnellauswahl „+1 Jahr / +3 Jahre“ für den Folgetermin plus Hinweis „Mit Folgetermin erinnern wir dich 30 Tage vorher“. 8 neue Tests, Typecheck Web + App grün. Commit `c74bbff`, App-Teil per OTA Runtime 1.0.6: Android `64144379-e335-45ca-bb05-bc12155da54c`, iOS `b8f31fed-aa02-41a2-97c3-3707d02857ce`. Schema per `db push` auf dem NAS (rein additiv), Rolling-Deploy (`7ab74f8`; der erste Versuch brach ab, weil ein Test aus `src/__tests__` nach `mobile/` importierte, das im Docker-Build fehlt — Test liegt jetzt in `mobile/src/lib/date-format.test.ts`), Crontab täglich 08:15. Testaufruf: HTTP 200, `sent: 0` (derzeit nichts fällig). Der echte Versand inkl. Push ist noch nicht mit einer fälligen Impfung beobachtet, die App nicht auf einem Gerät getestet.
- [x] Feedback iOS Datums eingabe läuft noch mittels - Trennung statt . ( option für iOS - Data Wheel Picker)
→ **Trennzeichen umgesetzt (04.10.):** Neue `DateField`-Komponente (`src/components/DateField.tsx`) für alle Datumsfelder (Geburtstag, Adoptionstag, Gewicht, Tierarzt, Impfungen): Zifferntastatur, die Punkte setzt die App beim Tippen selbst (TT.MM.JJJJ; die iOS-Zifferntastatur hat keinen Punkt). Ältere Eingaben mit „-“ oder „/“ und einstellige Tage/Monate werden weiter akzeptiert. Tests in `src/__tests__/mobile-date-format.test.ts`. Commit `c74bbff`, OTA wie oben. Nicht auf einem Gerät getestet. **Wheel-Picker weiterhin offen** — braucht ein natives Modul, steht in `PENDING-NATIVE-BUILD.md` für den nächsten Build.
- [x] Nutzer-Feedback iOS (Screenshot `feedback-ios/WhatsApp Image 2026-10-04 at 12.50.58.jpeg`): Beim Antippen eines Datumsfelds in „Profil bearbeiten“ verdeckt die Tastatur das Feld, die Eingabe ist nicht zu sehen.
→ **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.
- [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.
- [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.
- [x] Feedback - iOS, wenn man Nachrichten erhält bekommt man zwar eine Benachrichtung aber in der App wird bei der Sprechblase kein Indicator angezeigt z.b. ein Orangener Punkt der auf eine ungelesene nachricht hindeutet.
→ **Umgesetzt (04.10.):** Die Sprechblase oben zeigt jetzt wie die Glocke einen orangen Zähler mit der Zahl ungelesener Nachrichten (`conversations.unreadCount`, gab es serverseitig schon). Aktualisiert sich alle 30 s, solange die App offen ist, und verschwindet nach dem Lesen des Chats. Commit `2bd4b2d`, OTA Runtime 1.0.6: Android `70b064d1-3424-44af-b834-7e77a60475e6`, iOS `e0993ecc-0961-4270-8876-a999e8339ba3`. Nicht auf einem Gerät getestet.
- [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).
+13 -2
View File
@@ -57,21 +57,32 @@ function AppleSignInButtonInner({ onError, legalAccepted, onNeedsConsent, disabl
const result = await startAppleAuthenticationFlow();
const { setActive, signUp } = result;
let createdSessionId = result.createdSessionId;
let missingFields = signUp?.status === "missing_requirements" ? signUp.missingFields : [];
if (!createdSessionId && signUp?.status === "missing_requirements" && signUp.missingFields.includes("legal_accepted")) {
if (!createdSessionId && signUp && missingFields.includes("legal_accepted")) {
if (!legalAccepted) {
onNeedsConsent?.();
return;
}
const updatedSignUp = await signUp.update({ legalAccepted: true });
createdSessionId = updatedSignUp.createdSessionId;
missingFields = updatedSignUp.status === "missing_requirements" ? updatedSignUp.missingFields : [];
}
if (createdSessionId && setActive) {
await setActive({ session: createdSessionId });
router.replace("/(app)");
return;
}
// No createdSessionId otherwise (user cancelled the Apple sheet).
// Any other required field Apple can't supply (e.g. first/last name —
// Apple only sends the name on the very first authorization) would
// otherwise leave the button silently doing nothing.
if (missingFields.length > 0) {
Sentry.captureMessage(`Apple sign-up missing requirements: ${missingFields.join(", ")}`);
onError(t("auth.appleSignInError"));
}
// No createdSessionId and nothing missing: user cancelled the Apple sheet.
} catch (err) {
Sentry.captureException(err);
onError(err instanceof Error ? err.message : t("auth.appleSignInError"));
+25
View File
@@ -0,0 +1,25 @@
import type { TextInputProps } from "react-native";
import { TextField } from "./TextField";
import { formatDateInput } from "../lib/date-format";
const DISPLAY_DATE_LENGTH = 10; // DD.MM.YYYY
type DateFieldProps = Omit<TextInputProps, "onChangeText" | "keyboardType" | "maxLength"> & {
onChangeText: (value: string) => void;
};
/**
* Text date input with a DD.MM.YYYY mask: number pad only, the dots are
* inserted automatically (iOS' number pad has no "." key). A native wheel
* picker needs @react-native-community/datetimepicker → PENDING-NATIVE-BUILD.md.
*/
export function DateField({ onChangeText, ...props }: DateFieldProps) {
return (
<TextField
{...props}
keyboardType="number-pad"
maxLength={DISPLAY_DATE_LENGTH}
onChangeText={(text) => onChangeText(formatDateInput(text))}
/>
);
}
+98 -11
View File
@@ -1,9 +1,22 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { View, Text, Pressable, ScrollView, ActivityIndicator, Modal, StyleSheet } from "react-native";
import { ChevronDown } from "lucide-react-native";
import {
View,
Text,
TextInput,
Pressable,
ScrollView,
ActivityIndicator,
Modal,
StyleSheet,
Keyboard,
Platform,
useWindowDimensions,
} from "react-native";
import { ChevronDown, Search } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { useTheme } from "../context/ThemeContext";
import { radii, getTypography, type ColorTokens, type Typography } from "../theme/tokens";
import { radii, fontFamily, getTypography, type ColorTokens, type Typography } from "../theme/tokens";
import { filterOptionsByName } from "../lib/filter-options";
export type PickerOption = { id: string; name: string };
@@ -22,6 +35,11 @@ type Props = {
const BUTTON_HEIGHT = 48;
const LIST_GAP = 4;
const MAX_LIST_HEIGHT = 220;
// Ab so vielen Einträgen gibt es oben ein Suchfeld (found.md: Hunde-/Katzen-
// Rassenlisten sind lang). Tierarten (kurze Liste) bleiben ohne.
const SEARCH_MIN_OPTIONS = 10;
const SEARCH_HEIGHT = 44;
const MIN_LIST_TOP = 48;
type Anchor = { top: number; left: number; width: number };
@@ -64,6 +82,13 @@ export function OptionPicker({
const styles = useMemo(() => makeStyles(colors), [colors]);
const triggerRef = useRef<View>(null);
const [anchor, setAnchor] = useState<Anchor | null>(null);
const [query, setQuery] = useState("");
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [listHeight, setListHeight] = useState(0);
const { height: windowHeight } = useWindowDimensions();
const hasSearch = options.length >= SEARCH_MIN_OPTIONS;
const visibleOptions = useMemo(() => filterOptionsByName(options, query), [options, query]);
useEffect(() => {
// No need to reset `anchor` to null when closing — the Modal already
@@ -76,6 +101,38 @@ export function OptionPicker({
});
}, [open]);
// The search field brings up the keyboard; a list anchored low on the
// screen would end up underneath it, so it moves up while the keyboard is open.
useEffect(() => {
if (!open || !hasSearch) return;
const showEvent = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
const hideEvent = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
const showSub = Keyboard.addListener(showEvent, (e) => setKeyboardHeight(e.endCoordinates.height));
const hideSub = Keyboard.addListener(hideEvent, () => setKeyboardHeight(0));
return () => {
showSub.remove();
hideSub.remove();
setKeyboardHeight(0);
};
}, [open, hasSearch]);
const close = () => {
setQuery("");
onToggle();
};
const select = (option: PickerOption) => {
setQuery("");
onSelect(option);
};
const listTop = (() => {
if (!anchor) return 0;
if (!keyboardHeight || !listHeight) return anchor.top;
const maxTop = windowHeight - keyboardHeight - listHeight - LIST_GAP;
return Math.max(MIN_LIST_TOP, Math.min(anchor.top, maxTop));
})();
return (
<View>
<Pressable
@@ -87,7 +144,7 @@ export function OptionPicker({
<Text style={selectedName ? styles.value : styles.placeholder}>{selectedName ?? placeholder}</Text>
<ChevronDown color={colors.quietGray} size={18} />
</Pressable>
<Modal visible={open} transparent animationType="fade" onRequestClose={onToggle}>
<Modal visible={open} transparent animationType="fade" onRequestClose={close}>
{/*
Backdrop and list are SIBLINGS, not parent/child. The list must
not be a descendant of the backdrop Pressable at all — a Pressable
@@ -99,17 +156,36 @@ export function OptionPicker({
the list's bounds hit-tests into the list's own subtree and never
reaches the backdrop's Pressable underneath at all.
*/}
<Pressable style={styles.backdrop} onPress={onToggle} />
<Pressable style={styles.backdrop} onPress={close} />
{anchor && (
<View style={[styles.list, { top: anchor.top, left: anchor.left, width: anchor.width }]}>
<View
style={[styles.list, hasSearch && styles.listWithSearch, { top: listTop, left: anchor.left, width: anchor.width }]}
onLayout={(e) => setListHeight(e.nativeEvent.layout.height)}
>
{hasSearch && (
<View style={styles.searchRow}>
<Search color={colors.quietGray} size={16} />
<TextInput
value={query}
onChangeText={setQuery}
placeholder={t("common.searchPlaceholder")}
placeholderTextColor={colors.quietGray}
style={styles.searchInput}
autoCorrect={false}
autoCapitalize="none"
returnKeyType="search"
clearButtonMode="while-editing"
/>
</View>
)}
{isLoading ? (
<ActivityIndicator color={colors.marigoldOrange} style={styles.loading} />
) : options.length === 0 ? (
<Text style={styles.empty}>{emptyLabel ?? t("common.noResultsFound")}</Text>
) : visibleOptions.length === 0 ? (
<Text style={styles.empty}>{emptyLabel && !query ? emptyLabel : t("common.noResultsFound")}</Text>
) : (
<ScrollView style={styles.scrollView}>
{options.map((option) => (
<Pressable key={option.id} style={styles.row} onPress={() => onSelect(option)}>
<ScrollView style={styles.scrollView} keyboardShouldPersistTaps="handled">
{visibleOptions.map((option) => (
<Pressable key={option.id} style={styles.row} onPress={() => select(option)}>
<Text style={getTypography(colors).body}>{option.name}</Text>
</Pressable>
))}
@@ -154,7 +230,18 @@ function makeStyles(colors: ColorTokens) {
shadowOpacity: 0.15,
shadowRadius: 8,
},
listWithSearch: { maxHeight: MAX_LIST_HEIGHT + SEARCH_HEIGHT },
scrollView: { maxHeight: MAX_LIST_HEIGHT },
searchRow: {
height: SEARCH_HEIGHT,
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 14,
borderBottomWidth: 1,
borderBottomColor: colors.hairlineGray,
},
searchInput: { flex: 1, height: SEARCH_HEIGHT, fontFamily: fontFamily.regular, fontSize: 14, color: colors.ink },
row: { paddingHorizontal: 14, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
loading: { padding: 16 },
empty: { ...typography.body, color: colors.quietGray, padding: 16 },
+28 -3
View File
@@ -3,6 +3,7 @@ import Svg, { Circle } from "react-native-svg";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "../api/trpc";
import { useActivePet } from "../context/ActivePetContext";
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useReferralRanks";
type Props = {
petId: string;
@@ -10,6 +11,14 @@ type Props = {
/** Avatar size in px — used to scale the paw outline (default: 32) */
size?: number;
onPress?: () => void;
/** Nur Story-Leiste: eigene abgelaufene Storys (≤ 7 Tage) als grauer Ring. */
includeOwnRecent?: boolean;
/**
* Owner with the PROFILE_RING perk (found.md): an unseen story's paw takes
* the rank colour instead of orange, and without a story a plain ring in
* the rank colour is drawn. Seen stories stay gray either way.
*/
owner?: ReferralOwnerFields | null;
};
const RING_PAD = 8;
@@ -35,15 +44,21 @@ export function getPawRingBox(size: number) {
* instead of inline SVG markup. Orange = unseen stories, gray = all seen,
* no ring = no active story (STORY-02).
*/
export function PawRing({ petId, children, size = 32, onPress }: Props) {
export function PawRing({ petId, children, size = 32, onPress, includeOwnRecent, owner }: Props) {
const trpc = useTRPC();
const { activePetId } = useActivePet();
const { data = { hasStory: false, allSeen: false } } = useQuery(
trpc.stories.hasActiveStory.queryOptions({ petId, viewerPetId: activePetId ?? undefined })
trpc.stories.hasActiveStory.queryOptions({
petId,
viewerPetId: activePetId ?? undefined,
...(includeOwnRecent ? { includeOwnRecent: true } : {}),
})
);
const { hasStory, allSeen } = data;
const ringColor = allSeen ? SEEN_COLOR : UNSEEN_COLOR;
const rank = useOwnerRankWithPerk(owner, "PROFILE_RING");
const ringColor = allSeen ? SEEN_COLOR : (rank?.color ?? UNSEEN_COLOR);
const showRankRingOnly = !hasStory && !!rank;
const box = getPawRingBox(size);
const strokeWidth = Math.max(4, size / 8);
@@ -64,6 +79,16 @@ export function PawRing({ petId, children, size = 32, onPress }: Props) {
justifyContent: "center",
}}
>
{showRankRingOnly && (
<Svg
width={box.width}
height={box.height}
viewBox={`0 ${-BEAN_OVERHANG} 100 ${100 + BEAN_OVERHANG}`}
style={{ position: "absolute", top: 0, left: 0 }}
>
<Circle cx={50} cy={50} r={44} fill="none" stroke={rank.color} strokeWidth={strokeWidth * 0.6} />
</Svg>
)}
{hasStory && (
<Svg
width={box.width}
+39 -6
View File
@@ -4,6 +4,7 @@ import { Image } from "expo-image";
import { getAvatarUrl } from "../lib/media-url";
import { useTheme } from "../context/ThemeContext";
import { fontFamily, type ColorTokens } from "../theme/tokens";
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useReferralRanks";
const SIZE_PRESETS = { sm: 32, md: 40, lg: 56 } as const;
@@ -11,28 +12,60 @@ type Props = {
name: string;
avatarKey?: string | null;
size?: keyof typeof SIZE_PRESETS;
/**
* The pet's owner (referralRankId/referralPerks, part of petIdentitySelect).
* With the PROFILE_RING perk the avatar gets a ring in the rank colour.
* Don't pass it when a PawRing wraps this avatar — PawRing draws that ring.
*/
owner?: ReferralOwnerFields | null;
};
// Ring sits *outside* the avatar (absolute overlay) so ranked and unranked
// avatars keep the same footprint and list rows don't shift.
const RING_WIDTH = { sm: 2, md: 2, lg: 3 } as const;
const RING_GAP = 2;
/**
* Port of web's src/components/ui/pet-avatar.tsx — image if the pet has one,
* otherwise a colored initial fallback.
*/
export function PetAvatar({ name, avatarKey, size = "md" }: Props) {
export function PetAvatar({ name, avatarKey, size = "md", owner }: Props) {
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const rank = useOwnerRankWithPerk(owner, "PROFILE_RING");
const px = SIZE_PRESETS[size];
const uri = getAvatarUrl(avatarKey);
const dimensionStyle = { width: px, height: px, borderRadius: px / 2 };
if (uri) {
return <Image source={{ uri }} style={dimensionStyle} contentFit="cover" transition={150} />;
}
return (
const avatar = uri ? (
<Image source={{ uri }} style={dimensionStyle} contentFit="cover" transition={150} />
) : (
<View style={[styles.fallback, dimensionStyle]}>
<Text style={[styles.fallbackText, { fontSize: px * 0.4 }]}>{name.charAt(0).toUpperCase()}</Text>
</View>
);
if (!rank) return avatar;
const offset = RING_GAP + RING_WIDTH[size];
return (
<View style={{ width: px, height: px }}>
{avatar}
<View
pointerEvents="none"
style={{
position: "absolute",
top: -offset,
left: -offset,
width: px + offset * 2,
height: px + offset * 2,
borderRadius: (px + offset * 2) / 2,
borderWidth: RING_WIDTH[size],
borderColor: rank.color,
}}
/>
</View>
);
}
function makeStyles(colors: ColorTokens) {
+16 -4
View File
@@ -1,27 +1,39 @@
import { View, StyleSheet } from "react-native";
import { BadgeCheck, Sparkles } from "lucide-react-native";
import { BadgeCheck, Ribbon, Sparkles, Sprout } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { isActiveSponsor } from "../lib/sponsor";
import { colors } from "../theme/tokens";
import { useTheme } from "../context/ThemeContext";
import { ReferralIcon } from "../lib/referral-icons";
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useReferralRanks";
type Props = {
isVerified?: boolean;
sponsor?: { sponsorSince: Date | string | null; sponsorPeriodEnd: Date | string | null } | null;
/** Eines der ersten 250 Tiere (Pet.isFoundingMember). */
isFoundingMember?: boolean;
/** Gedenkmodus „In Erinnerung“ (Pet.deceasedAt gesetzt). */
isDeceased?: boolean;
sponsor?: { sponsorSince?: Date | string | null; sponsorPeriodEnd?: Date | string | null } | null;
/** Owner-Felder referralRankId/referralPerks — Rang-Abzeichen des Empfehlungsprogramms. */
referral?: ReferralOwnerFields | null;
};
/** Port of web's src/components/ui/pet-identity-badges.tsx. */
export function PetIdentityBadges({ isVerified, sponsor, referral }: Props) {
const FOUNDING_COLOR = "#059669"; // emerald-600, wie im Web
export function PetIdentityBadges({ isVerified, isFoundingMember, isDeceased, sponsor, referral }: Props) {
const { t } = useTranslation();
const { colors: theme } = useTheme();
const showSponsor = sponsor ? isActiveSponsor(sponsor) : false;
const rank = useOwnerRankWithPerk(referral, "NAME_BADGE");
if (!isVerified && !showSponsor && !rank) return null;
if (!isDeceased && !isVerified && !isFoundingMember && !showSponsor && !rank) return null;
return (
<View style={styles.row}>
{isDeceased && <Ribbon size={14} color={theme.ink} fill={theme.ink} accessibilityLabel={t("inMemory.label")} />}
{isVerified && <BadgeCheck size={14} color={colors.marigoldOrange} />}
{showSponsor && <Sparkles size={14} color={colors.marigoldOrange} />}
{isFoundingMember && <Sprout size={14} color={FOUNDING_COLOR} accessibilityLabel={t("foundingMember.label")} />}
{rank && <ReferralIcon icon={rank.icon} size={14} color={rank.color} fill={rank.color} accessibilityLabel={rank.name} />}
</View>
);
+10 -5
View File
@@ -18,6 +18,7 @@ import { getMediaUrl } from "../lib/media-url";
import { formatPetName, formatRelativeTime } from "../lib/format";
import { useTheme } from "../context/ThemeContext";
import { radii, getTypography, type ColorTokens, type Typography } from "../theme/tokens";
import type { ReferralOwnerFields } from "../hooks/useReferralRanks";
/**
* Structural rather than imported from a single router's inferred output:
@@ -39,7 +40,7 @@ type PostCardOriginalPost = {
type?: string | null;
images: PostCardImage[];
milestone?: unknown;
pet: { id: string; name: string; avatarKey?: string | null };
pet: { id: string; name: string; avatarKey?: string | null; owner?: ReferralOwnerFields | null };
};
export type PostCardPost = {
@@ -59,9 +60,13 @@ export type PostCardPost = {
nickname?: string | null;
avatarKey?: string | null;
isVerified?: boolean;
deceasedAt?: Date | string | null;
isFoundingMember?: boolean;
// Sponsor fields only come with petCardSelect (feed/explore); a plain pet
// identity's owner (posts.byPetId, comments) carries just the referral rank.
owner?: {
sponsorSince: Date | string | null;
sponsorPeriodEnd: Date | string | null;
sponsorSince?: Date | string | null;
sponsorPeriodEnd?: Date | string | null;
referralRankId?: string | null;
referralPerks?: string[] | null;
} | null;
@@ -144,11 +149,11 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr
<View style={styles.card}>
<View style={styles.headerRow}>
<Pressable style={styles.header} onPress={() => router.push(`/pets/${post.pet.id}`)}>
<PetAvatar name={post.pet.name} avatarKey={post.pet.avatarKey} />
<PetAvatar name={post.pet.name} avatarKey={post.pet.avatarKey} owner={post.pet.owner} />
<View style={styles.headerText}>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(post.pet)}</Text>
<PetIdentityBadges isVerified={post.pet.isVerified} sponsor={post.pet.owner} referral={post.pet.owner} />
<PetIdentityBadges isDeceased={!!post.pet.deceasedAt} isVerified={post.pet.isVerified} isFoundingMember={post.pet.isFoundingMember} sponsor={post.pet.owner} referral={post.pet.owner} />
{isTrending && (
<View style={styles.trendingBadge}>
<Flame size={11} color={colors.marigoldDeep} fill={colors.marigoldDeep} />
+1 -1
View File
@@ -45,7 +45,7 @@ export function RepostCard({ post }: Props) {
</Pressable>
<Pressable style={styles.header} onPress={() => router.push(`/pets/${origPet.id}`)}>
<PetAvatar name={origPet.name} avatarKey={origPet.avatarKey} size="sm" />
<PetAvatar name={origPet.name} avatarKey={origPet.avatarKey} size="sm" owner={origPet.owner} />
<Text style={styles.name}>{origPet.name}</Text>
</Pressable>
+31 -16
View File
@@ -16,20 +16,33 @@ const RING_BOX = getPawRingBox(AVATAR_SIZE);
/**
* Port of web's src/components/stories/StoryTray.tsx: an "add story" tile
* for the active pet, then followed pets with an active story (each in a
* PawRing). No viewer list, reactions, or comments on stories yet (Gitea
* for the active pet, then the active pet's own story while it has one
* ("Deine Story" — the followed-pets query never includes the viewer's own
* pet), then followed pets with an active story (each in a PawRing). No viewer list, reactions, or comments on stories yet (Gitea
* #29 on web) — that's a fast-follow, matching how earlier phases trimmed
* AI-disclosure/mentions from ported forms. Photo stories only, no video
* story upload/playback (mirrors mobile's post-creation scope decision).
* AI-disclosure/mentions from ported forms. Photo and video stories both
* play back the same way here — see story-viewer.tsx for the branch.
*/
export function StoryTray() {
const { t } = useTranslation();
const trpc = useTRPC();
const router = useRouter();
const { activePetId } = useActivePet();
const { activePetId, pets } = useActivePet();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const { data: ownStory } = useQuery({
...trpc.stories.hasActiveStory.queryOptions({ petId: activePetId ?? "", includeOwnRecent: true }),
enabled: !!activePetId,
});
const activePet = pets.find((pet) => pet.id === activePetId) ?? null;
const openViewer = (pet: { id: string; name: string; avatarKey?: string | null }) =>
router.push({
pathname: "/story-viewer",
params: { petId: pet.id, petName: pet.name, petAvatarKey: pet.avatarKey ?? "" },
});
const { data: followedWithStories = [] } = useQuery({
...trpc.follows.getPetsWithActiveStories.queryOptions({ followerPetId: activePetId ?? "" }),
enabled: !!activePetId,
@@ -48,18 +61,20 @@ export function StoryTray() {
</Text>
</Pressable>
{ownStory?.hasStory && activePet && (
<Pressable style={styles.item} onPress={() => openViewer(activePet)}>
<PawRing petId={activePet.id} size={AVATAR_SIZE} includeOwnRecent owner={activePet.owner}>
<PetAvatar name={activePet.name} avatarKey={activePet.avatarKey} size="lg" />
</PawRing>
<Text style={styles.label} numberOfLines={1}>
{t("feed.yourStory")}
</Text>
</Pressable>
)}
{followedWithStories.map((pet) => (
<Pressable
key={pet.id}
style={styles.item}
onPress={() =>
router.push({
pathname: "/story-viewer",
params: { petId: pet.id, petName: pet.name, petAvatarKey: pet.avatarKey ?? "" },
})
}
>
<PawRing petId={pet.id} size={AVATAR_SIZE}>
<Pressable key={pet.id} style={styles.item} onPress={() => openViewer(pet)}>
<PawRing petId={pet.id} size={AVATAR_SIZE} owner={pet.owner}>
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
</PawRing>
<Text style={styles.label} numberOfLines={1}>
@@ -6,10 +6,14 @@ import { Plus, Trash2 } 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 { Card } from "../Card";
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
import { addYearsToDisplayDate, displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
/** Übliche Auffrisch-Intervalle (z. B. Tollwut 1–3 Jahre) als Schnellauswahl für den Folgetermin. */
const NEXT_DUE_PRESET_YEARS = [1, 3];
/** Port of web's health.ts vaccine CRUD — see health/page.tsx's Impfungen tab. */
export function VaccineSection({ petId }: { petId: string }) {
@@ -80,8 +84,24 @@ export function VaccineSection({ petId }: { petId: string }) {
{showForm && (
<Card style={styles.form}>
<TextField placeholder={t("health.vaccineNamePlaceholder")} value={name} onChangeText={setName} maxLength={100} />
<TextField placeholder={t("health.vaccineGivenPlaceholder")} value={dateGiven} onChangeText={setDateGiven} />
<TextField placeholder={t("health.vaccineNextDuePlaceholder")} value={nextDueDate} onChangeText={setNextDueDate} />
<DateField placeholder={t("health.vaccineGivenPlaceholder")} value={dateGiven} onChangeText={setDateGiven} />
<DateField placeholder={t("health.vaccineNextDuePlaceholder")} value={nextDueDate} onChangeText={setNextDueDate} />
<View style={styles.presetRow}>
{NEXT_DUE_PRESET_YEARS.map((years) => {
const suggestion = addYearsToDisplayDate(dateGiven, years);
return (
<Pressable
key={years}
style={[styles.preset, !suggestion && styles.presetDisabled]}
disabled={!suggestion}
onPress={() => suggestion && setNextDueDate(suggestion)}
>
<Text style={styles.presetText}>{t("health.vaccineNextDuePreset", { count: years })}</Text>
</Pressable>
);
})}
</View>
<Text style={styles.hint}>{t("health.vaccineReminderHint")}</Text>
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={200} />
<Button
title={addMutation.isPending ? "…" : t("common.save")}
@@ -134,5 +154,10 @@ function makeStyles(colors: ColorTokens) {
rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily },
rowSecondary: { ...typography.label, textTransform: "none", letterSpacing: 0 },
overdue: { color: colors.alertRed },
presetRow: { flexDirection: "row", gap: 8 },
preset: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 999, backgroundColor: colors.marigoldTint },
presetDisabled: { opacity: 0.4 },
presetText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.ink },
hint: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray },
});
}
@@ -6,6 +6,7 @@ import { Plus, Trash2 } 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 { Card } from "../Card";
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
@@ -72,7 +73,7 @@ export function VetVisitSection({ petId }: { petId: string }) {
{showForm && (
<Card style={styles.form}>
<TextField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
<DateField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
<TextField placeholder={t("health.vetReasonPlaceholder")} value={reason} onChangeText={setReason} maxLength={100} />
<TextField placeholder={t("health.vetNamePlaceholder")} value={vetName} onChangeText={setVetName} maxLength={100} />
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={500} multiline />
@@ -0,0 +1,202 @@
import { useMemo, useState } from "react";
import { View, Text, Pressable, StyleSheet, type LayoutChangeEvent } from "react-native";
import Svg, { Path, Line, Circle, Text as SvgText, Defs, LinearGradient, Stop } from "react-native-svg";
import { useTranslation } from "react-i18next";
import { useTheme } from "../../context/ThemeContext";
import { Card } from "../Card";
import { getTypography, fontFamily, type ColorTokens, type Typography } from "../../theme/tokens";
import { buildWeightChart, nearestPointIndex, type WeightEntry } from "../../lib/weight-chart";
const CHART_HEIGHT = 170;
const BOX = { left: 40, right: 12, top: 12, bottom: 26 };
const AXIS_FONT_SIZE = 10;
const MAX_LABELED_Y_TICKS = 5;
const MARKER_RADIUS = 4;
const SELECTED_RADIUS = 6;
const LONG_SPAN_MS = 300 * 24 * 60 * 60 * 1000;
/**
* Weight history (found.md: Gewichtsdaten als Diagramm) — port of web's
* src/components/health/WeightChart.tsx. Tap anywhere on the plot to select
* the nearest weigh-in; its value and date show in the header (no floating
* tooltip to collide with the edges on a phone). The newest entry is
* selected by default. The entry list below the chart is the table view.
*/
export function WeightChart({ entries }: { entries: WeightEntry[] }) {
const { t, i18n } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const [width, setWidth] = useState(0);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const chart = useMemo(
() => (width > 0 ? buildWeightChart(entries, { width, height: CHART_HEIGHT, ...BOX }) : null),
[entries, width]
);
const formatKg = (kg: number, maximumFractionDigits = 2) =>
`${kg.toLocaleString(i18n.language, { maximumFractionDigits })} kg`;
const formatDate = (time: number, options: Intl.DateTimeFormatOptions) =>
new Date(time).toLocaleDateString(i18n.language, { timeZone: "UTC", ...options });
const onLayout = (e: LayoutChangeEvent) => setWidth(Math.round(e.nativeEvent.layout.width));
if (entries.length < 2) return null;
const points = chart?.points ?? [];
const activeIndex = selectedIndex !== null && selectedIndex < points.length ? selectedIndex : points.length - 1;
const active = points[activeIndex];
const delta = chart?.deltaKg ?? 0;
const deltaLabel = `${delta > 0 ? "+" : delta < 0 ? "−" : "±"}${formatKg(Math.abs(delta))}`;
const spansLong = points.length > 1 && points[points.length - 1].time - points[0].time > LONG_SPAN_MS;
const axisDateOptions: Intl.DateTimeFormatOptions = spansLong
? { month: "short", year: "2-digit" }
: { day: "2-digit", month: "2-digit" };
const summary =
points.length > 1
? t("health.weightChartA11y", {
from: formatKg(points[0].kg),
fromDate: formatDate(points[0].time, { dateStyle: "medium" }),
to: formatKg(points[points.length - 1].kg),
toDate: formatDate(points[points.length - 1].time, { dateStyle: "medium" }),
})
: "";
return (
<Card style={styles.card}>
<View style={styles.header}>
<View>
<Text style={styles.eyebrow}>{t("health.weightChartTitle")}</Text>
{active && (
<Text style={styles.readout}>
{formatKg(active.kg)}
<Text style={styles.readoutDate}>{" "}{formatDate(active.time, { dateStyle: "medium" })}</Text>
</Text>
)}
</View>
<View style={styles.deltaChip}>
<Text style={styles.deltaText}>{deltaLabel}</Text>
<Text style={styles.deltaCaption}>{t("health.weightChartSinceFirst")}</Text>
</View>
</View>
<Pressable
onLayout={onLayout}
onPress={(e) => chart && setSelectedIndex(nearestPointIndex(chart.points, e.nativeEvent.locationX))}
accessibilityRole="image"
accessibilityLabel={summary}
style={styles.plot}
>
{chart && (
<Svg width={width} height={CHART_HEIGHT}>
<Defs>
<LinearGradient id="weightArea" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor={colors.chartLine} stopOpacity={0.2} />
<Stop offset="1" stopColor={colors.chartLine} stopOpacity={0} />
</LinearGradient>
</Defs>
{chart.yTicks.map((tick, i) => (
<Line
key={`grid-${i}`}
x1={BOX.left}
x2={width - BOX.right}
y1={tick.y}
y2={tick.y}
stroke={colors.hairlineGray}
strokeWidth={1}
/>
))}
{chart.yTicks.map((tick, i) =>
chart.yTicks.length <= MAX_LABELED_Y_TICKS || i % 2 === 0 ? (
<SvgText
key={`ylabel-${i}`}
x={BOX.left - 6}
y={tick.y + AXIS_FONT_SIZE / 3}
fontSize={AXIS_FONT_SIZE}
fontFamily={fontFamily.regular}
fill={colors.quietGray}
textAnchor="end"
>
{tick.kg.toLocaleString(i18n.language, {
minimumFractionDigits: chart.yTickDecimals,
maximumFractionDigits: chart.yTickDecimals,
})}
</SvgText>
) : null
)}
<Path d={chart.areaPath} fill="url(#weightArea)" />
<Path
d={chart.linePath}
fill="none"
stroke={colors.chartLine}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{active && (
<Line
x1={active.x}
x2={active.x}
y1={BOX.top}
y2={chart.baselineY}
stroke={colors.quietGray}
strokeWidth={1}
strokeDasharray="3 3"
/>
)}
{chart.points.map((p, i) => (
<Circle
key={`pt-${i}`}
cx={p.x}
cy={p.y}
r={i === activeIndex ? SELECTED_RADIUS : MARKER_RADIUS}
fill={i === activeIndex ? colors.chartLine : colors.pureWhite}
stroke={i === activeIndex ? colors.pureWhite : colors.chartLine}
strokeWidth={2}
/>
))}
{chart.xLabelIndices.map((i) => {
const p = chart.points[i];
const isFirst = i === chart.xLabelIndices[0] && p.x - BOX.left < 20;
const isLast = i === chart.points.length - 1;
return (
<SvgText
key={`xlabel-${i}`}
x={p.x}
y={CHART_HEIGHT - 6}
fontSize={AXIS_FONT_SIZE}
fontFamily={fontFamily.regular}
fill={colors.quietGray}
textAnchor={isLast ? "end" : isFirst ? "start" : "middle"}
>
{formatDate(p.time, axisDateOptions)}
</SvgText>
);
})}
</Svg>
)}
</Pressable>
</Card>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
card: { gap: 8 },
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", gap: 12 },
eyebrow: { ...typography.label },
readout: { ...typography.title, fontSize: 20, marginTop: 2 },
readoutDate: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 12 },
deltaChip: { alignItems: "flex-end" },
deltaText: { ...typography.body, fontFamily: typography.title.fontFamily },
deltaCaption: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
plot: { height: CHART_HEIGHT },
});
}
@@ -6,8 +6,10 @@ import { Plus, Trash2 } 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 { Card } from "../Card";
import { WeightChart } from "./WeightChart";
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
@@ -65,7 +67,7 @@ export function WeightLogSection({ petId }: { petId: string }) {
{showForm && (
<Card style={styles.form}>
<TextField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
<DateField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
<TextField placeholder={t("health.weightKgPlaceholder")} value={weightKg} onChangeText={setWeightKg} keyboardType="decimal-pad" />
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={200} />
<Button
@@ -81,20 +83,23 @@ export function WeightLogSection({ petId }: { petId: string }) {
) : logs.length === 0 ? (
<Text style={styles.empty}>{t("health.weightEmpty")}</Text>
) : (
logs.map((log) => (
<View key={log.id} style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.rowPrimary}>{(log.weightGrams / 1000).toLocaleString(i18n.language)} kg</Text>
<Text style={styles.rowSecondary}>
{new Date(log.date).toLocaleDateString(i18n.language)}
{log.notes ? ` · ${log.notes}` : ""}
</Text>
<>
<WeightChart entries={logs} />
{logs.map((log) => (
<View key={log.id} style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.rowPrimary}>{(log.weightGrams / 1000).toLocaleString(i18n.language)} kg</Text>
<Text style={styles.rowSecondary}>
{new Date(log.date).toLocaleDateString(i18n.language)}
{log.notes ? ` · ${log.notes}` : ""}
</Text>
</View>
<Pressable onPress={() => deleteMutation.mutate({ id: log.id, petId })} hitSlop={8}>
<Trash2 color={colors.quietGray} size={16} />
</Pressable>
</View>
<Pressable onPress={() => deleteMutation.mutate({ id: log.id, petId })} hitSlop={8}>
<Trash2 color={colors.quietGray} size={16} />
</Pressable>
</View>
))
))}
</>
)}
</View>
);
@@ -0,0 +1,115 @@
import { useMemo, useState } from "react";
import { View, Text, Alert, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Ribbon } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { useTheme } from "../../context/ThemeContext";
import { DateField } from "../DateField";
import { Button } from "../Button";
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
import { displayDateToIso, isoToDisplayDate, todayAsDisplayDate } from "../../lib/date-format";
type Props = {
petId: string;
petName: string;
deceasedAt: Date | string | null;
};
/** YYYY-MM-DD eines @db.Date-Werts (kommt als UTC-Mitternacht). */
function toIso(value: Date | string | null): string | null {
return value ? new Date(value).toISOString().slice(0, 10) : null;
}
/**
* Gedenkmodus „In Erinnerung“ — Port von web src/components/pet/MemorialSettings.tsx.
* Datum setzen (mit Bestätigung, Follower werden einmalig still informiert),
* korrigieren oder beenden. Server: pets.setDeceased.
*/
export function MemorialSettings({ petId, petName, deceasedAt }: Props) {
const { t } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const savedIso = toIso(deceasedAt);
const isDeceased = savedIso != null;
const [date, setDate] = useState(() => (savedIso ? isoToDisplayDate(savedIso) : todayAsDisplayDate()));
const [error, setError] = useState<string | null>(null);
const mutation = useMutation(
trpc.pets.setDeceased.mutationOptions({
onSuccess: (_data, variables) => {
void queryClient.invalidateQueries();
Alert.alert(variables.deceasedAt ? t("inMemory.saved") : t("inMemory.removed"));
},
onError: () => Alert.alert(t("inMemory.saveFailed")),
})
);
/** Gültiges, nicht zukünftiges Datum als YYYY-MM-DD — sonst Fehlermeldung. */
function validIso(): string | null {
const iso = displayDateToIso(date);
const todayIso = displayDateToIso(todayAsDisplayDate());
if (!iso || (todayIso && iso > todayIso)) {
setError(t("inMemory.invalidDate"));
return null;
}
setError(null);
return iso;
}
function activate() {
const iso = validIso();
if (!iso) return;
Alert.alert(t("inMemory.confirmTitle", { name: petName }), t("inMemory.confirmDescription"), [
{ text: t("inMemory.cancel"), style: "cancel" },
{ text: t("inMemory.confirm"), onPress: () => mutation.mutate({ petId, deceasedAt: iso }) },
]);
}
function saveDate() {
const iso = validIso();
if (iso && iso !== savedIso) mutation.mutate({ petId, deceasedAt: iso });
}
return (
<View style={styles.section}>
<View style={styles.headingRow}>
<Ribbon size={16} color={colors.ink} />
<Text style={styles.heading}>{t("inMemory.settingsHeading")}</Text>
</View>
<Text style={styles.hint}>
{isDeceased ? t("inMemory.activeHint", { name: petName }) : t("inMemory.settingsHint")}
</Text>
<Text style={styles.label}>{t("inMemory.dateLabel")}</Text>
<DateField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
{error ? <Text style={styles.error}>{error}</Text> : null}
{isDeceased ? (
<>
<Button title={t("inMemory.saveDate")} variant="outline" disabled={mutation.isPending} onPress={saveDate} />
<Button
title={t("inMemory.remove")}
variant="outline"
disabled={mutation.isPending}
onPress={() => mutation.mutate({ petId, deceasedAt: null })}
/>
</>
) : (
<Button title={t("inMemory.activate")} variant="outline" disabled={mutation.isPending} onPress={activate} />
)}
</View>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
section: { marginTop: 24, gap: 8 },
headingRow: { flexDirection: "row", alignItems: "center", gap: 6 },
heading: { ...typography.title, fontSize: 16 },
hint: { ...typography.label, textTransform: "none", letterSpacing: 0 },
label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
error: { ...typography.body, color: colors.alertRed },
});
}
@@ -0,0 +1,142 @@
import { useMemo } from "react";
import { View, Text, Pressable, Alert, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Check } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { useTheme } from "../../context/ThemeContext";
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
type DmPolicy = "EVERYONE" | "FOLLOWERS_ONLY";
type FollowerVisibility = "EVERYONE" | "FOLLOWERS_ONLY" | "NOBODY";
type Props = {
petId: string;
petName: string;
dmPolicy: DmPolicy;
followerVisibility: FollowerVisibility;
};
/**
* Port of the "Privatsphäre" block from web's src/app/(app)/pets/[petId]/edit/page.tsx
* (found.md: only editable on web so far). Each choice is saved immediately via
* pets.update — no separate save button, so it can't get lost in the long form.
*/
export function PrivacySettings({ petId, petName, dmPolicy, followerVisibility }: Props) {
const { t } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const update = useMutation(
trpc.pets.update.mutationOptions({
onSuccess: () => queryClient.invalidateQueries({ queryKey: trpc.pets.byId.queryOptions({ petId }).queryKey }),
onError: () => Alert.alert(t("privacy.saveFailed")),
})
);
const dmOptions: { value: DmPolicy; label: string }[] = [
{ value: "EVERYONE", label: t("privacy.everyone") },
{ value: "FOLLOWERS_ONLY", label: t("privacy.onlyFollowedPets") },
];
const followerOptions: { value: FollowerVisibility; label: string }[] = [
{ value: "EVERYONE", label: t("privacy.everyone") },
{ value: "FOLLOWERS_ONLY", label: t("privacy.onlyMyFollowers") },
{ value: "NOBODY", label: t("privacy.onlyMe") },
];
const followerHint = {
EVERYONE: t("privacy.followerEveryoneHint", { name: petName }),
FOLLOWERS_ONLY: t("privacy.followerFollowersOnlyHint", { name: petName }),
NOBODY: t("privacy.followerNobodyHint", { name: petName }),
}[followerVisibility];
return (
<View style={styles.section}>
<Text style={styles.heading}>{t("privacy.heading")}</Text>
<Text style={styles.question}>{t("privacy.whoCanMessage", { name: petName })}</Text>
<OptionGroup
options={dmOptions}
value={dmPolicy}
disabled={update.isPending}
onChange={(value) => update.mutate({ petId, dmPolicy: value })}
styles={styles}
colors={colors}
/>
<Text style={styles.hint}>
{dmPolicy === "FOLLOWERS_ONLY" ? t("privacy.dmFollowersOnlyHint") : t("privacy.dmEveryoneHint")}
</Text>
<Text style={styles.question}>{t("privacy.whoCanSeeFollowers", { name: petName })}</Text>
<OptionGroup
options={followerOptions}
value={followerVisibility}
disabled={update.isPending}
onChange={(value) => update.mutate({ petId, followerVisibility: value })}
styles={styles}
colors={colors}
/>
<Text style={styles.hint}>{followerHint}</Text>
</View>
);
}
function OptionGroup<T extends string>({
options,
value,
disabled,
onChange,
styles,
colors,
}: {
options: { value: T; label: string }[];
value: T;
disabled: boolean;
onChange: (value: T) => void;
styles: ReturnType<typeof makeStyles>;
colors: ColorTokens;
}) {
return (
<View style={styles.group} accessibilityRole="radiogroup">
{options.map((option, index) => {
const isSelected = option.value === value;
return (
<Pressable
key={option.value}
style={[styles.option, index > 0 && styles.optionDivider]}
onPress={() => !isSelected && onChange(option.value)}
disabled={disabled}
accessibilityRole="radio"
accessibilityState={{ checked: isSelected, disabled }}
>
<Text style={[styles.optionText, isSelected && styles.optionTextSelected]}>{option.label}</Text>
{isSelected && <Check color={colors.marigoldOrange} size={18} />}
</Pressable>
);
})}
</View>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
section: { marginTop: 24, gap: 8 },
heading: { ...typography.title, fontSize: 16 },
question: { ...typography.body, fontFamily: typography.title.fontFamily, marginTop: 8 },
group: { borderWidth: 1, borderColor: colors.hairlineGray, borderRadius: radii.lg, overflow: "hidden" },
option: {
minHeight: 48,
paddingHorizontal: 14,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: colors.pureWhite,
},
optionDivider: { borderTopWidth: 1, borderTopColor: colors.hairlineGray },
optionText: { ...typography.body },
optionTextSelected: { fontFamily: typography.title.fontFamily, color: colors.marigoldOrange },
hint: { ...typography.label, textTransform: "none", letterSpacing: 0 },
});
}
@@ -0,0 +1,181 @@
import { useMemo, useState } from "react";
import {
ActivityIndicator,
FlatList,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Send, Trash2, X } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { useTheme } from "../../context/ThemeContext";
import { PetAvatar } from "../PetAvatar";
import { TextField } from "../TextField";
import { formatPetName, formatRelativeTime } from "../../lib/format";
import { getTypography, radii, type ColorTokens } from "../../theme/tokens";
type Props = {
storyId: string;
activePetId: string;
visible: boolean;
onClose: () => void;
/** Wird nach Hinzufügen/Löschen aufgerufen, damit der Viewer den Zähler neu lädt. */
onChanged: () => void;
onPressPet: (petId: string) => void;
/** Abgelaufene Story in der 7-Tage-Ansicht des Erstellers: nur lesen. */
readOnly?: boolean;
};
/**
* Port of web's StoryCommentsOverlay als Bottom-Sheet. Der Viewer pausiert die
* Story, solange das Sheet offen ist (anders als im Web — auf dem Handy
* verdeckt die Tastatur sonst die laufende Story und sie springt weiter,
* während man noch tippt).
*/
export function StoryCommentsSheet({ storyId, activePetId, visible, onClose, onChanged, onPressPet, readOnly = false }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]);
const trpc = useTRPC();
const qc = useQueryClient();
const [body, setBody] = useState("");
const { data: comments = [], isLoading } = useQuery({
...trpc.stories.listComments.queryOptions({ storyId }),
enabled: visible,
});
const refresh = () => {
void qc.invalidateQueries({ queryKey: trpc.stories.listComments.queryKey({ storyId }) });
onChanged();
};
const add = useMutation(
trpc.stories.addComment.mutationOptions({
onSuccess: () => {
setBody("");
refresh();
},
})
);
const remove = useMutation(trpc.stories.deleteComment.mutationOptions({ onSuccess: refresh }));
const trimmed = body.trim();
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<Pressable style={styles.backdrop} onPress={onClose} accessibilityLabel={t("common.close")} />
<View style={styles.sheet}>
<View style={styles.header}>
<Text style={styles.title}>{t("storyViewer.commentsTitle")}</Text>
<Pressable onPress={onClose} hitSlop={12} accessibilityLabel={t("common.close")}>
<X size={20} color={colors.ink} />
</Pressable>
</View>
{isLoading ? (
<ActivityIndicator style={styles.loading} color={colors.marigoldOrange} />
) : (
<FlatList
data={comments}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
ListEmptyComponent={<Text style={styles.muted}>{t("comments.empty")}</Text>}
renderItem={({ item }) => (
<View style={styles.row}>
<Pressable onPress={() => onPressPet(item.pet.id)}>
<PetAvatar name={item.pet.name} avatarKey={item.pet.avatarKey} size="sm" owner={item.pet.owner} />
</Pressable>
<View style={styles.rowBody}>
<View style={styles.rowHeader}>
<Text style={styles.petName} onPress={() => onPressPet(item.pet.id)}>
{formatPetName(item.pet)}
</Text>
<Text style={styles.time}>{formatRelativeTime(item.createdAt)}</Text>
</View>
<Text style={styles.body}>{item.body}</Text>
</View>
{item.pet.id === activePetId && (
<Pressable
onPress={() => remove.mutate({ commentId: item.id, petId: activePetId })}
hitSlop={8}
accessibilityLabel={t("storyViewer.deleteComment")}
>
<Trash2 size={16} color={colors.quietGray} />
</Pressable>
)}
</View>
)}
/>
)}
{readOnly ? (
<Text style={styles.readOnly}>{t("storyViewer.readOnlyExpired")}</Text>
) : (
<View style={styles.inputRow}>
<TextField
placeholder={t("comments.placeholder")}
value={body}
onChangeText={setBody}
maxLength={500}
style={styles.input}
returnKeyType="send"
onSubmitEditing={() => trimmed && add.mutate({ storyId, petId: activePetId, body: trimmed })}
/>
<Pressable
onPress={() => add.mutate({ storyId, petId: activePetId, body: trimmed })}
disabled={!trimmed || add.isPending}
style={[styles.send, (!trimmed || add.isPending) && styles.sendDisabled]}
accessibilityLabel={t("messages.send")}
>
{add.isPending ? <ActivityIndicator color="#ffffff" /> : <Send size={18} color="#ffffff" />}
</Pressable>
</View>
)}
{add.isError && <Text style={styles.error}>{t("storyViewer.commentError")}</Text>}
</View>
</KeyboardAvoidingView>
</Modal>
);
}
function makeStyles(colors: ColorTokens, insetBottom: number) {
const typography = getTypography(colors);
return StyleSheet.create({
flex: { flex: 1 },
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.35)" },
sheet: {
maxHeight: "65%",
backgroundColor: colors.porchWhite,
borderTopLeftRadius: radii["2xl"],
borderTopRightRadius: radii["2xl"],
paddingBottom: insetBottom + 12,
},
header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", padding: 16, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
title: { ...typography.title, fontSize: 16 },
loading: { padding: 24 },
list: { padding: 16, gap: 14 },
muted: { ...typography.body, color: colors.quietGray, textAlign: "center", paddingVertical: 16 },
row: { flexDirection: "row", gap: 10, alignItems: "flex-start" },
rowBody: { flex: 1, gap: 2 },
rowHeader: { flexDirection: "row", alignItems: "baseline", gap: 8 },
petName: { ...typography.body, fontWeight: "600", fontSize: 13 },
time: { ...typography.body, fontSize: 11, color: colors.quietGray },
body: { ...typography.body },
inputRow: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingTop: 10 },
input: { flex: 1 },
send: { width: 48, height: 48, borderRadius: radii.lg, backgroundColor: colors.marigoldOrange, alignItems: "center", justifyContent: "center" },
sendDisabled: { opacity: 0.5 },
readOnly: { ...typography.body, fontSize: 13, color: colors.quietGray, paddingHorizontal: 16, paddingTop: 10 },
error: { ...typography.body, fontSize: 12, color: colors.alertRed, paddingHorizontal: 12, paddingTop: 6 },
});
}
@@ -0,0 +1,72 @@
import { useState } from "react";
import { Pressable, Text, View, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { PawPrint } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { colors } from "../../theme/tokens";
type Props = {
storyId: string;
petId: string;
initialCount: number;
initialReacted: boolean;
};
/**
* Port of web's StoryPawButton — Pfote auf einer Story. Optimistisch: Zustand
* und Zähler springen sofort um und werden bei einem Fehler zurückgesetzt.
* Der Viewer setzt `key={story.id}`, damit der Zustand pro Story neu startet.
*/
export function StoryPawButton({ storyId, petId, initialCount, initialReacted }: Props) {
const { t } = useTranslation();
const trpc = useTRPC();
const [reacted, setReacted] = useState(initialReacted);
const [count, setCount] = useState(initialCount);
const toggle = useMutation(
trpc.stories.toggleReaction.mutationOptions({
onMutate: () => {
const previous = { reacted, count };
setReacted(!reacted);
setCount(reacted ? Math.max(0, count - 1) : count + 1);
return previous;
},
// Der Server ist maßgeblich: Zähler aus dem Stand vor dem Tippen + tatsächlicher Änderung.
onSuccess: (data, _vars, previous) => {
setReacted(data.reacted);
if (!previous) return;
const delta = data.reacted === previous.reacted ? 0 : data.reacted ? 1 : -1;
setCount(Math.max(0, previous.count + delta));
},
onError: (_err, _vars, previous) => {
if (previous) {
setReacted(previous.reacted);
setCount(previous.count);
}
},
})
);
return (
<Pressable
onPress={() => !toggle.isPending && toggle.mutate({ storyId, petId })}
accessibilityRole="button"
accessibilityLabel={reacted ? t("storyViewer.removePaw") : t("storyViewer.paw")}
accessibilityState={{ selected: reacted }}
hitSlop={8}
style={styles.wrap}
>
<View style={styles.circle}>
<PawPrint size={20} color={reacted ? colors.marigoldOrange : colors.pureWhite} fill={reacted ? colors.marigoldOrange : "transparent"} />
</View>
{count > 0 && <Text style={styles.count}>{count}</Text>}
</Pressable>
);
}
const styles = StyleSheet.create({
wrap: { alignItems: "center", gap: 2 },
circle: { width: 44, height: 44, borderRadius: 22, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center" },
count: { color: colors.pureWhite, fontSize: 12, fontWeight: "600", textShadowColor: "rgba(0,0,0,0.6)", textShadowRadius: 3 },
});
@@ -0,0 +1,88 @@
import { useMemo } from "react";
import { ActivityIndicator, FlatList, Modal, Pressable, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { X } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { useTheme } from "../../context/ThemeContext";
import { PetAvatar } from "../PetAvatar";
import { formatPetName, formatRelativeTime } from "../../lib/format";
import { getTypography, radii, type ColorTokens } from "../../theme/tokens";
type Props = {
storyId: string;
visible: boolean;
onClose: () => void;
onPressPet: (petId: string) => void;
};
/**
* Port of web's owner-only viewer Sheet — wer hat diese Story gesehen.
* stories.getViewers prüft serverseitig, dass nur der Owner der Story fragt.
*/
export function StoryViewersSheet({ storyId, visible, onClose, onPressPet }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]);
const trpc = useTRPC();
const { data: viewers = [], isLoading } = useQuery({
...trpc.stories.getViewers.queryOptions({ storyId }),
enabled: visible,
});
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose} accessibilityLabel={t("common.close")} />
<View style={styles.sheet}>
<View style={styles.header}>
<Text style={styles.title}>{t("storyViewer.viewersTitle", { count: viewers.length })}</Text>
<Pressable onPress={onClose} hitSlop={12} accessibilityLabel={t("common.close")}>
<X size={20} color={colors.ink} />
</Pressable>
</View>
{isLoading ? (
<ActivityIndicator style={styles.loading} color={colors.marigoldOrange} />
) : (
<FlatList
data={viewers}
keyExtractor={(item) => item.viewerPet.id}
contentContainerStyle={styles.list}
ListEmptyComponent={<Text style={styles.muted}>{t("storyViewer.noViewers")}</Text>}
renderItem={({ item }) => (
<Pressable style={styles.row} onPress={() => onPressPet(item.viewerPet.id)}>
<PetAvatar name={item.viewerPet.name} avatarKey={item.viewerPet.avatarKey} size="sm" owner={item.viewerPet.owner} />
<Text style={styles.name} numberOfLines={1}>{formatPetName(item.viewerPet)}</Text>
<Text style={styles.time}>{formatRelativeTime(item.viewedAt)}</Text>
</Pressable>
)}
/>
)}
</View>
</Modal>
);
}
function makeStyles(colors: ColorTokens, insetBottom: number) {
const typography = getTypography(colors);
return StyleSheet.create({
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.35)" },
sheet: {
maxHeight: "60%",
backgroundColor: colors.porchWhite,
borderTopLeftRadius: radii["2xl"],
borderTopRightRadius: radii["2xl"],
paddingBottom: insetBottom + 12,
},
header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", padding: 16, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
title: { ...typography.title, fontSize: 16 },
loading: { padding: 24 },
list: { padding: 16, gap: 12 },
muted: { ...typography.body, color: colors.quietGray, textAlign: "center", paddingVertical: 16 },
row: { flexDirection: "row", alignItems: "center", gap: 10 },
name: { ...typography.body, flex: 1, fontWeight: "600" },
time: { ...typography.body, fontSize: 11, color: colors.quietGray },
});
}
@@ -0,0 +1,131 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { PanResponder, View, type GestureResponderEvent, type PanResponderGestureState, type PanResponderInstance } from "react-native";
import { OverlayContent } from "./OverlayContent";
import { PositionedOverlay } from "./StoryOverlayLayer";
import { angleDelta, applyGesture, touchGeometry, type Rect, type StoryOverlay } from "../../lib/story-overlays";
const TAP_MAX_MOVE = 6;
const TAP_MAX_MS = 250;
type Callbacks = {
overlay: StoryOverlay;
rect: Rect;
onChange: (next: StoryOverlay) => void;
onTap: () => void;
/** Ziehen beginnt/läuft/endet — der Editor zeigt dann den Papierkorb. */
onDragStateChange: (dragging: boolean, pageY: number) => void;
/** Loslassen über dem Papierkorb? (pageY des Fingers) */
isOverTrash: (pageY: number) => boolean;
onDelete: () => void;
};
type Props = Callbacks & { mentionName?: string };
/**
* Ein verschiebbares Overlay im Story-Editor. Gesten mit React-Natives
* eingebautem PanResponder statt gesture-handler/reanimated: Die sind zwar
* über expo-router installiert, aber ob sie im Store-Build 1.0.6 nativ stecken,
* ist nicht sicher belegt — ein OTA-Update mit ihrem Import würde dann beim
* Start abstürzen. PanResponder ist RN-Core und damit OTA-sicher.
*
* 1 Finger: verschieben · 2 Finger: zoomen + drehen (+ verschieben) ·
* kurz tippen: bearbeiten · auf den Papierkorb ziehen: löschen.
*/
export function EditableOverlay({ mentionName, ...callbacks }: Props) {
// Die Gesten-Handler leben über Renders hinweg und lesen den aktuellen Stand
// erst beim Berühren aus diesem Ref (klassisches PanResponder-Muster).
const latest = useRef<Callbacks>(callbacks);
useLayoutEffect(() => {
latest.current = callbacks;
});
const getLatest = useCallback(() => latest.current, []);
// Die Fabrik legt nur Handler an; gelesen wird der Ref erst bei Berührung, nie beim Rendern.
// eslint-disable-next-line react-hooks/refs -- PanResponder-Muster, siehe createOverlayResponder
const [responder] = useState(() => createOverlayResponder(getLatest));
return (
<PositionedOverlay overlay={callbacks.overlay} rect={callbacks.rect} interactive>
<View {...responder.panHandlers} hitSlop={12}>
<OverlayContent overlay={callbacks.overlay} baseWidth={callbacks.rect.width} mentionName={mentionName} />
</View>
</PositionedOverlay>
);
}
/**
* Gestenlogik außerhalb der Komponente: hält ihren Zustand in der Closure und
* liest Props nur in Event-Handlern (nie während des Renderns).
*/
function createOverlayResponder(getLatest: () => Callbacks): PanResponderInstance {
const g = {
base: null as StoryOverlay | null,
offsetX: 0,
offsetY: 0,
touches: 0,
startDistance: 1,
startAngle: 0,
startCentroid: { x: 0, y: 0 },
startedAt: 0,
moved: 0,
};
// Fingerzahl geändert → neue Ausgangslage, sonst springt das Overlay.
function rebase(evt: GestureResponderEvent, state: Pick<PanResponderGestureState, "dx" | "dy">) {
const touches = evt.nativeEvent.touches;
g.base = getLatest().overlay;
g.offsetX = state.dx;
g.offsetY = state.dy;
g.touches = touches.length;
if (touches.length >= 2) {
const geo = touchGeometry(touches[0], touches[1]);
g.startDistance = geo.distance;
g.startAngle = geo.angle;
g.startCentroid = { x: (touches[0].pageX + touches[1].pageX) / 2, y: (touches[0].pageY + touches[1].pageY) / 2 };
}
}
function finish(evt: GestureResponderEvent) {
const { pageY } = evt.nativeEvent;
getLatest().onDragStateChange(false, pageY);
if (g.moved <= TAP_MAX_MOVE && Date.now() - g.startedAt <= TAP_MAX_MS) {
getLatest().onTap();
} else if (getLatest().isOverTrash(pageY)) {
getLatest().onDelete();
}
}
return PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: (evt) => {
g.startedAt = Date.now();
g.moved = 0;
rebase(evt, { dx: 0, dy: 0 });
getLatest().onDragStateChange(true, evt.nativeEvent.pageY);
},
onPanResponderMove: (evt, state) => {
const touches = evt.nativeEvent.touches;
if (touches.length !== g.touches) rebase(evt, state);
g.moved = Math.max(g.moved, Math.hypot(state.dx, state.dy));
let pinch = 1;
let rotationDelta = 0;
let dx = state.dx - g.offsetX;
let dy = state.dy - g.offsetY;
if (touches.length >= 2 && g.startDistance > 0) {
const now = touchGeometry(touches[0], touches[1]);
pinch = now.distance / g.startDistance;
rotationDelta = angleDelta(g.startAngle, now.angle);
dx = (touches[0].pageX + touches[1].pageX) / 2 - g.startCentroid.x;
dy = (touches[0].pageY + touches[1].pageY) / 2 - g.startCentroid.y;
g.moved = Math.max(g.moved, TAP_MAX_MOVE + 1);
}
const base = g.base ?? getLatest().overlay;
getLatest().onChange(applyGesture(base, getLatest().rect, { dx, dy, pinch, rotationDelta }));
getLatest().onDragStateChange(true, evt.nativeEvent.pageY);
},
onPanResponderRelease: (evt) => finish(evt),
onPanResponderTerminate: (evt) => finish(evt),
});
}
@@ -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 },
});
@@ -0,0 +1,95 @@
import { useEffect, useState } from "react";
import { View, Text, TextInput, Pressable, FlatList, ActivityIndicator, useWindowDimensions, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useTRPC } from "../../api/trpc";
import { useDebounce } from "../../hooks/useDebounce";
import { fontFamily } from "../../theme/tokens";
import { PickerSheet } from "./PickerSheet";
export type PickedGif = { id: string; url: string; width: number; height: number };
const COLUMNS = 3;
const GAP = 6;
/**
* GIF-Suche über unseren Server (stories.searchGifs → GIPHY, nur Freigabe G).
* Ohne Suchbegriff erscheinen die aktuellen Trends. „Powered by GIPHY“ ist
* Pflicht laut GIPHY-Bedingungen.
*/
export function GifPicker({ visible, onSelect, onClose }: { visible: boolean; onSelect: (gif: PickedGif) => void; onClose: () => void }) {
const { t } = useTranslation();
const trpc = useTRPC();
const { width } = useWindowDimensions();
const [query, setQuery] = useState("");
const debounced = useDebounce(query.trim(), 400);
const [offset, setOffset] = useState(0);
const [items, setItems] = useState<PickedGif[]>([]);
// Neue Suche → von vorne
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Liste beim Suchwechsel leeren ist gewollt
setOffset(0);
setItems([]);
}, [debounced]);
const { data, isFetching, isError } = useQuery({
...trpc.stories.searchGifs.queryOptions({ query: debounced, offset }),
enabled: visible,
staleTime: 10 * 60 * 1000,
});
useEffect(() => {
if (!data) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- Seiten werden angehängt, sobald sie da sind
setItems((prev) => {
const seen = new Set(prev.map((g) => g.id));
return [...prev, ...data.gifs.filter((g) => !seen.has(g.id))];
});
}, [data]);
const tile = (width - 32 - GAP * (COLUMNS - 1)) / COLUMNS;
return (
<PickerSheet visible={visible} title={t("storyEditor.gifTitle")} onClose={onClose}>
<TextInput
value={query}
onChangeText={setQuery}
placeholder={t("storyEditor.gifSearch")}
placeholderTextColor="#a3a3a3"
autoCorrect={false}
style={styles.search}
/>
{isError && <Text style={styles.hint}>{t("storyEditor.gifError")}</Text>}
<FlatList
data={items}
keyExtractor={(g) => g.id}
numColumns={COLUMNS}
columnWrapperStyle={{ gap: GAP }}
contentContainerStyle={{ gap: GAP }}
keyboardShouldPersistTaps="handled"
onEndReachedThreshold={0.6}
onEndReached={() => {
if (data?.nextOffset && !isFetching) setOffset(data.nextOffset);
}}
renderItem={({ item }) => (
<Pressable onPress={() => onSelect(item)} accessibilityRole="button" accessibilityLabel="GIF">
<Image source={{ uri: item.url }} style={{ width: tile, height: tile, borderRadius: 8, backgroundColor: "#2a2a2a" }} contentFit="cover" autoplay alt="GIF" />
</Pressable>
)}
ListFooterComponent={isFetching ? <ActivityIndicator color="#f97316" style={{ marginVertical: 12 }} /> : null}
/>
<View style={styles.attribution}>
<Text style={styles.attributionText}>Powered by GIPHY</Text>
</View>
</PickerSheet>
);
}
const styles = StyleSheet.create({
search: { backgroundColor: "#2a2a2a", color: "#ffffff", borderRadius: 12, paddingHorizontal: 14, height: 44, fontFamily: fontFamily.regular, fontSize: 15 },
hint: { color: "#a3a3a3", fontFamily: fontFamily.regular, fontSize: 13 },
attribution: { alignItems: "center", paddingTop: 4 },
attributionText: { color: "#a3a3a3", fontFamily: fontFamily.bold, fontSize: 12, letterSpacing: 0.5 },
});
@@ -0,0 +1,74 @@
import { useState } from "react";
import { Text, TextInput, Pressable, FlatList, ActivityIndicator, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "../../api/trpc";
import { useDebounce } from "../../hooks/useDebounce";
import { PetAvatar } from "../PetAvatar";
import { fontFamily } from "../../theme/tokens";
import { PickerSheet } from "./PickerSheet";
/**
* Tier zum Markieren suchen. Der Server liefert nur Tiere, die man laut ihrer
* Einstellung „Wer darf die Follower sehen?“ markieren darf (und keine
* blockierten) — darauf weist der Hinweis unten hin.
*/
export function MentionPicker({
visible,
petId,
onSelect,
onClose,
}: {
visible: boolean;
petId: string;
onSelect: (pet: { id: string; name: string }) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const trpc = useTRPC();
const [query, setQuery] = useState("");
const debounced = useDebounce(query.trim(), 300);
const { data: pets = [], isFetching } = useQuery({
...trpc.stories.searchMentionable.queryOptions({ petId, query: debounced }),
enabled: visible && debounced.length > 0,
});
return (
<PickerSheet visible={visible} title={t("storyEditor.mentionTitle")} onClose={onClose}>
<TextInput
value={query}
onChangeText={setQuery}
placeholder={t("storyEditor.mentionSearch")}
placeholderTextColor="#a3a3a3"
autoFocus
autoCapitalize="none"
autoCorrect={false}
style={styles.search}
/>
{isFetching && <ActivityIndicator color="#f97316" />}
<FlatList
data={debounced ? pets : []}
keyExtractor={(p) => p.id}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => (
<Pressable style={styles.row} onPress={() => onSelect({ id: item.id, name: item.name })} accessibilityRole="button">
<PetAvatar name={item.name} avatarKey={item.avatarKey} size="sm" owner={item.owner} />
<Text style={styles.name}>{item.name}</Text>
</Pressable>
)}
ListEmptyComponent={
debounced && !isFetching ? <Text style={styles.hint}>{t("storyEditor.mentionNoResults")}</Text> : null
}
/>
<Text style={styles.hint}>{t("storyEditor.mentionPrivacyHint")}</Text>
</PickerSheet>
);
}
const styles = StyleSheet.create({
search: { backgroundColor: "#2a2a2a", color: "#ffffff", borderRadius: 12, paddingHorizontal: 14, height: 44, fontFamily: fontFamily.regular, fontSize: 15 },
row: { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 10 },
name: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 15 },
hint: { color: "#a3a3a3", fontFamily: fontFamily.regular, fontSize: 12, paddingVertical: 6 },
});
@@ -0,0 +1,141 @@
import { memo } from "react";
import { View, Text, StyleSheet } from "react-native";
import { Image } from "expo-image";
import Svg, { Circle, Path, Rect as SvgRect } from "react-native-svg";
import { fontFamily } from "../../theme/tokens";
import { OVERLAY_SIZES, contrastTextColor, type StoryOverlay, type TextOverlay } from "../../lib/story-overlays";
import { STORY_STICKERS, STICKER_OUTLINE, type StickerPart } from "../../lib/story-stickers";
/**
* Sichtbarer Inhalt eines Story-Overlays, ohne Position/Drehung/Skalierung —
* das übernehmen StoryOverlayLayer (Anzeige) bzw. EditableOverlay (Editor).
* Alle Größen hängen nur von `baseWidth` (Breite der Medienfläche) ab, damit
* es auf jedem Bildschirm und im Web (StoryOverlayLayer.tsx dort) gleich aussieht.
*/
export const OverlayContent = memo(function OverlayContent({
overlay,
baseWidth,
mentionName,
}: {
overlay: StoryOverlay;
baseWidth: number;
mentionName?: string;
}) {
switch (overlay.kind) {
case "text":
return <TextContent overlay={overlay} baseWidth={baseWidth} />;
case "mention":
return <MentionChip name={mentionName ?? "…"} baseWidth={baseWidth} />;
case "gif": {
const width = OVERLAY_SIZES.gifWidth * baseWidth;
return (
<Image
source={{ uri: overlay.url }}
style={{ width, height: (width * overlay.height) / overlay.width, borderRadius: baseWidth * 0.02 }}
autoplay
contentFit="cover"
accessibilityLabel="GIF"
alt="GIF"
/>
);
}
case "sticker":
return <StickerContent stickerId={overlay.stickerId} size={OVERLAY_SIZES.sticker * baseWidth} />;
}
});
function TextContent({ overlay, baseWidth }: { overlay: TextOverlay; baseWidth: number }) {
const fontSize = OVERLAY_SIZES.textFont * baseWidth;
const { textColor, background } = textAppearance(overlay);
return (
<View
style={{
maxWidth: OVERLAY_SIZES.textMaxWidth * baseWidth,
backgroundColor: background,
borderRadius: fontSize * 0.3,
paddingHorizontal: background ? fontSize * 0.4 : 0,
paddingVertical: background ? fontSize * 0.18 : 0,
}}
>
<Text
style={[
styles.text,
{
fontSize,
lineHeight: fontSize * 1.2,
color: textColor,
fontFamily: overlay.font === "bold" ? fontFamily.bold : fontFamily.regular,
},
!background && styles.textShadow,
]}
>
{overlay.text}
</Text>
</View>
);
}
/** Farben je Textstil — identische Regeln im Web (src/components/stories/StoryOverlayLayer.tsx). */
export function textAppearance(overlay: Pick<TextOverlay, "color" | "style">): { textColor: string; background?: string } {
switch (overlay.style) {
case "box":
return { textColor: contrastTextColor(overlay.color), background: overlay.color };
case "soft":
return {
textColor: overlay.color,
background: overlay.color === "#171717" ? "rgba(255,255,255,0.85)" : "rgba(0,0,0,0.55)",
};
default:
return { textColor: overlay.color };
}
}
export function MentionChip({ name, baseWidth }: { name: string; baseWidth: number }) {
const fontSize = OVERLAY_SIZES.mentionFont * baseWidth;
return (
<View
style={{
backgroundColor: "#ffffff",
borderRadius: fontSize,
paddingHorizontal: fontSize * 0.6,
paddingVertical: fontSize * 0.25,
}}
>
<Text style={{ fontSize, lineHeight: fontSize * 1.25, color: "#f97316", fontFamily: fontFamily.bold }} numberOfLines={1}>
@{name}
</Text>
</View>
);
}
export function StickerContent({ stickerId, size }: { stickerId: string; size: number }) {
const sticker = STORY_STICKERS.find((s) => s.id === stickerId);
if (!sticker) return null;
return (
<Svg width={size} height={size} viewBox="-6 -6 112 112" accessibilityLabel={sticker.label}>
{sticker.parts.map((part, i) => (
<StickerShape key={`o${i}`} part={part} fill={STICKER_OUTLINE.color} stroke={STICKER_OUTLINE.color} strokeWidth={STICKER_OUTLINE.width} />
))}
{sticker.parts.map((part, i) => (
<StickerShape key={`f${i}`} part={part} fill={part.fill} />
))}
</Svg>
);
}
function StickerShape({ part, fill, stroke, strokeWidth }: { part: StickerPart; fill: string; stroke?: string; strokeWidth?: number }) {
const strokeProps = stroke ? { stroke, strokeWidth, strokeLinejoin: "round" as const } : {};
switch (part.type) {
case "circle":
return <Circle cx={part.cx} cy={part.cy} r={part.r} fill={fill} {...strokeProps} />;
case "rect":
return <SvgRect x={part.x} y={part.y} width={part.width} height={part.height} rx={part.rx} fill={fill} {...strokeProps} />;
case "path":
return <Path d={part.d} fill={fill} {...strokeProps} />;
}
}
const styles = StyleSheet.create({
text: { textAlign: "center" },
textShadow: { textShadowColor: "rgba(0,0,0,0.45)", textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 6 },
});
@@ -0,0 +1,32 @@
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) — 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 (
<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}>
<Text style={styles.title}>{title}</Text>
<Pressable onPress={onClose} hitSlop={12} accessibilityRole="button">
<X color="#ffffff" size={22} />
</Pressable>
</View>
{children}
</View>
</EditorLayer>
);
}
const styles = StyleSheet.create({
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" },
title: { color: "#ffffff", fontFamily: fontFamily.bold, fontSize: 17 },
});
@@ -0,0 +1,31 @@
import { View, Pressable, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { STORY_STICKERS } from "../../lib/story-stickers";
import { StickerContent } from "./OverlayContent";
import { PickerSheet } from "./PickerSheet";
export function StickerPicker({ visible, onSelect, onClose }: { visible: boolean; onSelect: (stickerId: string) => void; onClose: () => void }) {
const { t } = useTranslation();
return (
<PickerSheet visible={visible} title={t("storyEditor.stickerTitle")} onClose={onClose}>
<View style={styles.grid}>
{STORY_STICKERS.map((sticker) => (
<Pressable
key={sticker.id}
style={styles.cell}
onPress={() => onSelect(sticker.id)}
accessibilityRole="button"
accessibilityLabel={sticker.label}
>
<StickerContent stickerId={sticker.id} size={64} />
</Pressable>
))}
</View>
</PickerSheet>
);
}
const styles = StyleSheet.create({
grid: { flexDirection: "row", flexWrap: "wrap", justifyContent: "space-between", rowGap: 12, paddingBottom: 8 },
cell: { width: "23%", aspectRatio: 1, alignItems: "center", justifyContent: "center", borderRadius: 12, backgroundColor: "#2a2a2a" },
});
@@ -0,0 +1,22 @@
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
import { STORY_BACKGROUNDS } from "../../lib/story-overlays";
/**
* Farbverlauf einer Text-Story. Über react-native-svg statt expo-linear-gradient:
* letzteres wäre ein neues natives Modul (= Store-Build), svg ist schon drin.
*/
export function StoryBackground({ backgroundKey, width, height, radius = 0 }: { backgroundKey: string; width: number; height: number; radius?: number }) {
const [from, to] = STORY_BACKGROUNDS[backgroundKey] ?? STORY_BACKGROUNDS.sunset;
const id = `bg-${backgroundKey}`;
return (
<Svg width={width} height={height}>
<Defs>
<LinearGradient id={id} x1="0" y1="0" x2="0.35" y2="1">
<Stop offset="0" stopColor={from} />
<Stop offset="1" stopColor={to} />
</LinearGradient>
</Defs>
<Rect x={0} y={0} width={width} height={height} rx={radius} fill={`url(#${id})`} />
</Svg>
);
}
@@ -0,0 +1,321 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Pressable, Image as RNImage, ActivityIndicator, Alert, StyleSheet, type LayoutChangeEvent } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { useVideoPlayer, VideoView } from "expo-video";
import { AtSign, Smile, Trash2, Type, X } from "lucide-react-native";
import { fontFamily } from "../../theme/tokens";
import {
MAX_GIFS,
MAX_MENTIONS,
MAX_OVERLAYS,
STORY_BACKGROUND_KEYS,
STORY_BACKGROUNDS,
TEXT_STORY_ASPECT,
containRect,
type StoryOverlay,
type TextOverlay,
} from "../../lib/story-overlays";
import { EditableOverlay } from "./EditableOverlay";
import { StoryBackground } from "./StoryBackground";
import { TextEditorModal, type TextDraft } from "./TextEditorModal";
import { MentionPicker } from "./MentionPicker";
import { GifPicker } from "./GifPicker";
import { StickerPicker } from "./StickerPicker";
export type EditorMedia =
| { kind: "image"; uri: string; width: number; height: number }
| { kind: "video"; uri: string; width: number; height: number; mimeType?: string }
| { kind: "text" };
export type EditorResult = { overlays: StoryOverlay[]; background?: string; mentionNames: Record<string, string> };
type Picker = "text" | "mention" | "gif" | "sticker" | null;
const TRASH_ZONE_HEIGHT = 120;
/**
* Story-Editor (feature/story-editor): Foto, Video oder Farbverlauf als Fläche,
* darauf Text, @-Markierungen, GIFs und Sticker. Positionen werden relativ zur
* Medienfläche gespeichert (lib/story-overlays.ts), damit sie im Viewer — App
* und Web — an derselben Stelle sitzen.
*/
export function StoryEditor({
media,
petId,
isSubmitting,
submitLabel,
progress,
toolsEnabled = true,
onSubmit,
onCancel,
}: {
media: EditorMedia;
/** Notschalter aus → nur Vorschau + Teilen, keine Werkzeuge (wie vor dem Editor). */
toolsEnabled?: boolean;
petId: string;
isSubmitting: boolean;
submitLabel: string;
/** Upload-Fortschritt/Status unter dem Teilen-Knopf (Video). */
progress?: React.ReactNode;
onSubmit: (result: EditorResult) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [canvas, setCanvas] = useState({ width: 0, height: 0 });
const [overlays, setOverlays] = useState<StoryOverlay[]>([]);
const [background, setBackground] = useState<string>(STORY_BACKGROUND_KEYS[0]);
const [mentionNames, setMentionNames] = useState<Record<string, string>>({});
const [picker, setPicker] = useState<Picker>(media.kind === "text" ? "text" : null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [dragging, setDragging] = useState(false);
const [overTrash, setOverTrash] = useState(false);
const canvasTopRef = useRef(0);
const canvasRef = useRef<View>(null);
const mediaSize = media.kind === "text" ? TEXT_STORY_ASPECT : { width: media.width, height: media.height };
const rect = useMemo(
() => containRect(canvas.width, canvas.height, mediaSize.width, mediaSize.height),
[canvas.width, canvas.height, mediaSize.width, mediaSize.height]
);
const onCanvasLayout = (e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setCanvas({ width, height });
canvasRef.current?.measureInWindow((_x, y) => {
canvasTopRef.current = y;
});
};
const trashTop = () => canvasTopRef.current + canvas.height - TRASH_ZONE_HEIGHT;
const isOverTrash = useCallback((pageY: number) => pageY >= trashTop(), [canvas.height]); // eslint-disable-line react-hooks/exhaustive-deps
const gifCount = overlays.filter((o) => o.kind === "gif").length;
const mentionCount = new Set(overlays.flatMap((o) => (o.kind === "mention" ? [o.petId] : []))).size;
const isFull = overlays.length >= MAX_OVERLAYS;
const add = (overlay: StoryOverlay) => {
if (isFull) {
Alert.alert(t("storyEditor.limitOverlays", { max: MAX_OVERLAYS }));
return;
}
setOverlays((list) => [...list, overlay]);
};
// Neue Overlays erscheinen leicht versetzt in der Mitte, damit sie sich nicht genau überdecken.
const spawnPosition = () => {
const offset = (overlays.length % 5) * 0.04;
return { x: 0.5, y: 0.42 + offset, scale: 1, rotation: 0 };
};
const handleTextDone = (draft: TextDraft) => {
const text = draft.text.trim();
if (editingIndex !== null) {
setOverlays((list) =>
text
? list.map((o, i) => (i === editingIndex && o.kind === "text" ? { ...o, ...draft, text } : o))
: list.filter((_, i) => i !== editingIndex)
);
} else if (text) {
add({ kind: "text", ...spawnPosition(), ...draft, text });
}
setEditingIndex(null);
setPicker(null);
};
const editingText = editingIndex !== null && overlays[editingIndex]?.kind === "text" ? (overlays[editingIndex] as TextOverlay) : undefined;
const hasText = overlays.some((o) => o.kind === "text");
const canSubmit = !isSubmitting && rect.width > 0 && (media.kind !== "text" || hasText);
return (
<View style={styles.root}>
<View ref={canvasRef} style={styles.canvas} onLayout={onCanvasLayout}>
{rect.width > 0 && (
<View style={{ position: "absolute", left: rect.x, top: rect.y, width: rect.width, height: rect.height }}>
{media.kind === "text" ? (
<StoryBackground backgroundKey={background} width={rect.width} height={rect.height} />
) : media.kind === "video" ? (
<EditorVideo uri={media.uri} />
) : (
<RNImage source={{ uri: media.uri }} style={StyleSheet.absoluteFill} resizeMode="cover" />
)}
</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}
overlay={overlay}
rect={rect}
mentionName={overlay.kind === "mention" ? mentionNames[overlay.petId] : undefined}
onChange={(next) => setOverlays((list) => list.map((o, i) => (i === index ? next : o)))}
onTap={() => {
if (overlay.kind === "text") {
setEditingIndex(index);
setPicker("text");
}
}}
onDragStateChange={(isDragging, pageY) => {
setDragging(isDragging);
setOverTrash(isDragging && isOverTrash(pageY));
}}
isOverTrash={isOverTrash}
onDelete={() => setOverlays((list) => list.filter((_, i) => i !== index))}
/>
))}
{toolsEnabled && overlays.length === 0 && media.kind !== "text" && rect.width > 0 && (
<View pointerEvents="none" style={[styles.hintWrap, { top: rect.y + rect.height / 2 - 20 }]}>
<Text style={styles.hint}>{t("storyEditor.emptyHint")}</Text>
</View>
)}
{dragging && (
<View pointerEvents="none" style={[styles.trashZone, { height: TRASH_ZONE_HEIGHT }]}>
<View style={[styles.trashCircle, overTrash && styles.trashCircleActive]}>
<Trash2 color="#ffffff" size={24} />
</View>
</View>
)}
</View>
<View style={[styles.topBar, { top: insets.top + 8 }]}>
<ToolButton onPress={onCancel} label={t("common.cancel")}>
<X color="#ffffff" size={22} />
</ToolButton>
{toolsEnabled && <View style={styles.tools}>
{media.kind === "text" && (
<ToolButton
label={t("storyEditor.background")}
onPress={() => setBackground((bg) => STORY_BACKGROUND_KEYS[(STORY_BACKGROUND_KEYS.indexOf(bg) + 1) % STORY_BACKGROUND_KEYS.length])}
>
<View style={[styles.swatch, { backgroundColor: STORY_BACKGROUNDS[background][0], borderColor: STORY_BACKGROUNDS[background][1] }]} />
</ToolButton>
)}
<ToolButton label={t("storyEditor.addText")} onPress={() => setPicker("text")}>
<Type color="#ffffff" size={22} />
</ToolButton>
<ToolButton
label={t("storyEditor.addMention")}
onPress={() => (mentionCount >= MAX_MENTIONS ? Alert.alert(t("storyEditor.limitMentions", { max: MAX_MENTIONS })) : setPicker("mention"))}
>
<AtSign color="#ffffff" size={22} />
</ToolButton>
<ToolButton
label={t("storyEditor.addGif")}
onPress={() => (gifCount >= MAX_GIFS ? Alert.alert(t("storyEditor.limitGifs", { max: MAX_GIFS })) : setPicker("gif"))}
>
<Text style={styles.gifLabel}>GIF</Text>
</ToolButton>
<ToolButton label={t("storyEditor.addSticker")} onPress={() => setPicker("sticker")}>
<Smile color="#ffffff" size={22} />
</ToolButton>
</View>}
</View>
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + 12 }]}>
<Pressable
style={[styles.shareButton, !canSubmit && styles.shareButtonDisabled]}
disabled={!canSubmit}
onPress={() => onSubmit({ overlays, background: media.kind === "text" ? background : undefined, mentionNames })}
accessibilityRole="button"
>
{isSubmitting ? <ActivityIndicator color="#ffffff" /> : <Text style={styles.shareText}>{submitLabel}</Text>}
</Pressable>
{progress}
</View>
<TextEditorModal
visible={picker === "text"}
initial={editingText ? { text: editingText.text, color: editingText.color, style: editingText.style, font: editingText.font } : undefined}
onDone={handleTextDone}
onCancel={() => {
setEditingIndex(null);
setPicker(null);
if (media.kind === "text" && !hasText && editingIndex === null && overlays.length === 0) onCancel();
}}
/>
<MentionPicker
visible={picker === "mention"}
petId={petId}
onClose={() => setPicker(null)}
onSelect={(pet) => {
setMentionNames((names) => ({ ...names, [pet.id]: pet.name }));
if (!overlays.some((o) => o.kind === "mention" && o.petId === pet.id)) add({ kind: "mention", ...spawnPosition(), petId: pet.id });
setPicker(null);
}}
/>
<GifPicker
visible={picker === "gif"}
onClose={() => setPicker(null)}
onSelect={(gif) => {
add({ kind: "gif", ...spawnPosition(), gifId: gif.id, url: gif.url, width: gif.width, height: gif.height });
setPicker(null);
}}
/>
<StickerPicker
visible={picker === "sticker"}
onClose={() => setPicker(null)}
onSelect={(stickerId) => {
add({ kind: "sticker", ...spawnPosition(), stickerId });
setPicker(null);
}}
/>
</View>
);
}
function ToolButton({ label, onPress, children }: { label: string; onPress: () => void; children: React.ReactNode }) {
return (
<Pressable style={styles.toolButton} onPress={onPress} hitSlop={6} accessibilityRole="button" accessibilityLabel={label}>
{children}
</Pressable>
);
}
/** Stumm in Schleife — eigene Komponente, damit useVideoPlayer nicht bedingt aufgerufen wird. */
function EditorVideo({ uri }: { uri: string }) {
const player = useVideoPlayer(uri, (p) => {
p.loop = true;
p.muted = true;
p.play();
});
return <VideoView player={player} style={StyleSheet.absoluteFill} contentFit="cover" nativeControls={false} />;
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "#111111" },
canvas: { flex: 1 },
topBar: { position: "absolute", left: 12, right: 12, flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
tools: { flexDirection: "row", gap: 8 },
toolButton: { width: 42, height: 42, borderRadius: 21, backgroundColor: "rgba(0,0,0,0.45)", alignItems: "center", justifyContent: "center" },
gifLabel: { color: "#ffffff", fontFamily: fontFamily.extraBold, fontSize: 13, letterSpacing: 0.5 },
swatch: { width: 22, height: 22, borderRadius: 11, borderWidth: 3 },
bottomBar: { paddingHorizontal: 16, paddingTop: 12, gap: 8, backgroundColor: "#111111" },
shareButton: { height: 48, borderRadius: 24, backgroundColor: "#f97316", alignItems: "center", justifyContent: "center" },
shareButtonDisabled: { opacity: 0.5 },
shareText: { color: "#ffffff", fontFamily: fontFamily.bold, fontSize: 16 },
hintWrap: { position: "absolute", left: 24, right: 24, alignItems: "center" },
hint: {
color: "#ffffff",
fontFamily: fontFamily.semiBold,
fontSize: 15,
textAlign: "center",
textShadowColor: "rgba(0,0,0,0.6)",
textShadowRadius: 6,
},
trashZone: { position: "absolute", left: 0, right: 0, bottom: 0, alignItems: "center", justifyContent: "center" },
trashCircle: { width: 56, height: 56, borderRadius: 28, backgroundColor: "rgba(0,0,0,0.55)", alignItems: "center", justifyContent: "center", borderWidth: 2, borderColor: "rgba(255,255,255,0.6)" },
trashCircleActive: { backgroundColor: "#dc2626", transform: [{ scale: 1.2 }] },
});
@@ -0,0 +1,79 @@
import { View, Pressable, StyleSheet } from "react-native";
import { OverlayContent } from "./OverlayContent";
import { overlayCenter, type Rect, type StoryOverlay } from "../../lib/story-overlays";
type MentionedPet = { id: string; name: string };
/**
* Anzeige-Ebene im Story-Viewer: legt die Overlays auf die Medienfläche `rect`
* (gleiches Koordinatensystem wie im Editor). Nur Markierungen sind antippbar
* (→ Tierprofil), alles andere lässt Berührungen zu den Weiter/Zurück-Zonen durch.
*/
export function StoryOverlayLayer({
overlays,
rect,
mentionedPets,
onPressMention,
}: {
overlays: readonly StoryOverlay[];
rect: Rect;
mentionedPets: readonly MentionedPet[];
onPressMention?: (petId: string) => void;
}) {
if (!overlays.length || rect.width <= 0) return null;
const names = new Map(mentionedPets.map((p) => [p.id, p.name]));
return (
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
{overlays.map((overlay, i) => {
const content = (
<OverlayContent overlay={overlay} baseWidth={rect.width} mentionName={overlay.kind === "mention" ? names.get(overlay.petId) : undefined} />
);
return (
<PositionedOverlay key={i} overlay={overlay} rect={rect} interactive={overlay.kind === "mention" && !!onPressMention}>
{overlay.kind === "mention" && onPressMention ? (
<Pressable onPress={() => onPressMention(overlay.petId)} hitSlop={8} accessibilityRole="link">
{content}
</Pressable>
) : (
content
)}
</PositionedOverlay>
);
})}
</View>
);
}
/**
* Zentriert den Inhalt auf (x, y) der Medienfläche, ohne ihn messen zu müssen:
* ein großes, unsichtbares Quadrat um den Mittelpunkt mit zentriertem Inhalt.
* Drehung und Skalierung wirken um die Mitte — wie im Web (transform-origin center).
*/
export function PositionedOverlay({
overlay,
rect,
interactive,
children,
}: {
overlay: StoryOverlay;
rect: Rect;
interactive: boolean;
children: React.ReactNode;
}) {
const box = rect.width * 2;
const { left, top } = overlayCenter(rect, overlay);
return (
<View
pointerEvents="box-none"
style={{ position: "absolute", left: left - box / 2, top: top - box / 2, width: box, height: box, alignItems: "center", justifyContent: "center" }}
>
<View
pointerEvents={interactive ? "auto" : "none"}
style={{ transform: [{ rotate: `${overlay.rotation}deg` }, { scale: overlay.scale }] }}
>
{children}
</View>
</View>
);
}
@@ -0,0 +1,121 @@
import { useEffect, useState } from "react";
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 };
const DEFAULT_DRAFT: TextDraft = { text: "", color: "#ffffff", style: "plain", font: "bold" };
/**
* Vollbild-Texteingabe über dem Editor (wie Instagram): Text tippen, Farbe
* (8 Farben), Stil (schlicht → Box → weich) und Schrift (fett/normal) wählen.
* Leerer Text beim Fertig-Drücken = Overlay entfernen bzw. nicht anlegen.
*/
export function TextEditorModal({
visible,
initial,
onDone,
onCancel,
}: {
visible: boolean;
initial?: TextDraft;
onDone: (draft: TextDraft) => void;
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.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Zurücksetzen beim Öffnen ist hier gewollt
if (visible) setDraft(initial ?? DEFAULT_DRAFT);
}, [visible, initial]);
const { textColor, background } = textAppearance(draft);
const nextStyle = TEXT_STYLES[(TEXT_STYLES.indexOf(draft.style) + 1) % TEXT_STYLES.length];
return (
<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>
</Pressable>
<View style={styles.topTools}>
<Pressable
style={styles.toolButton}
onPress={() => setDraft((d) => ({ ...d, style: nextStyle }))}
accessibilityRole="button"
accessibilityLabel={t("storyEditor.textStyle")}
>
<Text style={styles.toolButtonText}>{t(`storyEditor.style.${draft.style}`)}</Text>
</Pressable>
<Pressable
style={styles.toolButton}
onPress={() => setDraft((d) => ({ ...d, font: d.font === "bold" ? "regular" : "bold" }))}
accessibilityRole="button"
accessibilityLabel={t("storyEditor.textFont")}
>
<Text style={[styles.toolButtonText, { fontFamily: draft.font === "bold" ? fontFamily.bold : fontFamily.regular }]}>Aa</Text>
</Pressable>
</View>
<Pressable onPress={() => onDone(draft)} hitSlop={12} accessibilityRole="button">
<Text style={[styles.topButton, styles.done]}>{t("common.done")}</Text>
</Pressable>
</View>
<View style={styles.center}>
<View style={[styles.preview, background ? { backgroundColor: background } : null]}>
<TextInput
value={draft.text}
onChangeText={(text) => setDraft((d) => ({ ...d, text }))}
autoFocus
multiline
maxLength={MAX_TEXT_LENGTH}
placeholder={t("storyEditor.textPlaceholder")}
placeholderTextColor="rgba(255,255,255,0.6)"
style={[
styles.input,
{ color: textColor, fontFamily: draft.font === "bold" ? fontFamily.bold : fontFamily.regular },
]}
/>
</View>
</View>
<View style={styles.colors}>
{TEXT_COLORS.map((color) => (
<Pressable
key={color}
onPress={() => setDraft((d) => ({ ...d, color }))}
style={[styles.colorDot, { backgroundColor: color }, draft.color === color && styles.colorDotActive]}
accessibilityRole="button"
accessibilityLabel={color}
accessibilityState={{ selected: draft.color === color }}
hitSlop={4}
/>
))}
</View>
</EditorLayer>
);
}
const styles = StyleSheet.create({
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" },
topTools: { flexDirection: "row", gap: 10 },
toolButton: { borderWidth: 1.5, borderColor: "#ffffff", borderRadius: 999, paddingHorizontal: 12, paddingVertical: 4 },
toolButtonText: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 14 },
center: { flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: 24 },
preview: { borderRadius: 10, paddingHorizontal: 12, paddingVertical: 6, maxWidth: "100%" },
input: { fontSize: 28, lineHeight: 34, textAlign: "center", minWidth: 120, textShadowColor: "rgba(0,0,0,0.35)", textShadowRadius: 4 },
colors: { flexDirection: "row", justifyContent: "center", gap: 12, paddingVertical: 8 },
colorDot: { width: 28, height: 28, borderRadius: 14, borderWidth: 2, borderColor: "rgba(255,255,255,0.6)" },
colorDotActive: { borderColor: "#ffffff", borderWidth: 3, transform: [{ scale: 1.15 }] },
});
@@ -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]);
}
+116 -9
View File
@@ -20,6 +20,7 @@
"done": "Fertig",
"optional": "optional",
"noResultsFound": "Keine Einträge gefunden.",
"searchPlaceholder": "Suchen …",
"edit": "Bearbeiten",
"authLoadTimeout": "Das dauert gerade länger als erwartet. Bitte erneut versuchen."
},
@@ -48,7 +49,8 @@
"feed": {
"empty": "Noch keine Beiträge im Feed.",
"storyLabel": "Story",
"trendingBadge": "Trending"
"trendingBadge": "Trending",
"yourStory": "Deine Story"
},
"explore": {
"allTab": "Alle",
@@ -65,7 +67,7 @@
"breedLabel": "Rasse",
"birthdayLabel": "Geburtstag (optional)",
"adoptedAtLabel": "Adoptionstag (optional)",
"invalidDate": "Bitte ein gültiges Datum im Format TT-MM-JJJJ eingeben oder leer lassen.",
"invalidDate": "Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben oder leer lassen.",
"deleteConfirmTitle": "Tier wirklich löschen?",
"deleteConfirmMessage": "{{name}} und alle zugehörigen Beiträge, Kommentare und Follower-Beziehungen werden unwiderruflich gelöscht.",
"deleteConfirmMessageFallback": "Dieses Tier",
@@ -84,11 +86,16 @@
"shareButton": "Teilen",
"deleteConfirmTitle": "Beitrag wirklich löschen?",
"deleteConfirmMessage": "Dieser Beitrag wird unwiderruflich gelöscht.",
"deleteError": "Beitrag konnte nicht gelöscht werden. Bitte versuch es erneut."
"deleteError": "Beitrag konnte nicht gelöscht werden. Bitte versuch es erneut.",
"unsupportedFormat": "Einige Fotos haben ein Format, das PawFeed nicht verarbeiten kann, und wurden übersprungen."
},
"story": {
"changePhotoHint": "Antippen, um ein anderes Foto zu wählen",
"shareButton": "Story teilen"
"changeVideoHint": "Antippen, um ein anderes Video zu wählen",
"shareButton": "Story teilen",
"videoTooLong": "Videos für Stories dürfen höchstens {{max}} Sekunden lang sein.",
"preparingUpload": "Upload wird vorbereitet …",
"uploadingVideo": "Video wird hochgeladen … {{percent}} %"
},
"milestoneTypes": {
"BIRTHDAY": "Geburtstag",
@@ -133,19 +140,25 @@
"manageDesc": "Ein Widerruf löscht sofort und unwiderruflich alle Gesundheitsdaten dieses Haustiers.",
"revokeLink": "Widerrufen",
"weightTitle": "Gewicht",
"weightDatePlaceholder": "TT-MM-JJJJ",
"weightDatePlaceholder": "TT.MM.JJJJ",
"weightKgPlaceholder": "Gewicht (kg), z.B. 4,2",
"notesPlaceholder": "Notizen (optional)",
"weightEmpty": "Noch keine Gewichtseinträge",
"invalidDate": "Bitte ein gültiges Datum im Format TT-MM-JJJJ eingeben.",
"weightChartTitle": "Verlauf",
"weightChartSinceFirst": "seit erster Messung",
"weightChartA11y": "Gewichtsverlauf von {{from}} am {{fromDate}} auf {{to}} am {{toDate}}. Tippen, um eine Messung auszuwählen.",
"invalidDate": "Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben.",
"vetTitle": "Tierarzt",
"vetReasonPlaceholder": "Grund, z.B. Jahresuntersuchung",
"vetNamePlaceholder": "Tierarzt / Klinik (optional)",
"vetEmpty": "Noch keine Tierarztbesuche",
"vaccineTitle": "Impfungen",
"vaccineNamePlaceholder": "Impfstoff, z.B. Tollwut, DHPP",
"vaccineGivenPlaceholder": "Gegeben am (TT-MM-JJJJ)",
"vaccineNextDuePlaceholder": "Nächste Fälligkeit (optional, TT-MM-JJJJ)",
"vaccineGivenPlaceholder": "Gegeben am (TT.MM.JJJJ)",
"vaccineNextDuePlaceholder": "Nächste Fälligkeit (optional, TT.MM.JJJJ)",
"vaccineNextDuePreset_one": "+{{count}} Jahr",
"vaccineNextDuePreset_other": "+{{count}} Jahre",
"vaccineReminderHint": "Mit Folgetermin erinnern wir dich 30 Tage vorher.",
"vaccineEmpty": "Noch keine Impfungen eingetragen",
"vaccineGiven": "Gegeben {{date}}",
"vaccineDue": "Fällig {{date}}",
@@ -184,6 +197,8 @@
"moreActions": "Weitere Aktionen"
},
"notifications": {
"vaccineDue": "💉 Die Impfung „{{name}}“ ist am {{date}} fällig – Zeit, einen Termin zu vereinbaren.",
"vaccineDueGeneric": "💉 Eine Impfung wird bald fällig.",
"follow": "folgt dir jetzt.",
"reaction": "hat deinen Beitrag mit einer Pfote versehen.",
"comment": "hat deinen Beitrag kommentiert.",
@@ -195,7 +210,12 @@
"petSuggestion": "könnte dir gefallen!",
"empty": "Keine Benachrichtigungen.",
"referralJoined": "ist über deine Einladung zu PawFeed gekommen.",
"referralRankUp": "Du hast einen neuen Empfehlungsrang erreicht!"
"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.",
"unknown": "Neue Mitteilung.",
"petDeceased": "ist leider verstorben. In Erinnerung 🕯️"
},
"pets": {
"empty": "Noch keine Tiere angelegt.",
@@ -399,5 +419,92 @@
"source_STORE_SEARCH": "App Store oder Google Play",
"source_PRESS": "Zeitung, Radio oder Blog",
"source_OTHER": "Anders"
},
"storyViewer": {
"paw": "Pfote geben",
"removePaw": "Pfote zurücknehmen",
"openComments": "Kommentare öffnen",
"commentsTitle": "Kommentare",
"deleteComment": "Kommentar löschen",
"commentError": "Kommentar konnte nicht gesendet werden. Bitte versuch es noch einmal.",
"viewersTitle_one": "{{count}} Aufruf",
"viewersTitle_other": "{{count}} Aufrufe",
"noViewers": "Noch hat niemand deine Story gesehen.",
"deleteStory": "Story löschen",
"deleteTitle": "Story löschen?",
"deleteDescription": "Die Story wird mit allen Aufrufen, Pfoten und Kommentaren endgültig gelöscht.",
"deleteConfirm": "Löschen",
"deleteError": "Die Story konnte nicht gelöscht werden. Bitte versuch es noch einmal.",
"expiredOwnerOnly": "Abgelaufen – nur für dich sichtbar",
"readOnlyExpired": "Die Story ist abgelaufen. Kommentare kannst du noch lesen, aber nicht mehr schreiben.",
"pawCount_one": "{{count}} Pfote",
"pawCount_other": "{{count}} Pfoten"
},
"foundingMember": {
"label": "Gründungsmitglied",
"tooltip": "Gründungsmitglied – eines der ersten 250 Tiere auf PawFeed"
},
"privacy": {
"heading": "Privatsphäre",
"whoCanMessage": "Wer kann {{name}} Nachrichten schreiben?",
"everyone": "Alle",
"onlyFollowedPets": "Nur Tiere, denen ich folge",
"dmFollowersOnlyHint": "Nur Tiere, denen du folgst, können dir Direktnachrichten schreiben.",
"dmEveryoneHint": "Jedes Tier auf PawFeed kann dir Direktnachrichten schreiben.",
"whoCanSeeFollowers": "Wer darf die Follower von {{name}} sehen?",
"onlyMyFollowers": "Nur meine Follower",
"onlyMe": "Nur ich",
"followerEveryoneHint": "Jeder kann die Follower- und Folgt-Listen von {{name}} sehen und {{name}} in Storys markieren.",
"followerFollowersOnlyHint": "Nur Tiere, die {{name}} folgen, können die Follower- und Folgt-Listen sehen und {{name}} in Storys markieren.",
"followerNobodyHint": "Nur du kannst die Follower- und Folgt-Listen von {{name}} sehen — markieren in Storys kann {{name}} niemand.",
"saveFailed": "Privatsphäre-Einstellung konnte nicht gespeichert werden"
},
"storyEditor": {
"textStory": "Text-Story",
"addText": "Text hinzufügen",
"addMention": "Tier markieren",
"addGif": "GIF hinzufügen",
"addSticker": "Sticker hinzufügen",
"background": "Hintergrund wechseln",
"emptyHint": "Füge Text, @Markierungen, GIFs oder Sticker hinzu — oder teile direkt.",
"textPlaceholder": "Schreib etwas …",
"textStyle": "Textstil",
"textFont": "Schrift",
"style": {
"plain": "Schlicht",
"box": "Box",
"soft": "Weich"
},
"mentionTitle": "Tier markieren",
"mentionSearch": "Tiername suchen",
"mentionNoResults": "Kein Tier gefunden, das du markieren darfst.",
"mentionPrivacyHint": "Tiere, deren Follower nur für ihre Follower oder niemanden sichtbar sind, kannst du nur markieren, wenn du ihnen folgst bzw. gar nicht.",
"gifTitle": "GIF auswählen",
"gifSearch": "GIPHY durchsuchen",
"gifError": "Die GIF-Suche ist gerade nicht erreichbar.",
"stickerTitle": "Sticker",
"limitOverlays": "Höchstens {{max}} Elemente pro Story.",
"limitMentions": "Höchstens {{max}} Tiere pro Story markieren.",
"limitGifs": "Höchstens {{max}} GIFs pro Story."
},
"inMemory": {
"label": "In Erinnerung",
"tooltip": "In Erinnerung – dieses Tier ist über die Regenbogenbrücke gegangen",
"banner": "In Erinnerung an {{name}} · {{date}}",
"settingsHeading": "Gedenkmodus",
"settingsHint": "Wenn dein Tier verstorben ist, kannst du sein Profil als Erinnerung erhalten. Es bekommt das Abzeichen „In Erinnerung“, Beiträge bleiben sichtbar und du kannst weiter Erinnerungen teilen. Geburtstags-Mitteilungen, Impf-Erinnerungen und Vorschläge enden.",
"activeHint": "{{name}} ist im Gedenkmodus. Du kannst das Datum korrigieren oder den Modus beenden.",
"dateLabel": "Verstorben am",
"activate": "Gedenkmodus aktivieren",
"saveDate": "Datum speichern",
"remove": "Gedenkmodus beenden",
"confirmTitle": "{{name}} in Erinnerung halten?",
"confirmDescription": "Das Profil bekommt das Abzeichen „In Erinnerung“. Wer deinem Tier folgt, erhält einmalig eine stille Mitteilung (ohne Push). Du kannst das jederzeit zurücknehmen.",
"confirm": "Ja, aktivieren",
"cancel": "Abbrechen",
"saved": "Gedenkmodus gespeichert",
"removed": "Gedenkmodus beendet",
"saveFailed": "Konnte nicht gespeichert werden",
"invalidDate": "Bitte ein gültiges Datum eingeben, das nicht in der Zukunft liegt."
}
}
+116 -9
View File
@@ -20,6 +20,7 @@
"done": "Done",
"optional": "optional",
"noResultsFound": "No entries found.",
"searchPlaceholder": "Search …",
"edit": "Edit",
"authLoadTimeout": "This is taking longer than expected. Please try again."
},
@@ -48,7 +49,8 @@
"feed": {
"empty": "No posts in your feed yet.",
"storyLabel": "Story",
"trendingBadge": "Trending"
"trendingBadge": "Trending",
"yourStory": "Your story"
},
"explore": {
"allTab": "All",
@@ -65,7 +67,7 @@
"breedLabel": "Breed",
"birthdayLabel": "Birthday (optional)",
"adoptedAtLabel": "Adoption date (optional)",
"invalidDate": "Please enter a valid date as DD-MM-YYYY, or leave it empty.",
"invalidDate": "Please enter a valid date as DD.MM.YYYY, or leave it empty.",
"deleteConfirmTitle": "Delete this pet?",
"deleteConfirmMessage": "{{name}} and all associated posts, comments, and follower relationships will be permanently deleted.",
"deleteConfirmMessageFallback": "This pet",
@@ -84,11 +86,16 @@
"shareButton": "Share",
"deleteConfirmTitle": "Delete this post?",
"deleteConfirmMessage": "This post will be permanently deleted.",
"deleteError": "Couldn't delete this post. Please try again."
"deleteError": "Couldn't delete this post. Please try again.",
"unsupportedFormat": "Some photos use a format PawFeed cannot process and were skipped."
},
"story": {
"changePhotoHint": "Tap to choose a different photo",
"shareButton": "Share story"
"changeVideoHint": "Tap to choose a different video",
"shareButton": "Share story",
"videoTooLong": "Story videos can be at most {{max}} seconds long.",
"preparingUpload": "Preparing upload …",
"uploadingVideo": "Uploading video … {{percent}}%"
},
"milestoneTypes": {
"BIRTHDAY": "Birthday",
@@ -133,19 +140,25 @@
"manageDesc": "Revoking immediately and permanently deletes all health data for this pet.",
"revokeLink": "Revoke",
"weightTitle": "Weight",
"weightDatePlaceholder": "DD-MM-YYYY",
"weightDatePlaceholder": "DD.MM.YYYY",
"weightKgPlaceholder": "Weight (kg), e.g. 4.2",
"notesPlaceholder": "Notes (optional)",
"weightEmpty": "No weight entries yet",
"invalidDate": "Please enter a valid date in DD-MM-YYYY format.",
"weightChartTitle": "History",
"weightChartSinceFirst": "since first entry",
"weightChartA11y": "Weight history from {{from}} on {{fromDate}} to {{to}} on {{toDate}}. Tap to select an entry.",
"invalidDate": "Please enter a valid date in DD.MM.YYYY format.",
"vetTitle": "Vet",
"vetReasonPlaceholder": "Reason, e.g. annual checkup",
"vetNamePlaceholder": "Vet / clinic (optional)",
"vetEmpty": "No vet visits yet",
"vaccineTitle": "Vaccinations",
"vaccineNamePlaceholder": "Vaccine, e.g. rabies, DHPP",
"vaccineGivenPlaceholder": "Given on (DD-MM-YYYY)",
"vaccineNextDuePlaceholder": "Next due (optional, DD-MM-YYYY)",
"vaccineGivenPlaceholder": "Given on (DD.MM.YYYY)",
"vaccineNextDuePlaceholder": "Next due (optional, DD.MM.YYYY)",
"vaccineNextDuePreset_one": "+{{count}} year",
"vaccineNextDuePreset_other": "+{{count}} years",
"vaccineReminderHint": "With a next due date, we'll remind you 30 days ahead.",
"vaccineEmpty": "No vaccinations recorded yet",
"vaccineGiven": "Given {{date}}",
"vaccineDue": "Due {{date}}",
@@ -184,6 +197,8 @@
"moreActions": "More actions"
},
"notifications": {
"vaccineDue": "💉 The \"{{name}}\" vaccination is due on {{date}} – time to book an appointment.",
"vaccineDueGeneric": "💉 A vaccination is due soon.",
"follow": "is now following you.",
"reaction": "pawed your post.",
"comment": "commented on your post.",
@@ -195,7 +210,12 @@
"petSuggestion": "might be a good match!",
"empty": "No notifications.",
"referralJoined": "joined PawFeed through your invite.",
"referralRankUp": "You reached a new referral rank!"
"referralRankUp": "You reached a new referral rank!",
"storyMention": "tagged you in a story.",
"storyComment": "commented on your story.",
"storyReaction": "gave your story a paw.",
"unknown": "New notification.",
"petDeceased": "has sadly passed away. In memory 🕯️"
},
"pets": {
"empty": "No pets added yet.",
@@ -399,5 +419,92 @@
"source_STORE_SEARCH": "App Store or Google Play",
"source_PRESS": "Newspaper, radio or blog",
"source_OTHER": "Other"
},
"storyViewer": {
"paw": "Give a paw",
"removePaw": "Remove paw",
"openComments": "Open comments",
"commentsTitle": "Comments",
"deleteComment": "Delete comment",
"commentError": "Your comment could not be sent. Please try again.",
"viewersTitle_one": "{{count}} view",
"viewersTitle_other": "{{count}} views",
"noViewers": "Nobody has seen your story yet.",
"deleteStory": "Delete story",
"deleteTitle": "Delete story?",
"deleteDescription": "The story and all its views, paws and comments will be deleted permanently.",
"deleteConfirm": "Delete",
"deleteError": "The story could not be deleted. Please try again.",
"expiredOwnerOnly": "Expired – only visible to you",
"readOnlyExpired": "This story has expired. You can still read comments but not add new ones.",
"pawCount_one": "{{count}} paw",
"pawCount_other": "{{count}} paws"
},
"foundingMember": {
"label": "Founding member",
"tooltip": "Founding member – one of the first 250 pets on PawFeed"
},
"privacy": {
"heading": "Privacy",
"whoCanMessage": "Who can message {{name}}?",
"everyone": "Everyone",
"onlyFollowedPets": "Only pets I follow",
"dmFollowersOnlyHint": "Only pets that you follow can send you direct messages.",
"dmEveryoneHint": "Any pet on PawFeed can send you direct messages.",
"whoCanSeeFollowers": "Who can see {{name}}'s followers?",
"onlyMyFollowers": "Only my followers",
"onlyMe": "Only me",
"followerEveryoneHint": "Anyone can see {{name}}'s followers and following lists and tag {{name}} in stories.",
"followerFollowersOnlyHint": "Only pets that follow {{name}} can see the followers and following lists and tag {{name}} in stories.",
"followerNobodyHint": "Only you can see {{name}}'s followers and following lists — nobody can tag {{name}} in stories.",
"saveFailed": "Failed to save privacy setting"
},
"storyEditor": {
"textStory": "Text story",
"addText": "Add text",
"addMention": "Tag a pet",
"addGif": "Add GIF",
"addSticker": "Add sticker",
"background": "Change background",
"emptyHint": "Add text, @tags, GIFs or stickers — or share right away.",
"textPlaceholder": "Write something …",
"textStyle": "Text style",
"textFont": "Font",
"style": {
"plain": "Plain",
"box": "Box",
"soft": "Soft"
},
"mentionTitle": "Tag a pet",
"mentionSearch": "Search pet name",
"mentionNoResults": "No pet found that you're allowed to tag.",
"mentionPrivacyHint": "Pets whose followers are visible only to their followers or nobody can only be tagged if you follow them, or not at all.",
"gifTitle": "Choose a GIF",
"gifSearch": "Search GIPHY",
"gifError": "GIF search is unavailable right now.",
"stickerTitle": "Stickers",
"limitOverlays": "At most {{max}} items per story.",
"limitMentions": "Tag at most {{max}} pets per story.",
"limitGifs": "At most {{max}} GIFs per story."
},
"inMemory": {
"label": "In memory",
"tooltip": "In memory – this pet has crossed the rainbow bridge",
"banner": "In memory of {{name}} · {{date}}",
"settingsHeading": "Memorial mode",
"settingsHint": "If your pet has passed away, you can keep their profile as a memorial. It gets the “In memory” badge, posts stay visible and you can keep sharing memories. Birthday notifications, vaccine reminders and suggestions stop.",
"activeHint": "{{name}} is in memorial mode. You can correct the date or end the mode.",
"dateLabel": "Passed away on",
"activate": "Turn on memorial mode",
"saveDate": "Save date",
"remove": "End memorial mode",
"confirmTitle": "Keep {{name}} in memory?",
"confirmDescription": "The profile gets the “In memory” badge. Everyone following your pet receives a single quiet notification (no push). You can undo this at any time.",
"confirm": "Yes, turn on",
"cancel": "Cancel",
"saved": "Memorial mode saved",
"removed": "Memorial mode ended",
"saveFailed": "Could not be saved",
"invalidDate": "Please enter a valid date that is not in the future."
}
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { addYearsToDisplayDate, displayDateToIso, formatDateInput, isoToDisplayDate } from "./date-format";
describe("mobile date-format", () => {
it("displays ISO dates with dots", () => {
expect(isoToDisplayDate("2026-10-04")).toBe("04.10.2026");
});
it("parses dotted, dashed and slashed input to ISO", () => {
expect(displayDateToIso("04.10.2026")).toBe("2026-10-04");
expect(displayDateToIso("04-10-2026")).toBe("2026-10-04");
expect(displayDateToIso("04/10/2026")).toBe("2026-10-04");
});
it("pads one-digit day and month", () => {
expect(displayDateToIso("4.1.2026")).toBe("2026-01-04");
});
it("rejects impossible calendar dates and garbage", () => {
expect(displayDateToIso("31.02.2026")).toBeNull();
expect(displayDateToIso("2026-10-04")).toBeNull();
expect(displayDateToIso("abc")).toBeNull();
});
it("masks typed digits as DD.MM.YYYY", () => {
expect(formatDateInput("04")).toBe("04");
expect(formatDateInput("041")).toBe("04.1");
expect(formatDateInput("0410")).toBe("04.10");
expect(formatDateInput("04102026")).toBe("04.10.2026");
expect(formatDateInput("041020269")).toBe("04.10.2026");
});
it("lets backspace remove a trailing separator instead of re-adding it", () => {
expect(formatDateInput("04.")).toBe("04");
});
it("suggests a next due date some years ahead", () => {
expect(addYearsToDisplayDate("04.10.2026", 1)).toBe("04.10.2027");
expect(addYearsToDisplayDate("04.10.2026", 3)).toBe("04.10.2029");
expect(addYearsToDisplayDate("29.02.2028", 1)).toBe("01.03.2029");
expect(addYearsToDisplayDate("", 1)).toBeNull();
});
});
+40 -10
View File
@@ -1,25 +1,30 @@
/**
* The health forms take a free-text date field validated against the
* backend's strict `YYYY-MM-DD` regex (src/trpc/routers/health.ts). Typing
* dates in that order isn't the German convention (DD-MM-YYYY) — found.md:
* users typing the natural order got a validation error the mutations never
* surfaced (no onError handler), which looked exactly like "data isn't being
* saved". Both problems share one fix: accept DD-MM-YYYY in the UI, convert
* to ISO right before the mutation call, and reject anything that doesn't
* parse as a real calendar date instead of silently sending garbage.
* dates in that order isn't the German convention — found.md: users typing
* the natural order got a validation error the mutations never surfaced (no
* onError handler), which looked exactly like "data isn't being saved". Fix:
* accept DD.MM.YYYY in the UI, convert to ISO right before the mutation call,
* and reject anything that doesn't parse as a real calendar date instead of
* silently sending garbage.
*
* Display uses "." (found.md Neu7: "-" is unusual in German). Parsing still
* accepts "-" and "/" and one-digit day/month so older habits keep working.
*/
const DISPLAY_PATTERN = /^(\d{2})-(\d{2})-(\d{4})$/;
const DISPLAY_SEPARATOR = ".";
const DISPLAY_PATTERN = /^(\d{1,2})[.\-/](\d{1,2})[.\-/](\d{4})$/;
const ISO_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const MAX_DATE_DIGITS = 8;
export function isoToDisplayDate(iso: string): string {
const match = ISO_PATTERN.exec(iso);
if (!match) return iso;
const [, year, month, day] = match;
return `${day}-${month}-${year}`;
return [day, month, year].join(DISPLAY_SEPARATOR);
}
/** Returns the equivalent `YYYY-MM-DD` string, or null if not a valid DD-MM-YYYY calendar date. */
/** Returns the equivalent `YYYY-MM-DD` string, or null if not a valid DD.MM.YYYY calendar date. */
export function displayDateToIso(display: string): string | null {
const match = DISPLAY_PATTERN.exec(display.trim());
if (!match) return null;
@@ -33,7 +38,32 @@ export function displayDateToIso(display: string): string | null {
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
if (!isRealDate) return null;
return `${yearStr}-${monthStr}-${dayStr}`;
return `${yearStr}-${monthStr.padStart(2, "0")}-${dayStr.padStart(2, "0")}`;
}
/**
* Live mask for typing: keeps digits only and inserts the separators itself
* (so the number pad, which has no "." on iOS, is enough). A separator is only
* added once a following digit exists — otherwise backspace over "04." would
* immediately re-add the dot and the field would get stuck.
*/
export function formatDateInput(text: string): string {
const digits = text.replace(/\D/g, "").slice(0, MAX_DATE_DIGITS);
if (digits.length <= 2) return digits;
if (digits.length <= 4) return `${digits.slice(0, 2)}${DISPLAY_SEPARATOR}${digits.slice(2)}`;
return `${digits.slice(0, 2)}${DISPLAY_SEPARATOR}${digits.slice(2, 4)}${DISPLAY_SEPARATOR}${digits.slice(4)}`;
}
/**
* Folgetermin-Vorschlag „+1 / +3 Jahre“ für Impfungen. Null bei ungültigem
* Ausgangsdatum. 29.02. + 1 Jahr landet (wie Date selbst) auf dem 01.03.
*/
export function addYearsToDisplayDate(display: string, years: number): string | null {
const iso = displayDateToIso(display);
if (!iso) return null;
const [year, month, day] = iso.split("-").map(Number);
const shifted = new Date(Date.UTC(year + years, month - 1, day));
return isoToDisplayDate(shifted.toISOString().slice(0, 10));
}
export function todayAsDisplayDate(): string {
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import { filterOptionsByName, normalizeForSearch } from "./filter-options";
const breeds = [
{ id: "1", name: "Bichon Frisé" },
{ id: "2", name: "Shiba Inu" },
{ id: "3", name: "Europäisch Kurzhaar" },
{ id: "4", name: "Bengalkatze" },
];
describe("filterOptionsByName", () => {
it("returns every option for an empty or blank query", () => {
expect(filterOptionsByName(breeds, "")).toEqual(breeds);
expect(filterOptionsByName(breeds, " ")).toEqual(breeds);
});
it("matches case-insensitively anywhere in the name", () => {
expect(filterOptionsByName(breeds, "INU").map((b) => b.id)).toEqual(["2"]);
expect(filterOptionsByName(breeds, "be").map((b) => b.id)).toEqual(["4"]);
});
it("ignores accents and umlauts", () => {
expect(filterOptionsByName(breeds, "frise").map((b) => b.id)).toEqual(["1"]);
expect(filterOptionsByName(breeds, "europaisch").map((b) => b.id)).toEqual(["3"]);
});
it("returns nothing when no name matches", () => {
expect(filterOptionsByName(breeds, "pudel")).toEqual([]);
});
});
describe("normalizeForSearch", () => {
it("lowercases, trims and strips diacritics", () => {
expect(normalizeForSearch(" Bichon Frisé ")).toBe("bichon frise");
});
});
+15
View File
@@ -0,0 +1,15 @@
/** Lowercase + strip diacritics, so "eu" finds "Européen" and "BICHON" finds "Bichon Frisé". */
export function normalizeForSearch(text: string): string {
return text
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.trim();
}
/** Keeps options whose name contains the query; an empty query keeps all of them. */
export function filterOptionsByName<T extends { name: string }>(options: T[], query: string): T[] {
const needle = normalizeForSearch(query);
if (!needle) return options;
return options.filter((option) => normalizeForSearch(option.name).includes(needle));
}
+71
View File
@@ -0,0 +1,71 @@
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"]) {
expect(notificationRoute({ type, actorPetId: "a1" }, NOW)).toBe("/pets/a1");
}
expect(notificationRoute({ type: "REFERRAL_RANK_UP" }, NOW)).toBe("/invite");
});
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();
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* 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":
return actorPetId ? `/pets/${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"),
};
}
+2 -2
View File
@@ -5,8 +5,8 @@
* TS type — always coerce with `new Date(...)` before comparing.
*/
export function isActiveSponsor(owner: {
sponsorSince: Date | string | null;
sponsorPeriodEnd: Date | string | null;
sponsorSince?: Date | string | null;
sponsorPeriodEnd?: Date | string | null;
}): boolean {
if (!owner.sponsorSince) return false;
if (!owner.sponsorPeriodEnd) return true;
@@ -0,0 +1,55 @@
/**
* Story-Editor: Web/Server und App haben je eine Kopie der Overlay-Konstanten,
* Sticker und Geometrie (die App kann nichts aus src/ importieren, der
* Docker-Build nichts aus mobile/). Dieser Test hält beide identisch. Er liegt
* bewusst in mobile/ (vom Docker-Build und der Web-tsconfig ausgeschlossen) und
* läuft über das Root-Vitest mit.
*/
import { describe, it, expect } from "vitest";
import * as web from "@/lib/story-overlays";
import * as webStickers from "@/lib/story-stickers";
import * as app from "./story-overlays";
import * as appStickers from "./story-stickers";
describe("Story-Overlay-Spiegel Web ↔ App", () => {
it("has identical constants", () => {
for (const key of [
"MAX_OVERLAYS",
"MAX_GIFS",
"MAX_MENTIONS",
"MAX_TEXT_LENGTH",
"TEXT_COLORS",
"TEXT_STYLES",
"TEXT_FONTS",
"STORY_BACKGROUNDS",
"STORY_BACKGROUND_KEYS",
"TEXT_STORY_ASPECT",
"OVERLAY_SIZES",
"MIN_SCALE",
"MAX_SCALE",
] as const) {
expect(app[key], key).toEqual(web[key]);
}
});
it("has identical stickers", () => {
expect(appStickers.STORY_STICKERS).toEqual(webStickers.STORY_STICKERS);
expect(appStickers.STICKER_OUTLINE).toEqual(webStickers.STICKER_OUTLINE);
});
it("computes identical geometry", () => {
const cases: [number, number, number, number][] = [
[400, 800, 1080, 1350],
[390, 844, 1920, 1080],
[1280, 720, 9, 16],
[0, 100, 10, 10],
];
for (const c of cases) expect(app.containRect(...c)).toEqual(web.containRect(...c));
const rect = { x: 10, y: 20, width: 300, height: 500 };
expect(app.overlayCenter(rect, { x: 0.25, y: 0.75 })).toEqual(web.overlayCenter(rect, { x: 0.25, y: 0.75 }));
for (const color of web.TEXT_COLORS) expect(app.contrastTextColor(color)).toBe(web.contrastTextColor(color));
for (const story of [{ mediaType: "TEXT" }, { mediaType: "VIDEO", aspectRatio: "16:9" }, { mediaType: "IMAGE", mediaWidth: 3, mediaHeight: 4 }]) {
expect(app.storyMediaSize(story)).toEqual(web.storyMediaSize(story));
}
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* Story-Editor — App-Spiegel von src/lib/story-overlays.ts (Web/Server).
* Konstanten und Geometrie müssen identisch bleiben, damit Overlays in App und
* Web an derselben Stelle sitzen (src/__tests__/story-overlays-parity.test.ts
* prüft das). Validiert wird nur auf dem Server; hier gibt es deshalb kein Zod.
*
* Positionen sind relativ zur Medienfläche (0–1), Größen relativ zu deren Breite.
*/
export const MAX_OVERLAYS = 15;
export const MAX_GIFS = 3;
export const MAX_MENTIONS = 10;
export const MAX_TEXT_LENGTH = 200;
export const TEXT_COLORS = ["#ffffff", "#171717", "#f97316", "#facc15", "#22c55e", "#3b82f6", "#ec4899", "#a855f7"] as const;
export const TEXT_STYLES = ["plain", "box", "soft"] as const;
export const TEXT_FONTS = ["regular", "bold"] as const;
export const STORY_BACKGROUNDS: Record<string, readonly [string, string]> = {
sunset: ["#f97316", "#ec4899"],
ocean: ["#0ea5e9", "#6366f1"],
forest: ["#22c55e", "#0f766e"],
night: ["#1e1b4b", "#7c3aed"],
peach: ["#fdba74", "#fde68a"],
ink: ["#262626", "#525252"],
};
export const STORY_BACKGROUND_KEYS = Object.keys(STORY_BACKGROUNDS);
export const TEXT_STORY_ASPECT = { width: 9, height: 16 } as const;
export const OVERLAY_SIZES = {
textFont: 0.07,
textMaxWidth: 0.82,
mentionFont: 0.055,
sticker: 0.26,
gifWidth: 0.42,
} as const;
export const MIN_SCALE = 0.3;
export const MAX_SCALE = 4;
export type TextColor = (typeof TEXT_COLORS)[number];
export type TextStyle = (typeof TEXT_STYLES)[number];
export type TextFont = (typeof TEXT_FONTS)[number];
type Base = { x: number; y: number; scale: number; rotation: number };
export type TextOverlay = Base & { kind: "text"; text: string; color: TextColor; style: TextStyle; font: TextFont };
export type MentionOverlay = Base & { kind: "mention"; petId: string };
export type GifOverlay = Base & { kind: "gif"; gifId: string; url: string; width: number; height: number };
export type StickerOverlay = Base & { kind: "sticker"; stickerId: string };
export type StoryOverlay = TextOverlay | MentionOverlay | GifOverlay | StickerOverlay;
export type Rect = { x: number; y: number; width: number; height: number };
export function containRect(containerWidth: number, containerHeight: number, mediaWidth: number, mediaHeight: number): Rect {
if (containerWidth <= 0 || containerHeight <= 0 || mediaWidth <= 0 || mediaHeight <= 0) {
return { x: 0, y: 0, width: Math.max(containerWidth, 0), height: Math.max(containerHeight, 0) };
}
const scale = Math.min(containerWidth / mediaWidth, containerHeight / mediaHeight);
const width = mediaWidth * scale;
const height = mediaHeight * scale;
return { x: (containerWidth - width) / 2, y: (containerHeight - height) / 2, width, height };
}
export function overlayCenter(rect: Rect, overlay: { x: number; y: number }): { left: number; top: number } {
return { left: rect.x + overlay.x * rect.width, top: rect.y + overlay.y * rect.height };
}
export function contrastTextColor(background: string): "#171717" | "#ffffff" {
const hex = background.replace("#", "");
const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return luminance > 0.6 ? "#171717" : "#ffffff";
}
export function storyMediaSize(story: {
mediaType: string;
mediaWidth?: number | null;
mediaHeight?: number | null;
aspectRatio?: string | null;
}): { width: number; height: number } | null {
if (story.mediaType === "TEXT") return { ...TEXT_STORY_ASPECT };
if (story.mediaWidth && story.mediaHeight) return { width: story.mediaWidth, height: story.mediaHeight };
const match = /^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/.exec(story.aspectRatio ?? "");
if (match) return { width: Number(match[1]), height: Number(match[2]) };
return null;
}
// ── Nur App (Editor) ─────────────────────────────────────────────────────────
export const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
/**
* Neue Lage eines Overlays aus einer Geste: Verschiebung in Pixeln (dx/dy),
* Zwei-Finger-Zoom (pinch = aktueller / Start-Fingerabstand) und Drehung
* (Grad). Bleibt innerhalb der Medienfläche und der erlaubten Größe.
*/
export function applyGesture<T extends Base>(
start: T,
rect: Rect,
gesture: { dx: number; dy: number; pinch: number; rotationDelta: number }
): T {
if (rect.width <= 0 || rect.height <= 0) return start;
return {
...start,
x: clamp(start.x + gesture.dx / rect.width, 0, 1),
y: clamp(start.y + gesture.dy / rect.height, 0, 1),
scale: clamp(start.scale * gesture.pinch, MIN_SCALE, MAX_SCALE),
rotation: normalizeRotation(start.rotation + gesture.rotationDelta),
};
}
/** Auf -180…180 Grad bringen (Server erlaubt -360…360). */
export function normalizeRotation(deg: number): number {
let r = deg % 360;
if (r > 180) r -= 360;
if (r < -180) r += 360;
return Math.round(r * 10) / 10;
}
/** Fingerabstand und -winkel zweier Berührungen (für Zoom/Drehen). */
export function touchGeometry(a: { pageX: number; pageY: number }, b: { pageX: number; pageY: number }) {
const dx = b.pageX - a.pageX;
const dy = b.pageY - a.pageY;
return { distance: Math.hypot(dx, dy), angle: (Math.atan2(dy, dx) * 180) / Math.PI };
}
/** Winkeldifferenz ohne Sprung bei ±180°. */
export function angleDelta(from: number, to: number): number {
let d = to - from;
if (d > 180) d -= 360;
if (d < -180) d += 360;
return d;
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Story-Sticker (Story-Editor). Reine Formdaten im 100×100-Raster, damit Web
* (DOM-SVG) und App (react-native-svg) exakt dasselbe zeichnen. Jeder Sticker
* wird zweimal gezeichnet: erst alle Teile als weißer Umriss (STICKER_OUTLINE),
* dann in Farbe — das gibt den typischen Aufkleber-Look auf jedem Hintergrund.
*
* ⚠️ Spiegel von src/lib/story-stickers.ts (Web/Server) — identisch halten
* (src/__tests__/story-overlays-parity.test.ts prüft das).
*/
export type StickerPart =
| { type: "circle"; cx: number; cy: number; r: number; fill: string }
| { type: "rect"; x: number; y: number; width: number; height: number; rx: number; fill: string }
| { type: "path"; d: string; fill: string };
export type StickerDef = { id: string; label: string; parts: StickerPart[] };
export const STICKER_OUTLINE = { color: "#ffffff", width: 8 } as const;
export const STORY_STICKERS: StickerDef[] = [
{
id: "paw",
label: "Pfote",
parts: [
{ type: "circle", cx: 50, cy: 64, r: 20, fill: "#f97316" },
{ type: "circle", cx: 22, cy: 42, r: 9, fill: "#f97316" },
{ type: "circle", cx: 38, cy: 26, r: 10, fill: "#f97316" },
{ type: "circle", cx: 62, cy: 26, r: 10, fill: "#f97316" },
{ type: "circle", cx: 78, cy: 42, r: 9, fill: "#f97316" },
],
},
{
id: "heart",
label: "Herz",
parts: [{ type: "path", d: "M50 86 C22 66 8 50 13 32 C18 15 40 12 50 29 C60 12 82 15 87 32 C92 50 78 66 50 86 Z", fill: "#ec4899" }],
},
{
id: "bone",
label: "Knochen",
parts: [
{ type: "rect", x: 24, y: 41, width: 52, height: 18, rx: 4, fill: "#fde68a" },
{ type: "circle", cx: 22, cy: 40, r: 11, fill: "#fde68a" },
{ type: "circle", cx: 22, cy: 60, r: 11, fill: "#fde68a" },
{ type: "circle", cx: 78, cy: 40, r: 11, fill: "#fde68a" },
{ type: "circle", cx: 78, cy: 60, r: 11, fill: "#fde68a" },
],
},
{
id: "star",
label: "Stern",
parts: [{ type: "path", d: "M50 8 L61 37 L93 38 L68 58 L77 90 L50 72 L23 90 L32 58 L7 38 L39 37 Z", fill: "#facc15" }],
},
{
id: "sparkle",
label: "Funkeln",
parts: [
{ type: "path", d: "M44 10 C47 34 54 41 78 44 C54 47 47 54 44 78 C41 54 34 47 10 44 C34 41 41 34 44 10 Z", fill: "#a855f7" },
{ type: "path", d: "M78 62 C79 72 82 75 92 76 C82 77 79 80 78 90 C77 80 74 77 64 76 C74 75 77 72 78 62 Z", fill: "#a855f7" },
],
},
{
id: "fish",
label: "Fisch",
parts: [
{ type: "path", d: "M74 50 L92 34 L92 66 Z", fill: "#3b82f6" },
{ type: "path", d: "M12 50 C24 28 58 26 76 50 C58 74 24 72 12 50 Z", fill: "#3b82f6" },
{ type: "circle", cx: 30, cy: 46, r: 5, fill: "#171717" },
],
},
{
id: "crown",
label: "Krone",
parts: [
{ type: "path", d: "M14 76 L18 30 L36 50 L50 22 L64 50 L82 30 L86 76 Z", fill: "#facc15" },
{ type: "circle", cx: 50, cy: 62, r: 6, fill: "#ec4899" },
],
},
{
id: "sun",
label: "Sonne",
parts: [
{ type: "path", d: "M50 4 L57 22 L43 22 Z M50 96 L43 78 L57 78 Z M4 50 L22 43 L22 57 Z M96 50 L78 57 L78 43 Z M17 17 L33 26 L26 33 Z M83 83 L67 74 L74 67 Z M83 17 L74 33 L67 26 Z M17 83 L26 67 L33 74 Z", fill: "#fb923c" },
{ type: "circle", cx: 50, cy: 50, r: 22, fill: "#facc15" },
],
},
];
export const STICKER_IDS = STORY_STICKERS.map((s) => s.id);
+6
View File
@@ -0,0 +1,6 @@
/**
* Mirrors src/lib/story-video-limits.ts on web — mobile and web don't share
* a package, so this value is duplicated. Keep both in sync manually if the
* limit ever changes.
*/
export const MAX_STORY_VIDEO_DURATION_SECS = 30;
+30
View File
@@ -20,3 +20,33 @@ export async function uploadImageToSupabase(uri: string, presignedUrl: string, m
throw new Error(`Upload fehlgeschlagen (Status ${result.status})`);
}
}
/**
* PUTs a local video file directly to a Mux direct-upload URL (from
* `stories.createVideoUpload`). Separate function rather than reusing
* `uploadImageToSupabase` above — same `File.upload` primitive, but a
* different destination (Mux, not Supabase) and no signature-matching
* requirement on Content-Type, so keeping the working photo path untouched.
*/
export async function uploadFileToUrl(
uri: string,
uploadUrl: string,
mimeType: string,
onProgress?: (fraction: number) => void
): Promise<void> {
const file = new File(uri);
const result = await file.upload(uploadUrl, {
httpMethod: "PUT",
mimeType,
headers: { "Content-Type": mimeType },
onProgress: onProgress
? ({ bytesSent, totalBytes }) => {
if (totalBytes > 0) onProgress(Math.min(bytesSent / totalBytes, 1));
}
: undefined,
});
if (result.status < 200 || result.status >= 300) {
throw new Error(`Video-Upload fehlgeschlagen (Status ${result.status})`);
}
}
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect } from "vitest";
import { buildWeightChart, nearestPointIndex } from "./weight-chart";
const box = { width: 300, height: 180, left: 40, right: 10, top: 10, bottom: 30 };
describe("buildWeightChart", () => {
it("needs at least two entries", () => {
expect(buildWeightChart([], box)).toBeNull();
expect(buildWeightChart([{ date: "2026-01-01", weightGrams: 4000 }], box)).toBeNull();
});
it("sorts by date and spaces x by time, not by index", () => {
const chart = buildWeightChart(
[
{ date: "2026-01-31", weightGrams: 4200 },
{ date: "2026-01-01", weightGrams: 4000 },
{ date: "2026-01-04", weightGrams: 4100 },
],
box
)!;
expect(chart.points.map((p) => p.kg)).toEqual([4, 4.1, 4.2]);
expect(chart.points[0].x).toBe(40);
expect(chart.points[2].x).toBe(290);
// 3 of 30 days → 10 % of the 250px plot width, not the middle.
expect(chart.points[1].x).toBeCloseTo(65, 5);
});
it("puts heavier weights higher and reports the change since the first entry", () => {
const chart = buildWeightChart(
[
{ date: "2026-01-01", weightGrams: 4000 },
{ date: "2026-02-01", weightGrams: 4350 },
],
box
)!;
expect(chart.points[1].y).toBeLessThan(chart.points[0].y);
expect(chart.deltaKg).toBeCloseTo(0.35, 5);
});
it("keeps a flat series inside the plot and never goes below 0 kg", () => {
const chart = buildWeightChart(
[
{ date: "2026-01-01", weightGrams: 300 },
{ date: "2026-01-02", weightGrams: 300 },
],
box
)!;
for (const p of chart.points) {
expect(p.y).toBeGreaterThan(box.top);
expect(p.y).toBeLessThan(chart.baselineY);
}
expect(chart.yTicks[0].kg).toBe(0);
});
it("falls back to index spacing when all entries share a date", () => {
const chart = buildWeightChart(
[
{ date: "2026-01-01", weightGrams: 4000 },
{ date: "2026-01-01", weightGrams: 4100 },
],
box
)!;
expect(chart.points.map((p) => p.x)).toEqual([40, 290]);
});
it("labels at most a handful of dates and always the newest", () => {
const entries = Array.from({ length: 12 }, (_, i) => ({ date: new Date(Date.UTC(2026, 0, 1 + i)), weightGrams: 4000 + i }));
const chart = buildWeightChart(entries, box)!;
expect(chart.xLabelIndices.length).toBeLessThanOrEqual(4);
expect(chart.xLabelIndices).toContain(11);
});
it("never places two date labels closer than a quarter of the plot", () => {
// Two weigh-ins a day apart, then one a year later: the first two must not both get a label.
const chart = buildWeightChart(
[
{ date: "2025-01-01", weightGrams: 4000 },
{ date: "2025-01-02", weightGrams: 4010 },
{ date: "2026-01-01", weightGrams: 4500 },
],
box
)!;
const xs = chart.xLabelIndices.map((i) => chart.points[i].x);
for (let i = 1; i < xs.length; i++) expect(xs[i] - xs[i - 1]).toBeGreaterThanOrEqual(250 / 4);
expect(chart.xLabelIndices.at(-1)).toBe(2);
});
it("builds closed area and open line paths", () => {
const chart = buildWeightChart(
[
{ date: "2026-01-01", weightGrams: 4000 },
{ date: "2026-01-11", weightGrams: 4100 },
],
box
)!;
expect(chart.linePath.startsWith("M 40 ")).toBe(true);
expect(chart.areaPath.endsWith("Z")).toBe(true);
expect(chart.areaPath).toContain(` ${chart.baselineY} `);
});
});
describe("y axis ticks", () => {
it("uses round, distinct tick values even for a tiny range", () => {
const chart = buildWeightChart(
[
{ date: "2026-09-01", weightGrams: 4200 },
{ date: "2026-10-01", weightGrams: 4150 },
],
box
)!;
const labels = chart.yTicks.map((t) => t.kg.toFixed(chart.yTickDecimals));
expect(new Set(labels).size).toBe(labels.length);
expect(chart.yTicks[0].kg).toBeLessThanOrEqual(4.15);
expect(chart.yTicks.at(-1)!.kg).toBeGreaterThanOrEqual(4.2);
expect(chart.yTickDecimals).toBe(2);
});
it("uses whole kilos for a big dog over a wide range", () => {
const chart = buildWeightChart(
[
{ date: "2025-01-01", weightGrams: 25000 },
{ date: "2026-01-01", weightGrams: 33000 },
],
box
)!;
expect(chart.yTickDecimals).toBe(0);
for (const t of chart.yTicks) expect(Number.isInteger(t.kg)).toBe(true);
});
});
describe("nearestPointIndex", () => {
it("picks the point closest to the tap", () => {
const chart = buildWeightChart(
[
{ date: "2026-01-01", weightGrams: 4000 },
{ date: "2026-01-11", weightGrams: 4100 },
{ date: "2026-01-21", weightGrams: 4200 },
],
box
)!;
expect(nearestPointIndex(chart.points, 0)).toBe(0);
expect(nearestPointIndex(chart.points, 170)).toBe(1);
expect(nearestPointIndex(chart.points, 999)).toBe(2);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* Pure geometry for the mobile weight-history chart (port of web's
* src/components/health/WeightChart.tsx). Kept free of React/SVG so it can
* be unit-tested.
*
* One deliberate difference from web: x is proportional to the date, not to
* the entry's index — weigh-ins are irregular, and index spacing would make a
* jump over three months look as steep as one over three days.
*/
export type WeightEntry = { date: Date | string; weightGrams: number };
export type ChartBox = { width: number; height: number; left: number; right: number; top: number; bottom: number };
export type ChartPoint = { x: number; y: number; kg: number; time: number; entry: WeightEntry };
export type WeightChartGeometry = {
points: ChartPoint[];
linePath: string;
areaPath: string;
yTicks: { y: number; kg: number }[];
/** Decimal places that make every y tick label distinct (derived from the tick step). */
yTickDecimals: number;
xLabelIndices: number[];
baselineY: number;
deltaKg: number;
};
const TARGET_Y_TICKS = 4;
const MAX_X_LABELS = 4;
const RANGE_PADDING = 0.18;
const FLAT_RANGE_PADDING_KG = 0.5;
/** Rounds a raw step up to 1, 2 or 5 × 10^n, so tick labels are round numbers (4,0 / 4,2 / 4,4 — not 4,13 / 4,27). */
function niceStep(rawStep: number): number {
const magnitude = 10 ** Math.floor(Math.log10(rawStep));
const normalized = rawStep / magnitude;
const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return nice * magnitude;
}
function smoothPath(points: ChartPoint[]): string {
return points
.map((p, i) => {
if (i === 0) return `M ${p.x} ${p.y}`;
const prev = points[i - 1];
const cpX = (prev.x + p.x) / 2;
return `C ${cpX} ${prev.y} ${cpX} ${p.y} ${p.x} ${p.y}`;
})
.join(" ");
}
/**
* Date labels chosen by on-screen distance, not by index (x is time-based, so
* index spacing could stack labels on top of each other): walk back from the
* newest point and keep one whenever it is far enough from the last kept one.
*/
function pickLabelIndices(points: { x: number }[], plotWidth: number): number[] {
const minGap = plotWidth / MAX_X_LABELS;
const kept = [points.length - 1];
for (let i = points.length - 2; i >= 0 && kept.length < MAX_X_LABELS; i--) {
if (points[kept[kept.length - 1]].x - points[i].x >= minGap) kept.push(i);
}
return kept.reverse();
}
/** Returns null for fewer than two entries — a single weigh-in is not a trend. */
export function buildWeightChart(entries: WeightEntry[], box: ChartBox): WeightChartGeometry | null {
if (entries.length < 2) return null;
const sorted = [...entries]
.map((entry) => ({ entry, time: new Date(entry.date).getTime(), kg: entry.weightGrams / 1000 }))
.sort((a, b) => a.time - b.time);
const kgs = sorted.map((e) => e.kg);
const minKg = Math.min(...kgs);
const maxKg = Math.max(...kgs);
const rawRange = maxKg - minKg;
const pad = rawRange === 0 ? FLAT_RANGE_PADDING_KG : rawRange * RANGE_PADDING;
const step = niceStep((maxKg + pad - Math.max(0, minKg - pad)) / TARGET_Y_TICKS);
// Snap the padded range outward to whole steps so every gridline sits on a round value.
const yMin = Math.max(0, Math.floor((minKg - pad) / step) * step);
const yMax = Math.ceil((maxKg + pad) / step) * step;
const tickCount = Math.round((yMax - yMin) / step);
const plotW = box.width - box.left - box.right;
const plotH = box.height - box.top - box.bottom;
const firstTime = sorted[0].time;
const timeSpan = sorted[sorted.length - 1].time - firstTime;
const xOf = (time: number, index: number) =>
box.left +
(timeSpan > 0 ? (time - firstTime) / timeSpan : index / (sorted.length - 1)) * plotW;
const yOf = (kg: number) => box.top + plotH - ((kg - yMin) / (yMax - yMin)) * plotH;
const points = sorted.map((e, i) => ({ x: xOf(e.time, i), y: yOf(e.kg), kg: e.kg, time: e.time, entry: e.entry }));
const baselineY = box.top + plotH;
const linePath = smoothPath(points);
const areaPath =
`M ${points[0].x} ${baselineY} L ${points[0].x} ${points[0].y} ` +
linePath.replace(/^M [^ ]+ [^ ]+ ?/, "") +
` L ${points[points.length - 1].x} ${baselineY} Z`;
const yTicks = Array.from({ length: tickCount + 1 }, (_, i) => {
// Rounded to kill float noise (0.1 + 0.2), the step decides the precision.
const kg = Number((yMin + step * i).toFixed(6));
return { kg, y: yOf(kg) };
});
return {
points,
linePath,
areaPath,
yTicks,
yTickDecimals: Math.max(0, -Math.floor(Math.log10(step) + 1e-9)),
xLabelIndices: pickLabelIndices(points, plotW),
baselineY,
deltaKg: kgs[kgs.length - 1] - kgs[0],
};
}
/** Index of the point closest to a tapped x position. */
export function nearestPointIndex(points: ChartPoint[], x: number): number {
let best = 0;
for (let i = 1; i < points.length; i++) {
if (Math.abs(points[i].x - x) < Math.abs(points[best].x - x)) best = i;
}
return best;
}
+8
View File
@@ -26,6 +26,12 @@ export interface ColorTokens {
quietGray: string;
alertRed: string;
alertRedTint: string;
/**
* Data line in charts (weight history). Checked with the dataviz palette
* validator: >= 3:1 against both surfaces and inside the lightness band in
* both modes — the brighter marigoldOrange fails one of the two each way.
*/
chartLine: string;
}
export const colors: ColorTokens = {
@@ -41,6 +47,7 @@ export const colors: ColorTokens = {
quietGray: "#737373",
alertRed: "#dc2626",
alertRedTint: "#fee2e2",
chartLine: "#ea580c",
};
/**
@@ -61,6 +68,7 @@ export const darkColors: ColorTokens = {
quietGray: "#a3a3a3",
alertRed: "#f87171",
alertRedTint: "#450a0a",
chartLine: "#ea580c",
};
export const radii = {
+7
View File
@@ -0,0 +1,7 @@
-- Gründungsmitglieder: alle bestehenden Tiere unter den ersten 250 (nach
-- Erstellzeitpunkt) nachträglich markieren. Neue Tiere setzt pets.create
-- selbst (src/lib/new-pet-welcome.ts). Idempotent, nach dem prisma db push
-- einmal ausführen.
UPDATE "Pet" SET "isFoundingMember" = true
WHERE "id" IN (SELECT "id" FROM "Pet" ORDER BY "createdAt" ASC LIMIT 250)
AND "isFoundingMember" = false;
+3
View File
@@ -0,0 +1,3 @@
-- Kurator-Begrüßung: neue Tabelle für den PostgREST-Zugriff sperren (siehe enable_rls.sql).
-- Nach dem prisma db push einmal ausführen.
ALTER TABLE public."CuratorSettings" ENABLE ROW LEVEL SECURITY;
+5
View File
@@ -0,0 +1,5 @@
-- Story-Editor (feature/story-editor): 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."StoryMention" ENABLE ROW LEVEL SECURITY;
+84 -15
View File
@@ -106,17 +106,38 @@ model ReferralRank {
// Wer hat welchen Rang wann erreicht — Grundlage für Bestandsschutz,
// einmalige Rang-Benachrichtigungen und spätere Gutscheine.
model ReferralRankAchievement {
id String @id @default(cuid())
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String
rank ReferralRank @relation(fields: [rankId], references: [id], onDelete: Restrict)
rankId String
achievedAt DateTime @default(now())
id String @id @default(cuid())
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String
rank ReferralRank @relation(fields: [rankId], references: [id], onDelete: Restrict)
rankId String
achievedAt DateTime @default(now())
// Admin-Steuerung (Tierverwaltung): ein entzogener Rang bleibt als Zeile
// stehen, damit ihn awardRanks/recomputeReferralRanks nicht neu vergeben —
// er zählt erst wieder, wenn ein Admin ihn freigibt (revokedAt = null).
revokedAt DateTime?
revokedBy String? // ownerId des Admins
revokeReason String? @db.VarChar(500)
grantedManually Boolean @default(false) // vom Admin vergeben (besondere Leistung), nicht über den Zähler
grantedBy String? // ownerId des Admins
@@unique([ownerId, rankId])
@@index([rankId])
}
// Kurator-Konto „PawFeed Team“: Begrüßung neuer Tiere. Genau eine Zeile
// (id = 1), im Admin-Panel unter /p/[secret]/curator bearbeitbar.
// {name} im Text wird durch den Tiernamen ersetzt.
model CuratorSettings {
id Int @id @default(1)
welcomeEnabled Boolean @default(true) // Begrüßungs-Direktnachricht an jedes neue Tier
autoFollowEnabled Boolean @default(true) // Team-Tier folgt jedem neuen Tier
welcomeTextDe String @db.VarChar(1000)
welcomeTextEn String @db.VarChar(1000)
updatedAt DateTime @updatedAt
updatedBy String? // ownerId des Admins
}
// Genau eine Zeile (id = 1), im Admin-Panel bearbeitbar.
model ReferralSettings {
id Int @id @default(1)
@@ -244,10 +265,15 @@ model Pet {
adoptionStory String?
avatarKey String? // Supabase Storage object key, NOT a full URL
isVerified Boolean @default(false) // admin-granted verification checkmark (Gitea #10 permission matrix)
isFoundingMember Boolean @default(false) // eines der ersten 250 Tiere — einmalig in pets.create gesetzt (src/lib/new-pet-welcome.ts), nie wieder entzogen
dmPolicy DmPolicy @default(EVERYONE) // who can send this pet DMs
followerVisibility FollowerVisibility @default(EVERYONE) // who can view this pet's followers/following lists (Gitea #33)
birthday DateTime? @db.Date // actual birthday for anniversary notifications
adoptedAt DateTime? @db.Date // adoption date for anniversary notifications
// Gedenkmodus „In Erinnerung“ (Roadmap 04.10.): vom Besitzer gesetzt, rückgängig
// machbar. Verstorbene Tiere bekommen keine Geburtstags-/Impf-Mitteilungen und
// werden nicht vorgeschlagen; Abzeichen + Banner in Web und App.
deceasedAt DateTime? @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -301,6 +327,9 @@ model Pet {
verificationRequests VerificationRequest[]
// Story-Editor: Storys, in denen dieses Tier markiert wurde
storyMentions StoryMention[] @relation("StoryMentions")
// 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.
@@ -490,6 +519,17 @@ model Story {
musicOffsetSecs Float? @default(0) // start point within the track, since story display duration is fixed
musicMuteOriginal Boolean @default(false) // VIDEO stories only — mute the baked-in Mux audio in favor of the track
// Story-Editor (feature/story-editor): Text, Markierungen, GIFs und Sticker
// als Daten über dem Medium, nicht ins Bild gerechnet — validiert über
// storyOverlaysSchema (src/lib/story-overlays.ts). Positionen relativ zur
// Medienfläche, daher mediaWidth/-Height auch für Bilder.
overlays Json?
background String? @db.VarChar(30) // Farbverlauf-Schlüssel, nur TEXT-Storys
mediaWidth Int?
mediaHeight Int?
mentions StoryMention[]
notifications Notification[]
@@index([petId, expiresAt]) // supports lazy-delete filter query
@@index([muxUploadId])
@@index([muxAssetId])
@@ -518,6 +558,22 @@ model MusicTrack {
enum StoryMediaType {
IMAGE
VIDEO
TEXT // reine Text-Story auf Farbverlauf (Story.background), ohne Foto/Video
}
// Markierte Tiere einer Story — eigene Tabelle (nicht nur im overlays-JSON),
// damit Block-/Sichtbarkeitsregeln, Benachrichtigungen und Löschungen sauber
// greifen. Cascade in beide Richtungen.
model StoryMention {
id String @id @default(cuid())
story Story @relation(fields: [storyId], references: [id], onDelete: Cascade)
storyId String
pet Pet @relation("StoryMentions", fields: [petId], references: [id], onDelete: Cascade)
petId String
createdAt DateTime @default(now())
@@unique([storyId, petId])
@@index([petId])
}
model StoryView {
@@ -736,14 +792,18 @@ model VetVisit {
}
model Vaccine {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String
name String @db.VarChar(100)
dateGiven DateTime @db.Date
nextDueDate DateTime? @db.Date
notes String? @db.VarChar(200)
createdAt DateTime @default(now())
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String
name String @db.VarChar(100)
dateGiven DateTime @db.Date
nextDueDate DateTime? @db.Date
notes String? @db.VarChar(200)
createdAt DateTime @default(now())
// Gesetzt vom Cron /api/cron/vaccine-reminders, sobald die Erinnerung
// (30 Tage vor nextDueDate) raus ist — verhindert Doppel-Erinnerungen.
reminderSentAt DateTime?
notifications Notification[]
@@index([petId, dateGiven(sort: Desc)])
}
@@ -815,6 +875,11 @@ enum NotificationType {
PET_SUGGESTION
REFERRAL_JOINED // ein Tier ist über die eigene Einladung zu PawFeed gekommen
REFERRAL_RANK_UP // eigener Empfehlungsrang aufgestiegen (actorPetId = null)
VACCINE_DUE // Impfung wird in <= 30 Tagen fällig (vaccineId gesetzt, actorPetId = null)
STORY_MENTION // Tier wurde in einer Story markiert (storyId gesetzt)
STORY_COMMENT // Kommentar auf eine eigene Story (storyId gesetzt)
STORY_REACTION // Pfote auf eine eigene Story (storyId gesetzt)
PET_DECEASED // ein gefolgtes Tier ist verstorben (actorPetId = das Tier, kein Push)
}
// ─── Phase 4 Models ──────────────────────────────────────────────────────────
@@ -830,6 +895,10 @@ model Notification {
postId String?
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
commentId String?
vaccine Vaccine? @relation(fields: [vaccineId], references: [id], onDelete: Cascade)
vaccineId String?
story Story? @relation(fields: [storyId], references: [id], onDelete: Cascade)
storyId String?
read Boolean @default(false)
createdAt DateTime @default(now())
@@ -975,7 +1044,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
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
targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE | AD | REPORT | PET | SYSTEM | MUSIC_TRACK
targetId String
reason String? @db.VarChar(500)
+91
View File
@@ -0,0 +1,91 @@
/**
* DSGVO-Pfade rund um das Empfehlungsprogramm: Datenauskunft (gdpr-export.ts)
* und Kontolöschung (delete-account.ts).
*/
import { describe, it, expect, vi } from "vitest";
vi.mock("server-only", () => ({}));
const mockDeleteUser = vi.fn().mockResolvedValue(undefined);
vi.mock("@clerk/nextjs/server", () => ({
clerkClient: vi.fn().mockResolvedValue({
users: { getUser: vi.fn().mockResolvedValue(null), deleteUser: (...a: unknown[]) => mockDeleteUser(...a) },
}),
}));
vi.mock("@/lib/supabase-storage", () => ({ deleteFiles: vi.fn().mockResolvedValue(undefined) }));
vi.mock("@/lib/media-url", () => ({ getMediaUrl: (key: string) => `media/${key}` }));
vi.mock("@/lib/avatar-url", () => ({ getAvatarUrl: (key: string | null) => (key ? `avatar/${key}` : null) }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
import { deleteOwnerAccount } from "@/lib/delete-account";
import { buildDataExport } from "@/lib/gdpr-export";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const asDb = (m: ReturnType<typeof createMockPrisma>) => m as any;
describe("deleteOwnerAccount", () => {
it("removes the deleted owner's id from other owners' referral and legacy invite links", async () => {
const prisma = createMockPrisma();
prisma.pet.findMany.mockResolvedValue([]);
prisma.pet.deleteMany.mockResolvedValue({ count: 0 });
prisma.owner.updateMany.mockResolvedValue({ count: 2 });
prisma.owner.delete.mockResolvedValue({});
await deleteOwnerAccount(asDb(prisma), "owner-gone");
expect(prisma.owner.updateMany).toHaveBeenCalledWith({
where: { invitedById: "owner-gone" },
data: { invitedById: null },
});
expect(prisma.inviteCode.updateMany).toHaveBeenCalledWith({
where: { usedById: "owner-gone" },
data: { usedById: null },
});
expect(mockDeleteUser).toHaveBeenCalledWith("owner-gone");
});
});
describe("buildDataExport — referral program", () => {
it("includes code, ranks and invites but names other people only by pet name", async () => {
const prisma = createMockPrisma();
prisma.owner.findUnique.mockResolvedValue({
id: "owner-1",
createdAt: new Date("2026-09-01"),
invitedById: "user_inviter_secret_id",
invitedAt: new Date("2026-09-02"),
referralQualifiedAt: new Date("2026-09-03"),
referralFlaggedAt: null,
referralRevokedAt: null,
referralCode: "LUNA-7K3M",
qualifiedReferralCount: 1,
referralPerks: ["PROFILE_RING"],
acquisitionSource: "FRIENDS",
acquisitionAnsweredAt: new Date("2026-09-02"),
legalAcknowledgedAt: null,
contentRightsConsentedAt: null,
dataProcessingConsentedAt: null,
ban: null,
adminRole: null,
pets: [],
rankAchievements: [{ achievedAt: new Date("2026-09-10"), rank: { nameDe: "Pfotenfreund", threshold: 1 } }],
issuedInvites: [],
});
prisma.pet.findFirst.mockResolvedValue({ name: "Bubi" });
prisma.owner.findMany.mockResolvedValue([
{ invitedAt: new Date("2026-09-09"), referralQualifiedAt: new Date("2026-09-10"), referralRevokedAt: null, pets: [{ name: "Kiki" }] },
]);
const data = await buildDataExport(asDb(prisma), "owner-1");
expect(data?.referralProgram).toMatchObject({
ownCode: "LUNA-7K3M",
acquisitionSource: "FRIENDS",
invitedBy: { petName: "Bubi" },
countedInvites: 1,
invitesMade: [{ petName: "Kiki" }],
ranksReached: [{ rank: "Pfotenfreund", threshold: 1 }],
currentPerks: ["PROFILE_RING"],
});
expect(JSON.stringify(data)).not.toContain("user_inviter_secret_id");
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* found.md: Rückmeldung an den Melder per Team-Nachricht — Begründung bei
* abgewiesenem Report, Dank bei bestätigter Löschung.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
vi.mock("server-only", () => ({}));
vi.mock("@clerk/nextjs/server", () => ({
auth: vi.fn().mockResolvedValue({ userId: "admin-1" }),
clerkClient: vi.fn(),
}));
vi.mock("@/lib/supabase-storage", () => ({
createSignedUploadUrl: vi.fn(),
ALLOWED_CONTENT_TYPES: ["image/jpeg", "image/png", "image/webp"],
MAX_FILE_SIZE: 10 * 1024 * 1024,
MUSIC_STORAGE_BUCKET: "music-tracks",
MUSIC_ALLOWED_CONTENT_TYPES: ["audio/mpeg", "audio/mp4", "audio/aac"],
MAX_MUSIC_FILE_SIZE: 5 * 1024 * 1024,
}));
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/gdpr-export", () => ({ buildDataExport: vi.fn() }));
vi.mock("@/lib/data-export-storage", () => ({ uploadExport: vi.fn() }));
vi.mock("@/lib/mail", () => ({ sendMail: vi.fn() }));
vi.mock("@/lib/maintenance", () => ({ isMaintenanceModeEnabled: vi.fn(), setMaintenanceMode: vi.fn() }));
const { mockSendTeamMessage } = vi.hoisted(() => ({ mockSendTeamMessage: vi.fn() }));
vi.mock("@/lib/team-pet", () => ({
ensureTeamPet: vi.fn(),
sendTeamMessage: (...args: unknown[]) => mockSendTeamMessage(...args),
}));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
async function createAdminCaller() {
const { adminRouter } = await import("@/trpc/routers/admin");
const { initTRPC } = await import("@trpc/server");
const mockPrisma = createMockPrisma();
mockPrisma.$transaction.mockImplementation((arg: unknown) => Promise.all(arg as unknown[]));
const t = initTRPC.context<{ userId: string | null; prisma: typeof mockPrisma }>().create();
const testRouter = t.router({ admin: adminRouter });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const caller = testRouter.createCaller({ userId: "admin-1", prisma: mockPrisma as any });
return { caller, mockPrisma };
}
afterEach(() => {
vi.unstubAllEnvs();
mockSendTeamMessage.mockReset();
});
describe("admin.dismissReport reporter feedback", () => {
it("sends the reporter a team message with the reason, naming the reported post's pet", async () => {
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
const { caller, mockPrisma } = await createAdminCaller();
mockPrisma.report.findUnique.mockResolvedValue({ id: "r1", reporterPetId: "reporter-1", targetPostId: "post-1", targetPetId: null });
mockPrisma.post.findUnique.mockResolvedValue({ pet: { name: "Bubi" } });
mockSendTeamMessage.mockResolvedValue(undefined);
await caller.admin.dismissReport({ reportId: "r1", message: "Das ist ein normales Spielvideo." });
expect(mockSendTeamMessage).toHaveBeenCalledTimes(1);
const [, petId, body] = mockSendTeamMessage.mock.calls[0];
expect(petId).toBe("reporter-1");
expect(body).toContain("Meldung zum Beitrag von Bubi");
expect(body).toContain("keinen Verstoß");
expect(body).toContain("Begründung: Das ist ein normales Spielvideo.");
expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ reason: "Das ist ein normales Spielvideo." }) })
);
});
it("names the profile for a pet report and omits an empty reason", async () => {
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
const { caller, mockPrisma } = await createAdminCaller();
mockPrisma.report.findUnique.mockResolvedValue({ id: "r2", reporterPetId: "reporter-1", targetPostId: null, targetPetId: "pet-9" });
mockPrisma.pet.findUnique.mockResolvedValue({ name: "Luna" });
mockSendTeamMessage.mockResolvedValue(undefined);
await caller.admin.dismissReport({ reportId: "r2" });
const body = mockSendTeamMessage.mock.calls[0][2] as string;
expect(body).toContain("Meldung zum Profil von Luna");
expect(body).not.toContain("Begründung");
});
it("sends nothing when notifyReporter is false", async () => {
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
const { caller, mockPrisma } = await createAdminCaller();
mockPrisma.report.findUnique.mockResolvedValue({ id: "r1", reporterPetId: "reporter-1", targetPostId: null, targetPetId: null });
await caller.admin.dismissReport({ reportId: "r1", notifyReporter: false });
expect(mockSendTeamMessage).not.toHaveBeenCalled();
});
it("still succeeds when the message fails", async () => {
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
const { caller, mockPrisma } = await createAdminCaller();
mockPrisma.report.findUnique.mockResolvedValue({ id: "r1", reporterPetId: "reporter-1", targetPostId: null, targetPetId: null });
mockSendTeamMessage.mockRejectedValue(new Error("db down"));
vi.spyOn(console, "error").mockImplementation(() => {});
await expect(caller.admin.dismissReport({ reportId: "r1" })).resolves.toEqual({ success: true });
});
});
describe("admin.deleteReportedPost reporter feedback", () => {
it("deletes the post's reports and thanks every reporter once", async () => {
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
const { caller, mockPrisma } = await createAdminCaller();
mockPrisma.post.findUnique.mockResolvedValue({ repost: null, pet: { name: "Bubi" } });
mockPrisma.report.findMany.mockResolvedValue([
{ reporterPetId: "reporter-1" },
{ reporterPetId: "reporter-2" },
{ reporterPetId: "reporter-1" },
]);
mockSendTeamMessage.mockResolvedValue(undefined);
await caller.admin.deleteReportedPost({ postId: "post-1", reason: "Gewaltdarstellung" });
expect(mockPrisma.report.deleteMany).toHaveBeenCalledWith({ where: { targetPostId: "post-1" } });
expect(mockSendTeamMessage.mock.calls.map((c) => c[1])).toEqual(["reporter-1", "reporter-2"]);
const body = mockSendTeamMessage.mock.calls[0][2] as string;
expect(body).toContain("Meldung zum Beitrag von Bubi");
expect(body).toContain("Inhalt entfernt");
// Die interne Lösch-Begründung geht nicht an den Melder.
expect(body).not.toContain("Gewaltdarstellung");
});
it("keeps notifying the other reporters when one message fails", async () => {
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
const { caller, mockPrisma } = await createAdminCaller();
mockPrisma.post.findUnique.mockResolvedValue({ repost: null, pet: { name: "Bubi" } });
mockPrisma.report.findMany.mockResolvedValue([{ reporterPetId: "reporter-1" }, { reporterPetId: "reporter-2" }]);
mockSendTeamMessage.mockRejectedValueOnce(new Error("fail")).mockResolvedValueOnce(undefined);
vi.spyOn(console, "error").mockImplementation(() => {});
await expect(caller.admin.deleteReportedPost({ postId: "post-1" })).resolves.toEqual({ success: true });
expect(mockSendTeamMessage).toHaveBeenCalledTimes(2);
});
it("sends nothing when notifyReporters is false", async () => {
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
const { caller, mockPrisma } = await createAdminCaller();
mockPrisma.post.findUnique.mockResolvedValue({ repost: null, pet: { name: "Bubi" } });
mockPrisma.report.findMany.mockResolvedValue([{ reporterPetId: "reporter-1" }]);
await caller.admin.deleteReportedPost({ postId: "post-1", notifyReporters: false });
expect(mockSendTeamMessage).not.toHaveBeenCalled();
});
});
+2 -1
View File
@@ -198,7 +198,7 @@ describe("admin transactional moderation actions", () => {
mockPrisma.report.delete.mockResolvedValue({ id: "report-1" });
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
const result = await caller.admin.dismissReport({ reportId: "report-1" });
const result = await caller.admin.dismissReport({ reportId: "report-1", notifyReporter: false });
expect(mockPrisma.$transaction).toHaveBeenCalledWith(expect.any(Array));
expect(mockPrisma.report.delete).toHaveBeenCalledWith({ where: { id: "report-1" } });
@@ -211,6 +211,7 @@ describe("admin transactional moderation actions", () => {
mockPrisma.post.findUnique.mockResolvedValue({ repost: null });
mockPrisma.post.delete.mockResolvedValue({ id: "post-1" });
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
mockPrisma.report.findMany.mockResolvedValue([]);
const result = await caller.admin.deleteReportedPost({ postId: "post-1" });
+94
View File
@@ -0,0 +1,94 @@
/**
* /app — Download-Weiterleitung (src/lib/app-redirect.ts, app-redirect-stats.ts).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("server-only", () => ({}));
const redisStore = new Map<string, Map<string, number>>();
vi.mock("@/lib/redis", () => ({
redis: {
hincrby: vi.fn(async (key: string, field: string, by: number) => {
const hash = redisStore.get(key) ?? new Map<string, number>();
hash.set(field, (hash.get(field) ?? 0) + by);
redisStore.set(key, hash);
return hash.get(field);
}),
expire: vi.fn(async () => 1),
hgetall: vi.fn(async (key: string) => Object.fromEntries(redisStore.get(key) ?? new Map())),
},
}));
import { PLAY_STORE_URL } from "@/lib/app-links";
import { detectPlatform, resolveAppRedirect } from "@/lib/app-redirect";
import { countAppRedirect, getAppRedirectStats } from "@/lib/app-redirect-stats";
const ORIGIN = "https://pawfeed.org";
const ANDROID = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/128 Mobile Safari/537.36";
const IPHONE = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1";
const DESKTOP = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128 Safari/537.36";
const params = (query: string) => new URLSearchParams(query);
describe("detectPlatform", () => {
it("recognises Android, iPhone and everything else", () => {
expect(detectPlatform(ANDROID)).toBe("android");
expect(detectPlatform(IPHONE)).toBe("ios");
expect(detectPlatform(DESKTOP)).toBe("other");
expect(detectPlatform(null)).toBe("other");
});
});
describe("resolveAppRedirect", () => {
it("sends Android to Google Play with the UTM values as Play referrer", () => {
const result = resolveAppRedirect(ANDROID, params("utm_source=flyer&utm_campaign=tierheim-xy"), ORIGIN);
expect(result.platform).toBe("android");
expect(result.source).toBe("flyer");
expect(result.location.startsWith(PLAY_STORE_URL)).toBe(true);
const referrer = new URL(result.location).searchParams.get("referrer");
expect(referrer).toBe("utm_source=flyer&utm_campaign=tierheim-xy");
});
it("sends iPhones to the web start page while there is no App Store URL", () => {
const result = resolveAppRedirect(IPHONE, params("utm_source=sticker"), ORIGIN, null);
expect(result.location).toBe("https://pawfeed.org/?utm_source=sticker");
});
it("sends iPhones to the App Store once the URL is configured", () => {
const store = "https://apps.apple.com/app/id123";
expect(resolveAppRedirect(IPHONE, params(""), ORIGIN, store).location).toBe(store);
});
it("turns ?ref=<code> into the invite page on the web", () => {
const result = resolveAppRedirect(DESKTOP, params("ref=bubi-7k3m&utm_source=qr"), ORIGIN, null);
expect(result.location).toBe("https://pawfeed.org/r/BUBI-7K3M?utm_source=qr");
});
it("drops unsafe UTM values and invalid codes instead of passing them on", () => {
const result = resolveAppRedirect(DESKTOP, params("utm_source=<script>&ref=nope"), ORIGIN, null);
expect(result.location).toBe("https://pawfeed.org/");
expect(result.source).toBe("direct");
});
it("uses the plain Play URL without any campaign data", () => {
expect(resolveAppRedirect(ANDROID, params(""), ORIGIN).location).toBe(PLAY_STORE_URL);
});
});
describe("app redirect stats", () => {
beforeEach(() => redisStore.clear());
it("sums clicks per source and platform over the requested days", async () => {
const today = new Date("2026-10-02T10:00:00Z");
const yesterday = new Date("2026-10-01T10:00:00Z");
await countAppRedirect("flyer", "android", today);
await countAppRedirect("flyer", "android", yesterday);
await countAppRedirect("flyer", "ios", today);
await countAppRedirect("direct", "other", today);
const stats = await getAppRedirectStats(30, today);
expect(stats[0]).toEqual({ source: "flyer", android: 2, ios: 1, other: 0, total: 3 });
expect(stats[1]).toEqual({ source: "direct", android: 0, ios: 0, other: 1, total: 1 });
});
});
@@ -0,0 +1,69 @@
/**
* /api/cron/cleanup-stories — löscht Storys, die älter als 7 Tage sind.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("server-only", () => ({}));
const mockDeleteFiles = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/supabase-storage", () => ({ deleteFiles: (...a: unknown[]) => mockDeleteFiles(...a) }));
const mockMuxDelete = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/mux", () => ({ mux: { video: { assets: { delete: (...a: unknown[]) => mockMuxDelete(...a) } } } }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
const mockPrisma = createMockPrisma();
// Getter: vi.mock wird nach oben gehoben, mockPrisma existiert erst beim Zugriff.
vi.mock("@/lib/prisma", () => ({
get prisma() {
return mockPrisma;
},
}));
import { GET } from "@/app/api/cron/cleanup-stories/route";
const request = (auth?: string) =>
new Request("https://pawfeed.org/api/cron/cleanup-stories", { headers: auth ? { authorization: auth } : {} });
beforeEach(() => {
vi.clearAllMocks();
process.env.CRON_SECRET = "test-secret";
mockPrisma.$transaction.mockResolvedValue([]);
});
describe("GET /api/cron/cleanup-stories", () => {
it("rejects requests without the cron secret", async () => {
expect((await GET(request())).status).toBe(401);
expect((await GET(request("Bearer wrong"))).status).toBe(401);
expect(mockPrisma.story.findMany).not.toHaveBeenCalled();
});
it("deletes stories older than 7 days with their media", async () => {
mockPrisma.story.findMany.mockResolvedValue([
{ id: "old-photo", storageKey: "stories/a.jpg", muxAssetId: null },
{ id: "old-video", storageKey: null, muxAssetId: "asset-1" },
]);
const res = await GET(request("Bearer test-secret"));
expect(await res.json()).toEqual({ ok: true, deleted: 2, failed: 0, more: false });
const where = mockPrisma.story.findMany.mock.calls[0][0].where;
const cutoffAgeDays = (Date.now() - where.createdAt.lt.getTime()) / (24 * 60 * 60 * 1000);
expect(Math.round(cutoffAgeDays)).toBe(7);
expect(mockPrisma.storyView.deleteMany).toHaveBeenCalledWith({ where: { storyId: "old-photo" } });
expect(mockDeleteFiles).toHaveBeenCalledWith(["stories/a.jpg"]);
expect(mockMuxDelete).toHaveBeenCalledWith("asset-1");
});
it("keeps going when one story fails and reports it", async () => {
mockPrisma.story.findMany.mockResolvedValue([
{ id: "s1", storageKey: null, muxAssetId: null },
{ id: "s2", storageKey: null, muxAssetId: null },
]);
mockPrisma.$transaction.mockRejectedValueOnce(new Error("db hiccup")).mockResolvedValueOnce([]);
const body = await (await GET(request("Bearer test-secret"))).json();
expect(body).toEqual({ ok: false, deleted: 1, failed: 1, more: false });
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* deletePetCompletely (src/lib/delete-pet.ts) — von pets.delete,
* Kontolöschung und Admin genutzt.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("server-only", () => ({}));
const mockDeleteFiles = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/supabase-storage", () => ({ deleteFiles: (...a: unknown[]) => mockDeleteFiles(...a) }));
const mockMuxDelete = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/mux", () => ({ mux: { video: { assets: { delete: (...a: unknown[]) => mockMuxDelete(...a) } } } }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
import { deletePetCompletely } from "@/lib/delete-pet";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const asDb = (m: ReturnType<typeof createMockPrisma>) => m as any;
const sqlOf = (call: unknown[]) => (call[0] as TemplateStringsArray).join("?");
beforeEach(() => vi.clearAllMocks());
describe("deletePetCompletely", () => {
function setup() {
const prisma = createMockPrisma();
prisma.pet.findUnique.mockResolvedValue({
avatarKey: "avatars/a.png",
posts: [
{ images: [{ storageKey: "posts/p1.jpg" }, { storageKey: "posts/p2.jpg" }], videoPost: null },
{ images: [], videoPost: { muxAssetId: "asset-post" } },
],
stories: [{ storageKey: "stories/s1.jpg", muxAssetId: null }, { storageKey: null, muxAssetId: "asset-story" }],
});
prisma.pet.delete.mockResolvedValue({});
prisma.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => fn(prisma));
return prisma;
}
it("fixes foreign counters, removes blockers and then deletes the pet", async () => {
const prisma = setup();
await deletePetCompletely(asDb(prisma), "pet-1");
const statements = prisma.$executeRaw.mock.calls.map(sqlOf);
expect(statements.some((s) => s.includes(`"reactionCount"`) && s.includes(`"Reaction"`))).toBe(true);
expect(statements.some((s) => s.includes(`"commentCount"`) && s.includes(`"Comment"`))).toBe(true);
expect(statements.some((s) => s.includes(`"repostCount"`))).toBe(true);
expect(statements.some((s) => s.includes(`"StoryReaction"`))).toBe(true);
expect(statements.some((s) => s.includes(`"StoryComment"`))).toBe(true);
expect(statements.some((s) => s.includes(`DELETE FROM "StoryView"`))).toBe(true);
expect(statements.some((s) => s.includes(`DELETE FROM "Report"`))).toBe(true);
expect(statements.some((s) => s.includes(`DELETE FROM "Conversation"`))).toBe(true);
expect(prisma.pet.delete).toHaveBeenCalledWith({ where: { id: "pet-1" } });
// Pet-Delete erst nach allen Vorbereitungen
const lastRaw = Math.max(...prisma.$executeRaw.mock.invocationCallOrder);
expect(prisma.pet.delete.mock.invocationCallOrder[0]).toBeGreaterThan(lastRaw);
});
it("removes avatar, post images, story images and Mux assets", async () => {
const prisma = setup();
await deletePetCompletely(asDb(prisma), "pet-1");
expect(mockDeleteFiles).toHaveBeenCalledWith(["avatars/a.png", "posts/p1.jpg", "posts/p2.jpg", "stories/s1.jpg"]);
expect(mockMuxDelete).toHaveBeenCalledWith("asset-post");
expect(mockMuxDelete).toHaveBeenCalledWith("asset-story");
});
it("does nothing for an unknown pet", async () => {
const prisma = createMockPrisma();
prisma.pet.findUnique.mockResolvedValue(null);
await deletePetCompletely(asDb(prisma), "nope");
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
+31
View File
@@ -0,0 +1,31 @@
/**
* HEIC/HEIF-Erkennung und -Umwandlung vor dem Upload (src/lib/heic-to-jpeg.ts).
* jsdom kann keine Bilder dekodieren — der Fehlerpfad steht damit für
* "Browser ohne HEIC-Unterstützung" (z. B. Chrome unter Windows).
*/
import { describe, it, expect } from "vitest";
import { HeicUnsupportedError, isHeicFile, normalizeImageFile } from "@/lib/heic-to-jpeg";
const file = (name: string, type: string) => new File([new Uint8Array([1, 2, 3])], name, { type });
describe("isHeicFile", () => {
it("detects HEIC/HEIF by MIME type or by extension when the type is empty", () => {
expect(isHeicFile(file("IMG_0001.HEIC", "image/heic"))).toBe(true);
expect(isHeicFile(file("photo.heif", "image/heif"))).toBe(true);
expect(isHeicFile(file("IMG_0002.heic", ""))).toBe(true);
expect(isHeicFile(file("photo.jpg", "image/jpeg"))).toBe(false);
expect(isHeicFile(file("photo.png", "image/png"))).toBe(false);
});
});
describe("normalizeImageFile", () => {
it("passes JPEG/PNG/WebP through unchanged", async () => {
const jpeg = file("photo.jpg", "image/jpeg");
expect(await normalizeImageFile(jpeg)).toBe(jpeg);
});
it("reports HeicUnsupportedError when the browser cannot decode HEIC", async () => {
await expect(normalizeImageFile(file("IMG_0001.HEIC", "image/heic"))).rejects.toBeInstanceOf(HeicUnsupportedError);
});
});
+37
View File
@@ -21,7 +21,9 @@ interface MockPetClient {
findFirst: MockFn;
update: MockFn;
delete: MockFn;
deleteMany: MockFn;
count: MockFn;
groupBy: MockFn;
}
interface MockOwnerClient {
@@ -31,12 +33,15 @@ interface MockOwnerClient {
create: MockFn;
update: MockFn;
updateMany: MockFn;
delete: MockFn;
count: MockFn;
}
interface MockInviteCodeClient {
count: MockFn;
createMany: MockFn;
deleteMany: MockFn;
updateMany: MockFn;
}
interface MockLegalDocumentVersionClient {
@@ -138,6 +143,7 @@ interface MockReportClient {
findMany: MockFn;
findUnique: MockFn;
delete: MockFn;
deleteMany: MockFn;
count: MockFn;
}
@@ -255,6 +261,7 @@ interface MockNotificationClient {
findUnique: MockFn;
count: MockFn;
updateMany: MockFn;
createMany: MockFn;
}
interface MockWeightLogClient {
@@ -279,6 +286,7 @@ interface MockVaccineClient {
create: MockFn;
delete: MockFn;
deleteMany: MockFn;
updateMany: MockFn;
}
interface MockHealthCardClient {
@@ -337,7 +345,9 @@ interface MockReferralRankClient {
interface MockReferralRankAchievementClient {
findMany: MockFn;
findUnique: MockFn;
createMany: MockFn;
upsert: MockFn;
deleteMany: MockFn;
groupBy: MockFn;
}
@@ -347,6 +357,11 @@ interface MockReferralSettingsClient {
upsert: MockFn;
}
interface MockCuratorSettingsClient {
findUnique: MockFn;
upsert: MockFn;
}
interface MockPrismaClient {
pet: MockPetClient;
owner: MockOwnerClient;
@@ -358,6 +373,7 @@ interface MockPrismaClient {
postImage: MockPostImageClient;
milestone: MockMilestoneClient;
story: MockStoryClient;
storyMention: { createMany: MockFn; findMany: MockFn; deleteMany: MockFn };
storyView: MockStoryViewClient;
follow: MockFollowClient;
block: MockBlockClient;
@@ -396,6 +412,7 @@ interface MockPrismaClient {
referralRank: MockReferralRankClient;
referralRankAchievement: MockReferralRankAchievementClient;
referralSettings: MockReferralSettingsClient;
curatorSettings: MockCuratorSettingsClient;
$transaction: MockFn;
$executeRaw: MockFn;
$disconnect: MockFn;
@@ -410,7 +427,9 @@ export function createMockPrisma(): MockPrismaClient {
findFirst: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
count: vi.fn(),
groupBy: vi.fn().mockResolvedValue([]),
},
owner: {
upsert: vi.fn(),
@@ -419,6 +438,7 @@ export function createMockPrisma(): MockPrismaClient {
create: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
// Legacy InviteCode table (pre-referral-program one-time codes) — only
@@ -426,6 +446,8 @@ export function createMockPrisma(): MockPrismaClient {
inviteCode: {
count: vi.fn().mockResolvedValue(0),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
legalDocumentVersion: {
create: vi.fn(),
@@ -484,6 +506,11 @@ export function createMockPrisma(): MockPrismaClient {
deleteMany: vi.fn(),
aggregate: vi.fn(),
},
storyMention: {
createMany: vi.fn().mockResolvedValue({ count: 0 }),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn(),
},
storyView: {
upsert: vi.fn(),
findMany: vi.fn(),
@@ -517,6 +544,7 @@ export function createMockPrisma(): MockPrismaClient {
findMany: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
count: vi.fn(),
},
userBan: {
@@ -615,6 +643,7 @@ export function createMockPrisma(): MockPrismaClient {
findUnique: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
weightLog: {
findMany: vi.fn(),
@@ -636,6 +665,7 @@ export function createMockPrisma(): MockPrismaClient {
create: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
updateMany: vi.fn(),
},
healthCard: {
findUnique: vi.fn(),
@@ -683,7 +713,9 @@ export function createMockPrisma(): MockPrismaClient {
},
referralRankAchievement: {
findMany: vi.fn().mockResolvedValue([]),
findUnique: vi.fn().mockResolvedValue(null),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
upsert: vi.fn().mockResolvedValue({}),
deleteMany: vi.fn(),
groupBy: vi.fn().mockResolvedValue([]),
},
@@ -692,6 +724,11 @@ export function createMockPrisma(): MockPrismaClient {
findUnique: vi.fn().mockResolvedValue(null),
upsert: vi.fn(),
},
// Ohne Zeile greifen die Standardwerte (getCuratorSettings in new-pet-welcome.ts).
curatorSettings: {
findUnique: vi.fn().mockResolvedValue(null),
upsert: vi.fn(),
},
$transaction: vi.fn(),
$executeRaw: vi.fn().mockResolvedValue(0),
$disconnect: vi.fn(),
+151
View File
@@ -0,0 +1,151 @@
/**
* Gründungsmitglieder + Kurator-Begrüßung beim Anlegen eines Tiers
* (src/lib/new-pet-welcome.ts, src/trpc/routers/curator-admin.ts).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("server-only", () => ({}));
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/notify", () => ({ notify: vi.fn() }));
const mockSendTeamMessage = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/team-pet", () => ({
ensureTeamPet: vi.fn().mockResolvedValue({ id: "pet-team" }),
sendTeamMessage: (...args: unknown[]) => mockSendTeamMessage(...args),
}));
vi.mock("@/lib/redis", () => ({ redis: {} }));
const mockBackfillOnFollow = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/feed-helpers", () => ({ backfillOnFollow: (...args: unknown[]) => mockBackfillOnFollow(...args) }));
const mockAssertAdmin = vi.fn();
vi.mock("@/trpc/routers/admin", () => ({ assertAdmin: (...args: unknown[]) => mockAssertAdmin(...args) }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
import { notify } from "@/lib/notify";
import { FOUNDING_MEMBER_LIMIT, markFoundingMember, renderWelcomeText, welcomeNewPet } from "@/lib/new-pet-welcome";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const asDb = (m: ReturnType<typeof createMockPrisma>) => m as any;
beforeEach(() => {
vi.clearAllMocks();
mockAssertAdmin.mockResolvedValue("SUPER_ADMIN");
});
describe("renderWelcomeText", () => {
it("replaces every {name} with the pet's name", () => {
expect(renderWelcomeText("Hallo {name}! Schön, dass {name} da ist.", "Luna")).toBe("Hallo Luna! Schön, dass Luna da ist.");
});
});
describe("markFoundingMember", () => {
it("uses one guarded SQL update with the 250 limit", async () => {
const prisma = createMockPrisma();
prisma.$executeRaw.mockResolvedValue(1);
expect(await markFoundingMember(asDb(prisma), "pet-1")).toBe(true);
const values = prisma.$executeRaw.mock.calls[0].slice(1);
expect(values).toContain("pet-1");
expect(values).toContain(FOUNDING_MEMBER_LIMIT);
expect(FOUNDING_MEMBER_LIMIT).toBe(250);
});
it("reports false once the 250 places are taken", async () => {
const prisma = createMockPrisma();
prisma.$executeRaw.mockResolvedValue(0);
expect(await markFoundingMember(asDb(prisma), "pet-251")).toBe(false);
});
});
describe("welcomeNewPet", () => {
it("follows the new pet as the team and sends a German welcome with the pet's name", async () => {
const prisma = createMockPrisma();
prisma.follow.upsert.mockResolvedValue({});
await welcomeNewPet(asDb(prisma), { id: "pet-luna", name: "Luna" }, "de");
expect(prisma.follow.upsert).toHaveBeenCalledWith(
expect.objectContaining({ create: { followerPetId: "pet-team", followeePetId: "pet-luna" } })
);
expect(notify).toHaveBeenCalledWith(expect.anything(), { recipientPetId: "pet-luna", type: "FOLLOW", actorPetId: "pet-team" });
expect(mockSendTeamMessage).toHaveBeenCalledWith(expect.anything(), "pet-luna", expect.stringContaining("Hallo Luna"));
});
it("makes the new pet follow the team back and seeds its feed with the team's posts", async () => {
const prisma = createMockPrisma();
prisma.follow.upsert.mockResolvedValue({});
await welcomeNewPet(asDb(prisma), { id: "pet-luna", name: "Luna" }, "de");
expect(prisma.follow.upsert).toHaveBeenCalledWith(
expect.objectContaining({ create: { followerPetId: "pet-luna", followeePetId: "pet-team" } })
);
expect(mockBackfillOnFollow).toHaveBeenCalledWith(expect.anything(), "pet-luna", "pet-team", expect.anything());
});
it("still sends the welcome when following the team back fails", async () => {
const prisma = createMockPrisma();
prisma.follow.upsert.mockResolvedValueOnce({}).mockRejectedValueOnce(new Error("db down"));
await welcomeNewPet(asDb(prisma), { id: "pet-luna", name: "Luna" }, "de");
expect(mockBackfillOnFollow).not.toHaveBeenCalled();
expect(mockSendTeamMessage).toHaveBeenCalled();
});
it("uses the English text for English users", async () => {
const prisma = createMockPrisma();
prisma.follow.upsert.mockResolvedValue({});
await welcomeNewPet(asDb(prisma), { id: "pet-max", name: "Max" }, "en");
expect(mockSendTeamMessage).toHaveBeenCalledWith(expect.anything(), "pet-max", expect.stringContaining("Hi Max"));
});
it("respects the admin switches and the edited text", async () => {
const prisma = createMockPrisma();
prisma.curatorSettings.findUnique.mockResolvedValue({
welcomeEnabled: true,
autoFollowEnabled: false,
welcomeTextDe: "Servus {name}!",
welcomeTextEn: "Hey {name}!",
});
await welcomeNewPet(asDb(prisma), { id: "pet-kiki", name: "Kiki" }, "de");
expect(prisma.follow.upsert).not.toHaveBeenCalled();
expect(mockBackfillOnFollow).not.toHaveBeenCalled();
expect(mockSendTeamMessage).toHaveBeenCalledWith(expect.anything(), "pet-kiki", "Servus Kiki!");
});
it("never welcomes the team pet itself", async () => {
const prisma = createMockPrisma();
await welcomeNewPet(asDb(prisma), { id: "pet-team", name: "PawFeed Team" }, "de");
expect(prisma.follow.upsert).not.toHaveBeenCalled();
expect(mockSendTeamMessage).not.toHaveBeenCalled();
});
});
describe("curatorAdmin.update", () => {
async function caller(prisma = createMockPrisma()) {
const { curatorAdminRouter } = await import("@/trpc/routers/curator-admin");
const { initTRPC } = await import("@trpc/server");
const t = initTRPC.context<{ userId: string; prisma: unknown; ip: string }>().create();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { c: t.router({ curatorAdmin: curatorAdminRouter }).createCaller({ userId: "admin-1", prisma: prisma as any, ip: "127.0.0.1" } as any), prisma };
}
const valid = { welcomeEnabled: true, autoFollowEnabled: true, welcomeTextDe: "Hallo {name}", welcomeTextEn: "Hi {name}" };
it("rejects a text without the {name} placeholder", async () => {
const { c } = await caller();
await expect(c.curatorAdmin.update({ ...valid, welcomeTextDe: "Hallo du" })).rejects.toThrow("{name}");
});
it("requires SUPER_ADMIN and writes the audit log", async () => {
const { c, prisma } = await caller();
prisma.$transaction.mockResolvedValue([{}]);
await c.curatorAdmin.update(valid);
expect(mockAssertAdmin).toHaveBeenCalledWith(expect.anything(), "admin-1", "SUPER_ADMIN");
expect(prisma.moderationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "UPDATE_CURATOR_SETTINGS" }),
});
});
});
+19
View File
@@ -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", () => {
+203
View File
@@ -0,0 +1,203 @@
/**
* Admin-Tierverwaltung (pet-admin.ts) und dauerhaftes Entziehen/Vergeben von
* Empfehlungsrängen (referral-admin.ts, referrals.ts).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("server-only", () => ({}));
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/redis", () => ({ redis: {} }));
vi.mock("@/lib/notify", () => ({ notify: vi.fn() }));
vi.mock("@/lib/feed-helpers", () => ({ backfillOnFollow: vi.fn() }));
const mockDeletePet = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/delete-pet", () => ({ deletePetCompletely: (...a: unknown[]) => mockDeletePet(...a) }));
const mockDeleteFiles = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/supabase-storage", () => ({ deleteFiles: (...a: unknown[]) => mockDeleteFiles(...a) }));
const mockSendTeamMessage = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/team-pet", () => ({
ensureTeamPet: vi.fn().mockResolvedValue({ id: "pet-team" }),
sendTeamMessage: (...a: unknown[]) => mockSendTeamMessage(...a),
}));
const mockSendMail = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/mail", () => ({ sendMail: (...a: unknown[]) => mockSendMail(...a) }));
vi.mock("@clerk/nextjs/server", () => ({
clerkClient: vi.fn().mockResolvedValue({
users: {
getUser: vi.fn().mockResolvedValue({
firstName: "Dani",
primaryEmailAddressId: "e1",
emailAddresses: [{ id: "e1", emailAddress: "owner@example.com" }],
}),
},
}),
}));
const mockAssertAdmin = vi.fn();
vi.mock("@/trpc/routers/admin", () => ({ assertAdmin: (...a: unknown[]) => mockAssertAdmin(...a) }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
import { awardRanks } from "@/lib/referrals";
type MockPrisma = ReturnType<typeof createMockPrisma>;
async function createCaller(prisma: MockPrisma = createMockPrisma()) {
const { petAdminRouter } = await import("@/trpc/routers/pet-admin");
const { referralAdminRouter } = await import("@/trpc/routers/referral-admin");
const { initTRPC } = await import("@trpc/server");
const t = initTRPC.context<{ userId: string; prisma: unknown; ip: string }>().create();
const r = t.router({ petAdmin: petAdminRouter, referralAdmin: referralAdminRouter });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { c: r.createCaller({ userId: "admin-1", prisma: prisma as any, ip: "127.0.0.1" } as any), prisma };
}
beforeEach(() => {
vi.clearAllMocks();
mockAssertAdmin.mockResolvedValue("SUPER_ADMIN");
});
describe("petAdmin.delete", () => {
it("deletes completely and tells the owner by DM on another of their pets", async () => {
const prisma = createMockPrisma();
prisma.pet.findUnique.mockResolvedValue({ name: "Luna", ownerId: "owner-1" });
prisma.pet.findFirst.mockResolvedValue({ id: "pet-other" });
prisma.moderationLog.create.mockResolvedValue({});
const { c } = await createCaller(prisma);
const res = await c.petAdmin.delete({ petId: "pet-luna", reason: "Fake-Profil", notifyOwner: true });
expect(res).toEqual({ notified: "dm" });
expect(mockAssertAdmin).toHaveBeenCalledWith(expect.anything(), "admin-1", "SUPER_ADMIN");
expect(mockDeletePet).toHaveBeenCalledWith(prisma, "pet-luna");
expect(mockSendTeamMessage).toHaveBeenCalledWith(expect.anything(), "pet-other", expect.stringContaining("Fake-Profil"));
expect(prisma.moderationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "DELETE_PET", targetType: "PET", targetId: "pet-luna" }),
});
});
it("falls back to e-mail when it was the owner's only pet", async () => {
const prisma = createMockPrisma();
prisma.pet.findUnique.mockResolvedValue({ name: "Luna", ownerId: "owner-1" });
prisma.pet.findFirst.mockResolvedValue(null);
prisma.moderationLog.create.mockResolvedValue({});
const { c } = await createCaller(prisma);
const res = await c.petAdmin.delete({ petId: "pet-luna", reason: "<b>Spam</b>", notifyOwner: true });
expect(res).toEqual({ notified: "email" });
const mail = mockSendMail.mock.calls[0][0];
expect(mail.to).toBe("owner@example.com");
expect(mail.html).toContain("&lt;b&gt;Spam&lt;/b&gt;");
expect(mail.html).not.toContain("<b>Spam</b>");
});
it("refuses to delete the curator pet", async () => {
const prisma = createMockPrisma();
prisma.pet.findUnique.mockResolvedValue({ name: "PawFeed Team", ownerId: "admin" });
const { c } = await createCaller(prisma);
await expect(c.petAdmin.delete({ petId: "pet-team", reason: "x", notifyOwner: false })).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mockDeletePet).not.toHaveBeenCalled();
});
});
describe("petAdmin.speciesDistribution", () => {
it("counts pets per species and breed, includes empty species and excludes the curator pet", async () => {
const prisma = createMockPrisma();
mockAssertAdmin.mockResolvedValue("MODERATOR");
prisma.species.findMany.mockResolvedValue([
{ id: "cat", name: "Cat", translations: [{ name: "Katze" }], breeds: [{ id: "bengal", name: "Bengal", translations: [] }] },
{ id: "dog", name: "Dog", translations: [{ name: "Hund" }], breeds: [] },
{ id: "spider", name: "Spider", translations: [{ name: "Spinne" }], breeds: [] },
]);
prisma.pet.groupBy
.mockResolvedValueOnce([
{ speciesId: "cat", _count: { _all: 3 } },
{ speciesId: "dog", _count: { _all: 1 } },
])
.mockResolvedValueOnce([
{ speciesId: "cat", breedId: "bengal", _count: { _all: 2 } },
{ speciesId: "cat", breedId: null, _count: { _all: 1 } },
{ speciesId: "dog", breedId: null, _count: { _all: 1 } },
]);
const { c } = await createCaller(prisma);
const result = await c.petAdmin.speciesDistribution();
expect(result.total).toBe(4);
expect(result.species.map((s) => [s.name, s.count])).toEqual([["Katze", 3], ["Hund", 1], ["Spinne", 0]]);
expect(result.species[0].breeds).toEqual([{ name: "Bengal", count: 2 }, { name: "Ohne Rassenangabe", count: 1 }]);
expect(prisma.pet.groupBy.mock.calls[0][0].where).toEqual({ id: { not: "pet-team" } });
});
});
describe("petAdmin.clearContent", () => {
it("clears the chosen fields, removes the avatar file and logs it", async () => {
const prisma = createMockPrisma();
prisma.pet.findUnique.mockResolvedValue({ avatarKey: "avatars/x.png" });
prisma.pet.update.mockResolvedValue({});
prisma.moderationLog.create.mockResolvedValue({});
mockAssertAdmin.mockResolvedValue("MODERATOR");
const { c } = await createCaller(prisma);
await c.petAdmin.clearContent({ petId: "pet-1", fields: ["avatar", "bio"], reason: "anstößig", notifyOwner: false });
expect(prisma.pet.update).toHaveBeenCalledWith({ where: { id: "pet-1" }, data: { avatarKey: null, bio: null } });
expect(mockDeleteFiles).toHaveBeenCalledWith(["avatars/x.png"]);
expect(mockSendTeamMessage).not.toHaveBeenCalled();
});
});
describe("referral ranks: revoke and grant", () => {
function txPrisma() {
const prisma = createMockPrisma();
prisma.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => fn(prisma));
prisma.owner.update.mockResolvedValue({});
prisma.moderationLog.create.mockResolvedValue({});
return prisma;
}
it("revoking keeps the row with revokedAt instead of deleting it", async () => {
const prisma = txPrisma();
const { c } = await createCaller(prisma);
await c.referralAdmin.revokeRank({ ownerId: "owner-1", rankId: "r3", reason: "Fake-Accounts" });
expect(prisma.referralRankAchievement.upsert).toHaveBeenCalledWith(
expect.objectContaining({ update: expect.objectContaining({ revokedAt: expect.any(Date), revokeReason: "Fake-Accounts" }) })
);
expect(prisma.referralRankAchievement.deleteMany).not.toHaveBeenCalled();
});
it("granting restores a revoked rank, or awards one manually", async () => {
const restored = txPrisma();
restored.referralRankAchievement.findUnique.mockResolvedValue({ revokedAt: new Date() });
expect(await (await createCaller(restored)).c.referralAdmin.grantRank({ ownerId: "owner-1", rankId: "r3" })).toEqual({ success: true, restored: true });
const manual = txPrisma();
manual.pet.findFirst.mockResolvedValue({ id: "pet-1" });
await (await createCaller(manual)).c.referralAdmin.grantRank({ ownerId: "owner-1", rankId: "r10", note: "Tierheim-Aktion" });
expect(manual.referralRankAchievement.upsert).toHaveBeenCalledWith(
expect.objectContaining({ create: expect.objectContaining({ grantedManually: true, grantedBy: "admin-1" }) })
);
expect(manual.moderationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "GRANT_REFERRAL_RANK" }) });
});
it("awardRanks never re-awards a revoked rank and leaves it out of the badge cache", async () => {
const prisma = createMockPrisma();
prisma.referralRank.findMany.mockResolvedValue([
{ id: "r1", threshold: 1, isActive: true, perks: ["PROFILE_RING"] },
{ id: "r3", threshold: 3, isActive: true, perks: ["NAME_BADGE"] },
]);
prisma.referralRankAchievement.findMany.mockResolvedValue([
{ rankId: "r1", revokedAt: null },
{ rankId: "r3", revokedAt: new Date() },
]);
prisma.owner.update.mockResolvedValue({});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newRanks = await awardRanks(prisma as any, "owner-1", 5);
expect(newRanks).toEqual([]);
expect(prisma.owner.update).toHaveBeenCalledWith({
where: { id: "owner-1" },
data: { referralRankId: "r1", referralPerks: ["PROFILE_RING"] },
});
});
});
+63 -6
View File
@@ -18,6 +18,8 @@ import { petsRouter } from "@/trpc/routers/pets";
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
vi.mock("server-only", () => ({}));
const mockDeletePetCompletely = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/delete-pet", () => ({ deletePetCompletely: (...args: unknown[]) => mockDeletePetCompletely(...args) }));
// Mock Clerk server auth — pets router imports from @clerk/nextjs/server via trpc/init
vi.mock("@clerk/nextjs/server", () => ({
@@ -188,20 +190,18 @@ describe("PET-03: Pet deletion by owner", () => {
vi.clearAllMocks();
});
it("pets.delete removes the pet row when called by the pet's owner", async () => {
it("pets.delete deletes the pet completely when called by the pet's owner", async () => {
mockPrisma.pet.findUnique.mockResolvedValue({
...MOCK_PET,
ownerId: TEST_OWNER_ID,
});
mockPrisma.pet.delete.mockResolvedValue(MOCK_PET);
const caller = createTestCaller(TEST_OWNER_ID, mockPrisma);
const result = await caller.delete({ petId: PET_ID });
expect(result).toEqual({ success: true });
expect(mockPrisma.pet.delete).toHaveBeenCalledWith({
where: { id: PET_ID },
});
// Vollständiges Löschen (Story-Aufrufe, Paw-Backs, Zähler, Dateien) — siehe delete-pet.test.ts
expect(mockDeletePetCompletely).toHaveBeenCalledWith(mockPrisma, PET_ID);
});
it("pets.delete throws FORBIDDEN when called by a non-owner userId", async () => {
@@ -218,7 +218,7 @@ describe("PET-03: Pet deletion by owner", () => {
code: "FORBIDDEN",
});
expect(mockPrisma.pet.delete).not.toHaveBeenCalled();
expect(mockDeletePetCompletely).not.toHaveBeenCalled();
});
});
@@ -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();
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* found.md: Profil-Ring (Vorteil PROFILE_RING) überall am Tierbild und
* Story-Pfote in der Rangfarbe.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const GOLD = "#c7951f";
const { storyState } = vi.hoisted(() => ({ storyState: { hasStory: false, allSeen: false } }));
vi.mock("@/hooks/useReferralRanks", () => ({
useReferralRanks: () => ({
ranks: [],
byId: new Map([["gold", { id: "gold", name: "Goldene Pfote", description: "", icon: "paw-print", color: GOLD }]]),
}),
}));
vi.mock("@/trpc/client", () => ({
useTRPC: () => ({ stories: { hasActiveStory: { queryOptions: () => ({ queryKey: ["x"] }) } } }),
}));
vi.mock("@/context/ActivePetContext", () => ({ useActivePet: () => ({ activePetId: "me" }) }));
vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: storyState }) }));
import { RankRing } from "@/components/referrals/RankRing";
import { PawRing } from "@/components/stories/PawRing";
const ranked = { referralRankId: "gold", referralPerks: ["PROFILE_RING", "NAME_BADGE"] };
const badgeOnly = { referralRankId: "gold", referralPerks: ["NAME_BADGE"] };
beforeEach(() => {
storyState.hasStory = false;
storyState.allSeen = false;
});
describe("RankRing", () => {
it("draws a ring in the rank colour with the PROFILE_RING perk", () => {
const html = renderToStaticMarkup(<RankRing owner={ranked}><b /></RankRing>);
expect(html).toContain(`0 0 0 4px ${GOLD}`);
});
it("renders the avatar unchanged without the perk or without an owner", () => {
expect(renderToStaticMarkup(<RankRing owner={badgeOnly}><b /></RankRing>)).toBe("<b></b>");
expect(renderToStaticMarkup(<RankRing owner={null}><b /></RankRing>)).toBe("<b></b>");
});
});
describe("PawRing with rank", () => {
it("colours an unseen story paw in the rank colour", () => {
storyState.hasStory = true;
const html = renderToStaticMarkup(<PawRing petId="p1" owner={ranked}><b /></PawRing>);
expect(html).toContain(`fill="${GOLD}"`);
expect(html).not.toContain("#f97316");
});
it("keeps a seen story gray", () => {
storyState.hasStory = true;
storyState.allSeen = true;
const html = renderToStaticMarkup(<PawRing petId="p1" owner={ranked}><b /></PawRing>);
expect(html).toContain("#9ca3af");
expect(html).not.toContain(GOLD);
});
it("draws a plain rank ring without a story", () => {
const html = renderToStaticMarkup(<PawRing petId="p1" owner={ranked}><b /></PawRing>);
expect(html).toContain(`stroke="${GOLD}"`);
});
it("stays orange / ringless for owners without the perk", () => {
expect(renderToStaticMarkup(<PawRing petId="p1" owner={badgeOnly}><b /></PawRing>)).not.toContain("<svg");
storyState.hasStory = true;
expect(renderToStaticMarkup(<PawRing petId="p1" owner={badgeOnly}><b /></PawRing>)).toContain("#f97316");
});
});
+3 -3
View File
@@ -77,17 +77,17 @@ describe("referrals.setAcquisitionSource", () => {
describe("referrals.onboardingState", () => {
it("shows the step once, inside the redeem window", async () => {
const { caller, prisma } = await createCaller();
prisma.owner.findUnique.mockResolvedValue({ createdAt: new Date(), invitedById: null, acquisitionAnsweredAt: null });
prisma.owner.findUnique.mockResolvedValue({ createdAt: new Date(), invitedAt: null, acquisitionAnsweredAt: null });
expect(await caller.referrals.onboardingState()).toEqual({ showStep: true, canRedeem: true });
prisma.owner.findUnique.mockResolvedValue({ createdAt: new Date(), invitedById: null, acquisitionAnsweredAt: new Date() });
prisma.owner.findUnique.mockResolvedValue({ createdAt: new Date(), invitedAt: null, acquisitionAnsweredAt: new Date() });
expect(await caller.referrals.onboardingState()).toEqual({ showStep: false, canRedeem: true });
});
it("hides everything for owners older than the window", async () => {
const { caller, prisma } = await createCaller();
const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
prisma.owner.findUnique.mockResolvedValue({ createdAt: old, invitedById: null, acquisitionAnsweredAt: null });
prisma.owner.findUnique.mockResolvedValue({ createdAt: old, invitedAt: null, acquisitionAnsweredAt: null });
expect(await caller.referrals.onboardingState()).toEqual({ showStep: false, canRedeem: false });
});
});
+9 -1
View File
@@ -151,7 +151,7 @@ describe("redeemReferralCode", () => {
expect(result).toEqual({ inviterPetName: "Bubi" });
expect(mockPrisma.owner.updateMany).toHaveBeenCalledWith({
where: { id: "owner-invitee", invitedById: null },
where: { id: "owner-invitee", invitedById: null, invitedAt: null },
data: { invitedById: "owner-inviter", invitedAt: NOW },
});
expect(mockPrisma.follow.upsert).toHaveBeenCalledTimes(2);
@@ -196,6 +196,14 @@ describe("redeemReferralCode", () => {
);
});
it("rejects a second redemption after the inviter's account was deleted", async () => {
// delete-account.ts leert invitedById, invitedAt bleibt als Marker stehen.
const mockPrisma = setupRedeem({ invitee: { createdAt: NOW, invitedById: null, invitedAt: new Date(NOW.getTime() - DAY) } });
await expect(redeemReferralCode(asDb(mockPrisma), "owner-invitee", "BUBI-7K3M", NOW)).rejects.toThrow(
REFERRAL_ERRORS.ALREADY_REDEEMED
);
});
it("lets only one of two concurrent redemptions win", async () => {
const mockPrisma = setupRedeem();
mockPrisma.owner.updateMany.mockResolvedValue({ count: 0 });
+155
View File
@@ -16,8 +16,16 @@ vi.mock("@/lib/supabase-storage", () => ({
}),
ALLOWED_CONTENT_TYPES: ["image/jpeg", "image/png", "image/webp"],
MAX_FILE_SIZE: 10 * 1024 * 1024,
deleteFiles: (...args: unknown[]) => mockDeleteFiles(...args),
}));
const mockDeleteFiles = vi.fn().mockResolvedValue(undefined);
const mockMuxDelete = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/mux", () => ({
mux: { video: { assets: { delete: (...args: unknown[]) => mockMuxDelete(...args), retrieve: vi.fn() } } },
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn().mockResolvedValue({ success: true }) }));
vi.mock("@clerk/nextjs/server", () => ({
auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
}));
@@ -141,6 +149,153 @@ describe("stories.create", () => {
});
});
describe("stories.delete", () => {
beforeEach(() => {
mockDeleteFiles.mockClear();
mockMuxDelete.mockClear();
});
it("deletes the owner's photo story with its views and storage file", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.story.findUnique.mockResolvedValue({ id: "s1", petId: "pet-1", storageKey: "stories/a.jpg", muxAssetId: null });
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
mockPrisma.$transaction.mockResolvedValue([]);
await expect(caller.delete({ storyId: "s1" })).resolves.toEqual({ deleted: true });
expect(mockPrisma.storyView.deleteMany).toHaveBeenCalledWith({ where: { storyId: "s1" } });
expect(mockPrisma.story.delete).toHaveBeenCalledWith({ where: { id: "s1" } });
expect(mockDeleteFiles).toHaveBeenCalledWith(["stories/a.jpg"]);
expect(mockMuxDelete).not.toHaveBeenCalled();
});
it("also removes the Mux asset of a video story", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.story.findUnique.mockResolvedValue({ id: "s2", petId: "pet-1", storageKey: null, muxAssetId: "asset-9" });
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
mockPrisma.$transaction.mockResolvedValue([]);
await caller.delete({ storyId: "s2" });
expect(mockMuxDelete).toHaveBeenCalledWith("asset-9");
expect(mockDeleteFiles).not.toHaveBeenCalled();
});
it("refuses to delete another owner's story", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.story.findUnique.mockResolvedValue({ id: "s3", petId: "pet-2", storageKey: "stories/b.jpg", muxAssetId: null });
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-2" });
await expect(caller.delete({ storyId: "s3" })).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
expect(mockDeleteFiles).not.toHaveBeenCalled();
});
it("still succeeds when the storage cleanup fails", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.story.findUnique.mockResolvedValue({ id: "s4", petId: "pet-1", storageKey: "stories/c.jpg", muxAssetId: null });
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
mockPrisma.$transaction.mockResolvedValue([]);
mockDeleteFiles.mockRejectedValueOnce(new Error("storage down"));
await expect(caller.delete({ storyId: "s4" })).resolves.toEqual({ deleted: true });
});
it("returns NOT_FOUND for unknown stories", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.story.findUnique.mockResolvedValue(null);
await expect(caller.delete({ storyId: "nope" })).rejects.toMatchObject({ code: "NOT_FOUND" });
});
});
describe("stories.listActive", () => {
it("marks stories the viewer already pawed and strips the raw reactions", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.story.findMany.mockResolvedValue([
{ id: "s1", petId: "pet-a", reactionCount: 2, reactions: [{ petId: "pet-me" }] },
{ id: "s2", petId: "pet-a", reactionCount: 0, reactions: [] },
]);
const result = await caller.listActive({ petId: "pet-a", viewerPetId: "pet-me" });
expect(result.map((s) => s.reactedByViewer)).toEqual([true, false]);
expect(result[0]).not.toHaveProperty("reactions");
expect(mockPrisma.story.findMany).toHaveBeenCalledWith(
expect.objectContaining({
include: expect.objectContaining({ reactions: { where: { petId: "pet-me" }, select: { petId: true }, take: 1 } }),
})
);
});
it("stays compatible with older clients that send only petId", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.story.findMany.mockResolvedValue([{ id: "s1", petId: "pet-a", reactionCount: 1 }]);
const result = await caller.listActive({ petId: "pet-a" });
expect(result[0].reactedByViewer).toBe(false);
const include = mockPrisma.story.findMany.mock.calls[0][0].include;
expect(include.musicTrack).toBe(true);
// Ohne viewerPetId keine Reaktions-Abfrage (ältere Clients)
expect(include.reactions).toBeUndefined();
});
});
describe("stories — 7-Tage-Ansicht für den Ersteller", () => {
beforeEach(() => vi.clearAllMocks());
it("listActive shows the owner their stories of the last 7 days, marked as expired", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
mockPrisma.story.findMany.mockResolvedValue([
{ id: "old", expiresAt: new Date(Date.now() - 60_000), reactionCount: 1 },
{ id: "new", expiresAt: new Date(Date.now() + 60_000), reactionCount: 0 },
]);
const result = await caller.listActive({ petId: "pet-1" });
expect(result.map((s) => s.isExpired)).toEqual([true, false]);
const where = mockPrisma.story.findMany.mock.calls[0][0].where;
expect(where.createdAt.gte).toBeInstanceOf(Date);
expect(where.expiresAt).toBeUndefined();
});
it("listActive still shows other viewers only the active 24 hours", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-2" });
mockPrisma.story.findMany.mockResolvedValue([]);
await caller.listActive({ petId: "pet-2" });
const where = mockPrisma.story.findMany.mock.calls[0][0].where;
expect(where.expiresAt.gt).toBeInstanceOf(Date);
expect(where.createdAt).toBeUndefined();
});
it("hasActiveStory reports a gray ring for the owner's expired stories only with includeOwnRecent", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.story.findMany.mockResolvedValue([]); // nichts aktiv
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
mockPrisma.story.count.mockResolvedValue(2);
expect(await caller.hasActiveStory({ petId: "pet-1", includeOwnRecent: true })).toEqual({
hasStory: true,
allSeen: true,
expiredOnly: true,
});
expect(await caller.hasActiveStory({ petId: "pet-1" })).toEqual({ hasStory: false, allSeen: false, expiredOnly: false });
});
it("hasActiveStory never shows someone else's expired stories", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.story.findMany.mockResolvedValue([]);
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-2" });
mockPrisma.story.count.mockResolvedValue(3);
expect(await caller.hasActiveStory({ petId: "pet-2", includeOwnRecent: true })).toMatchObject({ hasStory: false });
});
});
describe("stories.hasActiveStory", () => {
beforeEach(() => vi.clearAllMocks());
+336
View File
@@ -0,0 +1,336 @@
/**
* Story-Editor (feature/story-editor): Overlays, Markierungen, Text-Storys,
* Story-Benachrichtigungen, GIF-Suche, Notschalter.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("server-only", () => ({}));
vi.mock("@/lib/supabase-storage", () => ({
createSignedUploadUrl: vi.fn(),
ALLOWED_CONTENT_TYPES: ["image/jpeg", "image/png", "image/webp"],
MAX_FILE_SIZE: 10 * 1024 * 1024,
deleteFiles: vi.fn(),
}));
vi.mock("@/lib/mux", () => ({
mux: { video: { uploads: { create: vi.fn().mockResolvedValue({ id: "up-1", url: "https://mux/up" }) }, assets: { delete: vi.fn(), retrieve: vi.fn() } } },
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn().mockResolvedValue({ success: true }) }));
vi.mock("@clerk/nextjs/server", () => ({ auth: vi.fn().mockResolvedValue({ userId: "owner-1" }) }));
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
const { mockNotify, redisStore } = vi.hoisted(() => ({ mockNotify: vi.fn(), redisStore: new Map<string, string>() }));
vi.mock("@/lib/notify", () => ({ notify: (...a: unknown[]) => mockNotify(...a), sendPushForNotification: vi.fn() }));
vi.mock("@/lib/redis", () => ({
redis: {
get: vi.fn(async (k: string) => redisStore.get(k) ?? null),
set: vi.fn(async (k: string, v: string) => {
redisStore.set(k, v);
return "OK";
}),
del: vi.fn(async (k: string) => {
redisStore.delete(k);
return 1;
}),
},
}));
vi.mock("@/lib/spider-exclusion", () => ({ getSpiderExclusionId: vi.fn().mockResolvedValue(null) }));
import { storiesRouter } from "@/trpc/routers/stories";
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
import {
containRect,
contrastTextColor,
mentionedPetIds,
parseStoredOverlays,
storyMediaSize,
storyOverlaysSchema,
} from "@/lib/story-overlays";
import { filterMentionablePetIds, petsOfOtherOwners } from "@/lib/story-mentions";
import { isStoryEditorEnabled, setStoryEditorEnabled } from "@/lib/feature-flags";
function createCaller() {
const mockPrisma = createMockPrisma();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { caller: storiesRouter.createCaller({ userId: "owner-1", prisma: mockPrisma as any } as any), mockPrisma };
}
const text = (extra: Record<string, unknown> = {}) => ({ kind: "text", x: 0.5, y: 0.5, scale: 1, rotation: 0, text: "Hallo", color: "#ffffff", style: "plain", font: "bold", ...extra });
const mention = (petId: string) => ({ kind: "mention", x: 0.5, y: 0.3, scale: 1, rotation: 0, petId });
const gif = (extra: Record<string, unknown> = {}) => ({ kind: "gif", x: 0.5, y: 0.7, scale: 1, rotation: 10, gifId: "abc", url: "https://media2.giphy.com/media/abc/200w.webp", width: 200, height: 150, ...extra });
const sticker = (stickerId = "paw") => ({ kind: "sticker", x: 0.2, y: 0.2, scale: 1.5, rotation: -20, stickerId });
beforeEach(() => {
vi.clearAllMocks();
redisStore.clear();
});
describe("storyOverlaysSchema", () => {
it("accepts all four overlay kinds", () => {
expect(storyOverlaysSchema.safeParse([text(), mention("p2"), gif(), sticker()]).success).toBe(true);
});
it("rejects GIF URLs outside GIPHY, unknown stickers, empty text and colors off the palette", () => {
expect(storyOverlaysSchema.safeParse([gif({ url: "https://evil.example/x.gif" })]).success).toBe(false);
expect(storyOverlaysSchema.safeParse([sticker("skull")]).success).toBe(false);
expect(storyOverlaysSchema.safeParse([text({ text: " " })]).success).toBe(false);
expect(storyOverlaysSchema.safeParse([text({ color: "#123456" })]).success).toBe(false);
});
it("rejects positions outside the media area and absurd scales", () => {
expect(storyOverlaysSchema.safeParse([text({ x: 1.2 })]).success).toBe(false);
expect(storyOverlaysSchema.safeParse([text({ scale: 50 })]).success).toBe(false);
});
it("limits overlays, GIFs and distinct mentions", () => {
expect(storyOverlaysSchema.safeParse(Array.from({ length: 16 }, () => sticker())).success).toBe(false);
expect(storyOverlaysSchema.safeParse([gif(), gif(), gif(), gif()]).success).toBe(false);
expect(storyOverlaysSchema.safeParse(Array.from({ length: 11 }, (_, i) => mention(`p${i}`))).success).toBe(false);
});
it("parses stored JSON defensively", () => {
expect(parseStoredOverlays(null)).toEqual([]);
expect(parseStoredOverlays("kaputt")).toEqual([]);
expect(parseStoredOverlays([text()])).toHaveLength(1);
expect(mentionedPetIds([mention("a"), mention("a"), mention("b")] as never)).toEqual(["a", "b"]);
});
});
describe("overlay geometry", () => {
it("fits portrait media into a taller screen with letterboxing", () => {
expect(containRect(400, 800, 1080, 1350)).toEqual({ x: 0, y: 150, width: 400, height: 500 });
});
it("derives the media size per story type", () => {
expect(storyMediaSize({ mediaType: "TEXT" })).toEqual({ width: 9, height: 16 });
expect(storyMediaSize({ mediaType: "IMAGE", mediaWidth: 1080, mediaHeight: 1920 })).toEqual({ width: 1080, height: 1920 });
expect(storyMediaSize({ mediaType: "VIDEO", aspectRatio: "9:16" })).toEqual({ width: 9, height: 16 });
expect(storyMediaSize({ mediaType: "IMAGE" })).toBeNull();
});
it("picks a readable text color on a colored box", () => {
expect(contrastTextColor("#facc15")).toBe("#171717");
expect(contrastTextColor("#3b82f6")).toBe("#ffffff");
});
});
describe("filterMentionablePetIds — „Wer darf die Follower sehen?“ regelt das Markieren", () => {
function prismaWith(targets: { id: string; ownerId: string; followerVisibility: string }[], opts: { blocks?: string[][]; follows?: string[] } = {}) {
const p = createMockPrisma();
p.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
p.pet.findMany.mockResolvedValue(targets);
p.block.findMany.mockResolvedValue((opts.blocks ?? []).map(([blockerPetId, blockedPetId]) => ({ blockerPetId, blockedPetId })));
p.follow.findMany.mockResolvedValue((opts.follows ?? []).map((followeePetId) => ({ followeePetId })));
return p;
}
it("applies EVERYONE / FOLLOWERS_ONLY / NOBODY", async () => {
const p = prismaWith(
[
{ id: "everyone", ownerId: "o2", followerVisibility: "EVERYONE" },
{ id: "fans-followed", ownerId: "o3", followerVisibility: "FOLLOWERS_ONLY" },
{ id: "fans-not-followed", ownerId: "o4", followerVisibility: "FOLLOWERS_ONLY" },
{ id: "nobody", ownerId: "o5", followerVisibility: "NOBODY" },
],
{ follows: ["fans-followed"] }
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const allowed = await filterMentionablePetIds(p as any, "me", ["everyone", "fans-followed", "fans-not-followed", "nobody"]);
expect(allowed.sort()).toEqual(["everyone", "fans-followed"]);
});
it("always allows the owner's own pets, never self, never blocked pets", async () => {
const p = prismaWith(
[
{ id: "own-pet", ownerId: "owner-1", followerVisibility: "NOBODY" },
{ id: "blocked", ownerId: "o2", followerVisibility: "EVERYONE" },
],
{ blocks: [["blocked", "me"]] }
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const allowed = await filterMentionablePetIds(p as any, "me", ["me", "own-pet", "blocked"]);
expect(allowed).toEqual(["own-pet"]);
});
});
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();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "me", ownerId: "owner-1" });
mockPrisma.pet.findMany.mockResolvedValue([
{ id: "ok", ownerId: "o2", followerVisibility: "EVERYONE" },
{ id: "private", ownerId: "o3", followerVisibility: "NOBODY" },
]);
mockPrisma.block.findMany.mockResolvedValue([]);
mockPrisma.follow.findMany.mockResolvedValue([]);
mockPrisma.story.create.mockResolvedValue({ id: "story-1" });
await caller.create({
petId: "me",
storageKey: "stories/x.jpg",
overlays: [text(), mention("ok"), mention("private")] as never,
mediaWidth: 1080,
mediaHeight: 1350,
});
const data = mockPrisma.story.create.mock.calls[0][0].data;
expect(data.mediaWidth).toBe(1080);
expect(data.overlays.map((o: { kind: string; petId?: string }) => o.petId ?? o.kind)).toEqual(["text", "ok"]);
expect(mockPrisma.storyMention.createMany).toHaveBeenCalledWith({ data: [{ storyId: "story-1", petId: "ok" }], skipDuplicates: true });
expect(mockNotify).toHaveBeenCalledTimes(1);
expect(mockNotify.mock.calls[0][1]).toMatchObject({ recipientPetId: "ok", type: "STORY_MENTION", actorPetId: "me", storyId: "story-1" });
});
it("video stories save mentions but notify only once the video is ready", async () => {
process.env.MUX_TOKEN_ID = "id";
process.env.MUX_TOKEN_SECRET = "secret";
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "me", ownerId: "owner-1" });
mockPrisma.pet.findMany.mockResolvedValue([{ id: "ok", ownerId: "o2", followerVisibility: "EVERYONE" }]);
mockPrisma.block.findMany.mockResolvedValue([]);
mockPrisma.follow.findMany.mockResolvedValue([]);
mockPrisma.story.create.mockResolvedValue({ id: "story-v" });
await caller.createVideoUpload({ petId: "me", overlays: [mention("ok")] as never, mediaWidth: 720, mediaHeight: 1280 });
expect(mockPrisma.storyMention.createMany).toHaveBeenCalledOnce();
expect(mockNotify).not.toHaveBeenCalled();
});
});
describe("stories.createText", () => {
it("creates a TEXT story with background", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "me", ownerId: "owner-1" });
mockPrisma.story.create.mockResolvedValue({ id: "story-t" });
await caller.createText({ petId: "me", background: "sunset", overlays: [text()] as never });
const data = mockPrisma.story.create.mock.calls[0][0].data;
expect(data).toMatchObject({ mediaType: "TEXT", background: "sunset" });
expect(data.storageKey).toBeUndefined();
});
it("requires text and a known background", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "me", ownerId: "owner-1" });
await expect(caller.createText({ petId: "me", background: "sunset", overlays: [sticker()] as never })).rejects.toThrow();
await expect(caller.createText({ petId: "me", background: "rainbow", overlays: [text()] as never })).rejects.toThrow();
});
});
describe("story notifications for the owner", () => {
it("a paw on someone else's story notifies its owner, not on the own story", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "me", ownerId: "owner-1" });
mockPrisma.storyReaction.findUnique.mockResolvedValue(null);
mockPrisma.story.findUnique.mockResolvedValue({ petId: "other", expiresAt: new Date(Date.now() + 60_000) });
mockPrisma.$transaction.mockResolvedValue([]);
await caller.toggleReaction({ storyId: "s1", petId: "me" });
expect(mockNotify.mock.calls[0][1]).toMatchObject({ recipientPetId: "other", type: "STORY_REACTION", storyId: "s1" });
mockNotify.mockClear();
mockPrisma.story.findUnique.mockResolvedValue({ petId: "me", expiresAt: new Date(Date.now() + 60_000) });
await caller.toggleReaction({ storyId: "s2", petId: "me" });
expect(mockNotify).not.toHaveBeenCalled();
});
it("a comment notifies the story owner", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "me", ownerId: "owner-1" });
mockPrisma.story.findUnique.mockResolvedValue({ petId: "other", expiresAt: new Date(Date.now() + 60_000) });
mockPrisma.$transaction.mockResolvedValue([{ id: "c1" }]);
await caller.addComment({ storyId: "s1", petId: "me", body: "Süß!" });
expect(mockNotify.mock.calls[0][1]).toMatchObject({ recipientPetId: "other", type: "STORY_COMMENT", actorPetId: "me" });
});
});
describe("stories.listActive with overlays", () => {
it("returns parsed overlays and hides mentions of pets that no longer exist", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "someone-else" });
mockPrisma.story.findMany.mockResolvedValue([
{
id: "s1",
expiresAt: new Date(Date.now() + 60_000),
overlays: [text(), mention("alive"), mention("deleted")],
mentions: [{ pet: { id: "alive", name: "Bubi" } }],
},
]);
const [story] = await caller.listActive({ petId: "p" });
expect(story.overlays.map((o: { kind: string; petId?: string }) => o.petId ?? o.kind)).toEqual(["text", "alive"]);
expect(story.mentionedPets).toEqual([{ id: "alive", name: "Bubi" }]);
});
});
describe("stories.searchMentionable", () => {
it("returns only pets the actor may mention", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "me", ownerId: "owner-1" });
mockPrisma.pet.findMany
.mockResolvedValueOnce([{ id: "a", name: "Anton" }, { id: "b", name: "Bella" }]) // Kandidaten
.mockResolvedValueOnce([
{ id: "a", ownerId: "o2", followerVisibility: "EVERYONE" },
{ id: "b", ownerId: "o3", followerVisibility: "NOBODY" },
]); // Regeln
mockPrisma.block.findMany.mockResolvedValue([]);
mockPrisma.follow.findMany.mockResolvedValue([]);
const result = await caller.searchMentionable({ petId: "me", query: "a" });
expect(result.map((p: { id: string }) => p.id)).toEqual(["a"]);
});
});
describe("stories.searchGifs", () => {
it("maps GIPHY results, filters rating g and caches them", async () => {
process.env.GIPHY_API_KEY = "test-key";
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [
{ id: "g1", title: "Hund", images: { fixed_width: { webp: "https://media1.giphy.com/g1.webp", width: "200", height: "112" }, fixed_width_small: { webp: "https://media1.giphy.com/g1s.webp" } } },
{ id: "broken", images: {} },
],
pagination: { total_count: 50, count: 2, offset: 0 },
}),
});
vi.stubGlobal("fetch", fetchMock);
const { caller } = createCaller();
const first = await caller.searchGifs({ query: "Hund" });
expect(first.gifs).toEqual([{ id: "g1", title: "Hund", previewUrl: "https://media1.giphy.com/g1s.webp", url: "https://media1.giphy.com/g1.webp", width: 200, height: 112 }]);
expect(first.nextOffset).toBe(2);
expect(String(fetchMock.mock.calls[0][0])).toContain("rating=g");
await caller.searchGifs({ query: "hund" }); // gleiche Suche, anders geschrieben → Cache
expect(fetchMock).toHaveBeenCalledTimes(1);
vi.unstubAllGlobals();
});
});
describe("Notschalter", () => {
it("is on by default and can be switched off and on", async () => {
expect(await isStoryEditorEnabled()).toBe(true);
await setStoryEditorEnabled(false);
expect(await isStoryEditorEnabled()).toBe(false);
const { caller } = createCaller();
expect(await caller.featureFlags()).toEqual({ storyEditor: false });
await setStoryEditorEnabled(true);
expect(await isStoryEditorEnabled()).toBe(true);
});
});
@@ -0,0 +1,131 @@
/**
* /api/cron/vaccine-reminders — erinnert 30 Tage vor dem Impf-Folgetermin.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("server-only", () => ({}));
const { sendExpoPushNotification } = vi.hoisted(() => ({
sendExpoPushNotification: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/push", () => ({ sendExpoPushNotification }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
const mockPrisma = createMockPrisma();
// Getter: vi.mock wird nach oben gehoben, mockPrisma existiert erst beim Zugriff.
vi.mock("@/lib/prisma", () => ({
get prisma() {
return mockPrisma;
},
}));
import { GET } from "@/app/api/cron/vaccine-reminders/route";
import { sendVaccineReminders } from "@/lib/vaccine-reminders";
const request = (auth?: string) =>
new Request("https://pawfeed.org/api/cron/vaccine-reminders", { headers: auth ? { authorization: auth } : {} });
const dueVaccine = {
id: "vac-1",
petId: "pet-1",
name: "Tollwut",
nextDueDate: new Date("2026-11-01T00:00:00Z"),
pet: { name: "Bubi" },
};
beforeEach(() => {
vi.clearAllMocks();
process.env.CRON_SECRET = "test-secret";
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.vaccine.updateMany.mockResolvedValue({ count: 1 });
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: "ExponentPushToken[abc]" });
});
describe("GET /api/cron/vaccine-reminders", () => {
it("rejects requests without the cron secret", async () => {
expect((await GET(request())).status).toBe(401);
expect((await GET(request("Bearer wrong"))).status).toBe(401);
expect(mockPrisma.vaccine.findMany).not.toHaveBeenCalled();
});
it("returns the run summary", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine]);
const res = await GET(request("Bearer test-secret"));
expect(await res.json()).toEqual({ ok: true, sent: 1, failed: 0, more: false });
});
});
describe("sendVaccineReminders", () => {
const now = new Date("2026-10-04T08:00:00Z");
it("only looks at unreminded vaccines due between today and 30 days from now", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendVaccineReminders(mockPrisma as any, now);
const where = mockPrisma.vaccine.findMany.mock.calls[0][0].where;
expect(where.reminderSentAt).toBeNull();
expect(where.nextDueDate.gte.toISOString()).toBe("2026-10-04T00:00:00.000Z");
expect(where.nextDueDate.lte.toISOString()).toBe("2026-11-03T00:00:00.000Z");
});
it("claims the vaccine, creates a VACCINE_DUE notification and pushes the details", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await sendVaccineReminders(mockPrisma as any, now);
expect(result).toEqual({ sent: 1, failed: 0, more: false });
expect(mockPrisma.vaccine.updateMany).toHaveBeenCalledWith({
where: { id: "vac-1", reminderSentAt: null },
data: { reminderSentAt: now },
});
expect(mockPrisma.notification.create).toHaveBeenCalledWith({
data: { recipientPetId: "pet-1", type: "VACCINE_DUE", vaccineId: "vac-1" },
});
expect(sendExpoPushNotification).toHaveBeenCalledWith(
"ExponentPushToken[abc]",
expect.objectContaining({
title: "Impfung bald fällig",
body: expect.stringContaining("„Tollwut“ für Bubi ist am 01.11.2026 fällig"),
})
);
});
it("skips a vaccine another run already claimed", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine]);
mockPrisma.vaccine.updateMany.mockResolvedValue({ count: 0 });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await sendVaccineReminders(mockPrisma as any, now);
expect(result.sent).toBe(0);
expect(mockPrisma.notification.create).not.toHaveBeenCalled();
expect(sendExpoPushNotification).not.toHaveBeenCalled();
});
it("still counts the reminder when the push fails", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine]);
sendExpoPushNotification.mockRejectedValueOnce(new Error("expo down"));
vi.spyOn(console, "error").mockImplementation(() => {});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await sendVaccineReminders(mockPrisma as any, now);
expect(result).toEqual({ sent: 1, failed: 0, more: false });
});
it("keeps going after a failing vaccine and reports it", async () => {
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine, { ...dueVaccine, id: "vac-2" }]);
mockPrisma.notification.create.mockRejectedValueOnce(new Error("db")).mockResolvedValueOnce({});
vi.spyOn(console, "error").mockImplementation(() => {});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await sendVaccineReminders(mockPrisma as any, now);
expect(result).toEqual({ sent: 1, failed: 1, more: false });
// Fehlgeschlagene Erinnerung wird freigegeben, damit der nächste Lauf sie nachholt.
expect(mockPrisma.vaccine.updateMany).toHaveBeenCalledWith({
where: { id: "vac-1" },
data: { reminderSentAt: null },
});
});
});
+9 -6
View File
@@ -10,6 +10,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { getAvatarUrl } from "@/lib/avatar-url";
import { formatRelativeCompact } from "@/lib/format-relative-time";
import { RankRing } from "@/components/referrals/RankRing";
export default function MessagesPage() {
const t = useTranslations("Messages");
@@ -63,12 +64,14 @@ export default function MessagesPage() {
onClick={() => router.push(`/messages/${conv.id}`)}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left"
>
<Avatar className="w-12 h-12 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={conv.otherPet.name} />}
<AvatarFallback className="text-sm bg-muted">
{conv.otherPet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<RankRing owner={conv.otherPet.owner}>
<Avatar className="w-12 h-12 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={conv.otherPet.name} />}
<AvatarFallback className="text-sm bg-muted">
{conv.otherPet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
</RankRing>
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-2">
+93 -20
View File
@@ -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 } from "lucide-react";
import { Bell, UserPlus, Heart, MessageCircle, Cake, Home, AtSign, Repeat2, Sparkles, Gift, Award, Syringe, PawPrint, Ribbon } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
@@ -13,10 +13,14 @@ import { getMediaUrl } from "@/lib/media-url";
import { BlurImage } from "@/components/ui/blur-image";
import { PostDetailDialog } from "@/components/feed/PostDetailDialog";
import type { PostCardPost } from "@/components/feed/PostCard";
import { useTranslations } from "next-intl";
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";
type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE" | "BIRTHDAY" | "ADOPTION_DAY" | "MENTION" | "PAW_BACK" | "PET_SUGGESTION" | "REFERRAL_JOINED" | "REFERRAL_RANK_UP" | "VACCINE_DUE" | "STORY_MENTION" | "STORY_COMMENT" | "STORY_REACTION" | "PET_DECEASED";
const KNOWN_TYPES = new Set<string>(["FOLLOW", "REACTION", "COMMENT", "MESSAGE", "BIRTHDAY", "ADOPTION_DAY", "MENTION", "PAW_BACK", "PET_SUGGESTION", "REFERRAL_JOINED", "REFERRAL_RANK_UP", "VACCINE_DUE", "STORY_MENTION", "STORY_COMMENT", "STORY_REACTION", "PET_DECEASED"]);
function notificationIcon(type: NotificationType) {
switch (type) {
@@ -31,6 +35,11 @@ function notificationIcon(type: NotificationType) {
case "PET_SUGGESTION": return <Sparkles className="h-4 w-4 text-amber-500" />;
case "REFERRAL_JOINED": return <Gift className="h-4 w-4 text-orange-500" />;
case "REFERRAL_RANK_UP": return <Award className="h-4 w-4 text-orange-500" />;
case "VACCINE_DUE": return <Syringe className="h-4 w-4 text-sky-500" />;
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" />;
}
}
@@ -41,9 +50,10 @@ export default function NotificationsPage() {
const { activePetId } = useActivePet();
const trt = useTranslations("RelativeTime");
const t = useTranslations("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,
});
@@ -53,6 +63,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({
@@ -94,17 +107,43 @@ export default function NotificationsPage() {
case "FOLLOW":
case "PET_SUGGESTION":
case "REFERRAL_JOINED":
case "PET_DECEASED":
return notif.actorPet ? `/pets/${notif.actorPet.id}` : null;
case "REFERRAL_RANK_UP":
return "/einladen";
// 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 && !getStoryTarget(notif) ? `/pets/${notif.story.petId}` : null;
case "BIRTHDAY":
case "ADOPTION_DAY":
case "VACCINE_DUE":
return notif.recipientPetId ? `/pets/${notif.recipientPetId}/health` : null;
default:
return null;
}
}
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) {
@@ -159,17 +198,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"
@@ -186,16 +228,19 @@ export default function NotificationsPage() {
{/* Actor avatar + notification type icon */}
<div className="relative shrink-0">
<Avatar className="w-10 h-10">
{actorAvatarUrl && (
<AvatarImage src={actorAvatarUrl} alt={notif.actorPet?.name ?? ""} />
)}
<AvatarFallback className="text-xs bg-muted">
{notif.type === "BIRTHDAY" ? "🎂" :
notif.type === "ADOPTION_DAY" ? "🏠" :
notif.actorPet?.name?.[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<RankRing owner={notif.actorPet?.owner}>
<Avatar className="w-10 h-10">
{actorAvatarUrl && (
<AvatarImage src={actorAvatarUrl} alt={notif.actorPet?.name ?? ""} />
)}
<AvatarFallback className="text-xs bg-muted">
{notif.type === "BIRTHDAY" ? "🎂" :
notif.type === "ADOPTION_DAY" ? "🏠" :
notif.type === "VACCINE_DUE" ? "💉" :
notif.actorPet?.name?.[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
</RankRing>
<span className="absolute -bottom-0.5 -right-0.5 w-5 h-5 rounded-full bg-card border flex items-center justify-center">
{notificationIcon(notif.type as NotificationType)}
</span>
@@ -233,6 +278,22 @@ 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 === "VACCINE_DUE" && notif.vaccine?.nextDueDate &&
t("typeVaccineDue", {
name: notif.vaccine.name,
// @db.Date kommt als UTC-Mitternacht — in UTC formatieren, sonst kippt der Tag.
date: new Date(notif.vaccine.nextDueDate).toLocaleDateString(locale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "UTC",
}),
})}
</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">
@@ -264,6 +325,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);
}}
/>
)}
</>
);
}
+5
View File
@@ -9,6 +9,7 @@ import { useTranslations } from "next-intl";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { PetForm } from "@/components/pet/PetForm";
import { MemorialSettings } from "@/components/pet/MemorialSettings";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -279,6 +280,10 @@ export default function EditPetPage() {
)}
</div>
{/* Gedenkmodus „In Erinnerung“ */}
<Separator className="my-8" />
<MemorialSettings petId={pet.id} petName={pet.name} deceasedAt={pet.deceasedAt} />
{/* Destructive zone */}
<Separator className="my-8" />
<div className="flex flex-col gap-2">
+19 -1
View File
@@ -2,7 +2,8 @@ import { notFound } from "next/navigation";
import { formatPetName } from "@/lib/pet-display-name";
import Link from "next/link";
import { currentUser } from "@clerk/nextjs/server";
import { getTranslations } from "next-intl/server";
import { getFormatter, getTranslations } from "next-intl/server";
import { Ribbon } from "lucide-react";
import { createTRPCCaller } from "@/trpc/server";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
@@ -33,6 +34,9 @@ export default async function PetProfilePage({ params }: PageProps) {
const { petId } = await params;
const t = await getTranslations("PetProfile");
const tFounding = await getTranslations("FoundingMember");
const tMemory = await getTranslations("InMemory");
const format = await getFormatter();
const trpc = await createTRPCCaller();
let pet;
@@ -76,13 +80,27 @@ export default async function PetProfilePage({ params }: PageProps) {
<h1 className="flex items-center gap-1.5 text-xl font-semibold text-center">
{formatPetName(pet)}
<PetIdentityBadges
isDeceased={!!pet.deceasedAt}
isVerified={pet.isVerified}
isFoundingMember={pet.isFoundingMember}
sponsor={pet.owner}
referral={pet.owner}
size="lg"
verifiedLabel={t("verified")}
/>
</h1>
{pet.deceasedAt && (
<p className="flex items-center gap-1.5 rounded-full border border-border bg-muted px-3 py-1 text-xs text-muted-foreground">
<Ribbon className="h-3.5 w-3.5 fill-foreground text-foreground" aria-hidden />
{tMemory("banner", {
name: pet.name,
date: format.dateTime(new Date(pet.deceasedAt), { dateStyle: "long", timeZone: "UTC" }),
})}
</p>
)}
{pet.isFoundingMember && (
<p className="text-xs text-emerald-700 dark:text-emerald-400">{tFounding("tooltip")}</p>
)}
<Badge variant="secondary">{pet.species.name}</Badge>
{pet.invitedByPet && (
+11 -6
View File
@@ -16,6 +16,8 @@ import { FollowButton } from "@/components/social/FollowButton";
import { getAvatarUrl } from "@/lib/avatar-url";
import { getMediaUrl } from "@/lib/media-url";
import { BlurImage } from "@/components/ui/blur-image";
import { RankRing } from "@/components/referrals/RankRing";
import type { ReferralOwnerFields } from "@/components/referrals/ReferralRankBadge";
export default function SearchPage() {
const t = useTranslations("Search");
@@ -93,6 +95,7 @@ export default function SearchPage() {
name: string;
avatarKey: string | null;
species: { name: string };
owner?: ReferralOwnerFields | null;
breed: { name: string } | null;
_count: { followers: number };
};
@@ -106,12 +109,14 @@ export default function SearchPage() {
href={`/pets/${p.id}`}
className="flex items-center gap-3 flex-1 min-w-0"
>
<Avatar className="w-10 h-10 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={p.name} />}
<AvatarFallback className="text-sm bg-muted">
{p.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<RankRing owner={p.owner}>
<Avatar className="w-10 h-10 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={p.name} />}
<AvatarFallback className="text-sm bg-muted">
{p.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
</RankRing>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{p.name}</p>
<Badge variant="secondary" className="text-xs mt-0.5">
+2
View File
@@ -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;
+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { timingSafeStringEqual } from "@/lib/timing-safe-compare";
import { STORY_RETENTION_DAYS, deleteStoryWithMedia } from "@/lib/story-cleanup";
const DAY_MS = 24 * 60 * 60 * 1000;
// Pro Lauf begrenzt: run-cron.sh bricht nach 60 s ab, und jede Story kostet
// bis zu zwei externe Löschaufrufe (Supabase, Mux). Ein Rückstand wird über
// die folgenden Tage abgebaut.
const BATCH_SIZE = 200;
function authorized(req: Request): boolean {
const secret = process.env.CRON_SECRET;
if (!secret) return false;
return timingSafeStringEqual(req.headers.get("authorization") ?? "", `Bearer ${secret}`);
}
/**
* Löscht Storys, die älter als STORY_RETENTION_DAYS sind, endgültig — vorher
* blieben abgelaufene Storys samt Bildern und Mux-Videos für immer liegen
* („lazy delete“ hat sie nur ausgeblendet). Ausgelöst täglich vom NAS-Crontab
* über docker/run-cron.sh.
*/
export async function GET(req: Request) {
if (!authorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const cutoff = new Date(Date.now() - STORY_RETENTION_DAYS * DAY_MS);
const stories = await prisma.story.findMany({
where: { createdAt: { lt: cutoff } },
select: { id: true, storageKey: true, muxAssetId: true },
orderBy: { createdAt: "asc" },
take: BATCH_SIZE,
});
let deleted = 0;
let failed = 0;
for (const story of stories) {
try {
await deleteStoryWithMedia(prisma, story);
deleted++;
} catch (err) {
failed++;
console.error("[cron/cleanup-stories] delete failed", story.id, err);
}
}
return NextResponse.json({ ok: failed === 0, deleted, failed, more: stories.length === BATCH_SIZE });
}
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff. Show more