Author SHA1 Message Date
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
164 changed files with 5401 additions and 1093 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
+8 -6
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,13 @@ 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,moderators,posts,reports,ads,referrals,…}`.
### Invite system
### Referral program (Empfehlungsprogramm)
`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.
Registration is open; invites are a **Clerk-independent** referral layer on top. Every owner has one permanent, unlimited `Owner.referralCode` (e.g. `BUBI-7K3M`, prefix from the first pet's name — `src/lib/referral-code.ts`). A code is never part of the Clerk sign-up: it is redeemed **after** sign-up, in the onboarding step "Wie hast du von PawFeed erfahren?" (`AcquisitionStep`, web + mobile) or later on `/einladen` (mobile: `/invite`) within `ReferralSettings.redeemWindowDays`. `/r/[code]` is the public landing page (sets a `pf_ref` cookie only to prefill the web onboarding); old `/join?code=` links redirect there. Logic lives in `src/lib/referrals.ts`: redeem sets `Owner.invitedById` and makes both first pets follow each other; an invite **counts** (`referralQualifiedAt`, inviter's denormalized `qualifiedReferralCount` +1) only once the invitee has a pet **and** a first real post — `maybeQualifyReferral()` is called from `posts.create` and the Mux webhook on `READY`; a new post-creating mutation must call it too.
Ranks (`ReferralRank`) are data, fully editable in the admin panel (`/p/[secret]/referrals`, `referral-admin.ts`); only the perk catalog (`ReferralPerk`, `IMPLEMENTED_REFERRAL_PERKS` in `src/lib/referral-ranks.ts`) needs code. Perks are cumulative. Reached ranks are **grandfathered** via `ReferralRankAchievement` — raising a threshold never removes a rank. `Owner.referralRankId`/`referralPerks` are a cache maintained only by `src/lib/referrals.ts` and the set-based `recomputeReferralRanks()` (`src/lib/referral-recompute.ts`, run after every admin rank change); feed cards read them via `petCardSelect`. The legacy `InviteCode` table is history only — nothing writes to it anymore. One-time setup after the schema push: `npx tsx prisma/scripts/referral-setup.ts` (+ `prisma/enable_rls_referrals.sql`).
### Testing
+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.
+79 -4
View File
@@ -124,7 +124,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",
@@ -477,7 +478,9 @@
"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!"
},
"PhotoPostForm": {
"captionPlaceholder": "Bildunterschrift schreiben…",
@@ -632,7 +635,8 @@
"statsFollowers": "Follower",
"statsFollowing": "Folgt",
"adoptionStory": "Adoptionsgeschichte",
"verified": "Verifiziert"
"verified": "Verifiziert",
"invitedBy": "Eingeladen von <link>{name}</link>"
},
"EditPet": {
"title": "{name} bearbeiten",
@@ -742,7 +746,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 +769,72 @@
"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"
}
}
+79 -4
View File
@@ -124,7 +124,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",
@@ -477,7 +478,9 @@
"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!"
},
"PhotoPostForm": {
"captionPlaceholder": "Write a caption…",
@@ -632,7 +635,8 @@
"statsFollowers": "followers",
"statsFollowing": "following",
"adoptionStory": "Adoption story",
"verified": "Verified"
"verified": "Verified",
"invitedBy": "Invited by <link>{name}</link>"
},
"EditPet": {
"title": "Edit {name}",
@@ -742,7 +746,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 +769,72 @@
"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"
}
}
+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": {},
@@ -20,6 +20,7 @@ const ACTOR_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
MENTION: "notifications.mention",
PAW_BACK: "notifications.pawBack",
PET_SUGGESTION: "notifications.petSuggestion",
REFERRAL_JOINED: "notifications.referralJoined",
};
// BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the
@@ -28,6 +29,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
@@ -56,7 +58,10 @@ function getNotificationRoute(item: NotificationItem): string | null {
switch (item.type) {
case "FOLLOW":
case "PET_SUGGESTION":
case "REFERRAL_JOINED":
return item.actorPet ? `/pets/${item.actorPet.id}` : null;
case "REFERRAL_RANK_UP":
return "/invite";
case "BIRTHDAY":
case "ADOPTION_DAY":
return item.recipientPetId ? `/pets/${item.recipientPetId}/health` : null;
+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 },
});
}
+127
View File
@@ -0,0 +1,127 @@
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]}>
<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 },
});
}
+3 -1
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}`)} />
+3 -1
View File
@@ -12,6 +12,7 @@ import { Button } from "../../../../src/components/Button";
import { BreedPicker } from "../../../../src/components/pet/BreedPicker";
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
@@ -29,6 +30,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 +124,7 @@ export default function EditPetScreen() {
<FlatList
data={EMPTY_DATA}
renderItem={null}
contentContainerStyle={styles.container}
contentContainerStyle={[styles.container, bottomPadding]}
ListHeaderComponent={
<>
<Text style={styles.label}>{t("editPet.nameLabel")}</Text>
+3 -1
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,7 +33,7 @@ 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}`)}>
+3 -1
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,7 +33,7 @@ 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}`)}>
@@ -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,10 @@ 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]}>
<HealthConsentGate petId={petId}>
<WeightLogSection petId={petId} />
<VetVisitSection petId={petId} />
+147 -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,146 @@ 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} sponsor={pet.owner} referral={pet.owner} />
</View>
) : null}
<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 +227,8 @@ 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 },
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={
+4 -2
View File
@@ -14,6 +14,7 @@ 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";
type Tab = "pets" | "people" | "tags";
type Styles = ReturnType<typeof makeStyles>;
@@ -39,6 +40,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 +90,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 +107,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
+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>
);
+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")}
+25 -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 {
+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"
}
}
}
}
+21 -9
View File
@@ -38,7 +38,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 +110,15 @@ 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.
- [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 +129,20 @@ 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).
-
!!! iOS - Founds beginnen hier - nicht mit Android vertauschen !!!
+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",
+105
View File
@@ -0,0 +1,105 @@
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;
if (!createdSessionId && signUp?.status === "missing_requirements" && signUp.missingFields.includes("legal_accepted")) {
if (!legalAccepted) {
onNeedsConsent?.();
return;
}
const updatedSignUp = await signUp.update({ legalAccepted: true });
createdSessionId = updatedSignUp.createdSessionId;
}
if (createdSessionId && setActive) {
await setActive({ session: createdSessionId });
router.replace("/(app)");
}
// No createdSessionId otherwise (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 },
});
+27 -6
View File
@@ -13,9 +13,22 @@ type Props = {
};
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
@@ -32,7 +45,7 @@ export function PawRing({ petId, children, size = 32, onPress }: Props) {
const { hasStory, allSeen } = data;
const ringColor = allSeen ? SEEN_COLOR : UNSEEN_COLOR;
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 +55,21 @@ 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",
}}
>
{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) => (
+8 -2
View File
@@ -2,21 +2,27 @@ import { View, StyleSheet } from "react-native";
import { BadgeCheck, Sparkles } from "lucide-react-native";
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;
/** 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) {
export function PetIdentityBadges({ isVerified, sponsor, referral }: Props) {
const showSponsor = sponsor ? isActiveSponsor(sponsor) : false;
if (!isVerified && !showSponsor) return null;
const rank = useOwnerRankWithPerk(referral, "NAME_BADGE");
if (!isVerified && !showSponsor && !rank) return null;
return (
<View style={styles.row}>
{isVerified && <BadgeCheck size={14} color={colors.marigoldOrange} />}
{showSponsor && <Sparkles size={14} color={colors.marigoldOrange} />}
{rank && <ReferralIcon icon={rank.icon} size={14} color={rank.color} fill={rank.color} accessibilityLabel={rank.name} />}
</View>
);
}
+20 -3
View File
@@ -13,6 +13,7 @@ import { PawBackButton } from "./PawBackButton";
import { RepostCard } from "./RepostCard";
import { VideoPlayer } from "./VideoPlayer";
import { ReportSheet } from "./ReportSheet";
import { useBlockPet } from "../hooks/useBlockPet";
import { getMediaUrl } from "../lib/media-url";
import { formatPetName, formatRelativeTime } from "../lib/format";
import { useTheme } from "../context/ThemeContext";
@@ -58,7 +59,12 @@ export type PostCardPost = {
nickname?: string | null;
avatarKey?: string | null;
isVerified?: boolean;
owner?: { sponsorSince: Date | string | null; sponsorPeriodEnd: Date | string | null } | null;
owner?: {
sponsorSince: Date | string | null;
sponsorPeriodEnd: Date | string | null;
referralRankId?: string | null;
referralPerks?: string[] | null;
} | null;
};
};
@@ -90,6 +96,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 +126,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" },
]);
}
};
@@ -131,7 +148,7 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr
<View style={styles.headerText}>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(post.pet)}</Text>
<PetIdentityBadges isVerified={post.pet.isVerified} sponsor={post.pet.owner} />
<PetIdentityBadges isVerified={post.pet.isVerified} sponsor={post.pet.owner} referral={post.pet.owner} />
{isTrending && (
<View style={styles.trendingBadge}>
<Flame size={11} color={colors.marigoldDeep} fill={colors.marigoldDeep} />
+15 -4
View File
@@ -7,11 +7,12 @@ 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
@@ -37,8 +38,10 @@ 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")}
@@ -71,7 +74,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,
@@ -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,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 },
});
}
+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;
}
+95 -4
View File
@@ -179,7 +179,9 @@
"messageButton": "Nachricht",
"adoptionStoryTitle": "Adoptionsgeschichte",
"noFollowers": "Noch keine Follower.",
"noFollowing": "Folgt noch niemandem."
"noFollowing": "Folgt noch niemandem.",
"reportPet": "Tier melden",
"moreActions": "Weitere Aktionen"
},
"notifications": {
"follow": "folgt dir jetzt.",
@@ -191,7 +193,9 @@
"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!"
},
"pets": {
"empty": "Noch keine Tiere angelegt.",
@@ -240,7 +244,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 +313,91 @@
"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"
}
}
+95 -4
View File
@@ -179,7 +179,9 @@
"messageButton": "Message",
"adoptionStoryTitle": "Adoption story",
"noFollowers": "No followers yet.",
"noFollowing": "Not following anyone yet."
"noFollowing": "Not following anyone yet.",
"reportPet": "Report pet",
"moreActions": "More actions"
},
"notifications": {
"follow": "is now following you.",
@@ -191,7 +193,9 @@
"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!"
},
"pets": {
"empty": "No pets added yet.",
@@ -240,7 +244,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 +313,91 @@
"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"
}
}
+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);
}
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

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