Author SHA1 Message Date
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 Opus 5.5 32ba01f7cf refactor: altes Einladungs-Gate (InviteCode/pf_invite) durch Empfehlungsprogramm ersetzen
- Proxy-Sperre für /sign-up, INVITE_REQUIRED, set-invite/consume-invite,
  invites-Router, Admin-Invite-Seite und -Prozeduren, Startcodes in
  pets.create entfernt — Registrierung ist offen
- /join?code=… leitet auf /r/<code> um, /invite auf /einladen; alte geteilte
  Links laufen nicht ins Leere
- InviteCode-Tabelle bleibt nur als Historie (Kontolöschung räumt sie weiter auf)
- Datenschutzerklärung 4b (Empfehlungsprogramm, Herkunftsangabe, pf_ref-Cookie)
  und Nutzungsbedingungen 3b (Programmbedingungen, Bestandsschutz, Missbrauch)
- CLAUDE.md: Abschnitt "Referral program" statt "Invite system"

Build, Typecheck und alle Tests grün.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 06:35:22 +02:00
adminandClaude Opus 5.5 78e16c9210 feat(mobile): Empfehlungsprogramm in der App
- Bildschirm "Freunde einladen" (/invite, aus den Einstellungen erreichbar):
  eigener Code mit nativem Teilen-Dialog, Rangleiter mit Fortschritt,
  eingeladene Tiere, nachträgliche Code-Eingabe im Einlösefenster
- Onboarding-Schritt "Wie hast du von PawFeed erfahren?" mit Code-Eingabe,
  einmal pro Owner (referrals.onboardingState)
- Rang-Abzeichen in PetIdentityBadges (Feed und Profil), Profilring in
  Rangfarbe, "Eingeladen von …" im Tierprofil
- Benachrichtigungen REFERRAL_JOINED / REFERRAL_RANK_UP
- Keine neuen nativen Module (React Native Share) — per OTA auslieferbar

Typecheck grün; nicht auf einem Gerät getestet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 06:30:11 +02:00
adminandClaude Opus 5.5 4d0aa7ab9e feat: Clerk-unabhängiges Empfehlungsprogramm mit einstellbaren Rängen (Web + Admin)
Persönliche, unbegrenzt nutzbare Empfehlungscodes (z. B. BUBI-7K3M), die erst
nach der Registrierung im Onboarding eingelöst werden — keine Kopplung an den
Clerk-Sign-up-Ablauf mehr. Registrierung ohne Code bleibt möglich.

- Schema (rein additiv): Owner-Felder für Code, Einladungsstatus, Zähler und
  Rang-Cache, ReferralRank / ReferralRankAchievement / ReferralSettings,
  AcquisitionSource, NotificationType REFERRAL_JOINED / REFERRAL_RANK_UP
- Kernlogik src/lib/referrals.ts: Einlösen (7-Tage-Fenster, kein eigener Code,
  keine Kreis-Einladungen, gesperrte Einladende ungültig), gegenseitiges
  Folgen, Qualifizierung erst nach Tier + erstem Beitrag (Foto oder fertiges
  Video), Tageslimit mit Prüf-Markierung, Ränge mit Bestandsschutz
- Web: /einladen, öffentliche Seite /r/[code], Onboarding-Schritt
  "Wie hast du von PawFeed erfahren?", Rang-Abzeichen, Profilring,
  "Eingeladen von …", neue Benachrichtigungen
- Admin /p/[secret]/referrals: Ränge anlegen/bearbeiten/deaktivieren mit
  Live-Vorschau, Einstellungen, Übersicht mit Prüfung und Aberkennung;
  bevorzugte Verifizierung als Vorteil
- prisma/scripts/referral-setup.ts: Start-Ränge, Settings, Code-Backfill,
  optional --credit-existing für Alt-Einladungen

Noch offen: Mobile-App, Abbau des alten InviteCode-Ablaufs, Rechtstexte,
db push + Deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 06:25:13 +02:00
adminandClaude Opus 5.5 02662528cf chore(mobile/ios): bump iOS build number to 3
Build 2 was rejected in TestFlight beta review (missing demo credentials)
and cannot be resubmitted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:01:25 +02:00
adminandClaude Opus 5.5 959d67cc29 chore(mobile/ios): bump iOS build number to 2
Build 1.0.6 (1) was expired in TestFlight and cannot be re-used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:21:57 +02:00
adminandClaude Opus 5.5 2cd13537f7 docs(mobile): found.md — gesture-bar and story-ring fixes shipped via OTA
Also includes the user's own pending found.md edits (new entries, iOS
section header, trailing-whitespace cleanup).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:43:26 +02:00
adminandClaude Opus 5.5 2ef439c752 fix(mobile): story paw ring clipped at the top
The middle toe beans reach y = -9 in the 0–100 viewBox. Web draws the SVG
with overflow: visible; react-native-svg clips at the viewBox, cutting the
paw off. Extend the viewBox upward and grow the box to match; the "add
story" tile uses the same box so it lines up with the rings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:41:43 +02:00
adminandClaude Opus 5.5 de237f19a1 fix(mobile): content hidden under Android gesture bar on stack screens
Android is edge-to-edge, and stack screens (no tab bar below) had no
bottom inset on their scroll containers — the first post on a pet profile
sat in the gesture area and couldn't be tapped (found.md). New
useBottomSafePadding hook adds insets.bottom to the contentContainerStyle
of pet profile, followers, following, health, milestones, edit pet,
settings, search, messages list and delete account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:41:42 +02:00
adminandClaude Opus 5.5 e4139a1b9b docs(mobile/ios): App Store listing texts and iOS screenshots
- store-assets/app-store-beschreibung.md: subtitle, promotional text,
  keywords and description for App Store Connect (no emojis — ASC rejects
  them)
