Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32ba01f7cf | ||
|
|
78e16c9210 | ||
|
|
4d0aa7ab9e | ||
|
|
02662528cf | ||
|
|
959d67cc29 | ||
|
|
2cd13537f7 | ||
|
|
2ef439c752 | ||
|
|
de237f19a1 | ||
|
|
e4139a1b9b | ||
|
|
3b6735ebee | ||
|
|
4213d7029e | ||
|
|
0c3ab5c2a2 | ||
|
|
69e095ddcb | ||
|
|
1e7d76aa79 | ||
|
|
070c389786 | ||
|
|
71ac36b08a | ||
|
|
779f18966d | ||
|
|
17a17bd392 | ||
|
|
aa75fe612b | ||
|
|
ea3cf1dcd4 | ||
|
|
1e8e6bbd7d | ||
|
|
31b3f7b81b | ||
|
|
30c57b661c |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -625,3 +625,5 @@ npx vitest # watch mode
|
||||
| A 401 (not 403) from an admin tRPC procedure usually means a stale Clerk session, not a permissions bug | `protectedProcedure` throws `UNAUTHORIZED` (401) when Clerk's session is missing/expired; `assertAdmin()` throws `FORBIDDEN` (403) when the session is valid but the role is too low. A browser tab left open and backgrounded for hours can let Clerk's silent token refresh lapse — reload the page before suspecting a code regression. |
|
||||
| `eas update` must be run with `--platform android` (`mobile/`), not `--platform all` | This app has no web target — `react-native-web` isn't installed — so the default `--platform all` fails bundling with `Unable to resolve module react-native-web/dist/index`. Previously published updates were all Android-only too (check `eas channel:view <channel>` to confirm before assuming this changed). |
|
||||
| `mobile/node_modules` can end up silently corrupted (missing nested files) after an interrupted/partial install, without `npm install` re-detecting or fixing it | Symptom: Metro bundling fails with `Unable to resolve module ./some/nested/path.js` inside an already-installed package (seen with `@expo/cli`'s `utils/telemetry`, then `@sentry/browser` and `@sentry-internal/browser-utils` in the same session) — `npm install` reports "up to date" and does nothing. Fix: `rm -rf node_modules && npm install` (a full reinstall), not a targeted one; deleting just the one broken package's folder can just surface the *next* corrupted package one bundle-failure at a time. |
|
||||
| Mobile Google SSO can break with `redirect url ... does not match an authorized redirect URI` even though `GoogleSignInButton.tsx`/`app.json`'s scheme are unchanged | Clerk keeps its authorized-redirect-URL allowlist server-side per instance (`GET`/`POST https://api.clerk.com/v1/redirect_urls`, auth via `CLERK_SECRET_KEY`), entirely outside this repo — it can be missing or reset independent of any code change. Check that list before debugging app code; add the missing entry (`{"url":"pawfeed://sso-callback"}` for this app's `"scheme": "pawfeed"`) via `POST /v1/redirect_urls` — takes effect immediately, no app deploy/rebuild needed. |
|
||||
| Admin music-track upload can fail even with a genuinely valid MP3/M4A/AAC file | Two independent causes, both hit in the same incident: (1) the Supabase bucket must be named **exactly** `music-tracks` (plural) — it was once created as `music-track` (singular), so every upload failed with a bucket-not-found error surfaced only as a generic "Upload fehlgeschlagen" toast; verify via `GET {SUPABASE_URL}/storage/v1/bucket` with the service-role key. (2) The upload form used to validate against the browser-reported `File.type`, which is sniffed from the OS's own MIME registry and is inconsistent for `.m4a`/`.aac` (e.g. Windows Chrome reports `.m4a` as `audio/x-m4a`, not `audio/mp4`) — it now derives the content-type from the file extension instead (`src/lib/music-content-types.ts`), matching what `MUSIC_ALLOWED_CONTENT_TYPES` (server-side, `supabase-storage.ts`) actually expects. |
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -48,3 +48,6 @@ yarn-error.*
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
|
||||
# Local tester/debug screenshots (e.g. from appetize.io) — not app assets
|
||||
/debug
|
||||
@@ -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;
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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}`)} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`)}>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { View, ActivityIndicator } from "react-native";
|
||||
import { useTheme } from "../src/context/ThemeContext";
|
||||
|
||||
/**
|
||||
* On Android, the custom tab used by Clerk's useSSO() fires the OAuth
|
||||
* redirect intent (pawfeed://sso-callback) at the app itself, not just at
|
||||
* expo-web-browser's pending openAuthSessionAsync() promise — so
|
||||
* expo-router's own Linking listener also sees the URL. Without a matching
|
||||
* route here, that shows Expo Router's "Unmatched Route" error screen to
|
||||
* the user for however long the real handshake (GoogleSignInButton's
|
||||
* startSSOFlow -> setActive -> router.replace("/(app)")) takes to resolve
|
||||
* and navigate away.
|
||||
*
|
||||
* This route exists purely to give expo-router something valid to render
|
||||
* during that window. It deliberately does no auth-state logic itself —
|
||||
* app/_layout.tsx's useProtectedRoute() already redirects signed-out users
|
||||
* away from any non-(auth) screen, and GoogleSignInButton takes over
|
||||
* navigation the moment the real SSO flow completes.
|
||||
*/
|
||||
export default function SsoCallback() {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite }}>
|
||||
<ActivityIndicator color={colors.marigoldOrange} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,18 +110,39 @@ 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.
|
||||
- [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.
|
||||
**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 ?
|
||||
→ 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.
|
||||
→ Ursache gefunden: `app/(app)/post-new.tsx` gehörte noch zu den nicht auf Dark Mode migrierten Screens — es importierte die statischen, Light-only `colors`/`typography` aus `theme/tokens.ts` direkt, statt sie per `useTheme()` zu beziehen (gleiche Bug-Klasse wie die bereits behobenen Fälle aus dem Darkmode-Audit, siehe README/CLAUDE.md-Kommentar in `theme/tokens.ts`: "dark-mode support has to be added file-by-file"). Der Container-Hintergrund blieb dadurch immer hell (`colors.porchWhite`), während die geteilte `TextField`-Komponente **korrekt** theme-abhängige Textfarbe zieht (`colors.ink`, im Dark Mode hell) — macht bei aktivem Dark Mode aus hellem Text auf hellem Hintergrund den gemeldeten "weiß auf weiß"-Effekt in der Caption-Eingabe.
|
||||
Fix: `post-new.tsx` auf `useTheme()` + `makeStyles(colors)`-Muster umgestellt (identisch zu `milestone-new.tsx`). Gleiches Muster/gleicher Fund auch in `story-new.tsx` entdeckt (dort ohne Texteingabe, macht sich als falscher heller Hintergrund statt unlesbarem Text bemerkbar) — gleich mitbehoben.
|
||||
Typecheck + Lint sauber (0 neue Warnungen, 6 vorbestehende `exhaustive-deps`-Warnungen unverändert).
|
||||
**Nachtrag:** `app/(app)/search.tsx` (gleicher Fehler im Suchfeld) auf Nachfrage ebenfalls behoben — dort bekommen die separaten Unterkomponenten (`TabButton`, `PetRow`, `Loading`, `EmptyState`) `styles`/`colors` jetzt als Props von der Hauptkomponente durchgereicht, statt selbst die statischen Tokens zu importieren. Typecheck + Lint sauber (1 vorbestehende, unveränderte `alt-text`-Warnung bei `expo-image`).
|
||||
**Nachtrag:** `app/(app)/search.tsx` (gleicher Fehler im Suchfeld) auf Nachfrage ebenfalls behoben — dort bekommen die separaten Unterkomponenten (`TabButton`, `PetRow`, `Loading`, `EmptyState`) `styles`/`colors` jetzt als Props von der Hauptkomponente durchgereicht, statt selbst die statischen Tokens zu importieren. Typecheck + Lint sauber (1 vorbestehende, unveränderte `alt-text`-Warnung bei `expo-image`).
|
||||
- [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.
|
||||
- [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 (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 !!!
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
```
|
||||
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 496 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 451 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 650 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 510 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 184 KiB |