- store-assets/ios-screenshots (1320x2868, 6.9") and ios-screenshots-6.5
  (1284x2778, 6.5"): appetize.io iPhone screenshots scaled/cropped to
  Apple's required sizes
- .gitignore: ignore local debug/ screenshot folder

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:01:40 +02:00
adminandClaude Opus 5.5 3b6735ebee chore(mobile/ios): iOS build number and Apple Team ID for App Store submit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 11:19:17 +02:00
adminandClaude Opus 5.5 4213d7029e feat(mobile): block pets and delete account in-app (App Store requirements)
- Block (Guideline 1.2, UGC): useBlockPet hook (native confirm Alert,
  blocks.create). PostCard "..." on other pets' posts now offers
  Report / Block; pet profile gets a header "..." with Report pet / Block
  (hidden for any of the owner's own pets), navigating back after a block.
  Feed, comments and DMs already enforce blocks server-side; explore and
  search don't yet (server follow-up).
- Delete account (Guideline 5.1.1(v)): new delete-account screen reached
  from a quiet link below Log out in Settings. Mirrors web's
  DeleteAccountDialog (warning list + type a pet name) and calls the same
  POST /api/account/delete with the Clerk bearer token, then clears the
  query cache and signs out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 11:19:17 +02:00
adminandClaude Opus 5.5 0c3ab5c2a2 fix(mobile): onboarding steps unreadable in dark mode
ArachnophobiaOptIn, UploadConsent, OnboardingGuidelines and AvatarStep
still used the static light-palette tokens in module-level
StyleSheet.create, so titles and checkbox labels rendered dark-on-dark.
Switched them to useTheme() + makeStyles(colors) like the other screens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 09:07:49 +02:00
adminandClaude Opus 5.5 69e095ddcb fix(mobile/ios): back button showed "< (tabs)" label
iOS labels the back button with the previous screen's title, which was
the untitled (tabs) route group. Use headerBackButtonDisplayMode
"minimal" (chevron only) for all stack screens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 09:07:49 +02:00
adminandClaude Opus 5.5 1e7d76aa79 feat(mobile/ios): iOS simulator build profile and native Sign in with Apple
- eas.json: new `preview-simulator` profile (extends preview, ios.simulator)
  for EAS cloud simulator builds without an Apple Developer account
- app.json: iOS deployment target 17.0 via expo-build-properties
  (@clerk/expo's ClerkKit SPM package requires iOS 17 — pod install failed
  on the default 16.4), iPhone-only (supportsTablet: false),
  usesAppleSignIn, ITSAppUsesNonExemptEncryption: false
- AppleSignInButton: native Apple sheet via Clerk's useSignInWithApple
  (App Store Guideline 4.8, since Google login is offered); same
  legal-consent handling as GoogleSignInButton; iOS-only
- Not yet testable end-to-end: needs Apple Developer account, the
  Sign in with Apple capability on the App ID and Apple enabled in Clerk

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 09:07:49 +02:00
adminandClaude Opus 5.5 070c389786 docs: note self-hosted Inter font in DESIGN.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:13:39 +02:00
adminandClaude Opus 5.5 71ac36b08a feat(landing): self-host Inter font and promote Android app on Google Play
- Replace next/font/google with next/font/local + vendored Inter variable
  woff2 (latin, @fontsource-variable/inter@5.3.0), so neither visitors nor
  the Docker build contact Google's servers.
- Add PlayStoreBadge (official Google Play artwork, DE/EN by locale, served
  locally from /public/badges) to the hero, a new "PawFeed in your pocket"
  section, and the shared landing footer (also covers /ueber-uns).
- Centralize the Play Store URL in src/lib/app-links.ts (derived from the
  android.package id org.pawfeed.app).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:54:14 +02:00
adminandClaude Sonnet 5 779f18966d docs(mobile): note main-merge and OTA publish for sso-callback fix
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:36:04 +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
278 changed files with 14652 additions and 1528 deletions

No files matched your search

-5
View File
@@ -66,11 +66,6 @@ ADMIN_OWNER_ID="user_xxxxxxxxxxxxxxxxxxxx"
# Local dev: run `docker run -d -p 6379:6379 redis:7-alpine` or install Redis locally.
REDIS_URL="redis://localhost:6379"
# ── Invite Gate ───────────────────────────────────────────────
# Set to "false" to disable the invite requirement after the Alpha/Beta phase.
# Default: "true" (invite required — enforced in proxy.ts)
INVITE_REQUIRED="true"
# ── Error Monitoring (Sentry) ───────────────────────────────────
# Sentry Dashboard -> Settings -> Projects -> <project> -> Client Keys (DSN)
# Same DSN value in both — server-side (src/instrumentation.ts) and
+19 -7
View File
@@ -44,7 +44,7 @@ The client-side "which pet is currently active" state lives in `src/context/Acti
### tRPC layer
- `src/trpc/init.ts` — context is `{ userId, prisma }` (userId from Clerk `auth()`); `protectedProcedure` throws `UNAUTHORIZED` if `userId` is null and narrows it to non-null in `ctx` for everything downstream.
- `src/trpc/routers/_app.ts` — aggregates ~20 domain routers (pets, posts, stories, milestones, follows, blocks, reports, feed, reactions, comments, reposts, explore, search, notifications, videos, conversations/messages, health, admin, invites, ads). One router file per domain in `src/trpc/routers/`.
- `src/trpc/routers/_app.ts` — aggregates ~20 domain routers (pets, posts, stories, milestones, follows, blocks, reports, feed, reactions, comments, reposts, explore, search, notifications, videos, conversations/messages, health, admin, referrals, referralAdmin, ads). One router file per domain in `src/trpc/routers/`.
- `src/trpc/server.ts` exposes a server-side caller (`createTRPCCaller()`) used inside Server Components/layouts to prefetch without an HTTP round-trip (see `src/app/(app)/layout.tsx`). `src/trpc/client.tsx` + `query-client.ts` wire up the browser-side TanStack Query client.
- Pet-ownership checks go through the shared `assertPetOwnership(prisma, petId, userId)` helper in `src/lib/assert-pet-ownership.ts` — don't reintroduce a local copy of this check in a new router.
@@ -55,14 +55,14 @@ The client-side "which pet is currently active" state lives in `src/context/Acti
### Routing structure
- `(auth)` route group — sign-up/log-in/password reset, unauthenticated.
- `(app)` route group — the authenticated shell (`layout.tsx` does a belt-and-suspenders `auth()` check, redirects to onboarding if the owner has zero pets, redirects banned owners to `/banned`, and consumes a pending invite cookie).
- `(app)` route group — the authenticated shell (`layout.tsx` does a belt-and-suspenders `auth()` check, redirects to onboarding if the owner has zero pets, and redirects banned owners to `/banned`).
- `/p/[secret]/...` — the admin/moderation panel. The path segment itself is the secret (`ADMIN_SECRET` env var); wrong or missing secret returns a bare `404`, not `403`, so the panel's existence isn't revealed.
- `/health-card/[token]` — public, tokenized, no auth (shareable vet record).
- Root-level `/impressum`, `/datenschutz`, `/nutzungsbedingungen` — German legal pages, not under `(app)`, not localized (this is a `next-intl` project but these three routes are fixed-language).
### `src/proxy.ts` (not `middleware.ts`)
Next.js 16 renamed `middleware.ts` to `proxy.ts` — this **is** the middleware; don't go looking for a `middleware.ts` that doesn't exist. It layers three concerns in one Clerk middleware: (1) invite gate — blocks `/sign-up` unless a `pf_invite` cookie is present, unless `INVITE_REQUIRED=false`; (2) admin secret gate — 404s `/p/*` unless the path segment matches `ADMIN_SECRET`; (3) `auth.protect()` for everything else not in the public route matcher.
Next.js 16 renamed `middleware.ts` to `proxy.ts` — this **is** the middleware; don't go looking for a `middleware.ts` that doesn't exist. It layers two concerns in one Clerk middleware: (1) admin secret gate — 404s `/p/*` unless the path segment matches `ADMIN_SECRET`; (2) `auth.protect()` for everything else not in the public route matcher. (The old invite gate on `/sign-up` — `pf_invite` cookie, `INVITE_REQUIRED` — was removed with the referral program; sign-up is open.)
### Media & storage — code has diverged from the original stack plan
@@ -84,11 +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,invites}`.
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.
### Invite system
**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.
`InviteCode` model (word-prefix + 4-char code, e.g. `PAWS-7K3M`, generated in `src/lib/invite-codes.ts`) gates sign-up via a `pf_invite` cookie set by `/join` and consumed by `/api/auth/consume-invite` right after sign-up (the `(app)` layout redirects there if the cookie is still present). `INVITE_REQUIRED=false` turns the whole gate off for post-beta public launch.
### 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. 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
@@ -102,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
+1 -1
View File
@@ -129,7 +129,7 @@ A quiet, mostly neutral palette with one warm, confident accent — the accent i
## Typography
**Display/Body Font:** Inter (Google Font, `latin` subset, loaded via `next/font`), with a `system-ui` fallback stack. One family for the entire product — there is no separate display or mono face in active use.
**Display/Body Font:** Inter (variable weight, `latin` subset, self-hosted from `src/app/fonts/` via `next/font/local` — no requests to Google, not even at build time), with a `system-ui` fallback stack. One family for the entire product — there is no separate display or mono face in active use.
**Character:** Confident but unfussy — a standard humanist grotesque doing hierarchy through weight and scale rather than personality. It reads as approachable and modern, never corporate-cold or ornamental.
+110 -13
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",
@@ -124,7 +125,8 @@
"addPhoto": "Profilfoto hinzufügen",
"addPhotoDesc": "Gib deinem Tier ein Gesicht auf PawFeed. Du kannst das jederzeit ändern.",
"done": "Fertig",
"skipForNow": "Jetzt überspringen"
"skipForNow": "Jetzt überspringen",
"loading": "Wird geladen"
},
"Language": {
"switch": "Sprache",
@@ -258,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",
@@ -477,7 +480,13 @@
"typePawBack": "hat deinen Post geteilt (Paw-Back)",
"typeBirthday": "🎂 Alles Gute zum Geburtstag! Erstelle einen Jubiläums-Post.",
"typeAdoptionDay": "🏠 Heute ist der Adoptionstag! Feiere den Jahrestag.",
"typePetSuggestion": "könnte dir gefallen"
"typePetSuggestion": "könnte dir gefallen",
"typeReferralJoined": "ist über deine Einladung zu PawFeed gekommen",
"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."
},
"PhotoPostForm": {
"captionPlaceholder": "Bildunterschrift schreiben…",
@@ -507,7 +516,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?",
@@ -527,7 +537,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",
@@ -575,12 +586,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"
@@ -632,7 +653,8 @@
"statsFollowers": "Follower",
"statsFollowing": "Folgt",
"adoptionStory": "Adoptionsgeschichte",
"verified": "Verifiziert"
"verified": "Verifiziert",
"invitedBy": "Eingeladen von <link>{name}</link>"
},
"EditPet": {
"title": "{name} bearbeiten",
@@ -650,9 +672,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.",
@@ -742,7 +764,11 @@
"detailPoint2": "Gesundheitsdaten bleiben privat, außer du teilst sie bewusst per Link",
"footerTagline": "Wo Haustiere die Stars sind.",
"footerDesignedBy": "Designed & Powered by PawFeed",
"footerSecurityBadge": "Sicherheits-Check: Note A+"
"footerSecurityBadge": "Sicherheits-Check: Note A+",
"heroAndroidHint": "Auch als App für Android",
"playStoreAlt": "Jetzt bei Google Play",
"appHeading": "PawFeed für die Hosentasche",
"appBody": "Die Android-App bringt Feed, Storys und Nachrichten direkt aufs Handy — mit Push-Benachrichtigungen, wenn dein Tier ein neues Like oder einen neuen Follower bekommt."
},
"AboutPage": {
"metaTitle": "Über uns — PawFeed",
@@ -761,5 +787,76 @@
"statSponsors": "Unterstützer:innen",
"ctaHeading": "Lust, dabei zu sein?",
"ctaButton": "Jetzt starten"
},
"Referrals": {
"title": "Freunde einladen",
"subtitle": "Teile deinen Link. Jedes Tier, das über deine Einladung kommt und seinen ersten Beitrag postet, bringt dich einen Rang weiter.",
"programPaused": "Das Empfehlungsprogramm ist gerade pausiert. Dein Link funktioniert, neue Einladungen werden aber im Moment nicht gezählt.",
"shareHeading": "Dein Einladungslink",
"shareSubtitle": "Unbegrenzt oft nutzbar. Wer sich ohne Link registriert, kann deinen Code auch im Onboarding eingeben.",
"yourCode": "Dein Code",
"share": "Teilen",
"copyLink": "Link kopieren",
"copied": "Kopiert",
"copiedToast": "Link kopiert",
"shareTitle": "Komm zu PawFeed",
"shareText": "Mein Tier ist auf PawFeed, dem sozialen Netzwerk für Haustiere. Mit meinem Code {code} sind wir direkt verbunden:",
"ranksHeading": "Deine Ränge",
"countedInvites": "{count, plural, =0 {Noch keine gezählte Einladung} one {# gezählte Einladung} other {# gezählte Einladungen}}",
"nextRank": "Noch {remaining, plural, one {# Einladung} other {# Einladungen}} bis {name}",
"allRanksReached": "Du hast alle Ränge erreicht. Danke!",
"achieved": "Erreicht",
"threshold": "{count, plural, one {# Einladung} other {# Einladungen}}",
"inviteesHeading": "Über dich gekommen",
"inviteesEmpty": "Noch niemand. Teile deinen Link mit Freunden, die selbst ein Tier haben.",
"inviteeNoPet": "Noch kein Tier angelegt",
"statusCounted": "Zählt",
"statusWaiting": "Wartet auf ersten Beitrag",
"statusInReview": "Wird geprüft",
"statusRevoked": "Aberkannt",
"redeemHeading": "Wurdest du eingeladen?",
"redeemSubtitle": "Gib den Code der Person ein, die dich eingeladen hat. Das geht nur in den ersten Tagen nach der Registrierung.",
"redeemLabel": "Einladungscode",
"redeemCta": "Einlösen",
"redeemedToast": "Eingelöst. Du und {name} folgt euch jetzt.",
"redeemedToastGeneric": "Code eingelöst.",
"errorPaused": "Das Empfehlungsprogramm ist gerade pausiert.",
"errorInvalid": "Diesen Code gibt es nicht. Prüf die Schreibweise, z. B. BUBI-7K3M.",
"errorOwnCode": "Das ist dein eigener Code. Gib den Code der Person ein, die dich eingeladen hat.",
"errorAlreadyRedeemed": "Du hast schon einen Einladungscode eingelöst.",
"errorWindowExpired": "Einladungscodes können nur in den ersten Tagen nach der Registrierung eingelöst werden.",
"errorNoPet": "Leg zuerst ein Tier an, dann kannst du den Code einlösen.",
"errorTooMany": "Zu viele Versuche. Bitte warte ein paar Minuten.",
"errorGeneric": "Das hat nicht geklappt. Bitte versuch es noch einmal.",
"rulesNote": "Eine Einladung zählt, sobald das eingeladene Tier angelegt ist und seinen ersten Beitrag veröffentlicht hat. Ränge sind digitale Auszeichnungen ohne Geldwert. Einmal erreichte Ränge bleiben erhalten, außer bei Missbrauch."
},
"ReferralOnboarding": {
"title": "Wie hast du von PawFeed erfahren?",
"subtitle": "Freiwillig. Es hilft uns zu verstehen, wo wir neue Tierfreunde erreichen.",
"source_FRIENDS": "Freunde oder Familie",
"source_TIKTOK": "TikTok",
"source_INSTAGRAM": "Instagram",
"source_YOUTUBE": "YouTube",
"source_FACEBOOK": "Facebook",
"source_SHELTER_OR_PARTNER": "Tierheim, Verein oder Laden",
"source_STORE_SEARCH": "App Store oder Google Play",
"source_PRESS": "Zeitung, Radio oder Blog",
"source_OTHER": "Anders",
"redeemed": "Eingelöst. Du und {name} folgt euch jetzt.",
"redeemedGeneric": "Code eingelöst.",
"continue": "Weiter",
"skip": "Überspringen"
},
"ReferralLanding": {
"titleWithPet": "{name} lädt dich zu PawFeed ein",
"titleGeneric": "Du bist zu PawFeed eingeladen",
"subtitle": "PawFeed ist das soziale Netzwerk, in dem dein Tier das Profil hat: Fotos, Kurzvideos, Storys und Impfpass an einem Ort.",
"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"
}
}
+110 -13
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",
@@ -124,7 +125,8 @@
"addPhoto": "Add a profile photo",
"addPhotoDesc": "Give your pet a face on PawFeed. You can always change this later.",
"done": "Done",
"skipForNow": "Skip for now"
"skipForNow": "Skip for now",
"loading": "Loading"
},
"Language": {
"switch": "Language",
@@ -258,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",
@@ -477,7 +480,13 @@
"typePawBack": "shared your post (Paw-Back)",
"typeBirthday": "🎂 Happy Birthday! Create an anniversary post.",
"typeAdoptionDay": "🏠 Today is adoption day! Celebrate the anniversary.",
"typePetSuggestion": "might be a good match"
"typePetSuggestion": "might be a good match",
"typeReferralJoined": "joined PawFeed through your invite",
"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."
},
"PhotoPostForm": {
"captionPlaceholder": "Write a caption…",
@@ -507,7 +516,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?",
@@ -527,7 +537,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",
@@ -575,12 +586,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"
@@ -632,7 +653,8 @@
"statsFollowers": "followers",
"statsFollowing": "following",
"adoptionStory": "Adoption story",
"verified": "Verified"
"verified": "Verified",
"invitedBy": "Invited by <link>{name}</link>"
},
"EditPet": {
"title": "Edit {name}",
@@ -650,9 +672,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.",
@@ -742,7 +764,11 @@
"detailPoint2": "Health data stays private unless you deliberately share it via link",
"footerTagline": "Where pets are the stars.",
"footerDesignedBy": "Designed & Powered by PawFeed",
"footerSecurityBadge": "Security check: Grade A+"
"footerSecurityBadge": "Security check: Grade A+",
"heroAndroidHint": "Also available as an Android app",
"playStoreAlt": "Get it on Google Play",
"appHeading": "PawFeed in your pocket",
"appBody": "The Android app puts the feed, stories, and messages right on your phone — with push notifications when your pet gets a new like or follower."
},
"AboutPage": {
"metaTitle": "About us — PawFeed",
@@ -761,5 +787,76 @@
"statSponsors": "Supporters",
"ctaHeading": "Want to be part of it?",
"ctaButton": "Get started"
},
"Referrals": {
"title": "Invite friends",
"subtitle": "Share your link. Every pet that joins through your invite and publishes its first post moves you up a rank.",
"programPaused": "The referral program is paused right now. Your link still works, but new invites are not counted at the moment.",
"shareHeading": "Your invite link",
"shareSubtitle": "Use it as often as you like. People who sign up without the link can still enter your code during onboarding.",
"yourCode": "Your code",
"share": "Share",
"copyLink": "Copy link",
"copied": "Copied",
"copiedToast": "Link copied",
"shareTitle": "Join PawFeed",
"shareText": "My pet is on PawFeed, the social network for pets. Use my code {code} and we are connected right away:",
"ranksHeading": "Your ranks",
"countedInvites": "{count, plural, =0 {No counted invites yet} one {# counted invite} other {# counted invites}}",
"nextRank": "{remaining, plural, one {# more invite} other {# more invites}} until {name}",
"allRanksReached": "You reached every rank. Thank you!",
"achieved": "Reached",
"threshold": "{count, plural, one {# invite} other {# invites}}",
"inviteesHeading": "Joined through you",
"inviteesEmpty": "Nobody yet. Share your link with friends who have a pet.",
"inviteeNoPet": "No pet created yet",
"statusCounted": "Counts",
"statusWaiting": "Waiting for first post",
"statusInReview": "Under review",
"statusRevoked": "Revoked",
"redeemHeading": "Were you invited?",
"redeemSubtitle": "Enter the code of the person who invited you. This only works during the first days after signing up.",
"redeemLabel": "Invite code",
"redeemCta": "Redeem",
"redeemedToast": "Redeemed. You and {name} now follow each other.",
"redeemedToastGeneric": "Code redeemed.",
"errorPaused": "The referral program is paused right now.",
"errorInvalid": "That code does not exist. Check the spelling, e.g. BUBI-7K3M.",
"errorOwnCode": "That is your own code. Enter the code of the person who invited you.",
"errorAlreadyRedeemed": "You already redeemed an invite code.",
"errorWindowExpired": "Invite codes can only be redeemed during the first days after signing up.",
"errorNoPet": "Create a pet first, then you can redeem the code.",
"errorTooMany": "Too many attempts. Please wait a few minutes.",
"errorGeneric": "That did not work. Please try again.",
"rulesNote": "An invite counts once the invited pet is created and has published its first post. Ranks are digital awards without monetary value. Ranks you reach stay with you unless the program is abused."
},
"ReferralOnboarding": {
"title": "How did you hear about PawFeed?",
"subtitle": "Optional. It helps us understand where we reach new pet lovers.",
"source_FRIENDS": "Friends or family",
"source_TIKTOK": "TikTok",
"source_INSTAGRAM": "Instagram",
"source_YOUTUBE": "YouTube",
"source_FACEBOOK": "Facebook",
"source_SHELTER_OR_PARTNER": "Shelter, club or shop",
"source_STORE_SEARCH": "App Store or Google Play",
"source_PRESS": "Newspaper, radio or blog",
"source_OTHER": "Other",
"redeemed": "Redeemed. You and {name} now follow each other.",
"redeemedGeneric": "Code redeemed.",
"continue": "Continue",
"skip": "Skip"
},
"ReferralLanding": {
"titleWithPet": "{name} invites you to PawFeed",
"titleGeneric": "You are invited to PawFeed",
"subtitle": "PawFeed is the social network where your pet has the profile: photos, short videos, stories and vaccination record in one place.",
"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"
}
}
+3
View File
@@ -48,3 +48,6 @@ yarn-error.*
# generated native folders
/ios
/android
# Local tester/debug screenshots (e.g. from appetize.io) — not app assets
/debug
+17 -3
View File
@@ -8,8 +8,13 @@
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "org.pawfeed.app"
"supportsTablet": false,
"bundleIdentifier": "org.pawfeed.app",
"buildNumber": "3",
"usesAppleSignIn": true,
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"package": "org.pawfeed.app",
@@ -64,7 +69,16 @@
}
],
"expo-web-browser",
"@sentry/react-native"
"expo-apple-authentication",
"@sentry/react-native",
[
"expo-build-properties",
{
"ios": {
"deploymentTarget": "17.0"
}
}
]
],
"extra": {
"router": {},
+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>
+37 -2
View File
@@ -9,6 +9,7 @@ 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 { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
import type { NotificationItem } from "../../../src/api/types";
@@ -20,6 +21,10 @@ const ACTOR_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
MENTION: "notifications.mention",
PAW_BACK: "notifications.pawBack",
PET_SUGGESTION: "notifications.petSuggestion",
REFERRAL_JOINED: "notifications.referralJoined",
STORY_MENTION: "notifications.storyMention",
STORY_COMMENT: "notifications.storyComment",
STORY_REACTION: "notifications.storyReaction",
};
// BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the
@@ -28,6 +33,7 @@ const ACTOR_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
const SELF_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
BIRTHDAY: "notifications.birthday",
ADOPTION_DAY: "notifications.adoptionDay",
REFERRAL_RANK_UP: "notifications.referralRankUp",
};
// Suffix only — the actor's name is rendered separately as its own tappable
@@ -35,6 +41,11 @@ 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];
@@ -42,6 +53,11 @@ function getNotificationSuffix(item: NotificationItem, t: TFunction): string {
return item.type;
}
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
@@ -53,12 +69,31 @@ function getNotificationSuffix(item: NotificationItem, t: TFunction): string {
* there so this doesn't try to be smarter about it).
*/
function getNotificationRoute(item: NotificationItem): string | null {
// Story-Mitteilungen: Story öffnen, solange sie läuft (der Besitzer sieht
// eigene Storys 7 Tage), sonst das Tierprofil.
if (item.story && (item.type === "STORY_MENTION" || item.type === "STORY_COMMENT" || item.type === "STORY_REACTION")) {
const isOwnStory = item.story.petId === item.recipientPetId;
const isRunning = new Date(item.story.expiresAt).getTime() > Date.now();
if (isRunning || isOwnStory) {
const params = new URLSearchParams({
petId: item.story.petId,
petName: item.story.pet.name,
petAvatarKey: item.story.pet.avatarKey ?? "",
});
return `/story-viewer?${params.toString()}`;
}
return `/pets/${item.story.petId}`;
}
switch (item.type) {
case "FOLLOW":
case "PET_SUGGESTION":
case "REFERRAL_JOINED":
return item.actorPet ? `/pets/${item.actorPet.id}` : null;
case "REFERRAL_RANK_UP":
return "/invite";
case "BIRTHDAY":
case "ADOPTION_DAY":
case "VACCINE_DUE":
return item.recipientPetId ? `/pets/${item.recipientPetId}/health` : null;
case "REACTION":
case "COMMENT":
@@ -152,10 +187,10 @@ 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}>
+1 -1
View File
@@ -80,7 +80,7 @@ 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>
+5
View File
@@ -31,6 +31,9 @@ export default function AppLayout() {
// found.md: "kein Zurückbutton" on the health screen wasn't a missing
// button, it was a black-on-black one).
headerTintColor: colors.ink,
// iOS labels the back button with the previous screen's title — here the
// untitled `(tabs)` group, so it literally read "< (tabs)". Chevron only.
headerBackButtonDisplayMode: "minimal",
} as const;
return (
@@ -47,6 +50,8 @@ export default function AppLayout() {
<Stack.Screen name="messages/[conversationId]" options={{ ...headerOptions, title: t("screens.conversation") }} />
<Stack.Screen name="search" options={{ ...headerOptions, title: t("screens.search") }} />
<Stack.Screen name="settings" options={{ ...headerOptions, title: t("screens.settings") }} />
<Stack.Screen name="invite" options={{ ...headerOptions, title: t("screens.invite") }} />
<Stack.Screen name="delete-account" options={{ ...headerOptions, title: t("screens.deleteAccount") }} />
<Stack.Screen
name="post-new"
options={{
+142
View File
@@ -0,0 +1,142 @@
import { useMemo, useState } from "react";
import { View, Text, ScrollView, Alert, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useAuth } from "@clerk/expo";
import { useQueryClient } from "@tanstack/react-query";
import { TriangleAlert } from "lucide-react-native";
import * as Sentry from "@sentry/react-native";
import { useActivePet } from "../../src/context/ActivePetContext";
import { useTheme } from "../../src/context/ThemeContext";
import { Button } from "../../src/components/Button";
import { TextField } from "../../src/components/TextField";
import { env } from "../../src/lib/env";
import { getTypography, radii, type ColorTokens, type Typography } from "../../src/theme/tokens";
import { useBottomSafePadding } from "../../src/hooks/useBottomSafePadding";
const DELETED_ITEMS = ["itemPets", "itemPosts", "itemMedia", "itemMessages", "itemHealth", "itemAccount"] as const;
/**
* In-app account deletion — required by App Store Guideline 5.1.1(v) for any
* app that lets users create an account. Port of web's
* src/components/account/DeleteAccountDialog.tsx: same warning list, same
* "type one of your pets' names" confirmation, and the same backend route
* (POST /api/account/delete → deleteOwnerAccount(), which also deletes the
* Clerk user). That route reads Clerk's auth() like tRPC does, so the mobile
* session token in the Authorization header authenticates it.
*/
export default function DeleteAccountScreen() {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const bottomPadding = useBottomSafePadding();
const { getToken, signOut } = useAuth();
const { pets, clearActivePetId } = useActivePet();
const queryClient = useQueryClient();
const [petName, setPetName] = useState("");
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const isValidName = pets.some((pet) => pet.name.toLowerCase() === petName.trim().toLowerCase());
const deleteAccount = async () => {
setIsDeleting(true);
setError(null);
try {
const token = await getToken();
const res = await fetch(`${env.apiUrl}/api/account/delete`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ petNameConfirmation: petName.trim() }),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? t("deleteAccount.genericError"));
}
} catch (err) {
Sentry.captureException(err);
setError(err instanceof Error ? err.message : t("deleteAccount.genericError"));
setIsDeleting(false);
return;
}
// The Clerk user no longer exists server-side, so signOut() may reject —
// either way the local session is cleared and useProtectedRoute() sends
// the user back to sign-in.
queryClient.clear();
clearActivePetId();
Alert.alert(t("deleteAccount.doneTitle"));
await signOut().catch(() => undefined);
};
const confirmDelete = () => {
Alert.alert(t("deleteAccount.finalConfirmTitle"), t("deleteAccount.finalConfirmMessage"), [
{ text: t("common.cancel"), style: "cancel" },
{ text: t("deleteAccount.confirmDelete"), style: "destructive", onPress: () => void deleteAccount() },
]);
};
return (
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<ScrollView contentContainerStyle={[styles.content, bottomPadding]} keyboardShouldPersistTaps="handled">
<View style={styles.titleRow}>
<TriangleAlert color={colors.alertRed} size={22} />
<Text style={styles.title}>{t("deleteAccount.warningTitle")}</Text>
</View>
<Text style={styles.intro}>{t("deleteAccount.warningIntro")}</Text>
<View style={styles.card}>
{DELETED_ITEMS.map((item) => (
<Text key={item} style={styles.item}>
<Text style={styles.itemMark}>✕ </Text>
{t(`deleteAccount.${item}`)}
</Text>
))}
</View>
<Text style={styles.intro}>{t("deleteAccount.confirmIntro")}</Text>
<TextField
placeholder={t("deleteAccount.inputPlaceholder")}
value={petName}
onChangeText={setPetName}
autoCapitalize="none"
autoCorrect={false}
editable={!isDeleting}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title={isDeleting ? t("deleteAccount.deleting") : t("deleteAccount.confirmDelete")}
disabled={!isValidName || isDeleting}
onPress={confirmDelete}
style={styles.deleteButton}
/>
</ScrollView>
</KeyboardAvoidingView>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
flex: { flex: 1, backgroundColor: colors.porchWhite },
content: { padding: 20, gap: 12, flexGrow: 1, backgroundColor: colors.porchWhite },
titleRow: { flexDirection: "row", alignItems: "center", gap: 8 },
title: { ...typography.title, fontSize: 18, color: colors.alertRed },
intro: { ...typography.body, color: colors.quietGray },
card: {
gap: 8,
padding: 14,
borderRadius: radii.xl,
borderWidth: 1,
borderColor: colors.hairlineGray,
backgroundColor: colors.pureWhite,
},
item: { ...typography.body },
itemMark: { color: colors.alertRed, fontWeight: "700" },
error: { ...typography.body, color: colors.alertRed },
deleteButton: { marginTop: 8, backgroundColor: colors.alertRed },
});
}
+131
View File
@@ -0,0 +1,131 @@
import { useMemo } from "react";
import { ActivityIndicator, Pressable, ScrollView, Share, StyleSheet, Text, View } from "react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Share2 } from "lucide-react-native";
import { useTRPC } from "../../src/api/trpc";
import { useTheme } from "../../src/context/ThemeContext";
import { useBottomSafePadding } from "../../src/hooks/useBottomSafePadding";
import { useReferralRanks } from "../../src/hooks/useReferralRanks";
import { env } from "../../src/lib/env";
import { PetAvatar } from "../../src/components/PetAvatar";
import { RankLadder } from "../../src/components/referrals/RankLadder";
import { RedeemCodeForm } from "../../src/components/referrals/RedeemCodeForm";
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
const STATUS_KEYS = {
counted: "referrals.statusCounted",
waiting: "referrals.statusWaiting",
"in-review": "referrals.statusInReview",
revoked: "referrals.statusRevoked",
} as const;
/**
* Port of web's /einladen — eigener Link mit nativem Teilen-Dialog (React
* Natives eingebautes Share, kein zusätzliches natives Modul → per OTA
* auslieferbar), Rangfortschritt, eingeladene Tiere, nachträgliche Code-Eingabe.
*/
export default function InviteScreen() {
const { t } = useTranslation();
const router = useRouter();
const trpc = useTRPC();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const bottomPadding = useBottomSafePadding();
const { ranks } = useReferralRanks();
const { data, isLoading } = useQuery(trpc.referrals.me.queryOptions());
if (isLoading || !data) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.marigoldOrange} />
</View>
);
}
const link = `${env.apiUrl}/r/${data.code}`;
const achieved = new Set(data.achievedRankIds);
function handleShare() {
void Share.share({ message: `${t("referrals.shareText", { code: data!.code })} ${link}` });
}
return (
<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>}
<View style={styles.card}>
<Text style={styles.heading}>{t("referrals.shareHeading")}</Text>
<Text style={styles.muted}>{t("referrals.shareSubtitle")}</Text>
<View style={styles.codeBox}>
<Text style={styles.codeLabel}>{t("referrals.yourCode")}</Text>
<Text style={styles.code} selectable>{data.code}</Text>
</View>
<Pressable style={styles.shareButton} onPress={handleShare} accessibilityRole="button">
<Share2 size={18} color="#ffffff" />
<Text style={styles.shareText}>{t("referrals.share")}</Text>
</Pressable>
</View>
<RankLadder ranks={ranks} achievedRankIds={achieved} qualifiedCount={data.qualifiedCount} nextRankId={data.nextRankId} />
<View style={styles.card}>
<Text style={styles.heading}>{t("referrals.inviteesHeading")}</Text>
{data.invitees.length === 0 ? (
<Text style={styles.muted}>{t("referrals.inviteesEmpty")}</Text>
) : (
data.invitees.map((invitee, index) => (
<Pressable
key={invitee.pet?.id ?? `invitee-${index}`}
style={styles.inviteeRow}
disabled={!invitee.pet}
onPress={() => invitee.pet && router.push(`/pets/${invitee.pet.id}`)}
>
{invitee.pet && <PetAvatar name={invitee.pet.name} avatarKey={invitee.pet.avatarKey} size="sm" />}
<Text style={styles.inviteeName} numberOfLines={1}>{invitee.pet?.name ?? t("referrals.inviteeNoPet")}</Text>
<Text style={styles.muted}>{t(STATUS_KEYS[invitee.status])}</Text>
</Pressable>
))
)}
</View>
{data.canRedeem && (
<View style={styles.card}>
<Text style={styles.heading}>{t("referrals.redeemHeading")}</Text>
<Text style={styles.muted}>{t("referrals.redeemSubtitle")}</Text>
<RedeemCodeForm />
</View>
)}
<Text style={styles.footnote}>{t("referrals.rulesNote")}</Text>
</ScrollView>
);
}
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite },
content: { padding: 16, gap: 16, flexGrow: 1, backgroundColor: colors.porchWhite },
subtitle: { ...typography.body, color: colors.quietGray },
notice: { ...typography.body, fontSize: 13, padding: 12, borderRadius: radii.xl, backgroundColor: colors.marigoldTint, color: colors.ink },
card: { gap: 10, padding: 16, borderRadius: radii["2xl"], borderWidth: 1, borderColor: colors.hairlineGray, backgroundColor: colors.pureWhite },
heading: { ...typography.title, fontSize: 16 },
muted: { ...typography.body, fontSize: 13, color: colors.quietGray },
codeBox: { alignItems: "center", paddingVertical: 12, borderRadius: radii.xl, backgroundColor: colors.whisperGray },
codeLabel: { ...typography.body, fontSize: 11, letterSpacing: 1, textTransform: "uppercase", color: colors.quietGray },
code: { ...typography.title, fontSize: 24, letterSpacing: 2 },
shareButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, height: 48, borderRadius: radii.lg, backgroundColor: colors.marigoldOrange },
shareText: { ...typography.body, fontWeight: "600", color: "#ffffff" },
inviteeRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 },
inviteeName: { ...typography.body, flex: 1, fontWeight: "600" },
footnote: { ...typography.body, fontSize: 12, color: colors.quietGray },
});
}
+4 -2
View File
@@ -10,6 +10,7 @@ import { PetAvatar } from "../../../src/components/PetAvatar";
import { formatPetName, formatRelativeTime } from "../../../src/lib/format";
import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
import type { Conversation } from "../../../src/api/types";
import { useBottomSafePadding } from "../../../src/hooks/useBottomSafePadding";
/** Conversations list (conversations.list) — taps a row to open the chat detail/compose screen. */
export default function MessagesScreen() {
@@ -19,6 +20,7 @@ export default function MessagesScreen() {
const { activePetId } = useActivePet();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const bottomPadding = useBottomSafePadding();
const { data, isLoading, error } = useQuery({
...trpc.conversations.list.queryOptions({ petId: activePetId ?? "" }),
@@ -45,7 +47,7 @@ export default function MessagesScreen() {
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
contentContainerStyle={[styles.list, bottomPadding]}
ItemSeparatorComponent={() => <View style={styles.separator} />}
renderItem={({ item }) => (
<ConversationRow conversation={item} onPress={() => router.push(`/messages/${item.id}`)} />
@@ -64,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 -4
View File
@@ -8,15 +8,19 @@ 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 { radii, getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
import { displayDateToIso, isoToDisplayDate } from "../../../../src/lib/date-format";
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.
*/
@@ -29,6 +33,7 @@ export default function EditPetScreen() {
const { activePetId, clearActivePetId } = useActivePet();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const bottomPadding = useBottomSafePadding();
const { data: pet, isLoading } = useQuery(trpc.pets.byId.queryOptions({ petId }));
@@ -122,7 +127,9 @@ export default function EditPetScreen() {
<FlatList
data={EMPTY_DATA}
renderItem={null}
contentContainerStyle={styles.container}
contentContainerStyle={[styles.container, bottomPadding]}
automaticallyAdjustKeyboardInsets
keyboardShouldPersistTaps="handled"
ListHeaderComponent={
<>
<Text style={styles.label}>{t("editPet.nameLabel")}</Text>
@@ -154,15 +161,22 @@ 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}
/>
<Pressable style={styles.deleteRow} onPress={confirmDelete} disabled={deleteMutation.isPending}>
<Trash2 color={colors.alertRed} size={18} />
<Text style={styles.deleteText}>{t("editPet.deletePet")}</Text>
+4 -2
View File
@@ -9,6 +9,7 @@ import { useTheme } from "../../../../src/context/ThemeContext";
import { PetAvatar } from "../../../../src/components/PetAvatar";
import { formatPetName } from "../../../../src/lib/format";
import { type ColorTokens } from "../../../../src/theme/tokens";
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
export default function FollowersScreen() {
const { t } = useTranslation();
@@ -17,6 +18,7 @@ export default function FollowersScreen() {
const trpc = useTRPC();
const { colors, typography } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const bottomPadding = useBottomSafePadding();
const { data, isLoading } = useQuery(trpc.follows.listFollowers.queryOptions({ petId }));
if (isLoading) {
@@ -31,11 +33,11 @@ export default function FollowersScreen() {
<FlatList
data={data?.items ?? []}
keyExtractor={(item) => item.follower.id}
contentContainerStyle={styles.list}
contentContainerStyle={[styles.list, bottomPadding]}
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>
)}
+4 -2
View File
@@ -9,6 +9,7 @@ import { useTheme } from "../../../../src/context/ThemeContext";
import { PetAvatar } from "../../../../src/components/PetAvatar";
import { formatPetName } from "../../../../src/lib/format";
import { type ColorTokens } from "../../../../src/theme/tokens";
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
export default function FollowingScreen() {
const { t } = useTranslation();
@@ -17,6 +18,7 @@ export default function FollowingScreen() {
const trpc = useTRPC();
const { colors, typography } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const bottomPadding = useBottomSafePadding();
const { data, isLoading } = useQuery(trpc.follows.listFollowing.queryOptions({ petId }));
if (isLoading) {
@@ -31,11 +33,11 @@ export default function FollowingScreen() {
<FlatList
data={data?.items ?? []}
keyExtractor={(item) => item.followee.id}
contentContainerStyle={styles.list}
contentContainerStyle={[styles.list, bottomPadding]}
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>
)}
@@ -7,6 +7,7 @@ import { VetVisitSection } from "../../../../../src/components/health/VetVisitSe
import { VaccineSection } from "../../../../../src/components/health/VaccineSection";
import { useTheme } from "../../../../../src/context/ThemeContext";
import { type ColorTokens } from "../../../../../src/theme/tokens";
import { useBottomSafePadding } from "../../../../../src/hooks/useBottomSafePadding";
/**
* Port of web's src/app/(app)/pets/[petId]/health/page.tsx, scoped to the
@@ -18,9 +19,14 @@ export default function PetHealthScreen() {
const { petId } = useLocalSearchParams<{ petId: string }>();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const bottomPadding = useBottomSafePadding();
return (
<ScrollView contentContainerStyle={styles.container}>
<ScrollView
contentContainerStyle={[styles.container, bottomPadding]}
automaticallyAdjustKeyboardInsets
keyboardShouldPersistTaps="handled"
>
<HealthConsentGate petId={petId}>
<WeightLogSection petId={petId} />
<VetVisitSection petId={petId} />
+149 -88
View File
@@ -1,21 +1,25 @@
import { useMemo } from "react";
import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useMemo, useState } from "react";
import { View, Text, FlatList, Pressable, ActivityIndicator, Alert, StyleSheet } from "react-native";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation } from "@tanstack/react-query";
import { HeartPulse, Trophy, MessageCircle } from "lucide-react-native";
import { HeartPulse, Trophy, MessageCircle, MoreHorizontal } from "lucide-react-native";
import { useTRPC } from "../../../../src/api/trpc";
import { useActivePet } from "../../../../src/context/ActivePetContext";
import { useTheme } from "../../../../src/context/ThemeContext";
import { PetAvatar } from "../../../../src/components/PetAvatar";
import { ReferralAvatarRing } from "../../../../src/components/referrals/ReferralAvatarRing";
import { PetIdentityBadges } from "../../../../src/components/PetIdentityBadges";
import { PostCard } from "../../../../src/components/PostCard";
import { Button } from "../../../../src/components/Button";
import { FollowButton } from "../../../../src/components/FollowButton";
import { ReportSheet } from "../../../../src/components/ReportSheet";
import { useBlockPet } from "../../../../src/hooks/useBlockPet";
import { useVisiblePostIds } from "../../../../src/hooks/useVisiblePostIds";
import { formatPetName } from "../../../../src/lib/format";
import { getAvatarUrl } from "../../../../src/lib/media-url";
import { getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
/**
* Port of web's src/app/(app)/pets/[petId]/page.tsx: identity, bio/adoption
@@ -29,9 +33,10 @@ export default function PetProfileScreen() {
const { petId } = useLocalSearchParams<{ petId: string }>();
const router = useRouter();
const trpc = useTRPC();
const { activePetId } = useActivePet();
const { activePetId, pets: ownPets } = useActivePet();
const { colors, typography } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const bottomPadding = useBottomSafePadding();
const { visibleIds, viewabilityConfigCallbackPairs } = useVisiblePostIds();
const { data: pet, isLoading, error } = useQuery(trpc.pets.getProfile.queryOptions({ petId }));
@@ -40,6 +45,11 @@ export default function PetProfileScreen() {
const { data: postsPage } = useQuery(trpc.posts.byPetId.queryOptions({ petId }));
const isOwnPet = activePetId === petId;
// Any of the owner's pets, not just the active one — blocking/reporting
// your own other pet makes no sense.
const isOwnedByMe = ownPets.some((ownPet) => ownPet.id === petId);
const [reportOpen, setReportOpen] = useState(false);
const { confirmBlock } = useBlockPet(() => router.back());
const getOrCreateConversation = useMutation(
trpc.conversations.getOrCreate.mutationOptions({
@@ -64,99 +74,147 @@ export default function PetProfileScreen() {
}
const posts = postsPage?.posts ?? [];
const canModerate = !isOwnedByMe && !!activePetId;
// Report + block for other pets (App Store Guideline 1.2), matching web's
// ProfileActions "..." menu.
const openMoreMenu = () => {
if (!activePetId) return;
Alert.alert(formatPetName(pet), undefined, [
{ text: t("petProfile.reportPet"), onPress: () => setReportOpen(true) },
{
text: t("block.menuItem", { petName: pet.name }),
style: "destructive",
onPress: () => confirmBlock(activePetId, { id: pet.id, name: pet.name }),
},
{ text: t("common.cancel"), style: "cancel" },
]);
};
return (
<FlatList
data={posts}
keyExtractor={(post) => post.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<PostCard post={item} actingPetId={activePetId ?? petId} isVisible={visibleIds.has(item.id)} />
<>
{canModerate && (
<>
<Stack.Screen
options={{
headerRight: () => (
<Pressable onPress={openMoreMenu} hitSlop={12} accessibilityLabel={t("petProfile.moreActions")}>
<MoreHorizontal color={colors.ink} size={22} />
</Pressable>
),
}}
/>
<ReportSheet
visible={reportOpen}
onClose={() => setReportOpen(false)}
reporterPetId={activePetId}
targetPetId={pet.id}
/>
</>
)}
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
ItemSeparatorComponent={() => (
<View style={styles.postSeparatorWrap}>
<View style={styles.postSeparatorLine} />
</View>
)}
ListHeaderComponent={
<View style={styles.header}>
{pet.avatarKey ? (
<Pressable
onPress={() =>
router.push({
pathname: "/image-viewer",
params: { images: JSON.stringify([getAvatarUrl(pet.avatarKey)]), index: "0" },
})
}
>
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
</Pressable>
) : (
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
)}
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(pet)}</Text>
<PetIdentityBadges isVerified={pet.isVerified} sponsor={pet.owner} />
<FlatList
data={posts}
keyExtractor={(post) => post.id}
contentContainerStyle={[styles.list, bottomPadding]}
renderItem={({ item }) => (
<PostCard post={item} actingPetId={activePetId ?? petId} isVisible={visibleIds.has(item.id)} />
)}
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
ItemSeparatorComponent={() => (
<View style={styles.postSeparatorWrap}>
<View style={styles.postSeparatorLine} />
</View>
<Text style={styles.species}>{pet.species.name}</Text>
<View style={styles.statsRow}>
<Pressable style={styles.stat} onPress={() => router.push(`/pets/${petId}/followers`)}>
<Text style={styles.statCount}>{followers?.items.length ?? 0}</Text>
<Text style={styles.statLabel}>{t("petProfile.followerStat")}</Text>
</Pressable>
<Pressable style={styles.stat} onPress={() => router.push(`/pets/${petId}/following`)}>
<Text style={styles.statCount}>{following?.items.length ?? 0}</Text>
<Text style={styles.statLabel}>{t("petProfile.followingStat")}</Text>
</Pressable>
</View>
{isOwnPet ? (
<Button title={t("common.edit")} variant="outline" onPress={() => router.push(`/pets/${petId}/edit`)} />
) : (
activePetId && <FollowButton followerPetId={activePetId} followeePetId={petId} />
)}
<View style={styles.iconRow}>
{isOwnPet && (
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/health`)}>
<HeartPulse color={colors.ink} size={18} />
<Text style={styles.iconButtonText}>{t("petProfile.healthButton")}</Text>
</Pressable>
)}
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/milestones`)}>
<Trophy color={colors.ink} size={18} />
<Text style={styles.iconButtonText}>{t("petProfile.milestonesButton")}</Text>
</Pressable>
{!isOwnPet && activePetId && (
)}
ListHeaderComponent={
<View style={styles.header}>
<ReferralAvatarRing owner={pet.owner}>
{pet.avatarKey ? (
<Pressable
style={styles.iconButton}
onPress={() => getOrCreateConversation.mutate({ senderPetId: activePetId, recipientPetId: petId })}
onPress={() =>
router.push({
pathname: "/image-viewer",
params: { images: JSON.stringify([getAvatarUrl(pet.avatarKey)]), index: "0" },
})
}
>
<MessageCircle color={colors.ink} size={18} />
<Text style={styles.iconButtonText}>{t("petProfile.messageButton")}</Text>
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
</Pressable>
) : (
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
)}
</View>
{pet.bio ? <Text style={styles.section}>{pet.bio}</Text> : null}
{pet.adoptionStory ? (
<View>
<Text style={styles.sectionTitle}>{t("petProfile.adoptionStoryTitle")}</Text>
<Text style={styles.section}>{pet.adoptionStory}</Text>
</ReferralAvatarRing>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(pet)}</Text>
<PetIdentityBadges isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
</View>
) : null}
{pet.isFoundingMember && <Text style={styles.foundingHint}>{t("foundingMember.tooltip")}</Text>}
<Text style={styles.species}>{pet.species.name}</Text>
{pet.invitedByPet && (
<Text style={styles.invitedBy}>
{t("referrals.invitedBy")}{" "}
<Text style={styles.invitedByName} onPress={() => router.push(`/pets/${pet.invitedByPet?.id}`)}>
{pet.invitedByPet.name}
</Text>
</Text>
)}
<View style={styles.divider} />
</View>
}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>{t("petProfile.noPosts")}</Text>
</View>
}
/>
<View style={styles.statsRow}>
<Pressable style={styles.stat} onPress={() => router.push(`/pets/${petId}/followers`)}>
<Text style={styles.statCount}>{followers?.items.length ?? 0}</Text>
<Text style={styles.statLabel}>{t("petProfile.followerStat")}</Text>
</Pressable>
<Pressable style={styles.stat} onPress={() => router.push(`/pets/${petId}/following`)}>
<Text style={styles.statCount}>{following?.items.length ?? 0}</Text>
<Text style={styles.statLabel}>{t("petProfile.followingStat")}</Text>
</Pressable>
</View>
{isOwnPet ? (
<Button title={t("common.edit")} variant="outline" onPress={() => router.push(`/pets/${petId}/edit`)} />
) : (
activePetId && <FollowButton followerPetId={activePetId} followeePetId={petId} />
)}
<View style={styles.iconRow}>
{isOwnPet && (
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/health`)}>
<HeartPulse color={colors.ink} size={18} />
<Text style={styles.iconButtonText}>{t("petProfile.healthButton")}</Text>
</Pressable>
)}
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/milestones`)}>
<Trophy color={colors.ink} size={18} />
<Text style={styles.iconButtonText}>{t("petProfile.milestonesButton")}</Text>
</Pressable>
{!isOwnPet && activePetId && (
<Pressable
style={styles.iconButton}
onPress={() => getOrCreateConversation.mutate({ senderPetId: activePetId, recipientPetId: petId })}
>
<MessageCircle color={colors.ink} size={18} />
<Text style={styles.iconButtonText}>{t("petProfile.messageButton")}</Text>
</Pressable>
)}
</View>
{pet.bio ? <Text style={styles.section}>{pet.bio}</Text> : null}
{pet.adoptionStory ? (
<View>
<Text style={styles.sectionTitle}>{t("petProfile.adoptionStoryTitle")}</Text>
<Text style={styles.section}>{pet.adoptionStory}</Text>
</View>
) : null}
<View style={styles.divider} />
</View>
}
ListEmptyComponent={
<View style={styles.center}>
<Text style={typography.body}>{t("petProfile.noPosts")}</Text>
</View>
}
/>
</>
);
}
@@ -170,6 +228,9 @@ function makeStyles(colors: ColorTokens) {
nameRow: { flexDirection: "row", alignItems: "center", marginTop: 8 },
name: { ...typography.title },
species: { ...typography.body, color: colors.quietGray },
invitedBy: { ...typography.body, fontSize: 12, color: colors.quietGray },
foundingHint: { ...typography.body, fontSize: 12, color: "#059669" },
invitedByName: { color: colors.ink, fontWeight: "600" },
statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 },
stat: { alignItems: "center" },
statCount: { ...typography.title, fontSize: 18 },
+3 -1
View File
@@ -12,6 +12,7 @@ import { getMediaUrl } from "../../../../src/lib/media-url";
import { MILESTONE_META, type MilestoneType } from "../../../../src/lib/milestone-meta";
import { radii, getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
import type { MilestonePost } from "../../../../src/api/types";
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
/**
* Port of web's Milestones profile tab (milestones.listByPet). Structured
@@ -25,6 +26,7 @@ export default function MilestonesScreen() {
const trpc = useTRPC();
const { colors, typography } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const bottomPadding = useBottomSafePadding();
const isOwnPet = activePetId === petId;
const { data: milestones = [], isLoading } = useQuery(trpc.milestones.listByPet.queryOptions({ petId }));
@@ -41,7 +43,7 @@ export default function MilestonesScreen() {
<FlatList
data={milestones}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
contentContainerStyle={[styles.list, bottomPadding]}
ItemSeparatorComponent={() => <View style={styles.separator} />}
renderItem={({ item }) => <MilestoneRow post={item} />}
ListEmptyComponent={
+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}>
+7 -3
View File
@@ -14,6 +14,8 @@ import { PetAvatar } from "../../src/components/PetAvatar";
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>;
@@ -24,6 +26,7 @@ type SearchPet = {
avatarKey: string | null;
species: { name: string };
breed: { name: string } | null;
owner?: ReferralOwnerFields | null;
};
/**
@@ -39,6 +42,7 @@ export default function SearchScreen() {
const { activePetId } = useActivePet();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const bottomPadding = useBottomSafePadding();
const [query, setQuery] = useState("");
const [tab, setTab] = useState<Tab>("pets");
@@ -88,7 +92,7 @@ export default function SearchScreen() {
<FlatList
data={petResults?.pets ?? []}
keyExtractor={(pet) => pet.id}
contentContainerStyle={styles.list}
contentContainerStyle={[styles.list, bottomPadding]}
ListEmptyComponent={<EmptyState styles={styles} text={t("search.noPetsFor", { query: trimmed })} />}
renderItem={({ item }) => (
<PetRow styles={styles} pet={item} activePetId={activePetId} onPress={() => router.push(`/pets/${item.id}`)} />
@@ -105,7 +109,7 @@ export default function SearchScreen() {
<FlatList
data={ownerResults?.owners ?? []}
keyExtractor={(owner) => owner.id}
contentContainerStyle={styles.list}
contentContainerStyle={[styles.list, bottomPadding]}
ListEmptyComponent={<EmptyState styles={styles} text={t("search.noPeopleFor", { query: trimmed })} />}
renderItem={({ item: owner }) => {
const displayName = owner.username
@@ -205,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>
+7 -1
View File
@@ -4,10 +4,13 @@ import { useTheme } from "../../src/context/ThemeContext";
import { AppearanceSection } from "../../src/components/settings/AppearanceSection";
import { LanguageSection } from "../../src/components/settings/LanguageSection";
import { PetSuggestionsSection } from "../../src/components/settings/PetSuggestionsSection";
import { InviteFriendsSection } from "../../src/components/settings/InviteFriendsSection";
import { LegalSection } from "../../src/components/settings/LegalSection";
import { LogoutSection } from "../../src/components/settings/LogoutSection";
import { DeleteAccountSection } from "../../src/components/settings/DeleteAccountSection";
import { FooterErrorBoundary } from "../../src/components/settings/FooterErrorBoundary";
import type { ColorTokens } from "../../src/theme/tokens";
import { useBottomSafePadding } from "../../src/hooks/useBottomSafePadding";
/**
* Dedicated Settings screen, reached via the gear icon in every tab's header
@@ -34,15 +37,18 @@ import type { ColorTokens } from "../../src/theme/tokens";
export default function SettingsScreen() {
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const bottomPadding = useBottomSafePadding();
return (
<FooterErrorBoundary>
<ScrollView contentContainerStyle={styles.content}>
<ScrollView contentContainerStyle={[styles.content, bottomPadding]}>
<InviteFriendsSection />
<AppearanceSection />
<LanguageSection />
<PetSuggestionsSection />
<LegalSection />
<LogoutSection />
<DeleteAccountSection />
</ScrollView>
</FooterErrorBoundary>
);
+213 -76
View File
@@ -1,23 +1,32 @@
import { useState, useCallback, useMemo } from "react";
import { View, Text, Image as RNImage, Pressable, ActivityIndicator, StyleSheet, Alert } from "react-native";
import { useRouter } from "expo-router";
import { useState, useCallback, useMemo, useLayoutEffect } from "react";
import { View, Text, Image as RNImage, Pressable, StyleSheet, Alert } from "react-native";
import { useNavigation, useRouter } from "expo-router";
import * 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 { useMutation, useQuery, useQueryClient } 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 { uploadImageToSupabase, uploadFileToUrl } 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 { StoryEditor, type EditorMedia, type EditorResult } from "../../src/components/story-editor/StoryEditor";
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
/** Echte Pixelmaße der (verkleinerten) Datei — der Picker meldet bei gedrehten Handyfotos teils vertauschte Werte. */
function imageSize(uri: string): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => RNImage.getSize(uri, (width, height) => resolve({ width, height }), reject));
}
/**
* 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: Foto, kurzes Video oder reine Text-Story (Farbverlauf),
* danach der Story-Editor (Text, @-Markierungen, GIFs, Sticker — siehe
* src/components/story-editor/). Overlays gehen als Daten mit, nicht ins Bild
* gerechnet. Der Video-Pfad entspricht web's StoryForm.tsx: createVideoUpload
* legt die (PROCESSING) Story an, die Bytes gehen direkt zu Mux. Ist der
* Notschalter (Admin) aus, gibt es nur Vorschau + Teilen wie früher.
*/
export default function NewStoryScreen() {
const { t } = useTranslation();
@@ -26,23 +35,34 @@ export default function NewStoryScreen() {
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 { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
const editorEnabled = flags?.storyEditor ?? false;
const [media, setMedia] = useState<EditorMedia | null>(null);
const navigation = useNavigation();
// Im Editor gehört der ganze Bildschirm der Story — Kopfzeile ausblenden.
useLayoutEffect(() => {
navigation.setOptions({ headerShown: media === null });
}, [navigation, media]);
const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
// Video-Upload in Prozent (0–100), null solange kein Video hochgeladen wird.
const [uploadPercent, setUploadPercent] = useState<number | null>(null);
const getPresignedUrlMutation = useMutation(trpc.stories.getPresignedUrl.mutationOptions());
const createMutation = useMutation(trpc.stories.create.mutationOptions());
const createTextMutation = useMutation(trpc.stories.createText.mutationOptions());
const createVideoUploadMutation = useMutation(trpc.stories.createVideoUpload.mutationOptions());
const 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 finish = useCallback(async () => {
await queryClient.invalidateQueries(trpc.follows.pathFilter());
await queryClient.invalidateQueries(trpc.stories.pathFilter());
router.back();
}, [queryClient, trpc, router]);
const pickFromGallery = useCallback(async () => {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
@@ -50,9 +70,21 @@ 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) {
if (picked.duration / 1000 > MAX_STORY_VIDEO_DURATION_SECS) {
Alert.alert(t("story.videoTooLong", { max: MAX_STORY_VIDEO_DURATION_SECS }));
return;
}
setMedia({ kind: "video", uri: picked.uri, width: picked.width, height: picked.height, mimeType: picked.mimeType ?? undefined });
return;
}
setMedia({ kind: "image", uri: picked.uri, width: picked.width, height: picked.height });
}, [t]);
const takePhoto = useCallback(async () => {
const permission = await ImagePicker.requestCameraPermissionsAsync();
@@ -61,65 +93,159 @@ 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];
setMedia({ kind: "image", uri: photo.uri, width: photo.width, height: photo.height });
}
}, [asset, activePetId, getPresignedUrlMutation, createMutation]);
}, [t]);
const isSubmitting = getPresignedUrlMutation.isPending || createMutation.isPending;
const handleSubmit = useCallback(
async ({ overlays, background }: EditorResult) => {
if (!media || !activePetId) return;
setError(null);
const editorFields = overlays.length > 0 ? { overlays } : {};
try {
if (media.kind === "text") {
await createTextMutation.mutateAsync({ petId: activePetId, background: background ?? "sunset", overlays });
await finish();
return;
}
if (media.kind === "video") {
const { uploadUrl } = await createVideoUploadMutation.mutateAsync({
petId: activePetId,
aiDisclosure: "NONE",
...editorFields,
...(media.width > 0 && media.height > 0 ? { mediaWidth: media.width, mediaHeight: media.height } : {}),
});
setIsUploading(true);
setUploadPercent(0);
await uploadFileToUrl(media.uri, uploadUrl ?? "", media.mimeType ?? "video/mp4", (fraction) => {
const pct = Math.round(fraction * 100);
setUploadPercent((prev) => (prev === pct ? prev : pct));
});
// Story existiert schon (PROCESSING) — story-viewer.tsx fragt den Status ab.
await finish();
return;
}
setIsUploading(true);
const resized = await resizeForUpload(media.uri, media.width, media.height);
const size = await imageSize(resized.uri).catch(() => ({ width: media.width, height: media.height }));
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
petId: activePetId,
contentType: resized.mimeType,
fileSize: resized.fileSize,
});
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
await createMutation.mutateAsync({
petId: activePetId,
storageKey: key,
blurhash: null,
...editorFields,
mediaWidth: size.width,
mediaHeight: size.height,
});
await finish();
} catch (err) {
setError(err instanceof Error && err.message ? err.message : t("onboarding.uploadError"));
} finally {
setIsUploading(false);
setUploadPercent(null);
}
},
[media, activePetId, createTextMutation, createVideoUploadMutation, getPresignedUrlMutation, createMutation, finish, t]
);
const isSubmitting =
isUploading ||
getPresignedUrlMutation.isPending ||
createMutation.isPending ||
createTextMutation.isPending ||
createVideoUploadMutation.isPending;
if (media && activePetId) {
return (
<StoryEditor
media={media}
petId={activePetId}
toolsEnabled={editorEnabled}
isSubmitting={isSubmitting}
submitLabel={isSubmitting ? t("milestones.sharing") : t("story.shareButton")}
onSubmit={handleSubmit}
onCancel={() => {
if (!isSubmitting) {
setMedia(null);
setError(null);
}
}}
progress={
<>
{error ? <Text style={styles.errorOnDark}>{error}</Text> : null}
{isSubmitting && media.kind === "video" ? <UploadProgress percent={uploadPercent} styles={styles} /> : null}
</>
}
/>
);
}
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={() => setMedia({ 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) {
/**
* Fortschritt beim Video-Upload zu Mux. `percent === null` = Upload wird noch
* vorbereitet (createVideoUpload läuft), dann echter Fortschritt aus
* expo-file-system `onProgress`.
*/
function UploadProgress({ percent, styles }: { percent: number | null; styles: ReturnType<typeof makeStyles> }) {
const { t } = useTranslation();
const label = percent === null ? t("story.preparingUpload") : t("story.uploadingVideo", { percent });
return (
<View
style={styles.progressWrap}
accessibilityRole="progressbar"
accessibilityValue={{ min: 0, max: 100, now: percent ?? 0 }}
accessibilityLabel={label}
>
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${percent ?? 0}%` }]} />
</View>
<Text style={styles.progressLabel}>{label}</Text>
</View>
);
}
function makeStyles(colors: ColorTokens, bottomInset: number) {
const typography = getTypography(colors);
return StyleSheet.create({
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 +259,20 @@ 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 },
errorOnDark: { ...typography.body, color: "#f87171", textAlign: "center" },
progressWrap: { gap: 6 },
progressTrack: { height: 6, borderRadius: 3, backgroundColor: "rgba(255,255,255,0.2)", overflow: "hidden" },
progressFill: { height: "100%", borderRadius: 3, backgroundColor: colors.marigoldOrange },
progressLabel: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", color: "#ffffff" },
});
}
+342 -19
View File
@@ -1,25 +1,37 @@
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<{
@@ -27,26 +39,106 @@ export default function StoryViewerScreen() {
petName: string;
petAvatarKey?: string;
}>();
const { t } = useTranslation();
const router = useRouter();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
const insets = useSafeAreaInsets();
const isOwnStory = activePetId === petId;
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 [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 +162,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 +188,27 @@ export default function StoryViewerScreen() {
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [currentStory, advance]);
}, [currentStory, isVideo, isPaused, advance]);
// 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 +222,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 +441,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,
+5
View File
@@ -8,6 +8,7 @@ import { TextField } from "../../src/components/TextField";
import { PasswordField } from "../../src/components/PasswordField";
import { LanguageSwitcher } from "../../src/components/LanguageSwitcher";
import { GoogleSignInButton } from "../../src/components/GoogleSignInButton";
import { AppleSignInButton } from "../../src/components/AppleSignInButton";
import { useTheme } from "../../src/context/ThemeContext";
import { getTypography, type ColorTokens, type Typography } from "../../src/theme/tokens";
@@ -107,6 +108,10 @@ export default function SignInScreen() {
onError={setError}
onNeedsConsent={() => setError(t("auth.googleNeedsSignUpError"))}
/>
<AppleSignInButton
onError={setError}
onNeedsConsent={() => setError(t("auth.appleNeedsSignUpError"))}
/>
<Text style={styles.divider}>{t("auth.orDivider")}</Text>
<TextField
autoCapitalize="none"
+5 -2
View File
@@ -9,6 +9,7 @@ import { TextField } from "../../src/components/TextField";
import { PasswordField } from "../../src/components/PasswordField";
import { LanguageSwitcher } from "../../src/components/LanguageSwitcher";
import { GoogleSignInButton } from "../../src/components/GoogleSignInButton";
import { AppleSignInButton } from "../../src/components/AppleSignInButton";
import { useTheme } from "../../src/context/ThemeContext";
import { env } from "../../src/lib/env";
import { getTypography, type ColorTokens, type Typography } from "../../src/theme/tokens";
@@ -16,8 +17,9 @@ import { getTypography, type ColorTokens, type Typography } from "../../src/them
/**
* Email/password sign-up — the mobile app's other half of sign-in.tsx (that
* screen only ever covered login, per its own comment "sign-up screens are
* follow-up work"). No invite-code step: INVITE_REQUIRED is globally off on
* the backend since launch (post-beta), unlike web's /join gate.
* follow-up work"). No invite-code step here: registration is open, and a
* referral code is redeemed later in onboarding (AcquisitionStep), never
* as part of the Clerk sign-up itself.
*
* Uses Clerk's "Futures" API (signUp.password(), signUp.verifications,
* signUp.finalize()), matching sign-in.tsx's pattern — see AGENTS.md before
@@ -127,6 +129,7 @@ export default function SignUpScreen() {
<LanguageSwitcher />
<Text style={styles.title}>{t("auth.signUpTitle")}</Text>
<GoogleSignInButton onError={setError} legalAccepted={legalAccepted} disabled={!legalAccepted} />
<AppleSignInButton onError={setError} legalAccepted={legalAccepted} disabled={!legalAccepted} />
<Text style={styles.divider}>{t("auth.orDivider")}</Text>
<TextField
placeholder={t("auth.firstNamePlaceholder")}
+27 -4
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { FlatList } from "react-native";
import { ActivityIndicator, FlatList, View } from "react-native";
import { useRouter } from "expo-router";
import { useActivePet } from "../../src/context/ActivePetContext";
import { OnboardingGuidelines } from "../../src/components/onboarding/OnboardingGuidelines";
@@ -7,12 +7,16 @@ import { UploadConsent } from "../../src/components/onboarding/UploadConsent";
import { ArachnophobiaOptIn } from "../../src/components/onboarding/ArachnophobiaOptIn";
import { PetCreateForm } from "../../src/components/onboarding/PetCreateForm";
import { AvatarStep } from "../../src/components/onboarding/AvatarStep";
import { AcquisitionStep } from "../../src/components/onboarding/AcquisitionStep";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "../../src/api/trpc";
import { useTheme } from "../../src/context/ThemeContext";
/**
* Port of web's src/app/onboarding/pet/page.tsx — same five-step flow and
* step order: community guidelines -> upload-rights/data-processing consent
* -> Arachnophobie-Helfer opt-in -> pet profile -> avatar photo. Lives as
* Port of web's src/app/onboarding/pet/page.tsx — same step order:
* community guidelines -> upload-rights/data-processing consent ->
* Arachnophobie-Helfer opt-in -> pet profile -> "Wie hast du von PawFeed
* erfahren?" + referral code (once per owner) -> avatar photo. Lives as
* its own top-level route (not under (auth) or (app)) so app/_layout.tsx's
* "zero pets -> redirect here" check (see OnboardingRedirect) doesn't loop.
*/
@@ -24,6 +28,15 @@ export default function OnboardingPetScreen() {
const [uploadConsentGiven, setUploadConsentGiven] = useState(false);
const [arachnophobiaStepDone, setArachnophobiaStepDone] = useState(false);
const [createdPet, setCreatedPet] = useState<{ id: string; name: string } | null>(null);
const [acquisitionStepDone, setAcquisitionStepDone] = useState(false);
const trpc = useTRPC();
// Erst nach dem Anlegen des Tiers abfragen — vorher existiert die Owner-Zeile ggf. noch nicht.
const { data: referralState } = useQuery({
...trpc.referrals.onboardingState.queryOptions(),
enabled: !!createdPet,
});
const isReferralStateLoading = !!createdPet && !acquisitionStepDone && referralState === undefined;
const showAcquisitionStep = !!createdPet && !acquisitionStepDone && !!referralState?.showStep;
const finish = () => router.replace("/(app)");
@@ -34,6 +47,14 @@ export default function OnboardingPetScreen() {
content = <UploadConsent onContinue={() => setUploadConsentGiven(true)} />;
} else if (!arachnophobiaStepDone) {
content = <ArachnophobiaOptIn onContinue={() => setArachnophobiaStepDone(true)} />;
} else if (isReferralStateLoading) {
content = (
<View style={{ padding: 48, alignItems: "center" }}>
<ActivityIndicator color={colors.marigoldOrange} />
</View>
);
} else if (showAcquisitionStep) {
content = <AcquisitionStep canRedeem={referralState?.canRedeem ?? false} onDone={() => setAcquisitionStepDone(true)} />;
} else if (createdPet) {
content = <AvatarStep petId={createdPet.id} petName={createdPet.name} onDone={finish} />;
} else {
@@ -58,6 +79,8 @@ export default function OnboardingPetScreen() {
data={EMPTY_DATA}
renderItem={null}
contentContainerStyle={{ flexGrow: 1, backgroundColor: colors.porchWhite }}
automaticallyAdjustKeyboardInsets
keyboardShouldPersistTaps="handled"
ListHeaderComponent={<>{content}</>}
/>
);
+11 -1
View File
@@ -20,6 +20,12 @@
},
"channel": "preview"
},
"preview-simulator": {
"extends": "preview",
"ios": {
"simulator": true
}
},
"production": {
"android": {
"buildType": "app-bundle"
@@ -35,6 +41,10 @@
}
},
"submit": {
"production": {}
"production": {
"ios": {
"appleTeamId": "C72YB42R2D"
}
}
}
}
+73 -10
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
@@ -38,7 +39,7 @@ TODO Neu2:
→ Bei eigenem Beitrag öffnet "⋮" jetzt einen Lösch-Bestätigungsdialog statt der Report-Ansicht, bei fremden Beiträgen bleibt Report wie gewohnt; beides live bestätigt.
- [x] im Feed fehlt ein Indikator wenn in einem Post mehrere Bilder hinterlegt sind. das wischen funktioniert, aber nur wenn man weiß das dort mehrere bilder hinterlegt sind. unwissende würde nur ein bild sehen können.
→ Zähler-Badge oben rechts ("1 / 3") plus Punkte-Indikator unter dem Bild ergänzt (analog zur Web-Version), live bestätigt.
- [x] Kommentar Fenster ist nicht im Darkmode
- [x] Kommentar Fenster ist nicht im Darkmode
- [x] Tier Anlegen ist nicht im Darkmode + Rassen werden als Dropdown angezeigt lassen sich aber nicht scrollen.
→ Darkmode: PetCreateForm.tsx + onboarding/pet.tsx nutzten noch die statischen (Hell-only) `colors`/`typography` statt `useTheme()` — umgestellt auf das gleiche Muster wie sign-up.tsx/OptionPicker.tsx. Scroll-Bug: onboarding/pet.tsx nutzte als äußeren Container ein normales `ScrollView`, darin verschachtelt der Rassen-Dropdown (OptionPicker) sein eigenes `ScrollView` — zwei gleichachsige ScrollViews ineinander verschlucken auf Android die Scroll-Geste des inneren, auch mit `nestedScrollEnabled`. Fix: äußeren Container auf `FlatList` (leere Liste + `ListHeaderComponent`) umgestellt, exakt das bereits funktionierende Muster aus explore.tsx für dieselbe OptionPicker-Komponente. Typecheck + Lint sauber, i18n-Parität unverändert (243/243) — nicht live auf Android getestet (kein Emulator in dieser Session), nur strukturell gegen den bekannten Explore-Fix abgeglichen.
- [x] Nach dem Speichern des ersten Tiers, und dem Hochladen des Profilfotos, wird man wieder zurück geworfen auf Tier erstellen. erst ein neustart der app lässt einen auf den feed zugreifen. (BUG)
@@ -110,15 +111,16 @@ ErrorRecovery: exception encountered: Error: Missing required env var EXPO_PUBLI
**✅ Am Gerät bestätigt: Logout-Button erscheint, funktioniert.** Das OTA-Update kommt jetzt tatsächlich an und startet erfolgreich — die fehlenden EAS-Server-Env-Vars waren die echte, vollständige Ursache der gesamten Update-Odyssee dieser Session. `expo-dev-client`-Entfernung bleibt trotzdem sinnvoll (gehört nicht in Production), war aber nicht der entscheidende Fix. **TODO Neu6 damit abgeschlossen.**
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 ?
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.
- [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.
→ **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.
- [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.
→ Umgesetzt (Plan: `~/.claude/plans/mobile-settings-tab-extraction.md`, Gitea-Issue #36, Branch `feature/settings-header-icon`). Statt eines 5. Bottom-Tabs (ursprünglich vorgeschlagene Variante, siehe Mockup `mobile-settings-tab-mockup.png` im Plan-Ordner) wurde ein **Zahnrad-Icon im Header** gewählt, neben der Mailbox — auf Wunsch des Users, da Settings realistisch nur 1–2× im Halbjahr geöffnet wird und ein permanenter 5. Tab dafür unnötig Platz kostet (Mockup: `mobile-settings-header-icon-mockup.png`). Neuer Screen `app/(app)/settings.tsx` (Stack-Push wie "Suche", kein Modal, keine Tab-Bar sichtbar währenddessen), erreichbar über `SettingsHeaderButton` in `(tabs)/_layout.tsx` — erscheint auf allen 4 Tabs neben `MessagesHeaderButton`. Die 5 Footer-Sektionen aus `pets.tsx` (Darstellung/Sprache/Tier-Vorschläge/Rechtliches/Logout) wurden nach `src/components/settings/*.tsx` extrahiert (DRY, je eigene Datei). `pets.tsx` ist jetzt reine Tierliste. Nebenbei behoben: `FooterErrorBoundary` schützt jetzt den **gesamten** Settings-Screen statt nur den Logout-Button (Lücke aus der Code-Review beim OTA-Indikator-Feature). Typecheck + Lint sauber, i18n-Parität geprüft.
**Gemerged nach `main` in Commit `30c57b6`** (Merge von `feature/settings-header-icon`, Issue #36 geschlossen), end-to-end auf einem Android-Emulator verifiziert (Zahnrad öffnet Einstellungen korrekt, Zurück-Navigation funktioniert, keine Tab-Bar sichtbar währenddessen, "Meine Tiere" zeigt nur noch die Tierliste) und per OTA live veröffentlicht (Update-Group `f31e3b54`).
- [x] Ein user hat die meldung erhalten, "you do not have premission to access this pet" - kannst du mir erklären woher das kommen könnte ?
- [x] Ein user hat die meldung erhalten, "you do not have premission to access this pet" - kannst du mir erklären woher das kommen könnte ?
→ Ursache im Code gefunden (Web-seitig, nicht Mobile): Der Fehlertext kommt exakt aus `assertPetOwnership` (`src/lib/assert-pet-ownership.ts:19`), dem gemeinsamen Ownership-Check, den praktisch jede pet-bezogene Mutation zuerst durchläuft. Web speicherte die "aktive Tier"-ID in `localStorage["active_pet_id"]` (`src/context/ActivePetContext.tsx`) — anders als Mobile (siehe TODO Neu6: `LogoutSection` ruft dort bewusst `clearActivePetId()` auf) wurde dieser Wert auf Web **nirgends geleert**, auch nicht beim Sign-out über Clerks `<UserButton />`. `ActivePetInitializer` setzt nur dann einen Wert, wenn `activePetId` leer ist — er prüfte nie, ob die gespeicherte ID überhaupt noch zum aktuell eingeloggten Owner gehört. Konkretes Szenario: Nutzer A meldet sich ab, Nutzer B meldet sich im selben Browser an (gemeinsamer Rechner, oder A erstellt ein zweites Konto) — B's Session lud weiterhin A's alte `active_pet_id`, und jede Aktion schickte diese fremde Pet-ID ans Backend, das sie zu Recht mit genau dieser Fehlermeldung ablehnte. Gleiche Bug-Klasse wie der bereits auf Mobile behobene Logout-Fund, hier aber nie für Web nachgezogen.
Fix: `clearActivePetId()` in `src/context/ActivePetContext.tsx` ergänzt (Pendant zu Mobile). Da `ActivePetProvider` im Root-Layout **außerhalb** von `ClerkThemeProvider` sitzt (Clerk-Hooks dort nicht verfügbar — bewusste Architektur laut `(app)/layout.tsx`-Kommentar, um den Clerk-Bundle aus öffentlichen Routen rauszuhalten), kein direkter `useAuth()`-Zugriff im Context selbst möglich. Stattdessen neue `SignOutCleanup`-Komponente (`src/components/layout/SignOutCleanup.tsx`), mounted in `(app)/layout.tsx` neben `ActivePetInitializer` — beobachtet `useAuth().isSignedIn` und ruft `clearActivePetId()` beim Wechsel von `true` auf `false`, statt einen Klick-Handler an Clerks `<UserButton/>` zu hängen (die bietet dafür keinen sauberen Hook-Punkt). Deckt damit jeden Sign-out ab, unabhängig vom Auslöser. Typecheck + voller Testlauf (209/210, 1 skip wie zuvor) sauber, keine neuen Lint-Fehler.
- [x] Nutzer Feedback - DarkMode - Neuer Beitrag - Text die getippt werden sind weiß auf weißem grund.
@@ -129,8 +131,69 @@ Neu 7:
- [x] Google-SSO-Login ging nicht mehr, obwohl das schon mal gelöst war ("The current redirect url passed in the sign in or sign up request does not match an authorized redirect URI for this instance... pawfeed://sso-callback").
→ **Kein Code-Bug** — `GoogleSignInButton.tsx`/`useWarmUpBrowser.ts` (Commit `99575dd`) waren unverändert korrekt. Ursache lag außerhalb des Repos: Clerk führt pro Instanz eine eigene, serverseitige Allowlist autorisierter Redirect-URLs (`GET/POST https://api.clerk.com/v1/redirect_urls`, Auth via `CLERK_SECRET_KEY`) — komplett getrennt vom App-Code, kann also unabhängig vom letzten "war doch schon gelöst"-Stand geändert/geleert worden sein. Registriert war nur ein falscher/veralteter Eintrag `clerk://org.pawfeed.app2.callback` (vermutlich Rest einer früheren Bundle-ID-Iteration mit einer "2" drin) — `pawfeed://sso-callback` (aus `app.json`s `"scheme": "pawfeed"`) fehlte komplett.
Fix: fehlenden Eintrag per Clerk Backend API ergänzt (`POST /v1/redirect_urls`, `{"url":"pawfeed://sso-callback"}`) — live sofort wirksam, kein App-Deploy/Rebuild nötig. Am Emulator bis zum Google-Consent-Screen verifiziert (öffnet korrekt, leitet zu pawfeed.org weiter), kompletten Rücksprung dann vom User auf echtem Gerät bestätigt.
**Merke für künftige Sessions:** wenn SSO trotz unverändertem Code plötzlich bricht, zuerst die Clerk-Redirect-URL-Allowlist prüfen, bevor im App-Code gesucht wird — die liegt nicht in git und kann von außerhalb dieser Session geändert worden sein.
- Story Ring - ist abgeschnitten in der Mobil-App.
**Merke für künftige Sessions:** wenn SSO trotz unverändertem Code plötzlich bricht, zuerst die Clerk-Redirect-URL-Allowlist prüfen, bevor im App-Code gesucht wird — die liegt nicht in git und kann von außerhalb dieser Session geändert worden sein.
- [x] Story Ring - ist abgeschnitten in der Mobil-App.
→ Ursache: Die zwei mittleren "Zehen" der Pfote (`cy: 1, r: 10`) ragen in der 0–100-viewBox bis y = -9. Web zeichnet das SVG mit `overflow: visible`, `react-native-svg` schneidet dagegen an der viewBox ab. Fix in `src/components/PawRing.tsx`: viewBox nach oben erweitert (`0 -10 100 110`), Box entsprechend höher (`getPawRingBox()`); der "Story hinzufügen"-Knopf in `StoryTray.tsx` nutzt dieselbe Box und sitzt jetzt auf gleicher Höhe. Commit `2ef439c`, per OTA für Android veröffentlicht (Update-Group `4dc6f797`), noch nicht am Gerät bestätigt.
- [x] Mehrere Nutzer melden nach Google-Login die Meldung "Unmatched Route pawfeed://sso-callback".
→ Anderer Bug als der frühere Clerk-Redirect-URL-Allowlist-Fund oben (der Eintrag ist weiterhin korrekt registriert) — diesmal ein echter Code-Bug, spezifisch Android. `useSSO()` (`@clerk/expo`) baut die Redirect-URI selbst via `AuthSession.makeRedirectUri({ path: "sso-callback" })` → `pawfeed://sso-callback`, und `WebBrowser.openAuthSessionAsync()` fängt die Rückleitung normalerweise über eine eigene Custom-Tab-Session ab (das ist der Zweck von `WebBrowser.maybeCompleteAuthSession()` in `app/_layout.tsx`). Auf Android liefert das OS den Redirect-Intent aber zusätzlich als normalen Deep-Link an die App — den sieht auch Expo Routers eigener Linking-Listener, und weil es dafür keine passende Route gab, zeigte Expo Router kurz seinen eingebauten "Unmatched Route"-Fehlerschirm, bevor `GoogleSignInButton`s `router.replace("/(app)")` (nach erfolgreichem `setActive()`) übernahm — bei langsameren Geräten/Netzwerken blieb der Fehlerschirm sichtbar hängen statt wegzuflippen. Bekanntes Muster bei Clerk+Expo-Router (u. a. expo/expo#22662, mehrere Community-Fixes mit identischer Lösung).
Fix: neue Datei `app/sso-callback.tsx` — reiner Lade-Spinner (kein eigenes Redirect/Auth-Logic), gibt Expo Router für dieses kurze Zeitfenster ein gültiges Ziel. Die bestehende `useProtectedRoute()`-Umleitung in `app/_layout.tsx` übernimmt danach wie gewohnt (leitet nicht-eingeloggte Nutzer automatisch weiter, sobald sie außerhalb der `(auth)`-Gruppe landen), `GoogleSignInButton` navigiert bei Erfolg wie bisher aktiv weiter. Reine JS/TSX-Änderung, kein natives Modul betroffen → OTA-fähig, kein neuer Store-Build nötig. Typecheck + Lint sauber.
Fix: neue Datei `app/sso-callback.tsx` — reiner Lade-Spinner (kein eigenes Redirect/Auth-Logic), gibt Expo Router für dieses kurze Zeitfenster ein gültiges Ziel. Die bestehende `useProtectedRoute()`-Umleitung in `app/_layout.tsx` übernimmt danach wie gewohnt (leitet nicht-eingeloggte Nutzer automatisch weiter, sobald sie außerhalb der `(auth)`-Gruppe landen), `GoogleSignInButton` navigiert bei Erfolg wie bisher aktiv weiter. Reine JS/TSX-Änderung, kein natives Modul betroffen → OTA-fähig, kein neuer Store-Build nötig. Typecheck + Lint sauber (kein Live-Test auf Emulator/Gerät möglich in dieser Session).
**Gemerged nach `main`** (Fast-Forward von `fix/sso-callback-unmatched-route`, Commit `17a17bd`, unabhängig von der noch offenen `feature/mobile-video-stories`-Merge-Entscheidung) und **per OTA live veröffentlicht** (`npx eas-cli update --branch production --environment production --platform android`, Update-Group `df2a3ce9-7eba-4cb8-bf10-7d478a4a8a60`, Runtime-Version `1.0.6`). Installierte Apps ziehen das automatisch beim nächsten Start.
**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.
- Feature: Storie-Edit Modus, mittels Overlays eigene Storys hübsch machen, durch Texteingaben in das Bild oder Effekte oder Gifs. (Analog zu Instagram) ->Branch
- [x] Admin-Dashboard -> Zurückgewiesene Reports, als Meldung an den Melder mit einer Begründung warum abgewiesen wurde. Sowie Meldung bei bestätigter Report löschung, z.b mit Danksagung für die Meldung.
→ **Umgesetzt und live (04.10.), als Team-Nachricht.** Admin → Reports: „Report ignorieren“ hat jetzt ein Feld „Begründung für den Melder“; der Melder bekommt vom PawFeed-Team „Danke für deine Meldung zum Beitrag/Profil von X … kein Verstoß … Begründung: …“ (Begründung steht zusätzlich im Moderations-Log). „Post löschen“ dankt jedem Melder des Beitrags einmal („… Inhalt entfernt. Danke, dass du hilfst …“); die interne Lösch-Begründung geht bewusst nicht an den Melder. Beides per Schalter im Dialog abschaltbar, Versand erst nach erfolgreicher Moderation und ohne sie bei einem Fehler zu kippen. **Nebenbei behoben:** Beim Löschen eines gemeldeten Beitrags blieben dessen Reports verwaist in der Liste (`targetPostId` ist keine Relation) — werden jetzt mitgelöscht. 7 neue Tests (`admin-report-feedback.test.ts`), alle 336 Tests grün. Commit `f51b158`, Rolling-Deploy auf dem NAS. Kein App-Update nötig. Im Live-Admin noch nicht mit einem echten Report ausprobiert.
- Feedback - iOS & Andriod, Story-Bilder und Videos mit Overlays bearbeiten direkt in der App. Text hinzufügen, Gifs einbinden, Markierungen auf andere Pets setzten (Analog zu Instagram) bitte als neues Branch anlegen.
- [x] 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.
+32 -4
View File
@@ -17,8 +17,11 @@
"@trpc/client": "11.17.0",
"@trpc/tanstack-react-query": "11.17.0",
"expo": "~57.0.22",
"expo-apple-authentication": "~57.0.2",
"expo-auth-session": "~57.0.12",
"expo-build-properties": "~57.0.22",
"expo-constants": "~57.0.16",
"expo-crypto": "~57.0.3",
"expo-dev-client": "~57.0.19",
"expo-file-system": "~57.0.6",
"expo-font": "~57.0.3",
@@ -6311,6 +6314,17 @@
}
}
},
"node_modules/expo-apple-authentication": {
"version": "57.0.2",
"resolved": "https://registry.npmjs.org/expo-apple-authentication/-/expo-apple-authentication-57.0.2.tgz",
"integrity": "sha512-00KnlyRceRKski+ljZz8q8JhJZ4D3ZScU+Y0RqS6ElJ8GaCWfd7Qh5tMr47yIU/dKDrOnkzkFUM+6vyHS5HI7Q==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-application": {
"version": "57.0.3",
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.3.tgz",
@@ -6353,11 +6367,16 @@
"react-native": "*"
}
},
"node_modules/expo-auth-session/node_modules/expo-crypto": {
"version": "57.0.3",
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.3.tgz",
"integrity": "sha512-SAWqEfF37nc8ot7bXsVmu9AFYwQdC2kGvH/eNAHiR3bmlcf1Vv8wu9AnGKT+a90r3T8YfwMpZP4QAYlo4vCn9w==",
"node_modules/expo-build-properties": {
"version": "57.0.22",
"resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-57.0.22.tgz",
"integrity": "sha512-A5oRf2I02XDRu2NnqKo7Au5kUu244unvyoRMUsB+xvg/BOlsR+G/oyDFGkjbda1cJbeKm9eqposIXbA2ri5UrQ==",
"license": "MIT",
"dependencies": {
"@expo/schema-utils": "^57.0.2",
"resolve-from": "^5.0.0",
"semver": "^7.6.0"
},
"peerDependencies": {
"expo": "*"
}
@@ -6375,6 +6394,15 @@
"react-native": "*"
}
},
"node_modules/expo-crypto": {
"version": "57.0.3",
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.3.tgz",
"integrity": "sha512-SAWqEfF37nc8ot7bXsVmu9AFYwQdC2kGvH/eNAHiR3bmlcf1Vv8wu9AnGKT+a90r3T8YfwMpZP4QAYlo4vCn9w==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-dev-client": {
"version": "57.0.19",
"resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-57.0.19.tgz",
+3
View File
@@ -12,8 +12,11 @@
"@trpc/client": "11.17.0",
"@trpc/tanstack-react-query": "11.17.0",
"expo": "~57.0.22",
"expo-apple-authentication": "~57.0.2",
"expo-auth-session": "~57.0.12",
"expo-build-properties": "~57.0.22",
"expo-constants": "~57.0.16",
"expo-crypto": "~57.0.3",
"expo-dev-client": "~57.0.19",
"expo-file-system": "~57.0.6",
"expo-font": "~57.0.3",
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useState } from "react";
import { Platform, View, StyleSheet } from "react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import * as AppleAuthentication from "expo-apple-authentication";
import { useSignInWithApple } from "@clerk/expo/apple";
import * as Sentry from "@sentry/react-native";
import { useTheme } from "../context/ThemeContext";
import { radii } from "../theme/tokens";
type Props = {
onError: (message: string) => void;
/** Same contract as GoogleSignInButton's `legalAccepted` — see there. */
legalAccepted?: boolean;
/** Same contract as GoogleSignInButton's `onNeedsConsent` — see there. */
onNeedsConsent?: () => void;
disabled?: boolean;
};
/**
* "Sign in with Apple" — required by App Store Review Guideline 4.8 because
* the app offers Google login. iOS only: renders nothing on Android or on an
* iOS device where Apple Authentication isn't available.
*
* Uses Clerk's native `useSignInWithApple()` (expo-apple-authentication
* sheet + `oauth_token_apple` token exchange) instead of the browser-based
* `oauth_apple` SSO flow — Apple expects the native sheet, and it avoids the
* redirect-URL pitfalls the Google SSO flow had.
*
* Uses Apple's own AppleAuthenticationButton (HIG-mandated look), not a
* custom Pressable like GoogleSignInButton. Legal-consent handling mirrors
* GoogleSignInButton: Clerk's sign-up comes back `missing_requirements`
* with `legal_accepted` for brand-new accounts.
*/
export function AppleSignInButton(props: Props) {
const [isAvailable, setIsAvailable] = useState(false);
useEffect(() => {
if (Platform.OS !== "ios") return;
AppleAuthentication.isAvailableAsync().then(setIsAvailable).catch(() => setIsAvailable(false));
}, []);
if (!isAvailable) return null;
return <AppleSignInButtonInner {...props} />;
}
function AppleSignInButtonInner({ onError, legalAccepted, onNeedsConsent, disabled }: Props) {
const { t } = useTranslation();
const { scheme } = useTheme();
const router = useRouter();
const { startAppleAuthenticationFlow } = useSignInWithApple();
const [isPending, setIsPending] = useState(false);
const onPress = async () => {
setIsPending(true);
try {
const result = await startAppleAuthenticationFlow();
const { setActive, signUp } = result;
let createdSessionId = result.createdSessionId;
let missingFields = signUp?.status === "missing_requirements" ? signUp.missingFields : [];
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;
}
// 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"));
} finally {
setIsPending(false);
}
};
const isInactive = isPending || !!disabled;
return (
<View style={isInactive && styles.disabled} pointerEvents={isInactive ? "none" : "auto"}>
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
buttonStyle={
scheme === "dark"
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK
}
cornerRadius={radii.lg}
style={styles.button}
onPress={onPress}
/>
</View>
);
}
const styles = StyleSheet.create({
button: { height: 48, width: "100%" },
disabled: { opacity: 0.5 },
});
+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 },
+55 -9
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,29 +11,56 @@ 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;
// The two middle toe beans (cy 1, r 10) reach up to y = -9 in the 0–100
// viewBox. Web renders the SVG with `overflow: visible`; react-native-svg
// clips at the viewBox instead, which cut the paw off at the top (found.md
// "Story Ring ist abgeschnitten"). So the viewBox starts BEAN_OVERHANG units
// higher and the box grows by the same proportion.
const BEAN_OVERHANG = 10;
const UNSEEN_COLOR = "#f97316";
const SEEN_COLOR = "#9ca3af";
/** Outer box of a PawRing for a given avatar size — lets siblings (StoryTray's "add" tile) line up with it. */
export function getPawRingBox(size: number) {
const width = size + RING_PAD * 2;
const overhang = (width * BEAN_OVERHANG) / 100;
return { width, height: width + overhang, paddingTop: overhang };
}
/**
* Port of web's src/components/stories/PawRing.tsx — a paw-print outline
* (one big circle + four "toe bean" circles) drawn with react-native-svg
* 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 outerSize = size + RING_PAD * 2;
const box = getPawRingBox(size);
const strokeWidth = Math.max(4, size / 8);
const beans = [
{ cx: 13, cy: 10, r: 8 },
@@ -42,13 +70,31 @@ export function PawRing({ petId, children, size = 32, onPress }: Props) {
];
const content = (
<View style={{ width: outerSize, height: outerSize, alignItems: "center", justifyContent: "center" }}>
<View
style={{
width: box.width,
height: box.height,
paddingTop: box.paddingTop,
alignItems: "center",
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={outerSize}
height={outerSize}
viewBox="0 0 100 100"
style={{ position: "absolute" }}
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={ringColor} strokeWidth={strokeWidth} />
{beans.map((b, i) => (
+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) {
+17 -4
View File
@@ -1,22 +1,35 @@
import { View, StyleSheet } from "react-native";
import { BadgeCheck, Sparkles } from "lucide-react-native";
import { BadgeCheck, Sparkles, Sprout } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { isActiveSponsor } from "../lib/sponsor";
import { colors } from "../theme/tokens";
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;
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 }: Props) {
const FOUNDING_COLOR = "#059669"; // emerald-600, wie im Web
export function PetIdentityBadges({ isVerified, isFoundingMember, sponsor, referral }: Props) {
const { t } = useTranslation();
const showSponsor = sponsor ? isActiveSponsor(sponsor) : false;
if (!isVerified && !showSponsor) return null;
const rank = useOwnerRankWithPerk(referral, "NAME_BADGE");
if (!isVerified && !isFoundingMember && !showSponsor && !rank) return null;
return (
<View style={styles.row}>
{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>
);
}
+26 -5
View File
@@ -13,10 +13,12 @@ import { PawBackButton } from "./PawBackButton";
import { RepostCard } from "./RepostCard";
import { VideoPlayer } from "./VideoPlayer";
import { ReportSheet } from "./ReportSheet";
import { useBlockPet } from "../hooks/useBlockPet";
import { getMediaUrl } from "../lib/media-url";
import { formatPetName, formatRelativeTime } from "../lib/format";
import { useTheme } from "../context/ThemeContext";
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:
@@ -38,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 = {
@@ -58,7 +60,15 @@ export type PostCardPost = {
nickname?: string | null;
avatarKey?: string | null;
isVerified?: boolean;
owner?: { sponsorSince: Date | string | null; sponsorPeriodEnd: Date | string | null } | 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;
referralRankId?: string | null;
referralPerks?: string[] | null;
} | null;
};
};
@@ -90,6 +100,7 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr
const cardWidth = width - 32; // matches the 16px screen padding used by the feed list
const [reportOpen, setReportOpen] = useState(false);
const isOwnPost = post.pet.id === actingPetId;
const { confirmBlock } = useBlockPet();
const deleteMutation = useMutation(
trpc.posts.delete.mutationOptions({
@@ -119,7 +130,17 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr
{ text: t("common.delete"), style: "destructive", onPress: () => deleteMutation.mutate({ postId: post.id }) },
]);
} else {
setReportOpen(true);
// Report + block (App Store Guideline 1.2), matching web PostCard's
// "..." menu for other pets' posts.
Alert.alert(formatPetName(post.pet), undefined, [
{ text: t("report.title"), onPress: () => setReportOpen(true) },
{
text: t("block.menuItem", { petName: post.pet.name }),
style: "destructive",
onPress: () => confirmBlock(actingPetId, { id: post.pet.id, name: post.pet.name }),
},
{ text: t("common.cancel"), style: "cancel" },
]);
}
};
@@ -127,11 +148,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} />
<PetIdentityBadges 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>
+46 -20
View File
@@ -7,28 +7,42 @@ import { PlusCircle } from "lucide-react-native";
import { useTRPC } from "../api/trpc";
import { useActivePet } from "../context/ActivePetContext";
import { useTheme } from "../context/ThemeContext";
import { PawRing } from "./PawRing";
import { PawRing, getPawRingBox } from "./PawRing";
import { PetAvatar } from "./PetAvatar";
import { fontFamily, type ColorTokens } from "../theme/tokens";
const AVATAR_SIZE = 56;
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,
@@ -37,26 +51,30 @@ export function StoryTray() {
return (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.row}>
<Pressable style={styles.item} onPress={() => router.push("/story-new")}>
<View style={styles.addButton}>
<PlusCircle color={colors.pureWhite} size={26} />
<View style={styles.addButtonBox}>
<View style={styles.addButton}>
<PlusCircle color={colors.pureWhite} size={26} />
</View>
</View>
<Text style={styles.label} numberOfLines={1}>
{t("feed.storyLabel")}
</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}>
@@ -71,7 +89,15 @@ export function StoryTray() {
function makeStyles(colors: ColorTokens) {
return StyleSheet.create({
row: { gap: 16, paddingHorizontal: 16, paddingVertical: 12 },
item: { alignItems: "center", gap: 4, width: 72 },
item: { alignItems: "center", gap: 4, width: RING_BOX.width },
// Same box as a PawRing so the "add" tile lines up with the story rings.
addButtonBox: {
width: RING_BOX.width,
height: RING_BOX.height,
paddingTop: RING_BOX.paddingTop,
alignItems: "center",
justifyContent: "center",
},
addButton: {
width: AVATAR_SIZE,
height: AVATAR_SIZE,
@@ -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,107 @@
import { useMemo, useState } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { CheckCircle2 } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { useTheme } from "../../context/ThemeContext";
import { Button } from "../Button";
import { RedeemCodeForm } from "../referrals/RedeemCodeForm";
import { radii, getTypography, type ColorTokens } from "../../theme/tokens";
const SOURCES = [
"FRIENDS",
"TIKTOK",
"INSTAGRAM",
"YOUTUBE",
"FACEBOOK",
"SHELTER_OR_PARTNER",
"STORE_SEARCH",
"PRESS",
"OTHER",
] as const;
type Source = (typeof SOURCES)[number];
type Props = { canRedeem: boolean; onDone: () => void };
/**
* Port of web's src/components/referrals/AcquisitionStep.tsx — freiwillige
* Herkunftsangabe nach dem ersten Tier, bei „Freunde“ mit Code-Eingabe.
* Kein Vorausfüllen wie im Web (dort per Cookie aus /r/<code>): nach dem
* Store-Umweg gibt es in der App keinen Weg, den Link-Code mitzunehmen.
*/
export function AcquisitionStep({ canRedeem, onDone }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const trpc = useTRPC();
const [source, setSource] = useState<Source | null>(null);
const [inviterName, setInviterName] = useState<string | null | undefined>(undefined);
const saveSource = useMutation(trpc.referrals.setAcquisitionSource.mutationOptions({ onSettled: onDone }));
function handleContinue() {
if (source) saveSource.mutate({ source });
else onDone();
}
return (
<View style={styles.container}>
<Text style={styles.title}>{t("referrals.onboardingTitle")}</Text>
<Text style={styles.subtitle}>{t("referrals.onboardingSubtitle")}</Text>
<View style={styles.chips} accessibilityRole="radiogroup">
{SOURCES.map((option) => {
const isSelected = source === option;
return (
<Pressable
key={option}
accessibilityRole="radio"
accessibilityState={{ checked: isSelected }}
onPress={() => setSource(option)}
style={[styles.chip, isSelected && styles.chipSelected]}
>
<Text style={[styles.chipText, isSelected && styles.chipTextSelected]}>{t(`referrals.source_${option}`)}</Text>
</Pressable>
);
})}
</View>
{source === "FRIENDS" && canRedeem && (
<View style={styles.redeemBox}>
{inviterName !== undefined ? (
<View style={styles.redeemedRow}>
<CheckCircle2 size={18} color={colors.marigoldOrange} />
<Text style={styles.redeemedText}>
{inviterName ? t("referrals.redeemed", { name: inviterName }) : t("referrals.redeemedGeneric")}
</Text>
</View>
) : (
<RedeemCodeForm onRedeemed={setInviterName} />
)}
</View>
)}
<Button title={saveSource.isPending ? "…" : t("common.continue")} disabled={saveSource.isPending} onPress={handleContinue} style={styles.cta} />
<Button title={t("common.skip")} variant="outline" onPress={onDone} />
</View>
);
}
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
container: { padding: 24, gap: 8 },
title: { ...typography.title, fontSize: 18 },
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 12 },
chips: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginBottom: 12 },
chip: { paddingHorizontal: 14, paddingVertical: 9, borderRadius: 999, borderWidth: 1, borderColor: colors.hairlineGray },
chipSelected: { borderColor: colors.marigoldOrange, backgroundColor: colors.marigoldTint },
chipText: { ...typography.body, fontSize: 14 },
chipTextSelected: { color: colors.marigoldDeep, fontWeight: "600" },
redeemBox: { padding: 14, borderRadius: radii.xl, borderWidth: 1, borderColor: colors.hairlineGray, marginBottom: 12 },
redeemedRow: { flexDirection: "row", alignItems: "center", gap: 8 },
redeemedText: { ...typography.body, flex: 1 },
cta: { marginTop: 4 },
});
}
@@ -1,11 +1,12 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { Bug, Check } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { Button } from "../Button";
import { colors, radii, typography } from "../../theme/tokens";
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
import { useTheme } from "../../context/ThemeContext";
type Props = { onContinue: () => void };
@@ -16,6 +17,8 @@ type Props = { onContinue: () => void };
*/
export function ArachnophobiaOptIn({ onContinue }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const trpc = useTRPC();
const [hideSpiders, setHideSpiders] = useState(false);
@@ -53,32 +56,35 @@ export function ArachnophobiaOptIn({ onContinue }: Props) {
);
}
const styles = StyleSheet.create({
container: { padding: 24, gap: 4 },
titleRow: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 },
title: { ...typography.title, fontSize: 18 },
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 },
checkboxRow: {
flexDirection: "row",
gap: 12,
padding: 14,
borderRadius: radii.xl,
borderWidth: 1,
borderColor: colors.hairlineGray,
marginBottom: 16,
},
checkbox: {
width: 20,
height: 20,
borderRadius: 5,
borderWidth: 1.5,
borderColor: colors.hairlineGray,
alignItems: "center",
justifyContent: "center",
marginTop: 2,
},
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 },
hint: { ...typography.label, textTransform: "none", letterSpacing: 0, marginBottom: 16 },
cta: { marginTop: 4 },
});
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
container: { padding: 24, gap: 4 },
titleRow: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 },
title: { ...typography.title, fontSize: 18 },
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 },
checkboxRow: {
flexDirection: "row",
gap: 12,
padding: 14,
borderRadius: radii.xl,
borderWidth: 1,
borderColor: colors.hairlineGray,
marginBottom: 16,
},
checkbox: {
width: 20,
height: 20,
borderRadius: 5,
borderWidth: 1.5,
borderColor: colors.hairlineGray,
alignItems: "center",
justifyContent: "center",
marginTop: 2,
},
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 },
hint: { ...typography.label, textTransform: "none", letterSpacing: 0, marginBottom: 16 },
cta: { marginTop: 4 },
});
}
+41 -35
View File
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { View, Text, Image as RNImage, Pressable, ActivityIndicator, StyleSheet, Alert } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { useTranslation } from "react-i18next";
@@ -9,7 +9,8 @@ import { uploadImageToSupabase } from "../../lib/upload";
import { resizeForUpload } from "../../lib/resize-image";
import { PetAvatar } from "../PetAvatar";
import { Button } from "../Button";
import { colors, radii, typography } from "../../theme/tokens";
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
import { useTheme } from "../../context/ThemeContext";
type Props = {
petId: string;
@@ -26,6 +27,8 @@ type Props = {
*/
export function AvatarStep({ petId, petName, onDone }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const trpc = useTRPC();
const [asset, setAsset] = useState<ImagePicker.ImagePickerAsset | null>(null);
const [uploadedKey, setUploadedKey] = useState<string | null>(null);
@@ -120,36 +123,39 @@ export function AvatarStep({ petId, petName, onDone }: Props) {
);
}
const styles = StyleSheet.create({
container: { padding: 24, gap: 4, alignItems: "center" },
title: { ...typography.title, fontSize: 18, textAlign: "center" },
subtitle: { ...typography.body, color: colors.quietGray, textAlign: "center", marginBottom: 20 },
previewWrap: { width: 96, height: 96, borderRadius: 48, overflow: "hidden", backgroundColor: colors.hairlineGray },
preview: { width: "100%", height: "100%" },
previewOverlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.4)",
alignItems: "center",
justifyContent: "center",
},
error: { ...typography.body, color: colors.alertRed, marginTop: 12, textAlign: "center" },
pickerRow: { flexDirection: "row", gap: 12, marginTop: 20, alignSelf: "stretch" },
pickerButton: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
height: 48,
borderRadius: radii.lg,
borderWidth: 1,
borderColor: colors.hairlineGray,
},
pickerText: { ...typography.body, color: colors.ink },
cta: { marginTop: 20, alignSelf: "stretch" },
skipLink: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray, marginTop: 12 },
});
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
container: { padding: 24, gap: 4, alignItems: "center" },
title: { ...typography.title, fontSize: 18, textAlign: "center" },
subtitle: { ...typography.body, color: colors.quietGray, textAlign: "center", marginBottom: 20 },
previewWrap: { width: 96, height: 96, borderRadius: 48, overflow: "hidden", backgroundColor: colors.hairlineGray },
preview: { width: "100%", height: "100%" },
previewOverlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.4)",
alignItems: "center",
justifyContent: "center",
},
error: { ...typography.body, color: colors.alertRed, marginTop: 12, textAlign: "center" },
pickerRow: { flexDirection: "row", gap: 12, marginTop: 20, alignSelf: "stretch" },
pickerButton: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
height: 48,
borderRadius: radii.lg,
borderWidth: 1,
borderColor: colors.hairlineGray,
},
pickerText: { ...typography.body, color: colors.ink },
cta: { marginTop: 20, alignSelf: "stretch" },
skipLink: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray, marginTop: 12 },
});
}
@@ -1,12 +1,13 @@
import { View, Text, Pressable, ActivityIndicator, Linking, StyleSheet } from "react-native";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ScrollText } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { env } from "../../lib/env";
import { Button } from "../Button";
import { colors, radii, typography } from "../../theme/tokens";
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
import { useTheme } from "../../context/ThemeContext";
type Props = { onContinue: () => void };
@@ -18,6 +19,8 @@ type Props = { onContinue: () => void };
*/
export function OnboardingGuidelines({ onContinue }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const trpc = useTRPC();
const queryClient = useQueryClient();
@@ -80,22 +83,25 @@ export function OnboardingGuidelines({ onContinue }: Props) {
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 40 },
container: { padding: 24, gap: 4 },
titleRow: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 },
title: { ...typography.title, fontSize: 18 },
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 },
card: {
gap: 10,
padding: 14,
borderRadius: radii.xl,
borderWidth: 1,
borderColor: colors.hairlineGray,
backgroundColor: colors.pureWhite,
marginBottom: 16,
},
summary: { ...typography.body },
link: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.marigoldDeep, marginBottom: 8 },
cta: { marginTop: 12 },
});
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 40 },
container: { padding: 24, gap: 4 },
titleRow: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 },
title: { ...typography.title, fontSize: 18 },
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 },
card: {
gap: 10,
padding: 14,
borderRadius: radii.xl,
borderWidth: 1,
borderColor: colors.hairlineGray,
backgroundColor: colors.pureWhite,
marginBottom: 16,
},
summary: { ...typography.body },
link: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.marigoldDeep, marginBottom: 8 },
cta: { marginTop: 12 },
});
}
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { View, Text, Pressable, ActivityIndicator, Linking, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -6,7 +6,8 @@ import { ShieldCheck, Check } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { env } from "../../lib/env";
import { Button } from "../Button";
import { colors, radii, typography } from "../../theme/tokens";
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
import { useTheme } from "../../context/ThemeContext";
type Props = { onContinue: () => void };
@@ -18,6 +19,8 @@ type Props = { onContinue: () => void };
*/
export function UploadConsent({ onContinue }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const trpc = useTRPC();
const queryClient = useQueryClient();
const [contentRightsChecked, setContentRightsChecked] = useState(false);
@@ -85,6 +88,8 @@ export function UploadConsent({ onContinue }: Props) {
}
function ConsentCheckbox({ checked, onToggle, label }: { checked: boolean; onToggle: () => void; label: string }) {
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
return (
<Pressable style={styles.checkboxRow} onPress={onToggle}>
<View style={[styles.checkbox, checked && styles.checkboxChecked]}>
@@ -95,33 +100,36 @@ function ConsentCheckbox({ checked, onToggle, label }: { checked: boolean; onTog
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 40 },
container: { padding: 24, gap: 4 },
titleRow: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 },
title: { ...typography.title, fontSize: 18 },
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 },
checkboxRow: {
flexDirection: "row",
gap: 12,
padding: 14,
borderRadius: radii.xl,
borderWidth: 1,
borderColor: colors.hairlineGray,
marginBottom: 12,
},
checkbox: {
width: 20,
height: 20,
borderRadius: 5,
borderWidth: 1.5,
borderColor: colors.hairlineGray,
alignItems: "center",
justifyContent: "center",
marginTop: 2,
},
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 },
link: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.marigoldDeep, marginBottom: 16, marginTop: 4 },
cta: { marginTop: 4 },
});
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 40 },
container: { padding: 24, gap: 4 },
titleRow: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 },
title: { ...typography.title, fontSize: 18 },
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 },
checkboxRow: {
flexDirection: "row",
gap: 12,
padding: 14,
borderRadius: radii.xl,
borderWidth: 1,
borderColor: colors.hairlineGray,
marginBottom: 12,
},
checkbox: {
width: 20,
height: 20,
borderRadius: 5,
borderWidth: 1.5,
borderColor: colors.hairlineGray,
alignItems: "center",
justifyContent: "center",
marginTop: 2,
},
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 },
link: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.marigoldDeep, marginBottom: 16, marginTop: 4 },
cta: { marginTop: 4 },
});
}
@@ -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,90 @@
import { useMemo } from "react";
import { View, Text, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { Check } from "lucide-react-native";
import { useTheme } from "../../context/ThemeContext";
import { ReferralIcon } from "../../lib/referral-icons";
import type { ReferralRankView } from "../../hooks/useReferralRanks";
import { radii, getTypography, type ColorTokens } from "../../theme/tokens";
type Props = {
ranks: ReferralRankView[];
achievedRankIds: ReadonlySet<string>;
qualifiedCount: number;
nextRankId: string | null;
};
/** Port of web's RankLadder — Fortschritt zum nächsten Rang plus alle aktiven Ränge. */
export function RankLadder({ ranks, achievedRankIds, qualifiedCount, nextRankId }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const activeRanks = ranks.filter((rank) => rank.isActive);
const next = activeRanks.find((rank) => rank.id === nextRankId) ?? null;
const progress = next ? Math.min(100, Math.round((qualifiedCount / next.threshold) * 100)) : 100;
return (
<View style={styles.card}>
<View style={styles.headerRow}>
<Text style={styles.heading}>{t("referrals.ranksHeading")}</Text>
<Text style={styles.muted}>{t("referrals.countedInvites", { count: qualifiedCount })}</Text>
</View>
{next ? (
<>
<View style={styles.track} accessibilityRole="progressbar" accessibilityValue={{ min: 0, max: next.threshold, now: qualifiedCount }}>
<View style={[styles.fill, { width: `${progress}%`, backgroundColor: next.color }]} />
</View>
<Text style={styles.muted}>
{t("referrals.nextRank", { count: Math.max(0, next.threshold - qualifiedCount), name: next.name })}
</Text>
</>
) : (
<Text style={styles.muted}>{t("referrals.allRanksReached")}</Text>
)}
<View style={styles.list}>
{activeRanks.map((rank) => {
const isAchieved = achievedRankIds.has(rank.id);
return (
<View
key={rank.id}
style={[styles.row, isAchieved ? { borderColor: rank.color } : styles.rowPending]}
>
<View style={[styles.iconCircle, { borderColor: rank.color, backgroundColor: isAchieved ? rank.color : "transparent" }]}>
<ReferralIcon icon={rank.icon} size={16} color={isAchieved ? "#ffffff" : rank.color} />
</View>
<View style={styles.rowText}>
<Text style={styles.rankName}>{rank.name}</Text>
<Text style={styles.rankDescription}>{rank.description}</Text>
</View>
{isAchieved ? (
<Check size={16} color={colors.marigoldOrange} accessibilityLabel={t("referrals.achieved")} />
) : (
<Text style={styles.muted}>{t("referrals.threshold", { count: rank.threshold })}</Text>
)}
</View>
);
})}
</View>
</View>
);
}
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
card: { gap: 10, padding: 16, borderRadius: radii["2xl"], borderWidth: 1, borderColor: colors.hairlineGray, backgroundColor: colors.pureWhite },
headerRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "baseline", gap: 8 },
heading: { ...typography.title, fontSize: 16 },
muted: { ...typography.body, fontSize: 13, color: colors.quietGray },
track: { height: 8, borderRadius: 4, backgroundColor: colors.whisperGray, overflow: "hidden" },
fill: { height: 8, borderRadius: 4 },
list: { gap: 8, marginTop: 6 },
row: { flexDirection: "row", alignItems: "center", gap: 12, padding: 10, borderRadius: radii.xl, borderWidth: 1 },
rowPending: { borderColor: colors.hairlineGray, borderStyle: "dashed" },
iconCircle: { width: 32, height: 32, borderRadius: 16, borderWidth: 2, alignItems: "center", justifyContent: "center" },
rowText: { flex: 1 },
rankName: { ...typography.body, fontWeight: "600" },
rankDescription: { ...typography.body, fontSize: 12, color: colors.quietGray },
});
}
@@ -0,0 +1,74 @@
import { useMemo, useState } from "react";
import { View, Text, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "../../api/trpc";
import { useTheme } from "../../context/ThemeContext";
import { Button } from "../Button";
import { TextField } from "../TextField";
import { getTypography, type ColorTokens } from "../../theme/tokens";
/** Fehler-Schlüssel aus REFERRAL_ERRORS (src/lib/referrals.ts im Web) → Übersetzungsschlüssel. */
const ERROR_KEYS: Record<string, string> = {
REFERRAL_PROGRAM_PAUSED: "referrals.errorPaused",
REFERRAL_CODE_INVALID: "referrals.errorInvalid",
REFERRAL_OWN_CODE: "referrals.errorOwnCode",
REFERRAL_ALREADY_REDEEMED: "referrals.errorAlreadyRedeemed",
REFERRAL_WINDOW_EXPIRED: "referrals.errorWindowExpired",
REFERRAL_NO_PET: "referrals.errorNoPet",
};
/** Port of web's RedeemCodeForm — genutzt im Onboarding und auf dem Einladen-Bildschirm. */
export function RedeemCodeForm({ onRedeemed }: { onRedeemed?: (inviterPetName: string | null) => void }) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const trpc = useTRPC();
const qc = useQueryClient();
const [code, setCode] = useState("");
const [errorKey, setErrorKey] = useState<string | null>(null);
const redeem = useMutation(
trpc.referrals.redeem.mutationOptions({
onSuccess: (result) => {
setErrorKey(null);
void qc.invalidateQueries({ queryKey: trpc.referrals.me.queryKey() });
onRedeemed?.(result.inviterPetName);
},
onError: (err) => {
const isRateLimited = err.data?.code === "TOO_MANY_REQUESTS";
setErrorKey(isRateLimited ? "referrals.errorTooMany" : (ERROR_KEYS[err.message] ?? "referrals.errorGeneric"));
},
})
);
return (
<View style={styles.container}>
<Text style={styles.label}>{t("referrals.redeemLabel")}</Text>
<TextField
value={code}
onChangeText={(value) => setCode(value.toUpperCase())}
placeholder="BUBI-7K3M"
autoCapitalize="characters"
autoCorrect={false}
maxLength={32}
accessibilityLabel={t("referrals.redeemLabel")}
/>
{errorKey && <Text style={styles.error}>{t(errorKey)}</Text>}
<Button
title={redeem.isPending ? "…" : t("referrals.redeemCta")}
disabled={!code.trim() || redeem.isPending}
onPress={() => redeem.mutate({ code: code.trim() })}
/>
</View>
);
}
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
container: { gap: 8 },
label: { ...typography.body, fontWeight: "600" },
error: { ...typography.body, fontSize: 13, color: colors.alertRed },
});
}
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { View } from "react-native";
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../../hooks/useReferralRanks";
const RING_WIDTH = 3;
const RING_GAP = 2;
/**
* Port of web's ReferralAvatarRing — Ring in der Rangfarbe um das Tierbild
* im Profilkopf (Vorteil PROFILE_RING). Nur im Profil, nicht im Feed: dort
* steht der Ring (PawRing) für Storys.
*/
export function ReferralAvatarRing({ owner, children }: { owner: ReferralOwnerFields | null | undefined; children: ReactNode }) {
const rank = useOwnerRankWithPerk(owner, "PROFILE_RING");
if (!rank) return <>{children}</>;
return (
<View style={{ borderWidth: RING_WIDTH, borderColor: rank.color, borderRadius: 999, padding: RING_GAP }}>{children}</View>
);
}
@@ -0,0 +1,33 @@
import { useMemo } from "react";
import { Pressable, Text, StyleSheet } from "react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useTheme } from "../../context/ThemeContext";
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
/**
* Entry point to app/(app)/delete-account.tsx. Deliberately a quiet text
* link below Log out (like web's DeleteAccountDialog trigger) rather than a
* prominent button — it has to be findable (App Store Guideline 5.1.1(v)),
* not inviting.
*/
export function DeleteAccountSection() {
const { t } = useTranslation();
const router = useRouter();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
return (
<Pressable onPress={() => router.push("/delete-account")} hitSlop={8} style={styles.row}>
<Text style={styles.text}>{t("deleteAccount.trigger")}</Text>
</Pressable>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
row: { alignItems: "center", paddingVertical: 4 },
text: { ...typography.body, fontSize: 13, color: colors.alertRed },
});
}
@@ -0,0 +1,36 @@
import { useMemo } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { ChevronRight, Gift } from "lucide-react-native";
import { useTheme } from "../../context/ThemeContext";
import { getTypography, type ColorTokens } from "../../theme/tokens";
import { sectionTitleStyle } from "./sectionTitleStyle";
/** Einstieg zum Empfehlungsprogramm (Bildschirm /invite) aus den Einstellungen. */
export function InviteFriendsSection() {
const { t } = useTranslation();
const router = useRouter();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
return (
<View>
<Text style={styles.sectionTitle}>{t("referrals.title")}</Text>
<Pressable style={styles.row} onPress={() => router.push("/invite")} accessibilityRole="button">
<Gift size={20} color={colors.marigoldOrange} />
<Text style={styles.label}>{t("referrals.settingsLabel")}</Text>
<ChevronRight size={18} color={colors.quietGray} />
</Pressable>
</View>
);
}
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
sectionTitle: sectionTitleStyle(colors),
row: { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 6 },
label: { ...typography.body, flex: 1 },
});
}
@@ -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,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,33 @@
import { Modal, View, Text, Pressable, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { X } from "lucide-react-native";
import { fontFamily } from "../../theme/tokens";
/** Dunkles Bottom-Sheet für die Auswahlfenster des Story-Editors (Markieren, GIF, Sticker). */
export function PickerSheet({ visible, title, onClose, children }: { visible: boolean; title: string; onClose: () => void; children: React.ReactNode }) {
const insets = useSafeAreaInsets();
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<KeyboardAvoidingView style={styles.backdrop} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<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>
</KeyboardAvoidingView>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: { flex: 1, justifyContent: "flex-end" },
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,311 @@
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>
)}
{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,120 @@
import { useEffect, useState } from "react";
import { Modal, View, Text, TextInput, Pressable, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
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";
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 [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 (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel} statusBarTranslucent>
<KeyboardAvoidingView style={styles.backdrop} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<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>
</KeyboardAvoidingView>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.7)", paddingTop: 48, paddingBottom: 16 },
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 }] },
});
+55
View File
@@ -0,0 +1,55 @@
import { Alert } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "../api/trpc";
type BlockTarget = { id: string; name: string };
/**
* Port of web's src/components/safety/BlockDialog.tsx (SAFE-01) as a native
* confirm Alert instead of a dialog component. Required for App Store
* Guideline 1.2 (UGC apps must let users block abusive accounts).
*
* The block itself is enforced server-side: feed.getFeed filters blocked
* pets, comments/DMs reject blocked pairs. Explore/search don't filter blocks
* yet (server change) — invalidating them here is harmless groundwork.
*
* `onBlocked` lets a caller leave a screen that shows the blocked pet (the
* pet profile navigates back).
*/
export function useBlockPet(onBlocked?: () => void) {
const { t } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
const blockMutation = useMutation(
trpc.blocks.create.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries(trpc.feed.pathFilter());
queryClient.invalidateQueries(trpc.explore.pathFilter());
queryClient.invalidateQueries(trpc.search.pathFilter());
queryClient.invalidateQueries(trpc.posts.pathFilter());
queryClient.invalidateQueries(trpc.follows.pathFilter());
onBlocked?.();
},
onError: () => Alert.alert(t("block.errorToast")),
})
);
const confirmBlock = (blockerPetId: string, target: BlockTarget) => {
Alert.alert(t("block.title", { petName: target.name }), t("block.description", { petName: target.name }), [
{ text: t("block.cancel"), style: "cancel" },
{
text: t("block.confirm"),
style: "destructive",
onPress: () =>
blockMutation.mutate(
{ blockerPetId, blockedPetId: target.id },
{ onSuccess: () => Alert.alert(t("block.successToast", { petName: target.name })) }
),
},
]);
};
return { confirmBlock, isBlocking: blockMutation.isPending };
}
+18
View File
@@ -0,0 +1,18 @@
import { useMemo } from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const BASE_PADDING = 16;
/**
* Bottom padding for scroll containers on stack screens (no tab bar below).
* Android is edge-to-edge, so without this the last item scrolls under the
* gesture/navigation bar — found.md: the first post on a pet profile sat in
* the gesture area and couldn't be tapped. Same idea as comments.tsx /
* messages/[conversationId].tsx, which pass insets.bottom into makeStyles.
*
* Usage: `contentContainerStyle={[styles.list, bottomPadding]}`.
*/
export function useBottomSafePadding(base: number = BASE_PADDING) {
const { bottom } = useSafeAreaInsets();
return useMemo(() => ({ paddingBottom: base + bottom }), [base, bottom]);
}
+48
View File
@@ -0,0 +1,48 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useTRPC } from "../api/trpc";
const RANKS_STALE_MS = 30 * 60 * 1000; // Ränge ändern sich selten — einmal laden, lange halten
export type ReferralRankView = {
id: string;
threshold: number;
name: string;
description: string;
icon: string;
color: string;
perks: string[];
isActive: boolean;
};
export type ReferralOwnerFields = {
referralRankId?: string | null;
referralPerks?: readonly string[] | null;
};
/** Port of web's src/hooks/useReferralRanks.ts — Rangliste in der aktuellen App-Sprache. */
export function useReferralRanks(): { ranks: ReferralRankView[]; byId: Map<string, ReferralRankView> } {
const trpc = useTRPC();
const { i18n } = useTranslation();
const isGerman = i18n.language.startsWith("de");
const { data = [] } = useQuery({ ...trpc.referrals.ranks.queryOptions(), staleTime: RANKS_STALE_MS });
const ranks = data.map((rank) => ({
id: rank.id,
threshold: rank.threshold,
name: isGerman ? rank.nameDe : rank.nameEn,
description: isGerman ? rank.descriptionDe : rank.descriptionEn,
icon: rank.icon,
color: rank.color,
perks: rank.perks,
isActive: rank.isActive,
}));
return { ranks, byId: new Map(ranks.map((rank) => [rank.id, rank])) };
}
/** Rang mit dem Vorteil `perk` für diesen Owner, sonst null (Vorteile sind kumulativ, siehe Owner.referralPerks). */
export function useOwnerRankWithPerk(owner: ReferralOwnerFields | null | undefined, perk: string): ReferralRankView | null {
const { byId } = useReferralRanks();
if (!owner?.referralRankId || !owner.referralPerks?.includes(perk)) return null;
return byId.get(owner.referralRankId) ?? null;
}
+188 -12
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}}",
@@ -179,9 +192,13 @@
"messageButton": "Nachricht",
"adoptionStoryTitle": "Adoptionsgeschichte",
"noFollowers": "Noch keine Follower.",
"noFollowing": "Folgt noch niemandem."
"noFollowing": "Folgt noch niemandem.",
"reportPet": "Tier melden",
"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.",
@@ -191,7 +208,12 @@
"birthday": "🎂 Alles Gute zum Geburtstag!",
"adoptionDay": "🏠 Heute ist der Adoptionstag!",
"petSuggestion": "könnte dir gefallen!",
"empty": "Keine Benachrichtigungen."
"empty": "Keine Benachrichtigungen.",
"referralJoined": "ist über deine Einladung zu PawFeed gekommen.",
"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."
},
"pets": {
"empty": "Noch keine Tiere angelegt.",
@@ -240,7 +262,9 @@
"googleButton": "Mit Google anmelden",
"orDivider": "oder",
"googleSignInError": "Anmeldung mit Google fehlgeschlagen. Bitte versuche es erneut.",
"googleNeedsSignUpError": "Für dieses Google-Konto existiert noch kein PawFeed-Account. Bitte über \"Registrieren\" fortfahren, um den Nutzungsbedingungen zuzustimmen."
"googleNeedsSignUpError": "Für dieses Google-Konto existiert noch kein PawFeed-Account. Bitte über \"Registrieren\" fortfahren, um den Nutzungsbedingungen zuzustimmen.",
"appleSignInError": "Anmeldung mit Apple fehlgeschlagen. Bitte versuche es erneut.",
"appleNeedsSignUpError": "Für diese Apple-ID existiert noch kein PawFeed-Account. Bitte über \"Registrieren\" fortfahren, um den Nutzungsbedingungen zuzustimmen."
},
"onboarding": {
"addPetTitle": "Tier hinzufügen",
@@ -307,6 +331,158 @@
"settings": "Einstellungen",
"newPost": "Neuer Beitrag",
"newStory": "Neue Story",
"newMilestone": "Neuer Meilenstein"
"newMilestone": "Neuer Meilenstein",
"deleteAccount": "Konto löschen",
"invite": "Freunde einladen"
},
"block": {
"menuItem": "{{petName}} blockieren",
"title": "{{petName}} blockieren?",
"description": "Posts von {{petName}} werden in deinem Feed ausgeblendet, und ihr könnt euch nicht mehr kommentieren oder schreiben. Niemand erfährt, dass du blockiert hast.",
"cancel": "Nicht blockieren",
"confirm": "Blockieren",
"successToast": "Du hast {{petName}} blockiert.",
"errorToast": "Tier konnte nicht blockiert werden. Bitte versuch es erneut."
},
"deleteAccount": {
"trigger": "Konto löschen",
"warningTitle": "Konto wirklich löschen?",
"warningIntro": "Diese Aktion ist unwiderruflich. Folgendes wird dauerhaft gelöscht:",
"itemPets": "Alle Tierprofile",
"itemPosts": "Alle Posts, Kommentare und Reaktionen",
"itemMedia": "Alle Bilder und Videos",
"itemMessages": "Alle Chats und Nachrichten",
"itemHealth": "Alle Gesundheitsdaten",
"itemAccount": "Dein PawFeed-Konto",
"confirmIntro": "Gib den Namen eines deiner Haustiere ein, um die Löschung zu bestätigen:",
"inputPlaceholder": "Tiername eingeben…",
"confirmDelete": "Konto endgültig löschen",
"finalConfirmTitle": "Letzte Bestätigung",
"finalConfirmMessage": "Dein Konto und alle Daten werden sofort und endgültig gelöscht. Das kann nicht rückgängig gemacht werden.",
"deleting": "Konto wird gelöscht…",
"doneTitle": "Dein Konto wurde gelöscht.",
"genericError": "Etwas ist schiefgelaufen. Bitte versuch es erneut."
},
"referrals": {
"title": "Freunde einladen",
"settingsLabel": "Einladungslink, Ränge und eingeladene Tiere",
"subtitle": "Teile deinen Link. Jedes Tier, das über deine Einladung kommt und seinen ersten Beitrag postet, bringt dich einen Rang weiter.",
"programPaused": "Das Empfehlungsprogramm ist gerade pausiert. Dein Link funktioniert, neue Einladungen werden aber im Moment nicht gezählt.",
"shareHeading": "Dein Einladungslink",
"shareSubtitle": "Unbegrenzt oft nutzbar. Wer sich ohne Link registriert, kann deinen Code auch im Onboarding eingeben.",
"yourCode": "Dein Code",
"share": "Link teilen",
"shareText": "Mein Tier ist auf PawFeed, dem sozialen Netzwerk für Haustiere. Mit meinem Code {{code}} sind wir direkt verbunden:",
"ranksHeading": "Deine Ränge",
"countedInvites_zero": "Noch keine gezählte Einladung",
"countedInvites_one": "{{count}} gezählte Einladung",
"countedInvites_other": "{{count}} gezählte Einladungen",
"nextRank_one": "Noch {{count}} Einladung bis {{name}}",
"nextRank_other": "Noch {{count}} Einladungen bis {{name}}",
"allRanksReached": "Du hast alle Ränge erreicht. Danke!",
"achieved": "Erreicht",
"threshold_one": "{{count}} Einladung",
"threshold_other": "{{count}} Einladungen",
"inviteesHeading": "Über dich gekommen",
"inviteesEmpty": "Noch niemand. Teile deinen Link mit Freunden, die selbst ein Tier haben.",
"inviteeNoPet": "Noch kein Tier angelegt",
"statusCounted": "Zählt",
"statusWaiting": "Wartet auf ersten Beitrag",
"statusInReview": "Wird geprüft",
"statusRevoked": "Aberkannt",
"redeemHeading": "Wurdest du eingeladen?",
"redeemSubtitle": "Gib den Code der Person ein, die dich eingeladen hat. Das geht nur in den ersten Tagen nach der Registrierung.",
"redeemLabel": "Einladungscode",
"redeemCta": "Einlösen",
"redeemed": "Eingelöst. Du und {{name}} folgt euch jetzt.",
"redeemedGeneric": "Code eingelöst.",
"errorPaused": "Das Empfehlungsprogramm ist gerade pausiert.",
"errorInvalid": "Diesen Code gibt es nicht. Prüf die Schreibweise, z. B. BUBI-7K3M.",
"errorOwnCode": "Das ist dein eigener Code. Gib den Code der Person ein, die dich eingeladen hat.",
"errorAlreadyRedeemed": "Du hast schon einen Einladungscode eingelöst.",
"errorWindowExpired": "Einladungscodes können nur in den ersten Tagen nach der Registrierung eingelöst werden.",
"errorNoPet": "Leg zuerst ein Tier an, dann kannst du den Code einlösen.",
"errorTooMany": "Zu viele Versuche. Bitte warte ein paar Minuten.",
"errorGeneric": "Das hat nicht geklappt. Bitte versuch es noch einmal.",
"rulesNote": "Eine Einladung zählt, sobald das eingeladene Tier angelegt ist und seinen ersten Beitrag veröffentlicht hat. Ränge sind digitale Auszeichnungen ohne Geldwert. Einmal erreichte Ränge bleiben erhalten, außer bei Missbrauch.",
"invitedBy": "Eingeladen von",
"onboardingTitle": "Wie hast du von PawFeed erfahren?",
"onboardingSubtitle": "Freiwillig. Es hilft uns zu verstehen, wo wir neue Tierfreunde erreichen.",
"source_FRIENDS": "Freunde oder Familie",
"source_TIKTOK": "TikTok",
"source_INSTAGRAM": "Instagram",
"source_YOUTUBE": "YouTube",
"source_FACEBOOK": "Facebook",
"source_SHELTER_OR_PARTNER": "Tierheim, Verein oder Laden",
"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."
}
}
+188 -12
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}}",
@@ -179,9 +192,13 @@
"messageButton": "Message",
"adoptionStoryTitle": "Adoption story",
"noFollowers": "No followers yet.",
"noFollowing": "Not following anyone yet."
"noFollowing": "Not following anyone yet.",
"reportPet": "Report pet",
"moreActions": "More actions"
},
"notifications": {
"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.",
@@ -191,7 +208,12 @@
"birthday": "🎂 Happy birthday!",
"adoptionDay": "🏠 Today is adoption day!",
"petSuggestion": "might be a good match!",
"empty": "No notifications."
"empty": "No notifications.",
"referralJoined": "joined PawFeed through your invite.",
"referralRankUp": "You reached a new referral rank!",
"storyMention": "tagged you in a story.",
"storyComment": "commented on your story.",
"storyReaction": "gave your story a paw."
},
"pets": {
"empty": "No pets added yet.",
@@ -240,7 +262,9 @@
"googleButton": "Continue with Google",
"orDivider": "or",
"googleSignInError": "Google sign-in failed. Please try again.",
"googleNeedsSignUpError": "There's no PawFeed account for this Google account yet. Please continue via \"Sign up\" to accept the Terms of Service."
"googleNeedsSignUpError": "There's no PawFeed account for this Google account yet. Please continue via \"Sign up\" to accept the Terms of Service.",
"appleSignInError": "Apple sign-in failed. Please try again.",
"appleNeedsSignUpError": "There's no PawFeed account for this Apple ID yet. Please continue via \"Sign up\" to accept the Terms of Service."
},
"onboarding": {
"addPetTitle": "Add pet",
@@ -307,6 +331,158 @@
"settings": "Settings",
"newPost": "New post",
"newStory": "New story",
"newMilestone": "New milestone"
"newMilestone": "New milestone",
"deleteAccount": "Delete account",
"invite": "Invite friends"
},
"block": {
"menuItem": "Block {{petName}}",
"title": "Block {{petName}}?",
"description": "{{petName}}'s posts will be hidden from your feed, and you won't be able to comment on or message each other. They won't know you blocked them.",
"cancel": "Don't block",
"confirm": "Block",
"successToast": "You've blocked {{petName}}.",
"errorToast": "Couldn't block this pet. Please try again."
},
"deleteAccount": {
"trigger": "Delete account",
"warningTitle": "Really delete your account?",
"warningIntro": "This can't be undone. The following will be permanently deleted:",
"itemPets": "All pet profiles",
"itemPosts": "All posts, comments and reactions",
"itemMedia": "All photos and videos",
"itemMessages": "All chats and messages",
"itemHealth": "All health records",
"itemAccount": "Your PawFeed account",
"confirmIntro": "Type the name of one of your pets to confirm the deletion:",
"inputPlaceholder": "Enter pet name…",
"confirmDelete": "Permanently delete account",
"finalConfirmTitle": "Final confirmation",
"finalConfirmMessage": "Your account and all data will be deleted immediately and permanently. This can't be undone.",
"deleting": "Deleting account…",
"doneTitle": "Your account has been deleted.",
"genericError": "Something went wrong. Please try again."
},
"referrals": {
"title": "Invite friends",
"settingsLabel": "Invite link, ranks and invited pets",
"subtitle": "Share your link. Every pet that joins through your invite and publishes its first post moves you up a rank.",
"programPaused": "The referral program is paused right now. Your link still works, but new invites are not counted at the moment.",
"shareHeading": "Your invite link",
"shareSubtitle": "Use it as often as you like. People who sign up without the link can still enter your code during onboarding.",
"yourCode": "Your code",
"share": "Share link",
"shareText": "My pet is on PawFeed, the social network for pets. Use my code {{code}} and we are connected right away:",
"ranksHeading": "Your ranks",
"countedInvites_zero": "No counted invites yet",
"countedInvites_one": "{{count}} counted invite",
"countedInvites_other": "{{count}} counted invites",
"nextRank_one": "{{count}} more invite until {{name}}",
"nextRank_other": "{{count}} more invites until {{name}}",
"allRanksReached": "You reached every rank. Thank you!",
"achieved": "Reached",
"threshold_one": "{{count}} invite",
"threshold_other": "{{count}} invites",
"inviteesHeading": "Joined through you",
"inviteesEmpty": "Nobody yet. Share your link with friends who have a pet.",
"inviteeNoPet": "No pet created yet",
"statusCounted": "Counts",
"statusWaiting": "Waiting for first post",
"statusInReview": "Under review",
"statusRevoked": "Revoked",
"redeemHeading": "Were you invited?",
"redeemSubtitle": "Enter the code of the person who invited you. This only works during the first days after signing up.",
"redeemLabel": "Invite code",
"redeemCta": "Redeem",
"redeemed": "Redeemed. You and {{name}} now follow each other.",
"redeemedGeneric": "Code redeemed.",
"errorPaused": "The referral program is paused right now.",
"errorInvalid": "That code does not exist. Check the spelling, e.g. BUBI-7K3M.",
"errorOwnCode": "That is your own code. Enter the code of the person who invited you.",
"errorAlreadyRedeemed": "You already redeemed an invite code.",
"errorWindowExpired": "Invite codes can only be redeemed during the first days after signing up.",
"errorNoPet": "Create a pet first, then you can redeem the code.",
"errorTooMany": "Too many attempts. Please wait a few minutes.",
"errorGeneric": "That did not work. Please try again.",
"rulesNote": "An invite counts once the invited pet is created and has published its first post. Ranks are digital awards without monetary value. Ranks you reach stay with you unless the program is abused.",
"invitedBy": "Invited by",
"onboardingTitle": "How did you hear about PawFeed?",
"onboardingSubtitle": "Optional. It helps us understand where we reach new pet lovers.",
"source_FRIENDS": "Friends or family",
"source_TIKTOK": "TikTok",
"source_INSTAGRAM": "Instagram",
"source_YOUTUBE": "YouTube",
"source_FACEBOOK": "Facebook",
"source_SHELTER_OR_PARTNER": "Shelter, club or shop",
"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."
}
}
+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));
}
+42
View File
@@ -0,0 +1,42 @@
import { createElement } from "react";
import {
Award,
Crown,
Flame,
Gem,
Heart,
Leaf,
Medal,
PawPrint,
Rocket,
Shield,
Star,
Trophy,
type LucideIcon,
type LucideProps,
} from "lucide-react-native";
/**
* Gegenstück zu REFERRAL_ICON_KEYS in src/lib/referral-ranks.ts (Web). Die
* Schlüssel müssen dort und hier identisch bleiben — das Admin-Panel bietet
* nur Symbole an, die es in beiden lucide-Paketen gibt.
*/
const ICONS: Record<string, LucideIcon> = {
"paw-print": PawPrint,
heart: Heart,
shield: Shield,
star: Star,
award: Award,
crown: Crown,
gem: Gem,
medal: Medal,
trophy: Trophy,
flame: Flame,
rocket: Rocket,
leaf: Leaf,
};
/** Rang-Symbol per Schlüssel; unbekannte Schlüssel fallen auf die Pfote zurück. */
export function ReferralIcon({ icon, ...props }: { icon: string } & LucideProps) {
return createElement(ICONS[icon] ?? PawPrint, props);
}
+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 = {
@@ -0,0 +1,66 @@
# App Store Eintrag — PawFeed (iOS, Deutsch)
Basis: `beschreibung.md` (Play Store), angepasst an die Felder von App Store Connect.
## Untertitel (30/30 Zeichen)
```
Das soziale Netz für Haustiere
```
## Werbetext (153/170 Zeichen, jederzeit ohne neue Prüfung änderbar)
```
Gib deinem Haustier die Bühne! Eigenes Profil, Fotos, Videos & Storys, Meilensteine und Gesundheits-Tracking – das Netzwerk, in dem Tiere die Stars sind.
```
## Keywords (99/100 Zeichen, nur Kommas, keine Leerzeichen)
```
haustier,hund,katze,tiere,tierprofil,social,netzwerk,fotos,kaninchen,vogel,reptilien,aquarium,pferd
```
## Beschreibung (2.011/4.000 Zeichen, keine Emojis — App Store Connect lehnt sie ab)
```
PawFeed – das soziale Netzwerk für Haustiere
Bei PawFeed steht nicht der Mensch im Mittelpunkt, sondern dein Haustier: Jedes Tier bekommt ein eigenes Profil, eigene Follower und eine eigene Geschichte – vom Hund über die Katze bis zur Vogelspinne.
WAS DU MIT PAWFEED MACHEN KANNST
Beiträge & Storys
Teile Fotos und kurze Videos aus dem Alltag deines Haustiers. Storys verschwinden nach 24 Stunden, Beiträge bleiben dauerhaft im Profil.
Reaktionen & Paw-Backs
Statt „Gefällt mir" gibt's die Pfote – und mit „Paw-Back" teilst du Lieblingsbeiträge anderer Tiere in deinem eigenen Feed.
Meilensteine
Halte besondere Momente fest: Geburtstage, Adoptionstag, den ersten Ausflug oder den ersten Tierarztbesuch.
Gesundheits-Tracking
Behalte Gewicht, Tierarztbesuche und Impfungen im Blick – freiwillig, mit ausdrücklicher Einwilligung, jederzeit widerrufbar und dann vollständig löschbar.
Nachrichten
Schreib anderen Tierprofilen direkt – ideal für Verabredungen zum Spielen oder einfach zum Plaudern.
Entdecken
Finde neue Tiere nach Tierart und Rasse – von Hund und Katze über Kaninchen, Vögel und Reptilien bis zum Aquarium.
Arachnophobie-Hilfe
Kein Fan von Spinnen? Blende Spinnen-Inhalte in Feed, Entdecken und Suche komplett aus.
Hell oder dunkel
PawFeed passt sich deinem iPhone an – mit hellem und dunklem Design, auf Deutsch und Englisch.
FÜR WELCHE TIERE?
Hunde, Katzen, Vögel, Pferde, Kaninchen, Meerschweinchen, Hamster, Mäuse, Ratten, Chinchillas, Schlangen, Echsen, Schildkröten, Geckos, Vogelspinnen und Aquarien.
SICHER & RESPEKTVOLL
Unangemessene Beiträge und Profile kannst du mit einem Tipp melden, andere Tiere blockieren – und dein Konto samt aller Daten jederzeit direkt in der App löschen.
DATENSCHUTZ ERNST GENOMMEN
Gesundheitsdaten werden nur nach ausdrücklicher Einwilligung verarbeitet und lassen sich jederzeit vollständig löschen. Details in unserer Datenschutzerklärung: https://pawfeed.org/datenschutz
Lade PawFeed herunter und gib deinem Haustier die Bühne, die es verdient.
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Loaded 100 of 278 files, more files were not shown because too many files have changed in this diff. Show more